Compare commits

...

2 Commits

Author SHA1 Message Date
ad16eee591 Spec 204: harden platform core vocabulary (#234)
## Summary
- add the Spec 204 platform vocabulary foundation, including canonical glossary terms, registry ownership descriptors, canonical operation type and alias resolution, and explicit reason ownership and platform reason-family metadata
- harden platform-facing compare, snapshot, evidence, monitoring, review, and reporting surfaces so they prefer governed-subject and canonical operation semantics while preserving intentional Intune-owned terminology
- extend Spec 204 unit, feature, Filament, and architecture coverage and add the full spec artifacts, checklist, and completed task ledger

## Verification
- ran the focused recent-change Sail verification pack for the new glossary and reason-semantics work
- ran the full Spec 204 quickstart verification pack under Sail
- ran `cd apps/platform && ./vendor/bin/sail bin pint --dirty --format agent`
- ran an integrated-browser smoke pass covering tenant dashboard, operations, operation detail, baseline compare, evidence, reviews, review packs, provider connections, inventory items, backup schedules, onboarding, and the system dashboard/operations/failures/run-detail surfaces

## Notes
- provider registration is unchanged and remains in `bootstrap/providers.php`
- no new destructive actions or asset-registration changes are introduced by this branch

Co-authored-by: Ahmed Darrazi <ahmed.darrazi@live.de>
Reviewed-on: #234
2026-04-14 06:09:42 +00:00
d644265d30 Spec 203: extract baseline compare strategy (#233)
## Summary
- extract baseline compare orchestration behind an explicit strategy contract and registry
- preserve the current Intune compare path through a dedicated `IntuneCompareStrategy`
- harden compare launch and review surfaces for mixed, unsupported, incomplete, and strategy-failure truth
- add Spec 203 artifacts, focused regression coverage, and future-domain strategy proof tests

## Testing
- `cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Unit/Baselines/CompareStrategyRegistryTest.php tests/Unit/Baselines/CompareSubjectResultContractTest.php tests/Feature/Baselines/BaselineCompareStrategySelectionTest.php tests/Feature/Baselines/BaselineComparePreconditionsTest.php tests/Feature/Baselines/BaselineCompareExecutionGuardTest.php tests/Feature/Baselines/BaselineCompareMatrixCompareAllActionTest.php tests/Feature/Filament/BaselineProfileCompareStartSurfaceTest.php tests/Feature/Filament/BaselineCompareLandingStartSurfaceTest.php tests/Feature/Filament/BaselineCompareLandingWhyNoFindingsTest.php tests/Feature/Filament/BaselineCompareMatrixPageTest.php tests/Feature/Filament/OperationRunBaselineTruthSurfaceTest.php`
- `cd apps/platform && ./vendor/bin/sail bin pint --dirty --format agent`

## Notes
- no new Filament panel/provider registration changes
- no global-search resource changes
- no new asset registration or deployment step changes

Co-authored-by: Ahmed Darrazi <ahmed.darrazi@live.de>
Reviewed-on: #233
2026-04-13 21:17:04 +00:00
122 changed files with 10846 additions and 219 deletions

View File

@ -180,6 +180,10 @@ ## Active Technologies
- PostgreSQL through existing tenant-owned and workspace-context models (`InventoryItem`, `InventoryLink`, `TenantPermission`, `EvidenceSnapshot`, `TenantReview`); no schema change planned (196-hard-filament-nativity-cleanup)
- PHP 8.4.15 + Laravel 12, Filament v5, Livewire v4, Pest v4, Tailwind CSS v4, Laravel Sail, existing `BaselineScope`, `InventoryPolicyTypeMeta`, `BaselineSupportCapabilityGuard`, `BaselineCaptureService`, and `BaselineCompareService` (202-governance-subject-taxonomy)
- PostgreSQL via existing `baseline_profiles.scope_jsonb`, `baseline_tenant_assignments.override_scope_jsonb`, and `operation_runs.context`; no new tables planned (202-governance-subject-taxonomy)
- PHP 8.4.15 + Laravel 12, Filament v5, Livewire v4, Pest v4, Laravel Sail, existing `BaselineCompareService`, `CompareBaselineToTenantJob`, `SubjectResolver`, `CurrentStateHashResolver`, `DriftHasher`, `BaselineCompareSummaryAssessor`, and finding lifecycle services (203-baseline-compare-strategy)
- PostgreSQL via existing baseline snapshots, baseline snapshot items, `operation_runs`, findings, and baseline scope JSON; no new top-level tables planned (203-baseline-compare-strategy)
- PHP 8.4.15 + Laravel 12, Filament v5, Livewire v4, Pest v4, Laravel Sail, existing `GovernanceSubjectTaxonomyRegistry`, `BaselineScope`, `CompareStrategyRegistry`, `OperationCatalog`, `OperationRunType`, `ReasonTranslator`, `ReasonResolutionEnvelope`, `ProviderReasonTranslator`, and current Filament monitoring or review surfaces (204-platform-core-vocabulary-hardening)
- PostgreSQL via existing `operation_runs.type`, `operation_runs.context`, `baseline_profiles.scope_jsonb`, `baseline_snapshot_items`, findings, evidence payloads, and current config-backed registries; no new top-level tables planned (204-platform-core-vocabulary-hardening)
- PHP 8.4.15 (feat/005-bulk-operations)
@ -214,8 +218,8 @@ ## Code Style
PHP 8.4.15: Follow standard conventions
## Recent Changes
- 204-platform-core-vocabulary-hardening: Added PHP 8.4.15 + Laravel 12, Filament v5, Livewire v4, Pest v4, Laravel Sail, existing `GovernanceSubjectTaxonomyRegistry`, `BaselineScope`, `CompareStrategyRegistry`, `OperationCatalog`, `OperationRunType`, `ReasonTranslator`, `ReasonResolutionEnvelope`, `ProviderReasonTranslator`, and current Filament monitoring or review surfaces
- 203-baseline-compare-strategy: Added PHP 8.4.15 + Laravel 12, Filament v5, Livewire v4, Pest v4, Laravel Sail, existing `BaselineCompareService`, `CompareBaselineToTenantJob`, `SubjectResolver`, `CurrentStateHashResolver`, `DriftHasher`, `BaselineCompareSummaryAssessor`, and finding lifecycle services
- 202-governance-subject-taxonomy: Added PHP 8.4.15 + Laravel 12, Filament v5, Livewire v4, Pest v4, Tailwind CSS v4, Laravel Sail, existing `BaselineScope`, `InventoryPolicyTypeMeta`, `BaselineSupportCapabilityGuard`, `BaselineCaptureService`, and `BaselineCompareService`
- 196-hard-filament-nativity-cleanup: Added PHP 8.4.15 + Laravel 12, Filament v5, Livewire v4, Pest v4, Tailwind CSS v4, existing `DependencyQueryService`, `DependencyTargetResolver`, `TenantRequiredPermissionsViewModelBuilder`, `ArtifactTruthPresenter`, `WorkspaceContext`, Filament `InteractsWithTable`, Filament `TableComponent`, and existing badge and action-surface helpers
- 195-action-surface-closure: Added PHP 8.4.15 + Laravel 12, Filament v5, Livewire v4, Pest v4, Tailwind CSS v4, existing `ActionSurfaceDiscovery`, `ActionSurfaceValidator`, `ActionSurfaceExemptions`, `GovernanceActionCatalog`, `UiEnforcement`, `WorkspaceContext`, and existing system/onboarding/auth helpers
<!-- MANUAL ADDITIONS START -->
<!-- MANUAL ADDITIONS END -->

8
.github/skills/giteaflow/SKILL.md vendored Normal file
View File

@ -0,0 +1,8 @@
---
name: giteaflow
description: Describe what this skill does and when to use it. Include keywords that help agents identify relevant tasks.
---
<!-- Tip: Use /create-skill in chat to generate content with agent assistance -->
comit all changes, push to remote, and create a pull request against dev with gitea mcp

View File

@ -21,6 +21,7 @@
use App\Support\OperationRunLinks;
use App\Support\OpsUx\OperationUxPresenter;
use App\Support\OpsUx\OpsUxBrowserEvents;
use App\Support\ReasonTranslation\ReasonPresenter;
use App\Support\Rbac\UiEnforcement;
use App\Support\Ui\ActionSurface\ActionSurfaceDeclaration;
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceProfile;
@ -203,6 +204,10 @@ public function refreshStats(): void
protected function getViewData(): array
{
$evidenceGapSummary = is_array($this->evidenceGapSummary) ? $this->evidenceGapSummary : [];
$reasonPresenter = app(ReasonPresenter::class);
$reasonSemantics = $reasonPresenter->semantics(
$reasonPresenter->forArtifactTruth($this->reasonCode, 'baseline_compare_landing'),
);
$hasCoverageWarnings = in_array($this->coverageStatus, ['warning', 'unproven'], true);
$evidenceGapsCountValue = is_numeric($evidenceGapSummary['count'] ?? null)
? (int) $evidenceGapSummary['count']
@ -276,6 +281,7 @@ protected function getViewData(): array
'whyNoFindingsFallback' => $whyNoFindingsFallback,
'whyNoFindingsColor' => $whyNoFindingsColor,
'summaryAssessment' => is_array($this->summaryAssessment) ? $this->summaryAssessment : null,
'reasonSemantics' => $reasonSemantics,
];
}
@ -381,17 +387,23 @@ private function compareNowAction(): Action
if (! ($result['ok'] ?? false)) {
$reasonCode = is_string($result['reason_code'] ?? null) ? (string) $result['reason_code'] : 'unknown';
$translation = is_array($result['reason_translation'] ?? null) ? $result['reason_translation'] : [];
$message = match ($reasonCode) {
\App\Support\Baselines\BaselineReasonCodes::COMPARE_ROLLOUT_DISABLED => 'Full-content baseline compare is currently disabled for controlled rollout.',
\App\Support\Baselines\BaselineReasonCodes::COMPARE_PROFILE_NOT_ACTIVE => 'The assigned baseline profile is not active.',
\App\Support\Baselines\BaselineReasonCodes::COMPARE_NO_ACTIVE_SNAPSHOT,
\App\Support\Baselines\BaselineReasonCodes::COMPARE_NO_CONSUMABLE_SNAPSHOT => 'No complete baseline snapshot is currently available for compare.',
\App\Support\Baselines\BaselineReasonCodes::COMPARE_SNAPSHOT_BUILDING => 'The latest baseline capture is still building. Compare will be available after it completes.',
\App\Support\Baselines\BaselineReasonCodes::COMPARE_SNAPSHOT_INCOMPLETE => 'The latest baseline capture is incomplete. Capture a new baseline before comparing.',
\App\Support\Baselines\BaselineReasonCodes::COMPARE_SNAPSHOT_SUPERSEDED => 'A newer complete baseline snapshot is current. Compare uses the latest complete baseline only.',
default => 'Reason: '.$reasonCode,
};
$message = is_string($translation['short_explanation'] ?? null) && trim((string) $translation['short_explanation']) !== ''
? trim((string) $translation['short_explanation'])
: match ($reasonCode) {
\App\Support\Baselines\BaselineReasonCodes::COMPARE_ROLLOUT_DISABLED => 'Full-content baseline compare is currently disabled for controlled rollout.',
\App\Support\Baselines\BaselineReasonCodes::COMPARE_PROFILE_NOT_ACTIVE => 'The assigned baseline profile is not active.',
\App\Support\Baselines\BaselineReasonCodes::COMPARE_NO_ACTIVE_SNAPSHOT,
\App\Support\Baselines\BaselineReasonCodes::COMPARE_NO_CONSUMABLE_SNAPSHOT => 'No complete baseline snapshot is currently available for compare.',
\App\Support\Baselines\BaselineReasonCodes::COMPARE_SNAPSHOT_BUILDING => 'The latest baseline capture is still building. Compare will be available after it completes.',
\App\Support\Baselines\BaselineReasonCodes::COMPARE_SNAPSHOT_INCOMPLETE => 'The latest baseline capture is incomplete. Capture a new baseline before comparing.',
\App\Support\Baselines\BaselineReasonCodes::COMPARE_SNAPSHOT_SUPERSEDED => 'A newer complete baseline snapshot is current. Compare uses the latest complete baseline only.',
\App\Support\Baselines\BaselineReasonCodes::COMPARE_INVALID_SCOPE => 'The assigned baseline scope is invalid and must be reviewed before compare can start.',
\App\Support\Baselines\BaselineReasonCodes::COMPARE_UNSUPPORTED_SCOPE => 'The selected governed subjects are not supported by any compare strategy.',
\App\Support\Baselines\BaselineReasonCodes::COMPARE_MIXED_SCOPE => 'The selected governed subjects span multiple compare strategy families and must be narrowed before compare can start.',
default => 'Reason: '.$reasonCode,
};
Notification::make()
->title('Cannot start comparison')

View File

@ -14,6 +14,8 @@
use App\Services\Baselines\BaselineCompareService;
use App\Support\Auth\Capabilities;
use App\Support\Baselines\BaselineCompareMatrixBuilder;
use App\Support\Baselines\BaselineReasonCodes;
use App\Support\Baselines\Compare\CompareStrategyRegistry;
use App\Support\Navigation\CanonicalNavigationContext;
use App\Support\OperationRunLinks;
use App\Support\OpsUx\OperationUxPresenter;
@ -128,15 +130,15 @@ public function form(Schema $schema): Schema
])
->schema([
Select::make('draftSelectedPolicyTypes')
->label('Policy types')
->label('Governed subjects')
->options(fn (): array => $this->matrixOptions('policyTypeOptions'))
->multiple()
->searchable()
->preload()
->native(false)
->placeholder('All policy types')
->placeholder('All governed subjects')
->helperText(fn (): ?string => $this->matrixOptions('policyTypeOptions') === []
? 'Policy type filters appear after a usable reference snapshot is available.'
? 'Governed subject filters appear after a usable reference snapshot is available.'
: null)
->extraFieldWrapperAttributes([
'data-testid' => 'matrix-policy-type-filter',
@ -246,7 +248,22 @@ protected function getHeaderActions(): array
->requireCapability(Capabilities::WORKSPACE_BASELINES_MANAGE)
->preserveDisabled()
->tooltip('You need workspace baseline manage access to compare the visible assigned set.')
->apply();
->apply()
->tooltip(function (): ?string {
$user = auth()->user();
$workspace = $this->workspace();
if ($user instanceof User && $workspace instanceof Workspace) {
/** @var WorkspaceCapabilityResolver $resolver */
$resolver = app(WorkspaceCapabilityResolver::class);
if ($resolver->isMember($user, $workspace) && ! $resolver->can($user, $workspace, Capabilities::WORKSPACE_BASELINES_MANAGE)) {
return 'You need workspace baseline manage access to compare the visible assigned set.';
}
}
return $this->compareAssignedTenantsDisabledReason();
});
return [
Action::make('backToBaselineProfile')
@ -409,7 +426,7 @@ public function activeFilterSummary(): array
$summary = [];
if ($this->selectedPolicyTypes !== []) {
$summary['Policy types'] = count($this->selectedPolicyTypes);
$summary['Governed subjects'] = count($this->selectedPolicyTypes);
}
if ($this->selectedStates !== []) {
@ -435,7 +452,7 @@ public function stagedFilterSummary(): array
$summary = [];
if ($this->draftSelectedPolicyTypes !== $this->selectedPolicyTypes) {
$summary['Policy types'] = count($this->draftSelectedPolicyTypes);
$summary['Governed subjects'] = count($this->draftSelectedPolicyTypes);
}
if ($this->draftSelectedStates !== $this->selectedStates) {
@ -616,9 +633,41 @@ private function compareAssignedTenantsDisabledReason(): ?string
return 'No visible assigned tenants are available for compare.';
}
return $this->compareStartReasonMessage($this->compareAssignedTenantsReasonCode());
}
private function compareAssignedTenantsReasonCode(): ?string
{
try {
$scope = $this->getRecord()->normalizedScope();
} catch (\Throwable) {
return BaselineReasonCodes::COMPARE_INVALID_SCOPE;
}
$selection = app(CompareStrategyRegistry::class)->select($scope);
if ($selection->isMixed()) {
return BaselineReasonCodes::COMPARE_MIXED_SCOPE;
}
if (! $selection->isSupported()) {
return BaselineReasonCodes::COMPARE_UNSUPPORTED_SCOPE;
}
return null;
}
private function compareStartReasonMessage(?string $reasonCode): ?string
{
return match ($reasonCode) {
BaselineReasonCodes::COMPARE_INVALID_SCOPE => 'The assigned baseline scope is invalid and must be reviewed before comparing assigned tenants.',
BaselineReasonCodes::COMPARE_UNSUPPORTED_SCOPE => 'The selected governed subjects are not supported by any compare strategy yet.',
BaselineReasonCodes::COMPARE_MIXED_SCOPE => 'The selected governed subjects span multiple compare strategy families and must be narrowed before comparing assigned tenants.',
'tenant_sync_required' => 'You need tenant sync access for each visible tenant before compare can start.',
default => null,
};
}
private function compareAssignedTenants(): void
{
$user = auth()->user();
@ -639,6 +688,15 @@ private function compareAssignedTenants(): void
(int) $result['visibleAssignedTenantCount'],
(int) $result['visibleAssignedTenantCount'] === 1 ? '' : 's',
);
$blockedReasonCodes = collect($result['targets'])
->where('launchState', 'blocked')
->pluck('reasonCode')
->filter(static fn (mixed $reasonCode): bool => is_string($reasonCode) && trim($reasonCode) !== '')
->unique()
->values();
$blockedReasonMessage = $blockedReasonCodes->count() === 1
? $this->compareStartReasonMessage((string) $blockedReasonCodes->first())
: null;
if ((int) $result['queuedCount'] > 0 || (int) $result['alreadyQueuedCount'] > 0) {
OpsUxBrowserEvents::dispatchRunEnqueued($this);
@ -661,7 +719,7 @@ private function compareAssignedTenants(): void
} else {
Notification::make()
->title('No baseline compares were started')
->body($summary)
->body($blockedReasonMessage !== null ? $blockedReasonMessage.' '.$summary : $summary)
->warning()
->send();
}

View File

@ -25,6 +25,7 @@
use App\Support\Baselines\BaselineFullContentRolloutGate;
use App\Support\Baselines\BaselineProfileStatus;
use App\Support\Baselines\BaselineReasonCodes;
use App\Support\Baselines\Compare\CompareStrategyRegistry;
use App\Support\Filament\FilterOptionCatalog;
use App\Support\Inventory\InventoryPolicyTypeMeta;
use App\Support\Navigation\CrossResourceNavigationMatrix;
@ -807,6 +808,7 @@ private static function compareReadinessLabel(BaselineProfile $profile): string
{
return match (self::compareAvailabilityReason($profile)) {
BaselineReasonCodes::COMPARE_INVALID_SCOPE => 'Invalid scope',
BaselineReasonCodes::COMPARE_MIXED_SCOPE => 'Mixed strategy scope',
BaselineReasonCodes::COMPARE_UNSUPPORTED_SCOPE => 'Unsupported governed subjects',
default => self::compareAvailabilityEnvelope($profile)?->operatorLabel ?? 'Ready',
};
@ -818,6 +820,7 @@ private static function compareReadinessColor(BaselineProfile $profile): string
null => 'success',
BaselineReasonCodes::COMPARE_PROFILE_NOT_ACTIVE => 'gray',
BaselineReasonCodes::COMPARE_INVALID_SCOPE,
BaselineReasonCodes::COMPARE_MIXED_SCOPE,
BaselineReasonCodes::COMPARE_UNSUPPORTED_SCOPE => 'danger',
default => 'warning',
};
@ -829,6 +832,7 @@ private static function compareReadinessIcon(BaselineProfile $profile): ?string
null => 'heroicon-m-check-badge',
BaselineReasonCodes::COMPARE_PROFILE_NOT_ACTIVE => 'heroicon-m-pause-circle',
BaselineReasonCodes::COMPARE_INVALID_SCOPE,
BaselineReasonCodes::COMPARE_MIXED_SCOPE,
BaselineReasonCodes::COMPARE_UNSUPPORTED_SCOPE => 'heroicon-m-no-symbol',
default => 'heroicon-m-exclamation-triangle',
};
@ -838,6 +842,7 @@ private static function profileNextStep(BaselineProfile $profile): string
{
return match (self::compareAvailabilityReason($profile)) {
BaselineReasonCodes::COMPARE_INVALID_SCOPE,
BaselineReasonCodes::COMPARE_MIXED_SCOPE,
BaselineReasonCodes::COMPARE_UNSUPPORTED_SCOPE => 'Review the governed subject selection before starting compare.',
default => self::compareAvailabilityEnvelope($profile)?->guidanceText() ?? 'No action needed.',
};
@ -873,7 +878,13 @@ private static function compareAvailabilityReason(BaselineProfile $profile): ?st
return BaselineReasonCodes::COMPARE_INVALID_SCOPE;
}
if (! $scope->operationEligibility('compare')['ok']) {
$selection = app(CompareStrategyRegistry::class)->select($scope);
if ($selection->isMixed()) {
return BaselineReasonCodes::COMPARE_MIXED_SCOPE;
}
if (! $selection->isSupported()) {
return BaselineReasonCodes::COMPARE_UNSUPPORTED_SCOPE;
}

View File

@ -250,19 +250,23 @@ private function compareNowAction(): Action
if (! ($result['ok'] ?? false)) {
$reasonCode = is_string($result['reason_code'] ?? null) ? (string) $result['reason_code'] : 'unknown';
$translation = is_array($result['reason_translation'] ?? null) ? $result['reason_translation'] : [];
$message = match ($reasonCode) {
BaselineReasonCodes::COMPARE_ROLLOUT_DISABLED => 'Full-content baseline compare is currently disabled for controlled rollout.',
BaselineReasonCodes::COMPARE_PROFILE_NOT_ACTIVE => 'This baseline profile is not active.',
BaselineReasonCodes::COMPARE_NO_ACTIVE_SNAPSHOT,
BaselineReasonCodes::COMPARE_NO_CONSUMABLE_SNAPSHOT => 'This baseline profile has no complete snapshot available for compare yet.',
BaselineReasonCodes::COMPARE_SNAPSHOT_BUILDING => 'The latest baseline capture is still building. Compare will be available after it completes.',
BaselineReasonCodes::COMPARE_SNAPSHOT_INCOMPLETE => 'The latest baseline capture is incomplete. Capture a new baseline before comparing.',
BaselineReasonCodes::COMPARE_SNAPSHOT_SUPERSEDED => 'A newer complete snapshot is current. Compare uses the latest complete baseline only.',
BaselineReasonCodes::COMPARE_INVALID_SCOPE => 'This baseline profile has an invalid governed-subject scope. Review the baseline definition before comparing.',
BaselineReasonCodes::COMPARE_UNSUPPORTED_SCOPE => 'This baseline profile includes governed subjects that are not currently supported for compare.',
default => 'Reason: '.str_replace('.', ' ', $reasonCode),
};
$message = is_string($translation['short_explanation'] ?? null) && trim((string) $translation['short_explanation']) !== ''
? trim((string) $translation['short_explanation'])
: match ($reasonCode) {
BaselineReasonCodes::COMPARE_ROLLOUT_DISABLED => 'Full-content baseline compare is currently disabled for controlled rollout.',
BaselineReasonCodes::COMPARE_PROFILE_NOT_ACTIVE => 'This baseline profile is not active.',
BaselineReasonCodes::COMPARE_NO_ACTIVE_SNAPSHOT,
BaselineReasonCodes::COMPARE_NO_CONSUMABLE_SNAPSHOT => 'This baseline profile has no complete snapshot available for compare yet.',
BaselineReasonCodes::COMPARE_SNAPSHOT_BUILDING => 'The latest baseline capture is still building. Compare will be available after it completes.',
BaselineReasonCodes::COMPARE_SNAPSHOT_INCOMPLETE => 'The latest baseline capture is incomplete. Capture a new baseline before comparing.',
BaselineReasonCodes::COMPARE_SNAPSHOT_SUPERSEDED => 'A newer complete snapshot is current. Compare uses the latest complete baseline only.',
BaselineReasonCodes::COMPARE_INVALID_SCOPE => 'This baseline profile has an invalid governed-subject scope. Review the baseline definition before comparing.',
BaselineReasonCodes::COMPARE_UNSUPPORTED_SCOPE => 'This baseline profile includes governed subjects that are not currently supported for compare.',
BaselineReasonCodes::COMPARE_MIXED_SCOPE => 'This baseline profile mixes governed subjects that require different compare strategies. Narrow the selection before comparing.',
default => 'Reason: '.str_replace('.', ' ', $reasonCode),
};
Notification::make()
->title('Cannot start comparison')

View File

@ -31,6 +31,7 @@
use App\Support\OpsUx\RunDurationInsights;
use App\Support\OpsUx\SummaryCountsNormalizer;
use App\Support\ReasonTranslation\ReasonPresenter;
use App\Support\ReasonTranslation\ReasonResolutionEnvelope;
use App\Support\RestoreSafety\RestoreSafetyResolver;
use App\Support\Tenants\ReferencedTenantLifecyclePresentation;
use App\Support\Ui\ActionSurface\ActionSurfaceDeclaration;
@ -219,6 +220,15 @@ public static function table(Table $table): Table
->all();
return FilterOptionCatalog::operationTypes(array_keys($types));
})
->query(function (Builder $query, array $data): Builder {
$value = data_get($data, 'value');
if (! is_string($value) || trim($value) === '') {
return $query;
}
return $query->whereIn('type', OperationCatalog::rawValuesForCanonical($value));
}),
Tables\Filters\SelectFilter::make('status')
->options(BadgeCatalog::options(BadgeDomain::OperationRunStatus, OperationRunStatus::values())),
@ -268,6 +278,7 @@ private static function enterpriseDetailPage(OperationRun $record): \App\Support
: null;
$artifactTruth = static::artifactTruthEnvelope($record);
$operatorExplanation = $artifactTruth?->operatorExplanation;
$reasonEnvelope = app(ReasonPresenter::class)->forOperationRun($record, 'run_detail');
$primaryNextStep = static::resolvePrimaryNextStep($record, $artifactTruth, $operatorExplanation);
$restoreContinuation = static::restoreContinuation($record);
$supportingGroups = static::supportingGroups(
@ -275,6 +286,7 @@ private static function enterpriseDetailPage(OperationRun $record): \App\Support
factory: $factory,
referencedTenantLifecycle: $referencedTenantLifecycle,
operatorExplanation: $operatorExplanation,
reasonEnvelope: $reasonEnvelope,
primaryNextStep: $primaryNextStep,
);
@ -439,7 +451,7 @@ private static function enterpriseDetailPage(OperationRun $record): \App\Support
id: 'baseline_compare_gap_details',
kind: 'type_specific_detail',
title: 'Evidence gap details',
description: 'Policies affected by evidence gaps, grouped by reason and searchable by reason, policy type, or subject key.',
description: 'Governed subjects affected by evidence gaps, grouped by reason and searchable by reason, governed subject, or subject key.',
view: 'filament.infolists.entries.evidence-gap-subjects',
viewData: [
'summary' => $gapSummary,
@ -537,10 +549,12 @@ private static function supportingGroups(
\App\Support\Ui\EnterpriseDetail\EnterpriseDetailSectionFactory $factory,
?ReferencedTenantLifecyclePresentation $referencedTenantLifecycle,
?OperatorExplanationPattern $operatorExplanation,
?ReasonResolutionEnvelope $reasonEnvelope,
array $primaryNextStep,
): array {
$groups = [];
$hasElevatedLifecycleState = static::lifecycleAttentionSummary($record) !== null;
$reasonSemantics = app(ReasonPresenter::class)->semantics($reasonEnvelope);
$guidanceItems = array_values(array_filter([
$operatorExplanation instanceof OperatorExplanationPattern && $operatorExplanation->coverageStatement !== null
@ -579,6 +593,24 @@ private static function supportingGroups(
);
}
$reasonSemanticsItems = array_values(array_filter([
is_string($reasonSemantics['owner_label'] ?? null)
? $factory->keyFact('Reason owner', (string) $reasonSemantics['owner_label'])
: null,
is_string($reasonSemantics['family_label'] ?? null)
? $factory->keyFact('Platform reason family', (string) $reasonSemantics['family_label'])
: null,
]));
if ($reasonSemanticsItems !== []) {
$groups[] = $factory->supportingFactsCard(
kind: 'reason_semantics',
title: 'Explanation semantics',
items: $reasonSemanticsItems,
description: 'Platform meaning stays separate from domain-specific diagnostic detail during rollout.',
);
}
$lifecycleItems = array_values(array_filter([
$referencedTenantLifecycle !== null
? $factory->keyFact(
@ -957,6 +989,49 @@ private static function baselineCompareFacts(
);
}
$strategy = data_get($context, 'baseline_compare.strategy');
if (is_array($strategy)) {
$strategyKey = is_string($strategy['key'] ?? null) && trim((string) $strategy['key']) !== ''
? trim((string) $strategy['key'])
: null;
$selectionState = is_string($strategy['selection_state'] ?? null) && trim((string) $strategy['selection_state']) !== ''
? trim((string) $strategy['selection_state'])
: null;
$operatorReason = is_string($strategy['operator_reason'] ?? null) && trim((string) $strategy['operator_reason']) !== ''
? trim((string) $strategy['operator_reason'])
: null;
$stateCounts = is_array($strategy['state_counts'] ?? null)
? array_filter(
array_map(static fn (mixed $count): int => (int) $count, $strategy['state_counts']),
static fn (int $count): bool => $count > 0,
)
: [];
if ($strategyKey !== null) {
$facts[] = $factory->keyFact(
'Compare strategy',
\Illuminate\Support\Str::of($strategyKey)->replace('_', ' ')->headline()->toString(),
);
}
if ($selectionState !== null) {
$facts[] = $factory->keyFact(
'Strategy selection',
\Illuminate\Support\Str::of($selectionState)->replace('_', ' ')->headline()->toString(),
$operatorReason,
);
}
if ($stateCounts !== []) {
$facts[] = $factory->keyFact(
'Strategy subject states',
collect($stateCounts)
->map(static fn (int $count, string $state): string => \Illuminate\Support\Str::of($state)->replace('_', ' ')->headline()->append(' ', (string) $count)->toString())
->implode(', '),
);
}
}
if ((int) ($gapSummary['count'] ?? 0) > 0) {
$facts[] = $factory->keyFact(
'Evidence gap detail',
@ -1009,6 +1084,9 @@ private static function baselineCompareEvidencePayload(OperationRun $record): ar
? (int) data_get($context, 'baseline_compare.evidence_gaps.count')
: null,
'resume_token' => data_get($context, 'baseline_compare.resume_token'),
'strategy' => is_array(data_get($context, 'baseline_compare.strategy'))
? data_get($context, 'baseline_compare.strategy')
: null,
'evidence_capture' => is_array(data_get($context, 'baseline_compare.evidence_capture'))
? data_get($context, 'baseline_compare.evidence_capture')
: null,

View File

@ -21,6 +21,7 @@
use App\Support\OperationRunLinks;
use App\Support\OperationRunType;
use App\Support\OpsUx\OperationUxPresenter;
use App\Support\ReasonTranslation\ReasonPresenter;
use App\Support\Rbac\UiEnforcement;
use App\Support\TenantReviewCompletenessState;
use App\Support\TenantReviewStatus;
@ -564,9 +565,12 @@ private static function reviewCompletenessCountLabel(string $state): string
private static function summaryPresentation(TenantReview $record): array
{
$summary = is_array($record->summary) ? $record->summary : [];
$truthEnvelope = static::truthEnvelope($record);
$reasonPresenter = app(ReasonPresenter::class);
return [
'operator_explanation' => static::truthEnvelope($record)->operatorExplanation?->toArray(),
'operator_explanation' => $truthEnvelope->operatorExplanation?->toArray(),
'reason_semantics' => $reasonPresenter->semantics($truthEnvelope->reason?->toReasonResolutionEnvelope()),
'highlights' => is_array($summary['highlights'] ?? null) ? $summary['highlights'] : [],
'next_actions' => is_array($summary['recommended_next_actions'] ?? null) ? $summary['recommended_next_actions'] : [],
'publish_blockers' => is_array($summary['publish_blockers'] ?? null) ? $summary['publish_blockers'] : [],

View File

@ -14,6 +14,7 @@
use App\Support\OperationRunType;
use App\Support\OpsUx\OperationUxPresenter;
use App\Support\OpsUx\OpsUxBrowserEvents;
use App\Support\ReasonTranslation\ReasonPresenter;
use App\Support\ReviewPackStatus;
use Filament\Actions\Action;
use Filament\Facades\Filament;
@ -131,7 +132,7 @@ protected function getViewData(): array
$canManage = $isTenantMember && $user->can(Capabilities::REVIEW_PACK_MANAGE, $tenant);
$latestPack = ReviewPack::query()
->with('tenantReview')
->with(['tenantReview', 'operationRun'])
->where('tenant_id', (int) $tenant->getKey())
->orderByDesc('created_at')
->orderByDesc('id')
@ -166,9 +167,24 @@ protected function getViewData(): array
}
$failedReason = null;
$failedReasonDetail = null;
$failedReasonSemantics = null;
if ($statusEnum === ReviewPackStatus::Failed && $latestPack->operationRun) {
$reasonPresenter = app(ReasonPresenter::class);
$failedEnvelope = $reasonPresenter->forOperationRun($latestPack->operationRun, 'review_pack_widget');
if ($failedEnvelope !== null) {
$failedReason = $failedEnvelope->operatorLabel;
$failedReasonDetail = $failedEnvelope->shortExplanation;
$failedReasonSemantics = $reasonPresenter->semantics($failedEnvelope);
}
$opContext = is_array($latestPack->operationRun->context) ? $latestPack->operationRun->context : [];
$failedReason = (string) ($opContext['reason_code'] ?? 'Unknown error');
if ($failedReason === null) {
$failedReason = (string) ($opContext['reason_code'] ?? 'Unknown error');
}
}
return [
@ -180,6 +196,8 @@ protected function getViewData(): array
'canManage' => $canManage,
'downloadUrl' => $downloadUrl,
'failedReason' => $failedReason,
'failedReasonDetail' => $failedReasonDetail,
'failedReasonSemantics' => $failedReasonSemantics,
'reviewUrl' => $reviewUrl,
];
}
@ -208,6 +226,8 @@ private function emptyState(): array
'canManage' => false,
'downloadUrl' => null,
'failedReason' => null,
'failedReasonDetail' => null,
'failedReasonSemantics' => null,
'reviewUrl' => null,
];
}

View File

@ -42,6 +42,13 @@
use App\Support\Baselines\BaselineReasonCodes;
use App\Support\Baselines\BaselineScope;
use App\Support\Baselines\BaselineSubjectKey;
use App\Support\Baselines\Compare\CompareFindingCandidate;
use App\Support\Baselines\Compare\CompareOrchestrationContext;
use App\Support\Baselines\Compare\CompareState;
use App\Support\Baselines\Compare\CompareStrategyRegistry;
use App\Support\Baselines\Compare\CompareStrategySelection;
use App\Support\Baselines\Compare\CompareSubjectResult;
use App\Support\Baselines\Compare\StrategySelectionState;
use App\Support\Baselines\PolicyVersionCapturePurpose;
use App\Support\Baselines\SubjectResolver;
use App\Support\Inventory\InventoryCoverage;
@ -102,6 +109,7 @@ public function handle(
?BaselineContentCapturePhase $contentCapturePhase = null,
?BaselineFullContentRolloutGate $rolloutGate = null,
?ContentEvidenceProvider $contentEvidenceProvider = null,
?CompareStrategyRegistry $compareStrategyRegistry = null,
): void {
$settingsResolver ??= app(SettingsResolver::class);
$baselineAutoCloseService ??= app(BaselineAutoCloseService::class);
@ -111,6 +119,7 @@ public function handle(
$contentCapturePhase ??= app(BaselineContentCapturePhase::class);
$rolloutGate ??= app(BaselineFullContentRolloutGate::class);
$contentEvidenceProvider ??= app(ContentEvidenceProvider::class);
$compareStrategyRegistry ??= app(CompareStrategyRegistry::class);
if (! $this->operationRun instanceof OperationRun) {
$this->fail(new RuntimeException('OperationRun context is required for CompareBaselineToTenantJob.'));
@ -339,6 +348,44 @@ public function handle(
$snapshot = $snapshotResolution['snapshot'];
$snapshotId = (int) $snapshot->getKey();
$strategySelection = $compareStrategyRegistry->select($effectiveScope);
$context = $this->withCompareStrategySelection($context, $strategySelection);
$this->operationRun->update(['context' => $context]);
$this->operationRun->refresh();
if (! $strategySelection->isSupported()) {
$this->auditStarted(
auditLogger: $auditLogger,
tenant: $tenant,
profile: $profile,
initiator: $initiator,
captureMode: $captureMode,
subjectsTotal: 0,
effectiveScope: $effectiveScope,
);
$this->completeWithCoverageWarning(
operationRunService: $operationRunService,
auditLogger: $auditLogger,
tenant: $tenant,
profile: $profile,
initiator: $initiator,
inventorySyncRun: $inventorySyncRun,
coverageProof: true,
effectiveTypes: $effectiveTypes,
coveredTypes: $coveredTypes,
uncoveredTypes: $uncoveredTypes,
errorsRecorded: max(1, count($coveredTypes !== [] ? $coveredTypes : $effectiveTypes)),
captureMode: $captureMode,
reasonCode: BaselineCompareReasonCode::UnsupportedSubjects,
evidenceGapsByReason: [
$this->strategySelectionGapReason($strategySelection) => max(1, count($coveredTypes !== [] ? $coveredTypes : $effectiveTypes)),
],
);
return;
}
$since = $snapshot->captured_at instanceof \DateTimeInterface
? CarbonImmutable::instance($snapshot->captured_at)
: null;
@ -459,32 +506,79 @@ public function handle(
resolvedMetaEvidence: $resolvedCurrentMetaEvidence,
);
$baselinePolicyVersionResolver = app(BaselinePolicyVersionResolver::class);
$driftHasher = app(DriftHasher::class);
$settingsNormalizer = app(SettingsNormalizer::class);
$assignmentsNormalizer = app(AssignmentsNormalizer::class);
$scopeTagsNormalizer = app(ScopeTagsNormalizer::class);
$computeResult = $this->computeDrift(
tenant: $tenant,
$strategy = $compareStrategyRegistry->resolve($strategySelection->strategyKey);
$orchestrationContext = new CompareOrchestrationContext(
workspaceId: (int) $workspace->getKey(),
tenantId: (int) $tenant->getKey(),
baselineProfileId: (int) $profile->getKey(),
baselineSnapshotId: (int) $snapshot->getKey(),
compareOperationRunId: (int) $this->operationRun->getKey(),
inventorySyncRunId: (int) $inventorySyncRun->getKey(),
baselineItems: $baselineItems,
currentItems: $currentItems,
resolvedCurrentEvidence: $resolvedEffectiveCurrentEvidence,
severityMapping: $this->resolveSeverityMapping($workspace, $settingsResolver),
baselinePolicyVersionResolver: $baselinePolicyVersionResolver,
hasher: $driftHasher,
settingsNormalizer: $settingsNormalizer,
assignmentsNormalizer: $assignmentsNormalizer,
scopeTagsNormalizer: $scopeTagsNormalizer,
contentEvidenceProvider: $contentEvidenceProvider,
operationRunId: (int) $this->operationRun->getKey(),
normalizedScope: $effectiveScope->toStoredJsonb(),
strategySelection: $strategySelection,
coverageContext: [
'inventory_sync_run_id' => (int) $inventorySyncRun->getKey(),
'effective_types' => $effectiveTypes,
'covered_types' => $coveredTypes,
'uncovered_types' => $uncoveredTypes,
],
launchContext: is_array($context['baseline_compare'] ?? null) ? $context['baseline_compare'] : [],
);
$driftResults = $computeResult['drift'];
$driftGaps = $computeResult['evidence_gaps'];
$rbacRoleDefinitionSummary = $computeResult['rbac_role_definitions'];
try {
$compareResult = $strategy->compare(
context: $orchestrationContext,
tenant: $tenant,
baselineItems: $baselineItems,
currentItems: $currentItems,
resolvedCurrentEvidence: $resolvedEffectiveCurrentEvidence,
severityMapping: $this->resolveSeverityMapping($workspace, $settingsResolver),
);
} catch (\Throwable $exception) {
$failedContext = $this->withCompareStrategyDiagnostics(
context: is_array($this->operationRun->context) ? $this->operationRun->context : [],
strategySelection: $strategySelection,
executionDiagnostics: [
'failed' => true,
'exception_class' => $exception::class,
],
);
$this->operationRun->update(['context' => $failedContext]);
$this->operationRun->refresh();
$this->completeWithCoverageWarning(
operationRunService: $operationRunService,
auditLogger: $auditLogger,
tenant: $tenant,
profile: $profile,
initiator: $initiator,
inventorySyncRun: $inventorySyncRun,
coverageProof: true,
effectiveTypes: $effectiveTypes,
coveredTypes: $coveredTypes,
uncoveredTypes: $uncoveredTypes,
errorsRecorded: max(1, count($coveredTypes !== [] ? $coveredTypes : $effectiveTypes)),
captureMode: $captureMode,
reasonCode: BaselineCompareReasonCode::StrategyFailed,
evidenceGapsByReason: [
'strategy_failed' => max(1, count($coveredTypes !== [] ? $coveredTypes : $effectiveTypes)),
],
);
return;
}
$normalizedStrategyResults = $this->normalizeStrategySubjectResults($compareResult['subject_results'] ?? []);
$driftResults = $normalizedStrategyResults['drift_results'];
$driftGaps = $normalizedStrategyResults['gap_counts'];
$rbacRoleDefinitionSummary = is_array($compareResult['diagnostics']['rbac_role_definitions'] ?? null)
? $compareResult['diagnostics']['rbac_role_definitions']
: $this->emptyRbacRoleDefinitionSummary();
$strategyGapSubjects = $normalizedStrategyResults['gap_subjects'];
$strategyStateCounts = $normalizedStrategyResults['state_counts'];
$strategyDiagnostics = is_array($compareResult['diagnostics'] ?? null)
? $compareResult['diagnostics']
: [];
$upsertResult = $this->upsertFindings(
$tenant,
@ -502,7 +596,7 @@ public function handle(
$gapSubjects = $this->collectGapSubjects(
ambiguousKeys: $ambiguousKeys,
phaseGapSubjects: $phaseGapSubjects ?? [],
driftGapSubjects: $computeResult['evidence_gap_subjects'] ?? [],
driftGapSubjects: $strategyGapSubjects,
);
$summaryCounts = [
@ -565,6 +659,9 @@ public function handle(
} elseif (count($driftResults) === 0) {
$reasonCode = match (true) {
$uncoveredTypes !== [] => BaselineCompareReasonCode::CoverageUnproven,
($strategyStateCounts[CompareState::Failed->value] ?? 0) > 0 => BaselineCompareReasonCode::StrategyFailed,
($strategyStateCounts[CompareState::Ambiguous->value] ?? 0) > 0 => BaselineCompareReasonCode::AmbiguousSubjects,
($strategyStateCounts[CompareState::Unsupported->value] ?? 0) > 0 => BaselineCompareReasonCode::UnsupportedSubjects,
$resumeToken !== null || $gapsCount > 0 => BaselineCompareReasonCode::EvidenceCaptureIncomplete,
default => BaselineCompareReasonCode::NoDriftDetected,
};
@ -577,6 +674,11 @@ public function handle(
'inventory_sync_run_id' => (int) $inventorySyncRun->getKey(),
'since' => $since?->toIso8601String(),
'subjects_total' => $subjectsTotal,
'strategy' => $this->strategyContext(
strategySelection: $strategySelection,
executionDiagnostics: $strategyDiagnostics,
stateCounts: $strategyStateCounts,
),
'evidence_capture' => $phaseStats,
'evidence_gaps' => [
'count' => $gapsCount,
@ -994,6 +1096,193 @@ private function withCompareReasonTranslation(array $context, ?string $reasonCod
return $context;
}
/**
* @param array<string, mixed> $context
* @return array<string, mixed>
*/
private function withCompareStrategySelection(array $context, CompareStrategySelection $strategySelection): array
{
$context['baseline_compare'] = array_merge(
is_array($context['baseline_compare'] ?? null) ? $context['baseline_compare'] : [],
[
'strategy' => $this->strategyContext($strategySelection),
],
);
return $context;
}
/**
* @param array<string, mixed> $context
* @param array<string, mixed> $executionDiagnostics
* @return array<string, mixed>
*/
private function withCompareStrategyDiagnostics(
array $context,
CompareStrategySelection $strategySelection,
array $executionDiagnostics,
array $stateCounts = [],
): array {
$context['baseline_compare'] = array_merge(
is_array($context['baseline_compare'] ?? null) ? $context['baseline_compare'] : [],
[
'strategy' => $this->strategyContext($strategySelection, $executionDiagnostics, $stateCounts),
],
);
return $context;
}
/**
* @param array<string, mixed> $executionDiagnostics
* @param array<string, int> $stateCounts
* @return array{
* key: ?string,
* selection_state: string,
* matched_scope_entries: list<array<string, mixed>>,
* rejected_scope_entries: list<array<string, mixed>>,
* operator_reason: string,
* diagnostics: array<string, mixed>,
* execution_diagnostics: array<string, mixed>,
* state_counts: array<string, int>
* }
*/
private function strategyContext(
CompareStrategySelection $strategySelection,
array $executionDiagnostics = [],
array $stateCounts = [],
): array {
return [
'key' => $strategySelection->strategyKey?->value,
'selection_state' => $strategySelection->selectionState->value,
'matched_scope_entries' => $strategySelection->matchedScopeEntries,
'rejected_scope_entries' => $strategySelection->rejectedScopeEntries,
'operator_reason' => $strategySelection->operatorReason,
'diagnostics' => $strategySelection->diagnostics,
'execution_diagnostics' => $executionDiagnostics,
'state_counts' => $stateCounts,
];
}
private function strategySelectionGapReason(CompareStrategySelection $strategySelection): string
{
return $strategySelection->selectionState === StrategySelectionState::Mixed
? 'mixed_scope'
: 'unsupported_subjects';
}
/**
* @param mixed $subjectResults
* @return array{
* drift_results: array<int, array{change_type: string, severity: string, evidence_fidelity: string, subject_type: string, subject_external_id: string, subject_key: string, policy_type: string, baseline_hash: string, current_hash: string, evidence: array<string, mixed>}>,
* gap_counts: array<string, int>,
* gap_subjects: list<array<string, mixed>>,
* state_counts: array<string, int>
* }
*/
private function normalizeStrategySubjectResults(mixed $subjectResults): array
{
if (! is_array($subjectResults)) {
return [
'drift_results' => [],
'gap_counts' => [],
'gap_subjects' => [],
'state_counts' => [],
];
}
$driftResults = [];
$gapCounts = [];
$gapSubjects = [];
$stateCounts = [];
foreach ($subjectResults as $subjectResult) {
if (! $subjectResult instanceof CompareSubjectResult) {
continue;
}
$state = $subjectResult->compareState->value;
$stateCounts[$state] = ($stateCounts[$state] ?? 0) + 1;
if ($subjectResult->compareState === CompareState::Drift && $subjectResult->findingCandidate instanceof CompareFindingCandidate) {
$driftResults[] = [
'change_type' => $subjectResult->findingCandidate->changeType,
'severity' => $subjectResult->findingCandidate->severity,
'subject_type' => $subjectResult->projection->platformSubjectClass,
'subject_external_id' => $subjectResult->subjectIdentity->externalSubjectId ?? '',
'subject_key' => $subjectResult->subjectIdentity->subjectKey,
'policy_type' => $subjectResult->subjectIdentity->subjectTypeKey,
'evidence_fidelity' => $subjectResult->evidenceQuality,
'baseline_hash' => is_string(data_get($subjectResult->findingCandidate->evidencePayload, 'baseline.hash'))
? (string) data_get($subjectResult->findingCandidate->evidencePayload, 'baseline.hash')
: '',
'current_hash' => is_string(data_get($subjectResult->findingCandidate->evidencePayload, 'current.hash'))
? (string) data_get($subjectResult->findingCandidate->evidencePayload, 'current.hash')
: '',
'evidence' => $subjectResult->findingCandidate->evidencePayload,
];
continue;
}
if (! $subjectResult->isGapState()) {
continue;
}
$reasonCode = $subjectResult->gapReasonCode() ?? $this->defaultGapReasonForState($subjectResult->compareState);
$gapCounts[$reasonCode] = ($gapCounts[$reasonCode] ?? 0) + 1;
$gapSubjects[] = $subjectResult->gapRecord() ?? $this->fallbackGapRecord($subjectResult, $reasonCode);
}
ksort($gapCounts);
ksort($stateCounts);
return [
'drift_results' => $driftResults,
'gap_counts' => $gapCounts,
'gap_subjects' => $gapSubjects,
'state_counts' => $stateCounts,
];
}
private function defaultGapReasonForState(CompareState $state): string
{
return match ($state) {
CompareState::Unsupported => 'unsupported_subject',
CompareState::Ambiguous => 'ambiguous_match',
CompareState::Failed => 'strategy_failed',
default => 'missing_current',
};
}
/**
* @return array<string, mixed>
*/
private function fallbackGapRecord(CompareSubjectResult $subjectResult, string $reasonCode): array
{
$descriptor = $this->subjectResolver()->describeForCompare(
policyType: $subjectResult->subjectIdentity->subjectTypeKey,
subjectExternalId: $subjectResult->subjectIdentity->externalSubjectId,
subjectKey: $subjectResult->subjectIdentity->subjectKey,
);
$outcome = match ($reasonCode) {
'missing_current' => $this->subjectResolver()->missingExpectedRecord($descriptor),
'ambiguous_match' => $this->subjectResolver()->ambiguousMatch($descriptor),
default => $this->subjectResolver()->captureFailed($descriptor),
};
return array_merge($descriptor->toArray(), $outcome->toArray(), [
'reason_code' => $reasonCode,
'search_text' => strtolower(implode(' ', array_filter([
$subjectResult->subjectIdentity->subjectTypeKey,
$subjectResult->subjectIdentity->subjectKey,
$reasonCode,
$subjectResult->projection->operatorLabel,
]))),
]);
}
/**
* Load current inventory items keyed by "policy_type|subject_key".
*
@ -1069,13 +1358,13 @@ private function loadCurrentInventory(
'subject_external_id' => (string) $inventoryItem->external_id,
'subject_key' => $subjectKey,
'policy_type' => (string) $inventoryItem->policy_type,
'meta_jsonb' => [
'display_name' => $inventoryItem->display_name,
'category' => $inventoryItem->category,
'platform' => $inventoryItem->platform,
'meta_jsonb' => array_replace($metaJsonb, [
'display_name' => $metaJsonb['display_name'] ?? $inventoryItem->display_name,
'category' => $metaJsonb['category'] ?? $inventoryItem->category,
'platform' => $metaJsonb['platform'] ?? $inventoryItem->platform,
'is_built_in' => $metaJsonb['is_built_in'] ?? null,
'role_permission_count' => $metaJsonb['role_permission_count'] ?? null,
],
]),
];
}
});

View File

@ -94,13 +94,13 @@ public function table(Table $table): Table
->sortable()
->wrap()
->toggleable(isToggledHiddenByDefault: true),
TextColumn::make('policy_type')
TextColumn::make('governed_subject_label')
->label(__('baseline-compare.evidence_gap_policy_type'))
->badge()
->formatStateUsing(TagBadgeRenderer::label(TagBadgeDomain::PolicyType))
->color(TagBadgeRenderer::color(TagBadgeDomain::PolicyType))
->icon(TagBadgeRenderer::icon(TagBadgeDomain::PolicyType))
->iconColor(TagBadgeRenderer::iconColor(TagBadgeDomain::PolicyType))
->formatStateUsing(fn (mixed $state): string => is_string($state) && trim($state) !== '' ? $state : 'Unknown governed subject')
->color(fn (mixed $state, Model $record): string => TagBadgeRenderer::spec(TagBadgeDomain::PolicyType, $record->getAttribute('policy_type'))->color)
->icon(fn (mixed $state, Model $record): ?string => TagBadgeRenderer::spec(TagBadgeDomain::PolicyType, $record->getAttribute('policy_type'))->icon)
->iconColor(fn (mixed $state, Model $record): ?string => TagBadgeRenderer::spec(TagBadgeDomain::PolicyType, $record->getAttribute('policy_type'))->iconColor)
->searchable()
->sortable()
->wrap(),

View File

@ -6,6 +6,7 @@
use App\Support\OperationCatalog;
use App\Support\OperationRunOutcome;
use App\Support\OperationRunStatus;
use App\Support\OperationTypeResolution;
use App\Support\OperationRunType;
use App\Support\Operations\OperationLifecyclePolicy;
use App\Support\Operations\OperationRunFreshnessState;
@ -291,6 +292,16 @@ public function inventoryCoverage(): ?InventoryCoverage
return InventoryCoverage::fromContext($this->context);
}
public function resolvedOperationType(): OperationTypeResolution
{
return OperationCatalog::resolve((string) $this->type);
}
public function canonicalOperationType(): string
{
return $this->resolvedOperationType()->canonical->canonicalCode;
}
public function isGovernanceArtifactOperation(): bool
{
return OperationCatalog::isGovernanceArtifactOperation((string) $this->type);

View File

@ -19,6 +19,9 @@
use App\Support\Baselines\BaselineReasonCodes;
use App\Support\Baselines\BaselineScope;
use App\Support\Baselines\BaselineSupportCapabilityGuard;
use App\Support\Baselines\Compare\CompareStrategyRegistry;
use App\Support\Baselines\Compare\CompareStrategySelection;
use App\Support\Baselines\Compare\StrategySelectionState;
use App\Support\OperationRunType;
use App\Support\ReasonTranslation\ReasonPresenter;
use InvalidArgumentException;
@ -31,6 +34,7 @@ public function __construct(
private readonly BaselineSnapshotTruthResolver $snapshotTruthResolver,
private readonly BaselineSupportCapabilityGuard $capabilityGuard,
private readonly CapabilityResolver $capabilityResolver,
private readonly CompareStrategyRegistry $compareStrategyRegistry,
) {}
/**
@ -109,9 +113,7 @@ public function startCompareForProfile(
$snapshotId = (int) $snapshot->getKey();
try {
$profileScope = BaselineScope::fromJsonb(
is_array($profile->scope_jsonb) ? $profile->scope_jsonb : null,
);
$profileScope = $profile->normalizedScope();
$overrideScope = $assignment->override_scope_jsonb !== null
? BaselineScope::fromJsonb(
is_array($assignment->override_scope_jsonb) ? $assignment->override_scope_jsonb : null,
@ -127,10 +129,10 @@ public function startCompareForProfile(
return $this->failedStart(BaselineReasonCodes::COMPARE_INVALID_SCOPE);
}
$eligibility = $effectiveScope->operationEligibility('compare', $this->capabilityGuard);
$selection = $this->compareStrategyRegistry->select($effectiveScope);
if (! $eligibility['ok']) {
return $this->failedStart(BaselineReasonCodes::COMPARE_UNSUPPORTED_SCOPE);
if (! $selection->isSupported()) {
return $this->failedStart($this->selectionFailureReasonCode($selection));
}
$captureMode = $profile->capture_mode instanceof BaselineCaptureMode
@ -146,6 +148,9 @@ public function startCompareForProfile(
'baseline_snapshot_id' => $snapshotId,
'effective_scope' => $effectiveScope->toEffectiveScopeContext($this->capabilityGuard, 'compare'),
'capture_mode' => $captureMode->value,
'baseline_compare' => [
'strategy' => $this->strategyContext($selection),
],
];
$run = $this->runs->ensureRunWithIdentity(
@ -275,6 +280,36 @@ private function validatePreconditions(BaselineProfile $profile): ?string
return null;
}
private function selectionFailureReasonCode(CompareStrategySelection $selection): string
{
return match ($selection->selectionState) {
StrategySelectionState::Mixed => BaselineReasonCodes::COMPARE_MIXED_SCOPE,
default => BaselineReasonCodes::COMPARE_UNSUPPORTED_SCOPE,
};
}
/**
* @return array{
* key: ?string,
* selection_state: string,
* matched_scope_entries: list<array<string, mixed>>,
* rejected_scope_entries: list<array<string, mixed>>,
* operator_reason: string,
* diagnostics: array<string, mixed>
* }
*/
private function strategyContext(CompareStrategySelection $selection): array
{
return [
'key' => $selection->strategyKey?->value,
'selection_state' => $selection->selectionState->value,
'matched_scope_entries' => $selection->matchedScopeEntries,
'rejected_scope_entries' => $selection->rejectedScopeEntries,
'operator_reason' => $selection->operatorReason,
'diagnostics' => $selection->diagnostics,
];
}
/**
* @return array{ok: false, reason_code: string, reason_translation?: array<string, mixed>}
*/

View File

@ -9,6 +9,7 @@
use App\Support\Badges\BadgeCatalog;
use App\Support\Badges\BadgeDomain;
use App\Support\Badges\BadgeRenderer;
use App\Support\Governance\PlatformSubjectDescriptorNormalizer;
use App\Support\Inventory\InventoryPolicyTypeMeta;
use App\Support\Ui\EnterpriseDetail\EnterpriseDetailBuilder;
use App\Support\Ui\EnterpriseDetail\EnterpriseDetailPageData;
@ -51,6 +52,8 @@ public function present(BaselineSnapshot $snapshot): RenderedSnapshot
static fn (RenderedSnapshotGroup $group): array => [
'policyType' => $group->policyType,
'label' => $group->label,
'governedSubjectLabel' => data_get($group->subjectDescriptor, 'display_label', $group->label),
'subjectDescriptor' => $group->subjectDescriptor,
'itemCount' => $group->itemCount,
'fidelity' => $group->fidelity->value,
'gapCount' => $group->gapSummary->count,
@ -166,7 +169,7 @@ public function presentEnterpriseDetail(BaselineSnapshot $snapshot, array $relat
title: 'Coverage summary',
view: 'filament.infolists.entries.baseline-snapshot-summary-table',
viewData: ['rows' => $rendered->summaryRows],
emptyState: $factory->emptyState('No captured policy types are available in this snapshot.'),
emptyState: $factory->emptyState('No captured governed subjects are available in this snapshot.'),
),
$factory->viewSection(
id: 'related_context',
@ -179,7 +182,7 @@ public function presentEnterpriseDetail(BaselineSnapshot $snapshot, array $relat
$factory->viewSection(
id: 'captured_policy_types',
kind: 'domain_detail',
title: 'Captured policy types',
title: 'Captured governed subjects',
view: 'filament.infolists.entries.baseline-snapshot-groups',
viewData: ['groups' => array_map(
static fn (RenderedSnapshotGroup $group): array => $group->toArray(),
@ -250,7 +253,8 @@ private function presentGroup(string $policyType, Collection $items): RenderedSn
$renderer = $this->registry->rendererFor($policyType);
$fallbackRenderer = $this->registry->fallbackRenderer();
$renderingError = null;
$technicalPayload = $this->technicalPayload($items);
$subjectDescriptor = $this->subjectDescriptor($policyType);
$technicalPayload = $this->technicalPayload($items) + ['subject_descriptor' => $subjectDescriptor];
try {
$renderedItems = $items
@ -261,7 +265,7 @@ private function presentGroup(string $policyType, Collection $items): RenderedSn
->map(fn (BaselineSnapshotItem $item): RenderedSnapshotItem => $fallbackRenderer->render($item))
->all();
$renderingError = 'Structured rendering failed for this policy type. Fallback metadata is shown instead.';
$renderingError = 'Structured rendering failed for this governed subject family. Fallback metadata is shown instead.';
}
/** @var array<int, RenderedSnapshotItem> $renderedItems */
@ -299,6 +303,7 @@ private function presentGroup(string $policyType, Collection $items): RenderedSn
coverageHint: $coverageHint,
capturedAt: is_string($capturedAt) ? $capturedAt : null,
technicalPayload: $technicalPayload,
subjectDescriptor: $subjectDescriptor,
);
}
@ -404,9 +409,28 @@ private function currentTruthPresentation(ArtifactTruthEnvelope $truth): array
private function typeLabel(string $policyType): string
{
return InventoryPolicyTypeMeta::baselineCompareLabel($policyType)
return (string) (data_get($this->subjectDescriptor($policyType), 'display_label')
?? InventoryPolicyTypeMeta::baselineCompareLabel($policyType)
?? InventoryPolicyTypeMeta::label($policyType)
?? Str::headline($policyType);
?? Str::headline($policyType));
}
/**
* @return array<string, mixed>
*/
private function subjectDescriptor(string $policyType): array
{
static $cache = [];
if (array_key_exists($policyType, $cache)) {
return $cache[$policyType];
}
$result = app(PlatformSubjectDescriptorNormalizer::class)->fromArray([
'policy_type' => $policyType,
], 'baseline_snapshot');
return $cache[$policyType] = $result->descriptor->toArray();
}
private function formatTimestamp(?string $value): string

View File

@ -9,6 +9,7 @@
/**
* @param array<int, RenderedSnapshotItem> $items
* @param array<string, mixed> $technicalPayload
* @param array<string, mixed> $subjectDescriptor
*/
public function __construct(
public string $policyType,
@ -22,6 +23,7 @@ public function __construct(
public ?string $coverageHint = null,
public ?string $capturedAt = null,
public array $technicalPayload = [],
public array $subjectDescriptor = [],
) {}
/**
@ -46,7 +48,8 @@ public function __construct(
* renderingError: ?string,
* coverageHint: ?string,
* capturedAt: ?string,
* technicalPayload: array<string, mixed>
* technicalPayload: array<string, mixed>,
* subjectDescriptor: array<string, mixed>
* }
*/
public function toArray(): array
@ -66,6 +69,7 @@ public function toArray(): array
'coverageHint' => $this->coverageHint,
'capturedAt' => $this->capturedAt,
'technicalPayload' => $this->technicalPayload,
'subjectDescriptor' => $this->subjectDescriptor,
];
}
}

View File

@ -5,6 +5,7 @@
namespace App\Support\Baselines;
use App\Models\OperationRun;
use App\Support\Governance\PlatformSubjectDescriptorNormalizer;
use Illuminate\Support\Str;
final class BaselineCompareEvidenceGapDetails
@ -151,6 +152,7 @@ public static function diagnosticsPayload(array $baselineCompare): array
'subjects_total' => self::intOrNull($baselineCompare['subjects_total'] ?? null),
'resume_token' => self::stringOrNull($baselineCompare['resume_token'] ?? null),
'fidelity' => self::stringOrNull($baselineCompare['fidelity'] ?? null),
'strategy' => is_array($baselineCompare['strategy'] ?? null) ? $baselineCompare['strategy'] : null,
'coverage' => is_array($baselineCompare['coverage'] ?? null) ? $baselineCompare['coverage'] : null,
'evidence_capture' => is_array($baselineCompare['evidence_capture'] ?? null) ? $baselineCompare['evidence_capture'] : null,
'evidence_gaps' => is_array($baselineCompare['evidence_gaps'] ?? null) ? $baselineCompare['evidence_gaps'] : null,
@ -332,6 +334,8 @@ public static function tableRows(array $buckets): array
'reason_code' => $reasonCode,
'reason_label' => self::reasonLabel($reasonCode),
'policy_type' => $policyType,
'governed_subject_label' => (string) ($row['governed_subject_label'] ?? self::governedSubjectLabel($policyType)),
'governed_subject' => is_array($row['governed_subject'] ?? null) ? $row['governed_subject'] : self::subjectDescriptor($policyType),
'subject_key' => $subjectKey,
'subject_class' => $subjectClass,
'subject_class_label' => self::subjectClassLabel($subjectClass),
@ -372,10 +376,11 @@ public static function reasonFilterOptions(array $rows): array
public static function policyTypeFilterOptions(array $rows): array
{
return collect($rows)
->pluck('policy_type')
->filter(fn (mixed $value): bool => is_string($value) && trim($value) !== '')
->mapWithKeys(fn (string $value): array => [$value => $value])
->sortKeysUsing('strnatcasecmp')
->filter(fn (array $row): bool => filled($row['policy_type'] ?? null))
->mapWithKeys(fn (array $row): array => [
(string) $row['policy_type'] => (string) ($row['governed_subject_label'] ?? $row['policy_type']),
])
->sortBy(fn (string $label): string => Str::lower($label))
->all();
}
@ -658,16 +663,20 @@ private static function projectSubjectRow(array $subject): array
$subjectClass = (string) $subject['subject_class'];
$resolutionOutcome = (string) $subject['resolution_outcome'];
$operatorActionCategory = (string) $subject['operator_action_category'];
$policyType = (string) ($subject['policy_type'] ?? '');
return array_merge($subject, [
'reason_label' => self::reasonLabel($reasonCode),
'governed_subject' => self::subjectDescriptor($policyType),
'governed_subject_label' => self::governedSubjectLabel($policyType),
'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'] ?? ''),
$policyType,
self::governedSubjectLabel($policyType),
(string) ($subject['subject_key'] ?? ''),
$subjectClass,
self::subjectClassLabel($subjectClass),
@ -681,6 +690,29 @@ private static function projectSubjectRow(array $subject): array
]);
}
/**
* @return array<string, mixed>
*/
private static function subjectDescriptor(string $policyType): array
{
static $cache = [];
if (array_key_exists($policyType, $cache)) {
return $cache[$policyType];
}
$result = app(PlatformSubjectDescriptorNormalizer::class)->fromArray([
'policy_type' => $policyType,
], 'baseline_compare');
return $cache[$policyType] = $result->descriptor->toArray();
}
private static function governedSubjectLabel(string $policyType): string
{
return (string) (data_get(self::subjectDescriptor($policyType), 'display_label') ?: $policyType);
}
private static function stringOrNull(mixed $value): ?string
{
if (! is_string($value)) {

View File

@ -128,7 +128,7 @@ public function forStats(BaselineCompareStats $stats): OperatorExplanationPatter
$stats->state === 'idle' => 'No compare run has produced a result yet for this tenant.',
$isFailed => 'The last compare run did not finish cleanly, so current counts should not be treated as decision-grade.',
$stats->coverageStatus === 'unproven' => 'Coverage proof was unavailable, so suppressed output is possible even when the findings count is zero.',
$stats->uncoveredTypes !== [] => 'One or more in-scope policy types were not fully covered in this compare run.',
$stats->uncoveredTypes !== [] => 'One or more in-scope governed subjects were not fully covered in this compare run.',
$hasEvidenceGaps => 'Evidence gaps limited the quality of the visible result for some subjects.',
$isInProgress => 'Counts will become decision-grade after the compare run finishes.',
in_array($family, [ExplanationFamily::MissingInput, ExplanationFamily::BlockedPrerequisite, ExplanationFamily::Unavailable], true) => $reason?->shortExplanation ?? 'The compare inputs were not complete enough to produce a normal result.',

View File

@ -17,6 +17,7 @@
use App\Support\Auth\Capabilities;
use App\Support\Badges\BadgeCatalog;
use App\Support\Badges\BadgeDomain;
use App\Support\Governance\PlatformSubjectDescriptorNormalizer;
use App\Support\Inventory\InventoryPolicyTypeMeta;
use App\Support\OperationRunOutcome;
use App\Support\OperationRunStatus;
@ -81,8 +82,8 @@ public function build(BaselineProfile $profile, User $user, array $filters = [])
->filter(static fn (mixed $type): bool => is_string($type) && trim($type) !== '')
->unique()
->sort()
->mapWithKeys(static fn (string $type): array => [
$type => InventoryPolicyTypeMeta::label($type) ?? $type,
->mapWithKeys(fn (string $type): array => [
$type => $this->governedSubjectLabel($type),
])
->all();
@ -118,7 +119,7 @@ public function build(BaselineProfile $profile, User $user, array $filters = [])
],
'subjectSortOptions' => [
'deviation_breadth' => 'Deviation breadth',
'policy_type' => 'Policy type',
'policy_type' => 'Governed subject',
'display_name' => 'Display name',
],
'stateLegend' => $this->legendSpecs(BadgeDomain::BaselineCompareMatrixState, [
@ -209,6 +210,8 @@ public function build(BaselineProfile $profile, User $user, array $filters = [])
$subject = [
'subjectKey' => $subjectKey,
'policyType' => (string) $item->policy_type,
'governedSubjectLabel' => $this->governedSubjectLabel((string) $item->policy_type),
'subjectDescriptor' => $this->subjectDescriptor((string) $item->policy_type),
'displayName' => $this->subjectDisplayName($item),
'baselineExternalId' => is_string($item->subject_external_id) ? $item->subject_external_id : null,
];
@ -572,7 +575,7 @@ private function reasonSummary(string $state, ?string $reasonCode, bool $policyT
'stale_result' => 'Refresh recommended before acting on this result.',
'not_compared' => $policyTypeCovered
? 'No completed compare result is available yet.'
: 'Policy type coverage was not proven in the latest compare run.',
: 'Governed subject coverage was not proven in the latest compare run.',
default => null,
};
}
@ -642,6 +645,8 @@ private function subjectSummary(array $subject, array $cells): array
return [
'subjectKey' => $subject['subjectKey'],
'policyType' => $subject['policyType'],
'governedSubjectLabel' => $subject['governedSubjectLabel'] ?? $this->governedSubjectLabel((string) $subject['policyType']),
'subjectDescriptor' => $subject['subjectDescriptor'] ?? $this->subjectDescriptor((string) $subject['policyType']),
'displayName' => $subject['displayName'],
'baselineExternalId' => $subject['baselineExternalId'],
'deviationBreadth' => $this->countStates($cells, ['differ', 'missing']),
@ -809,8 +814,13 @@ private function sortRows(array $rows, string $sort): array
$rightSubject = $right['subject'] ?? [];
return match ($sort) {
'policy_type' => [(string) ($leftSubject['policyType'] ?? ''), Str::lower((string) ($leftSubject['displayName'] ?? ''))]
<=> [(string) ($rightSubject['policyType'] ?? ''), Str::lower((string) ($rightSubject['displayName'] ?? ''))],
'policy_type' => [
Str::lower((string) ($leftSubject['governedSubjectLabel'] ?? $leftSubject['policyType'] ?? '')),
Str::lower((string) ($leftSubject['displayName'] ?? '')),
] <=> [
Str::lower((string) ($rightSubject['governedSubjectLabel'] ?? $rightSubject['policyType'] ?? '')),
Str::lower((string) ($rightSubject['displayName'] ?? '')),
],
'display_name' => [Str::lower((string) ($leftSubject['displayName'] ?? '')), (string) ($leftSubject['subjectKey'] ?? '')]
<=> [Str::lower((string) ($rightSubject['displayName'] ?? '')), (string) ($rightSubject['subjectKey'] ?? '')],
default => [
@ -914,6 +924,8 @@ private function compactResults(array $rows, array $tenantSummaries): array
'subjectKey' => (string) ($subject['subjectKey'] ?? ''),
'displayName' => $subject['displayName'] ?? $subject['subjectKey'] ?? 'Subject',
'policyType' => (string) ($subject['policyType'] ?? ''),
'governedSubjectLabel' => (string) ($subject['governedSubjectLabel'] ?? $this->governedSubjectLabel((string) ($subject['policyType'] ?? ''))),
'subjectDescriptor' => $subject['subjectDescriptor'] ?? $this->subjectDescriptor((string) ($subject['policyType'] ?? '')),
'baselineExternalId' => $subject['baselineExternalId'] ?? null,
'deviationBreadth' => (int) ($subject['deviationBreadth'] ?? 0),
'missingBreadth' => (int) ($subject['missingBreadth'] ?? 0),
@ -974,7 +986,7 @@ private function emptyState(
if ($renderedRowsCount === 0) {
return [
'title' => 'No rows match the current filters',
'body' => 'Adjust the policy type, state, or severity filters to broaden the matrix view.',
'body' => 'Adjust the governed subject, state, or severity filters to broaden the matrix view.',
];
}
@ -1002,4 +1014,31 @@ static function (string $value) use ($domain): array {
$values,
);
}
private function governedSubjectLabel(string $policyType): string
{
return (string) data_get(
$this->subjectDescriptor($policyType),
'display_label',
InventoryPolicyTypeMeta::label($policyType) ?? Str::headline($policyType),
);
}
/**
* @return array<string, mixed>
*/
private function subjectDescriptor(string $policyType): array
{
static $cache = [];
if (array_key_exists($policyType, $cache)) {
return $cache[$policyType];
}
$result = app(PlatformSubjectDescriptorNormalizer::class)->fromArray([
'policy_type' => $policyType,
], 'baseline_compare_matrix');
return $cache[$policyType] = $result->descriptor->toArray();
}
}

View File

@ -12,6 +12,9 @@ enum BaselineCompareReasonCode: string
case NoSubjectsInScope = 'no_subjects_in_scope';
case CoverageUnproven = 'coverage_unproven';
case EvidenceCaptureIncomplete = 'evidence_capture_incomplete';
case UnsupportedSubjects = 'unsupported_subjects';
case AmbiguousSubjects = 'ambiguous_subjects';
case StrategyFailed = 'strategy_failed';
case RolloutDisabled = 'rollout_disabled';
case NoDriftDetected = 'no_drift_detected';
case OverdueFindingsRemain = 'overdue_findings_remain';
@ -24,6 +27,9 @@ public function message(): string
self::NoSubjectsInScope => 'No subjects were in scope for this comparison.',
self::CoverageUnproven => 'Coverage proof was missing or incomplete, so some findings were suppressed for safety.',
self::EvidenceCaptureIncomplete => 'Evidence capture was incomplete, so some drift evaluation may have been suppressed.',
self::UnsupportedSubjects => 'One or more in-scope subjects could not be compared by the selected strategy.',
self::AmbiguousSubjects => 'One or more in-scope subjects could not be compared because identity matching stayed ambiguous.',
self::StrategyFailed => 'One or more in-scope subjects failed during strategy processing, so the compare result is incomplete.',
self::RolloutDisabled => 'Full-content baseline compare is currently disabled by rollout configuration.',
self::NoDriftDetected => 'No drift was detected for in-scope subjects.',
self::OverdueFindingsRemain => 'Overdue findings still need action even though the latest compare did not produce new drift.',
@ -38,10 +44,13 @@ public function explanationFamily(): ExplanationFamily
self::NoDriftDetected => ExplanationFamily::NoIssuesDetected,
self::CoverageUnproven,
self::EvidenceCaptureIncomplete,
self::UnsupportedSubjects,
self::AmbiguousSubjects,
self::RolloutDisabled,
self::OverdueFindingsRemain,
self::GovernanceExpiring,
self::GovernanceLapsed => ExplanationFamily::CompletedButLimited,
self::StrategyFailed => ExplanationFamily::BlockedPrerequisite,
self::NoSubjectsInScope => ExplanationFamily::MissingInput,
};
}
@ -52,9 +61,12 @@ public function trustworthinessLevel(): TrustworthinessLevel
self::NoDriftDetected => TrustworthinessLevel::Trustworthy,
self::CoverageUnproven,
self::EvidenceCaptureIncomplete,
self::UnsupportedSubjects,
self::AmbiguousSubjects,
self::OverdueFindingsRemain,
self::GovernanceExpiring,
self::GovernanceLapsed => TrustworthinessLevel::LimitedConfidence,
self::StrategyFailed,
self::RolloutDisabled,
self::NoSubjectsInScope => TrustworthinessLevel::Unusable,
};
@ -66,9 +78,12 @@ public function absencePattern(): ?string
self::NoDriftDetected => 'true_no_result',
self::CoverageUnproven,
self::EvidenceCaptureIncomplete,
self::UnsupportedSubjects,
self::AmbiguousSubjects,
self::OverdueFindingsRemain,
self::GovernanceExpiring,
self::GovernanceLapsed => 'suppressed_output',
self::StrategyFailed,
self::RolloutDisabled => 'blocked_prerequisite',
self::NoSubjectsInScope => 'missing_input',
};

View File

@ -122,9 +122,7 @@ public static function forTenant(?Tenant $tenant): self
$snapshotReasonMessage = self::missingSnapshotMessage($snapshotReasonCode);
try {
$profileScope = BaselineScope::fromJsonb(
is_array($profile->scope_jsonb) ? $profile->scope_jsonb : null,
);
$profileScope = $profile->normalizedScope();
$overrideScope = $assignment->override_scope_jsonb !== null
? BaselineScope::fromJsonb(
is_array($assignment->override_scope_jsonb) ? $assignment->override_scope_jsonb : null,

View File

@ -54,6 +54,8 @@ final class BaselineReasonCodes
public const string COMPARE_UNSUPPORTED_SCOPE = 'baseline.compare.unsupported_scope';
public const string COMPARE_MIXED_SCOPE = 'baseline.compare.mixed_scope';
public const string COMPARE_SNAPSHOT_BUILDING = 'baseline.compare.snapshot_building';
public const string COMPARE_SNAPSHOT_INCOMPLETE = 'baseline.compare.snapshot_incomplete';
@ -87,6 +89,7 @@ public static function all(): array
self::COMPARE_ROLLOUT_DISABLED,
self::COMPARE_INVALID_SCOPE,
self::COMPARE_UNSUPPORTED_SCOPE,
self::COMPARE_MIXED_SCOPE,
self::COMPARE_SNAPSHOT_BUILDING,
self::COMPARE_SNAPSHOT_INCOMPLETE,
self::COMPARE_SNAPSHOT_SUPERSEDED,
@ -121,6 +124,7 @@ public static function trustImpact(?string $reasonCode): ?string
self::COMPARE_SNAPSHOT_SUPERSEDED,
self::COMPARE_INVALID_SCOPE,
self::COMPARE_UNSUPPORTED_SCOPE,
self::COMPARE_MIXED_SCOPE,
self::CAPTURE_MISSING_SOURCE_TENANT,
self::CAPTURE_PROFILE_NOT_ACTIVE,
self::CAPTURE_INVALID_SCOPE,
@ -150,6 +154,7 @@ public static function absencePattern(?string $reasonCode): ?string
self::COMPARE_INVALID_SNAPSHOT,
self::COMPARE_INVALID_SCOPE,
self::COMPARE_UNSUPPORTED_SCOPE,
self::COMPARE_MIXED_SCOPE,
self::COMPARE_ROLLOUT_DISABLED,
self::SNAPSHOT_SUPERSEDED,
self::COMPARE_SNAPSHOT_SUPERSEDED => 'blocked_prerequisite',

View File

@ -7,6 +7,7 @@
use App\Support\Governance\GovernanceDomainKey;
use App\Support\Governance\GovernanceSubjectClass;
use App\Support\Governance\GovernanceSubjectTaxonomyRegistry;
use App\Support\Governance\PlatformSubjectDescriptorNormalizer;
use App\Support\Inventory\InventoryPolicyTypeMeta;
use InvalidArgumentException;
@ -187,9 +188,19 @@ public function allTypes(): array
{
$expanded = $this->expandDefaults();
$canonicalTypeKeys = [];
foreach ($expanded->entries as $entry) {
$canonicalTypeKeys = array_merge(
$canonicalTypeKeys,
is_array($entry['subject_type_keys'] ?? null) ? $entry['subject_type_keys'] : [],
);
}
return self::uniqueSorted(array_merge(
$expanded->policyTypes,
$expanded->foundationTypes,
$canonicalTypeKeys,
));
}
@ -293,6 +304,16 @@ public function summaryGroups(?GovernanceSubjectTaxonomyRegistry $registry = nul
return $groups;
}
/**
* @return list<array<string, mixed>>
*/
public function subjectDescriptors(?PlatformSubjectDescriptorNormalizer $normalizer = null): array
{
$normalizer ??= app(PlatformSubjectDescriptorNormalizer::class);
return $normalizer->descriptorsForScopeEntries($this->entries);
}
/**
* Effective scope payload for OperationRun.context.
*
@ -301,7 +322,7 @@ public function summaryGroups(?GovernanceSubjectTaxonomyRegistry $registry = nul
public function toEffectiveScopeContext(?BaselineSupportCapabilityGuard $guard = null, ?string $operation = null): array
{
$expanded = $this->expandDefaults();
$allTypes = self::uniqueSorted(array_merge($expanded->policyTypes, $expanded->foundationTypes));
$allTypes = $expanded->allTypes();
$context = [
'canonical_scope' => $expanded->toStoredJsonb(),
@ -311,6 +332,7 @@ public function toEffectiveScopeContext(?BaselineSupportCapabilityGuard $guard =
'all_types' => $allTypes,
'selected_type_keys' => $allTypes,
'foundations_included' => $expanded->foundationTypes !== [],
'governed_subjects' => $expanded->subjectDescriptors(),
];
if (! is_string($operation) || $operation === '') {

View File

@ -0,0 +1,46 @@
<?php
declare(strict_types=1);
namespace App\Support\Baselines\Compare;
use InvalidArgumentException;
final class CompareFindingCandidate
{
/**
* @param array<string, mixed> $fingerprintBasis
* @param array<string, mixed> $evidencePayload
*/
public function __construct(
public readonly string $changeType,
public readonly string $severity,
public readonly array $fingerprintBasis,
public readonly array $evidencePayload,
public readonly bool $autoCloseEligible = true,
) {
if (trim($this->changeType) === '' || trim($this->severity) === '') {
throw new InvalidArgumentException('Compare finding candidates require non-empty change type and severity values.');
}
}
/**
* @return array{
* change_type: string,
* severity: string,
* fingerprint_basis: array<string, mixed>,
* evidence_payload: array<string, mixed>,
* auto_close_eligible: bool
* }
*/
public function toArray(): array
{
return [
'change_type' => $this->changeType,
'severity' => $this->severity,
'fingerprint_basis' => $this->fingerprintBasis,
'evidence_payload' => $this->evidencePayload,
'auto_close_eligible' => $this->autoCloseEligible,
];
}
}

View File

@ -0,0 +1,82 @@
<?php
declare(strict_types=1);
namespace App\Support\Baselines\Compare;
use InvalidArgumentException;
final class CompareOrchestrationContext
{
/**
* @param array<string, mixed> $normalizedScope
* @param array<string, mixed> $coverageContext
* @param array<string, mixed> $launchContext
*/
public function __construct(
public readonly int $workspaceId,
public readonly int $tenantId,
public readonly int $baselineProfileId,
public readonly int $baselineSnapshotId,
public readonly int $operationRunId,
public readonly array $normalizedScope,
public readonly CompareStrategySelection $strategySelection,
public readonly array $coverageContext = [],
public readonly array $launchContext = [],
) {
if ($this->workspaceId <= 0 || $this->tenantId <= 0 || $this->baselineProfileId <= 0 || $this->baselineSnapshotId <= 0 || $this->operationRunId <= 0) {
throw new InvalidArgumentException('Compare orchestration contexts require positive workspace, tenant, profile, snapshot, and operation run identifiers.');
}
}
public function strategyKey(): CompareStrategyKey
{
if (! $this->strategySelection->strategyKey instanceof CompareStrategyKey) {
throw new InvalidArgumentException('Compare orchestration context requires a supported strategy selection before execution.');
}
return $this->strategySelection->strategyKey;
}
public function inventorySyncRunId(): ?int
{
$value = $this->coverageContext['inventory_sync_run_id'] ?? $this->launchContext['inventory_sync_run_id'] ?? null;
return is_numeric($value) ? (int) $value : null;
}
/**
* @return array{
* workspace_id: int,
* tenant_id: int,
* baseline_profile_id: int,
* baseline_snapshot_id: int,
* operation_run_id: int,
* normalized_scope: array<string, mixed>,
* strategy_selection: array{
* selection_state: string,
* strategy_key: ?string,
* matched_scope_entries: list<array<string, mixed>>,
* rejected_scope_entries: list<array<string, mixed>>,
* operator_reason: string,
* diagnostics: array<string, mixed>
* },
* coverage_context: array<string, mixed>,
* launch_context: array<string, mixed>
* }
*/
public function toArray(): array
{
return [
'workspace_id' => $this->workspaceId,
'tenant_id' => $this->tenantId,
'baseline_profile_id' => $this->baselineProfileId,
'baseline_snapshot_id' => $this->baselineSnapshotId,
'operation_run_id' => $this->operationRunId,
'normalized_scope' => $this->normalizedScope,
'strategy_selection' => $this->strategySelection->toArray(),
'coverage_context' => $this->coverageContext,
'launch_context' => $this->launchContext,
];
}
}

View File

@ -0,0 +1,15 @@
<?php
declare(strict_types=1);
namespace App\Support\Baselines\Compare;
enum CompareState: string
{
case NoDrift = 'no_drift';
case Drift = 'drift';
case Unsupported = 'unsupported';
case Incomplete = 'incomplete';
case Ambiguous = 'ambiguous';
case Failed = 'failed';
}

View File

@ -0,0 +1,34 @@
<?php
declare(strict_types=1);
namespace App\Support\Baselines\Compare;
use App\Models\Tenant;
use App\Services\Baselines\Evidence\ResolvedEvidence;
interface CompareStrategy
{
public function key(): CompareStrategyKey;
/**
* @return list<CompareStrategyCapability>
*/
public function capabilities(): array;
/**
* @param array<string, array{subject_type: string, subject_external_id: string, subject_key: string, policy_type: string, baseline_hash: string, meta_jsonb: array<string, mixed>}> $baselineItems
* @param array<string, array{subject_external_id: string, subject_key: string, policy_type: string, meta_jsonb: array<string, mixed>}> $currentItems
* @param array<string, ResolvedEvidence|null> $resolvedCurrentEvidence
* @param array<string, string> $severityMapping
* @return array{subject_results: list<CompareSubjectResult>, diagnostics: array<string, mixed>}
*/
public function compare(
CompareOrchestrationContext $context,
Tenant $tenant,
array $baselineItems,
array $currentItems,
array $resolvedCurrentEvidence,
array $severityMapping,
): array;
}

View File

@ -0,0 +1,84 @@
<?php
declare(strict_types=1);
namespace App\Support\Baselines\Compare;
use InvalidArgumentException;
final class CompareStrategyCapability
{
/**
* @param list<string> $domainKeys
* @param list<string> $subjectClasses
* @param list<string>|'all' $subjectTypeKeys
*/
public function __construct(
public readonly CompareStrategyKey $strategyKey,
public readonly array $domainKeys,
public readonly array $subjectClasses,
public readonly array|string $subjectTypeKeys = 'all',
public readonly bool $compareSupported = true,
public readonly bool $active = true,
) {
if ($this->domainKeys === [] || $this->subjectClasses === []) {
throw new InvalidArgumentException('Compare strategy capabilities require at least one domain key and one subject class.');
}
if ($this->subjectTypeKeys !== 'all' && $this->subjectTypeKeys === []) {
throw new InvalidArgumentException('Compare strategy capabilities must either support all subject type keys or at least one explicit subject type key.');
}
}
/**
* @param array{domain_key?: mixed, subject_class?: mixed, subject_type_keys?: mixed} $entry
*/
public function supportsEntry(array $entry): bool
{
if (! $this->active || ! $this->compareSupported) {
return false;
}
$domainKey = is_string($entry['domain_key'] ?? null) ? trim((string) $entry['domain_key']) : '';
$subjectClass = is_string($entry['subject_class'] ?? null) ? trim((string) $entry['subject_class']) : '';
$subjectTypeKeys = is_array($entry['subject_type_keys'] ?? null)
? array_values(array_filter($entry['subject_type_keys'], 'is_string'))
: [];
if ($domainKey === '' || $subjectClass === '' || $subjectTypeKeys === []) {
return false;
}
if (! in_array($domainKey, $this->domainKeys, true) || ! in_array($subjectClass, $this->subjectClasses, true)) {
return false;
}
if ($this->subjectTypeKeys === 'all') {
return true;
}
return array_diff($subjectTypeKeys, $this->subjectTypeKeys) === [];
}
/**
* @return array{
* strategy_key: string,
* domain_keys: list<string>,
* subject_classes: list<string>,
* subject_type_keys: list<string>|'all',
* compare_supported: bool,
* active: bool
* }
*/
public function toArray(): array
{
return [
'strategy_key' => $this->strategyKey->value,
'domain_keys' => $this->domainKeys,
'subject_classes' => $this->subjectClasses,
'subject_type_keys' => $this->subjectTypeKeys,
'compare_supported' => $this->compareSupported,
'active' => $this->active,
];
}
}

View File

@ -0,0 +1,44 @@
<?php
declare(strict_types=1);
namespace App\Support\Baselines\Compare;
use InvalidArgumentException;
use Stringable;
final class CompareStrategyKey implements Stringable
{
public readonly string $value;
public function __construct(string $value)
{
$normalized = trim(mb_strtolower($value));
if ($normalized === '' || ! preg_match('/^[a-z0-9_]+$/', $normalized)) {
throw new InvalidArgumentException('Compare strategy keys must be non-empty lowercase snake_case strings.');
}
$this->value = $normalized;
}
public static function from(self|string $value): self
{
return $value instanceof self ? $value : new self($value);
}
public static function intunePolicy(): self
{
return new self('intune_policy');
}
public function equals(self|string $other): bool
{
return $this->value === self::from($other)->value;
}
public function __toString(): string
{
return $this->value;
}
}

View File

@ -0,0 +1,202 @@
<?php
declare(strict_types=1);
namespace App\Support\Baselines\Compare;
use App\Support\Baselines\BaselineScope;
use App\Support\Governance\GovernanceDomainKey;
use App\Support\Governance\GovernanceSubjectClass;
use InvalidArgumentException;
final class CompareStrategyRegistry
{
/**
* @param list<CompareStrategy> $strategies
*/
public function __construct(
private readonly array $strategies = [],
) {}
/**
* @return list<CompareStrategy>
*/
public function all(): array
{
if ($this->strategies !== []) {
return $this->strategies;
}
return [app(IntuneCompareStrategy::class)];
}
public function find(CompareStrategyKey|string|null $strategyKey): ?CompareStrategy
{
if ($strategyKey === null) {
return null;
}
$normalizedKey = CompareStrategyKey::from($strategyKey);
foreach ($this->all() as $strategy) {
if ($strategy->key()->equals($normalizedKey)) {
return $strategy;
}
}
return null;
}
public function resolve(CompareStrategyKey|string $strategyKey): CompareStrategy
{
$strategy = $this->find($strategyKey);
if ($strategy instanceof CompareStrategy) {
return $strategy;
}
throw new InvalidArgumentException('Unknown compare strategy ['.CompareStrategyKey::from($strategyKey)->value.'].');
}
public function select(BaselineScope $scope): CompareStrategySelection
{
$entries = $this->entriesForScope($scope);
if ($entries === []) {
return CompareStrategySelection::unsupported(
matchedScopeEntries: [],
rejectedScopeEntries: [],
diagnostics: [],
operatorReason: 'No governed subjects were selected for compare.',
);
}
$matchedScopeEntries = [];
$rejectedScopeEntries = [];
$matchedStrategyKeys = [];
$entryMatches = [];
foreach ($entries as $entry) {
$matchingKeys = $this->matchingStrategyKeysForEntry($entry);
$entryFingerprint = $this->entryFingerprint($entry);
$entryMatches[$entryFingerprint] = $matchingKeys;
if ($matchingKeys === []) {
$rejectedScopeEntries[] = $entry;
continue;
}
$matchedScopeEntries[] = $entry;
$matchedStrategyKeys = array_values(array_unique(array_merge($matchedStrategyKeys, $matchingKeys)));
}
sort($matchedStrategyKeys, SORT_STRING);
if ($rejectedScopeEntries !== []) {
return CompareStrategySelection::unsupported(
matchedScopeEntries: $matchedScopeEntries,
rejectedScopeEntries: $rejectedScopeEntries,
diagnostics: [
'matched_strategy_keys' => $matchedStrategyKeys,
'entry_matches' => $entryMatches,
],
);
}
if (count($matchedStrategyKeys) !== 1) {
return CompareStrategySelection::mixed(
matchedScopeEntries: $matchedScopeEntries,
diagnostics: [
'matched_strategy_keys' => $matchedStrategyKeys,
'entry_matches' => $entryMatches,
],
);
}
return CompareStrategySelection::supported(
strategyKey: $matchedStrategyKeys[0],
matchedScopeEntries: $matchedScopeEntries,
diagnostics: [
'matched_strategy_keys' => $matchedStrategyKeys,
'entry_matches' => $entryMatches,
],
);
}
/**
* @param array{domain_key?: mixed, subject_class?: mixed, subject_type_keys?: mixed, filters?: mixed} $entry
* @return list<string>
*/
private function matchingStrategyKeysForEntry(array $entry): array
{
$matches = [];
foreach ($this->all() as $strategy) {
foreach ($strategy->capabilities() as $capability) {
if (! $capability->supportsEntry($entry)) {
continue;
}
$matches[] = $strategy->key()->value;
break;
}
}
$matches = array_values(array_unique($matches));
sort($matches, SORT_STRING);
return $matches;
}
/**
* @return list<array{domain_key: string, subject_class: string, subject_type_keys: list<string>, filters: array<string, mixed>}>
*/
private function entriesForScope(BaselineScope $scope): array
{
if ($scope->entries !== []) {
return $scope->entries;
}
$entries = [];
if ($scope->policyTypes !== []) {
$entries[] = [
'domain_key' => GovernanceDomainKey::Intune->value,
'subject_class' => GovernanceSubjectClass::Policy->value,
'subject_type_keys' => $scope->policyTypes,
'filters' => [],
];
}
if ($scope->foundationTypes !== []) {
$entries[] = [
'domain_key' => GovernanceDomainKey::PlatformFoundation->value,
'subject_class' => GovernanceSubjectClass::ConfigurationResource->value,
'subject_type_keys' => $scope->foundationTypes,
'filters' => [],
];
}
return $entries;
}
/**
* @param array{domain_key?: mixed, subject_class?: mixed, subject_type_keys?: mixed, filters?: mixed} $entry
*/
private function entryFingerprint(array $entry): string
{
$subjectTypeKeys = is_array($entry['subject_type_keys'] ?? null)
? array_values(array_filter($entry['subject_type_keys'], 'is_string'))
: [];
sort($subjectTypeKeys, SORT_STRING);
return implode('|', [
trim((string) ($entry['domain_key'] ?? '')),
trim((string) ($entry['subject_class'] ?? '')),
implode(',', $subjectTypeKeys),
]);
}
}

View File

@ -0,0 +1,129 @@
<?php
declare(strict_types=1);
namespace App\Support\Baselines\Compare;
use InvalidArgumentException;
final class CompareStrategySelection
{
/**
* @param list<array<string, mixed>> $matchedScopeEntries
* @param list<array<string, mixed>> $rejectedScopeEntries
* @param array<string, mixed> $diagnostics
*/
public function __construct(
public readonly StrategySelectionState $selectionState,
public readonly ?CompareStrategyKey $strategyKey,
public readonly array $matchedScopeEntries,
public readonly array $rejectedScopeEntries,
public readonly string $operatorReason,
public readonly array $diagnostics = [],
) {
if ($this->selectionState === StrategySelectionState::Supported && ! $this->strategyKey instanceof CompareStrategyKey) {
throw new InvalidArgumentException('Supported compare strategy selections require a strategy key.');
}
if (trim($this->operatorReason) === '') {
throw new InvalidArgumentException('Compare strategy selections require an operator-safe reason.');
}
}
/**
* @param list<array<string, mixed>> $matchedScopeEntries
* @param array<string, mixed> $diagnostics
*/
public static function supported(
CompareStrategyKey|string $strategyKey,
array $matchedScopeEntries,
array $diagnostics = [],
string $operatorReason = 'Compare strategy resolved successfully.',
): self {
return new self(
selectionState: StrategySelectionState::Supported,
strategyKey: CompareStrategyKey::from($strategyKey),
matchedScopeEntries: $matchedScopeEntries,
rejectedScopeEntries: [],
operatorReason: $operatorReason,
diagnostics: $diagnostics,
);
}
/**
* @param list<array<string, mixed>> $matchedScopeEntries
* @param list<array<string, mixed>> $rejectedScopeEntries
* @param array<string, mixed> $diagnostics
*/
public static function unsupported(
array $matchedScopeEntries,
array $rejectedScopeEntries,
array $diagnostics = [],
string $operatorReason = 'No compare strategy supports the selected governed subjects.',
): self {
return new self(
selectionState: StrategySelectionState::Unsupported,
strategyKey: null,
matchedScopeEntries: $matchedScopeEntries,
rejectedScopeEntries: $rejectedScopeEntries,
operatorReason: $operatorReason,
diagnostics: $diagnostics,
);
}
/**
* @param list<array<string, mixed>> $matchedScopeEntries
* @param array<string, mixed> $diagnostics
*/
public static function mixed(
array $matchedScopeEntries,
array $diagnostics = [],
string $operatorReason = 'The selected governed subjects span multiple compare strategy families.',
): self {
return new self(
selectionState: StrategySelectionState::Mixed,
strategyKey: null,
matchedScopeEntries: $matchedScopeEntries,
rejectedScopeEntries: [],
operatorReason: $operatorReason,
diagnostics: $diagnostics,
);
}
public function isSupported(): bool
{
return $this->selectionState === StrategySelectionState::Supported;
}
public function isUnsupported(): bool
{
return $this->selectionState === StrategySelectionState::Unsupported;
}
public function isMixed(): bool
{
return $this->selectionState === StrategySelectionState::Mixed;
}
/**
* @return array{
* selection_state: string,
* strategy_key: ?string,
* matched_scope_entries: list<array<string, mixed>>,
* rejected_scope_entries: list<array<string, mixed>>,
* operator_reason: string,
* diagnostics: array<string, mixed>
* }
*/
public function toArray(): array
{
return [
'selection_state' => $this->selectionState->value,
'strategy_key' => $this->strategyKey?->value,
'matched_scope_entries' => $this->matchedScopeEntries,
'rejected_scope_entries' => $this->rejectedScopeEntries,
'operator_reason' => $this->operatorReason,
'diagnostics' => $this->diagnostics,
];
}
}

View File

@ -0,0 +1,42 @@
<?php
declare(strict_types=1);
namespace App\Support\Baselines\Compare;
use InvalidArgumentException;
final class CompareSubjectIdentity
{
public function __construct(
public readonly string $domainKey,
public readonly string $subjectClass,
public readonly string $subjectTypeKey,
public readonly ?string $externalSubjectId,
public readonly string $subjectKey,
) {
if (trim($this->domainKey) === '' || trim($this->subjectClass) === '' || trim($this->subjectTypeKey) === '' || trim($this->subjectKey) === '') {
throw new InvalidArgumentException('Compare subject identities require non-empty domain, subject class, subject type key, and subject key values.');
}
}
/**
* @return array{
* domain_key: string,
* subject_class: string,
* subject_type_key: string,
* external_subject_id: ?string,
* subject_key: string
* }
*/
public function toArray(): array
{
return [
'domain_key' => $this->domainKey,
'subject_class' => $this->subjectClass,
'subject_type_key' => $this->subjectTypeKey,
'external_subject_id' => $this->externalSubjectId,
'subject_key' => $this->subjectKey,
];
}
}

View File

@ -0,0 +1,52 @@
<?php
declare(strict_types=1);
namespace App\Support\Baselines\Compare;
use InvalidArgumentException;
final class CompareSubjectProjection
{
/**
* @param array<string, string> $additionalLabels
* @param array<string, mixed>|null $subjectDescriptor
*/
public function __construct(
public readonly string $platformSubjectClass,
public readonly string $domainKey,
public readonly string $subjectTypeKey,
public readonly string $operatorLabel,
public readonly ?string $summaryKind = null,
public readonly array $additionalLabels = [],
public readonly ?array $subjectDescriptor = null,
) {
if (trim($this->platformSubjectClass) === '' || trim($this->domainKey) === '' || trim($this->subjectTypeKey) === '' || trim($this->operatorLabel) === '') {
throw new InvalidArgumentException('Compare subject projections require non-empty platform subject class, domain key, subject type key, and operator label values.');
}
}
/**
* @return array{
* platform_subject_class: string,
* domain_key: string,
* subject_type_key: string,
* operator_label: string,
* summary_kind: ?string,
* additional_labels: array<string, string>,
* subject_descriptor: ?array<string, mixed>
* }
*/
public function toArray(): array
{
return [
'platform_subject_class' => $this->platformSubjectClass,
'domain_key' => $this->domainKey,
'subject_type_key' => $this->subjectTypeKey,
'operator_label' => $this->operatorLabel,
'summary_kind' => $this->summaryKind,
'additional_labels' => $this->additionalLabels,
'subject_descriptor' => $this->subjectDescriptor,
];
}
}

View File

@ -0,0 +1,119 @@
<?php
declare(strict_types=1);
namespace App\Support\Baselines\Compare;
use InvalidArgumentException;
final class CompareSubjectResult
{
/**
* @param array<string, mixed> $diagnostics
*/
public function __construct(
public readonly CompareSubjectIdentity $subjectIdentity,
public readonly CompareSubjectProjection $projection,
public readonly string $baselineAvailability,
public readonly string $currentStateAvailability,
public readonly CompareState $compareState,
public readonly string $trustLevel,
public readonly string $evidenceQuality,
public readonly ?string $severityRecommendation = null,
public readonly ?CompareFindingCandidate $findingCandidate = null,
public readonly array $diagnostics = [],
) {
if (trim($this->baselineAvailability) === '' || trim($this->currentStateAvailability) === '' || trim($this->trustLevel) === '' || trim($this->evidenceQuality) === '') {
throw new InvalidArgumentException('Compare subject results require non-empty availability, trust level, and evidence quality values.');
}
if ($this->compareState === CompareState::Drift && ! $this->findingCandidate instanceof CompareFindingCandidate) {
throw new InvalidArgumentException('Drift compare subject results require a finding candidate.');
}
if ($this->compareState !== CompareState::Drift && $this->findingCandidate instanceof CompareFindingCandidate) {
throw new InvalidArgumentException('Only drift compare subject results may carry a finding candidate.');
}
}
public function hasFindingCandidate(): bool
{
return $this->findingCandidate instanceof CompareFindingCandidate;
}
public function isGapState(): bool
{
return in_array($this->compareState, [
CompareState::Unsupported,
CompareState::Incomplete,
CompareState::Ambiguous,
CompareState::Failed,
], true);
}
public function gapReasonCode(): ?string
{
$reasonCode = $this->diagnostics['reason_code'] ?? null;
return is_string($reasonCode) && trim($reasonCode) !== '' ? trim($reasonCode) : null;
}
/**
* @return array<string, mixed>|null
*/
public function gapRecord(): ?array
{
$gapRecord = $this->diagnostics['gap_record'] ?? null;
return is_array($gapRecord) ? $gapRecord : null;
}
/**
* @return array{
* subject_identity: array{
* domain_key: string,
* subject_class: string,
* subject_type_key: string,
* external_subject_id: ?string,
* subject_key: string
* },
* projection: array{
* platform_subject_class: string,
* domain_key: string,
* subject_type_key: string,
* operator_label: string,
* summary_kind: ?string,
* additional_labels: array<string, string>
* },
* baseline_availability: string,
* current_state_availability: string,
* compare_state: string,
* trust_level: string,
* evidence_quality: string,
* severity_recommendation: ?string,
* finding_candidate: ?array{
* change_type: string,
* severity: string,
* fingerprint_basis: array<string, mixed>,
* evidence_payload: array<string, mixed>,
* auto_close_eligible: bool
* },
* diagnostics: array<string, mixed>
* }
*/
public function toArray(): array
{
return [
'subject_identity' => $this->subjectIdentity->toArray(),
'projection' => $this->projection->toArray(),
'baseline_availability' => $this->baselineAvailability,
'current_state_availability' => $this->currentStateAvailability,
'compare_state' => $this->compareState->value,
'trust_level' => $this->trustLevel,
'evidence_quality' => $this->evidenceQuality,
'severity_recommendation' => $this->severityRecommendation,
'finding_candidate' => $this->findingCandidate?->toArray(),
'diagnostics' => $this->diagnostics,
];
}
}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,12 @@
<?php
declare(strict_types=1);
namespace App\Support\Baselines\Compare;
enum StrategySelectionState: string
{
case Supported = 'supported';
case Unsupported = 'unsupported';
case Mixed = 'mixed';
}

View File

@ -0,0 +1,39 @@
<?php
declare(strict_types=1);
namespace App\Support;
final readonly class CanonicalOperationType
{
public function __construct(
public string $canonicalCode,
public ?string $domainKey,
public ?string $artifactFamily,
public string $displayLabel,
public bool $supportsOperatorExplanation = false,
public ?int $expectedDurationSeconds = null,
) {}
/**
* @return array{
* canonical_code: string,
* domain_key: ?string,
* artifact_family: ?string,
* display_label: string,
* supports_operator_explanation: bool,
* expected_duration_seconds: ?int
* }
*/
public function toArray(): array
{
return [
'canonical_code' => $this->canonicalCode,
'domain_key' => $this->domainKey,
'artifact_family' => $this->artifactFamily,
'display_label' => $this->displayLabel,
'supports_operator_explanation' => $this->supportsOperatorExplanation,
'expected_duration_seconds' => $this->expectedDurationSeconds,
];
}
}

View File

@ -203,17 +203,7 @@ public static function findingGovernanceAttentionStates(): array
*/
public static function operationTypes(?iterable $types = null): array
{
$values = collect($types ?? array_keys(OperationCatalog::labels()))
->filter(fn (mixed $type): bool => is_string($type) && trim($type) !== '')
->map(fn (string $type): string => trim($type))
->unique()
->sort()
->values();
return $values
->mapWithKeys(fn (string $type): array => [$type => OperationCatalog::label($type)])
->sort()
->all();
return OperationCatalog::filterOptions($types);
}
/**

View File

@ -76,6 +76,50 @@ public function find(string $domainKey, string $subjectTypeKey): ?GovernanceSubj
return null;
}
public function findBySubjectTypeKey(string $subjectTypeKey, ?string $legacyBucket = null): ?GovernanceSubjectType
{
$subjectTypeKey = trim($subjectTypeKey);
$legacyBucket = is_string($legacyBucket) ? trim($legacyBucket) : null;
foreach ($this->all() as $subjectType) {
if ($subjectType->subjectTypeKey !== $subjectTypeKey) {
continue;
}
if ($legacyBucket !== null && $subjectType->legacyBucket !== $legacyBucket) {
continue;
}
return $subjectType;
}
return null;
}
/**
* @return list<string>
*/
public function canonicalNouns(): array
{
return ['domain_key', 'subject_class', 'subject_type_key', 'subject_type_label'];
}
public function ownershipDescriptor(?PlatformVocabularyGlossary $glossary = null): RegistryOwnershipDescriptor
{
$glossary ??= app(PlatformVocabularyGlossary::class);
return $glossary->registry('governance_subject_taxonomy_registry')
?? RegistryOwnershipDescriptor::fromArray([
'registry_key' => 'governance_subject_taxonomy_registry',
'boundary_classification' => PlatformVocabularyGlossary::BOUNDARY_CROSS_DOMAIN_GOVERNANCE,
'owner_layer' => PlatformVocabularyGlossary::OWNER_PLATFORM_CORE,
'source_class_or_file' => self::class,
'canonical_nouns' => $this->canonicalNouns(),
'allowed_consumers' => ['baseline_scope', 'compare', 'snapshot', 'review'],
'compatibility_notes' => 'Governed-subject registry lookups remain the canonical bridge from legacy policy-type payloads to platform-safe descriptors.',
]);
}
public function isKnownDomain(string $domainKey): bool
{
return array_key_exists(trim($domainKey), self::DOMAIN_CLASSES);

View File

@ -0,0 +1,60 @@
<?php
declare(strict_types=1);
namespace App\Support\Governance;
use InvalidArgumentException;
final readonly class PlatformSubjectDescriptor
{
public function __construct(
public string $domainKey,
public string $subjectClass,
public string $subjectTypeKey,
public string $subjectTypeLabel,
public string $platformNoun,
public string $displayLabel,
public ?string $legacyPolicyType = null,
public string $ownerLayer = PlatformVocabularyGlossary::OWNER_PLATFORM_CORE,
) {
foreach ([
'domain key' => $this->domainKey,
'subject class' => $this->subjectClass,
'subject type key' => $this->subjectTypeKey,
'subject type label' => $this->subjectTypeLabel,
'platform noun' => $this->platformNoun,
'display label' => $this->displayLabel,
] as $label => $value) {
if (trim($value) === '') {
throw new InvalidArgumentException(sprintf('Platform subject descriptors require a non-empty %s.', $label));
}
}
}
/**
* @return array{
* domain_key: string,
* subject_class: string,
* subject_type_key: string,
* subject_type_label: string,
* platform_noun: string,
* display_label: string,
* legacy_policy_type: ?string,
* owner_layer: string
* }
*/
public function toArray(): array
{
return [
'domain_key' => $this->domainKey,
'subject_class' => $this->subjectClass,
'subject_type_key' => $this->subjectTypeKey,
'subject_type_label' => $this->subjectTypeLabel,
'platform_noun' => $this->platformNoun,
'display_label' => $this->displayLabel,
'legacy_policy_type' => $this->legacyPolicyType,
'owner_layer' => $this->ownerLayer,
];
}
}

View File

@ -0,0 +1,184 @@
<?php
declare(strict_types=1);
namespace App\Support\Governance;
use Illuminate\Support\Str;
final class PlatformSubjectDescriptorNormalizer
{
public function __construct(
private readonly GovernanceSubjectTaxonomyRegistry $registry,
private readonly PlatformVocabularyGlossary $glossary,
) {}
/**
* @param array<string, mixed> $payload
*/
public function fromArray(array $payload, string $sourceSurface = 'runtime'): SubjectDescriptorNormalizationResult
{
$usedLegacySource = ! isset($payload['subject_type_key'])
&& (isset($payload['policy_type']) || isset($payload['subject_type']));
$subjectTypeKey = $this->stringValue(
$payload['subject_type_key']
?? $payload['policy_type']
?? $payload['subject_type']
?? null,
);
$result = $this->normalize(
subjectTypeKey: $subjectTypeKey,
domainKey: $this->stringValue($payload['domain_key'] ?? null),
subjectClass: $this->stringValue($payload['subject_class'] ?? null),
legacyPolicyType: $this->stringValue($payload['policy_type'] ?? null),
sourceSurface: $sourceSurface,
);
if (! $usedLegacySource || $result->usedLegacyAlias) {
return $result;
}
return new SubjectDescriptorNormalizationResult(
descriptor: $result->descriptor,
sourceSurface: $result->sourceSurface,
usedLegacyAlias: true,
warnings: array_values(array_unique(array_merge(
['Resolved a compatibility-only policy_type payload through governed-subject normalization.'],
$result->warnings,
))),
);
}
public function fromLegacyBucket(string $legacyBucket, string $subjectTypeKey, string $sourceSurface = 'runtime'): SubjectDescriptorNormalizationResult
{
$subjectType = $this->registry->findBySubjectTypeKey($subjectTypeKey, $legacyBucket);
return $this->buildResult(
subjectType: $subjectType,
sourceSurface: $sourceSurface,
legacyPolicyType: $subjectTypeKey,
usedLegacyAlias: true,
);
}
public function normalize(
?string $subjectTypeKey,
?string $domainKey = null,
?string $subjectClass = null,
?string $legacyPolicyType = null,
string $sourceSurface = 'runtime',
): SubjectDescriptorNormalizationResult {
$subjectType = null;
if (is_string($subjectTypeKey) && trim($subjectTypeKey) !== '') {
$subjectType = $domainKey !== null
? $this->registry->find($domainKey, $subjectTypeKey)
: $this->registry->findBySubjectTypeKey($subjectTypeKey);
}
if ($subjectType === null && is_string($legacyPolicyType) && trim($legacyPolicyType) !== '') {
return $this->fromLegacyBucket('policy_types', $legacyPolicyType, $sourceSurface);
}
return $this->buildResult(
subjectType: $subjectType,
sourceSurface: $sourceSurface,
explicitDomainKey: $domainKey,
explicitSubjectClass: $subjectClass,
legacyPolicyType: $legacyPolicyType,
usedLegacyAlias: false,
);
}
/**
* @param list<array{domain_key: string, subject_class: string, subject_type_keys: list<string>, filters: array<string, mixed>}> $entries
* @return list<array<string, mixed>>
*/
public function descriptorsForScopeEntries(array $entries): array
{
$descriptors = [];
foreach ($entries as $entry) {
foreach ($entry['subject_type_keys'] as $subjectTypeKey) {
$result = $this->normalize(
subjectTypeKey: $subjectTypeKey,
domainKey: $entry['domain_key'],
subjectClass: $entry['subject_class'],
legacyPolicyType: $subjectTypeKey,
sourceSurface: 'baseline_scope',
);
$descriptors[] = $result->toArray();
}
}
return $descriptors;
}
private function buildResult(
?GovernanceSubjectType $subjectType,
string $sourceSurface,
?string $explicitDomainKey = null,
?string $explicitSubjectClass = null,
?string $legacyPolicyType = null,
bool $usedLegacyAlias = false,
): SubjectDescriptorNormalizationResult {
$platformNoun = $this->glossary->term('governed_subject')?->canonicalLabel ?? 'Governed subject';
$warnings = [];
if (! $subjectType instanceof GovernanceSubjectType) {
$warnings[] = 'Governed-subject descriptor fell back to compatibility-only naming because the subject type could not be resolved in the taxonomy registry.';
return new SubjectDescriptorNormalizationResult(
descriptor: new PlatformSubjectDescriptor(
domainKey: $explicitDomainKey ?? GovernanceDomainKey::Intune->value,
subjectClass: $explicitSubjectClass ?? GovernanceSubjectClass::Policy->value,
subjectTypeKey: $legacyPolicyType ?? 'unknown_subject',
subjectTypeLabel: Str::headline($legacyPolicyType ?? 'Unknown subject'),
platformNoun: $platformNoun,
displayLabel: Str::headline($legacyPolicyType ?? 'Unknown subject'),
legacyPolicyType: $legacyPolicyType,
),
sourceSurface: $sourceSurface,
usedLegacyAlias: true,
warnings: $warnings,
);
}
if ($usedLegacyAlias) {
$warnings[] = sprintf(
'Resolved legacy subject alias "%s" through the governed-subject taxonomy registry for %s.',
$legacyPolicyType ?? $subjectType->subjectTypeKey,
$sourceSurface,
);
}
return new SubjectDescriptorNormalizationResult(
descriptor: new PlatformSubjectDescriptor(
domainKey: $subjectType->domainKey->value,
subjectClass: $subjectType->subjectClass->value,
subjectTypeKey: $subjectType->subjectTypeKey,
subjectTypeLabel: $subjectType->label,
platformNoun: $platformNoun,
displayLabel: $subjectType->label,
legacyPolicyType: $legacyPolicyType,
),
sourceSurface: $sourceSurface,
usedLegacyAlias: $usedLegacyAlias,
warnings: $warnings,
);
}
private function stringValue(mixed $value): ?string
{
if (! is_string($value)) {
return null;
}
$trimmed = trim($value);
return $trimmed !== '' ? $trimmed : null;
}
}

View File

@ -0,0 +1,570 @@
<?php
declare(strict_types=1);
namespace App\Support\Governance;
use App\Support\Inventory\InventoryPolicyTypeMeta;
use App\Support\OperationCatalog;
use App\Support\Providers\ProviderReasonCodes;
final class PlatformVocabularyGlossary
{
public const string BOUNDARY_PLATFORM_CORE = 'platform_core';
public const string BOUNDARY_CROSS_DOMAIN_GOVERNANCE = 'cross_domain_governance';
public const string BOUNDARY_INTUNE_SPECIFIC = 'intune_specific';
public const string OWNER_PLATFORM_CORE = 'platform_core';
public const string OWNER_DOMAIN_OWNED = 'domain_owned';
public const string OWNER_PROVIDER_OWNED = 'provider_owned';
public const string OWNER_COMPATIBILITY_ALIAS = 'compatibility_alias';
public const string OWNER_COMPATIBILITY_ONLY = 'compatibility_only';
/**
* @return list<PlatformVocabularyTerm>
*/
public function terms(): array
{
return array_values(array_map(
static fn (array $term): PlatformVocabularyTerm => PlatformVocabularyTerm::fromArray($term),
$this->configuredTerms(),
));
}
public function term(string $term): ?PlatformVocabularyTerm
{
$normalized = trim(mb_strtolower($term));
foreach ($this->terms() as $candidate) {
if (trim(mb_strtolower($candidate->termKey)) === $normalized) {
return $candidate;
}
}
return $this->resolveAlias($term);
}
public function resolveAlias(string $term, ?string $context = null): ?PlatformVocabularyTerm
{
$normalized = trim(mb_strtolower($term));
$normalizedContext = is_string($context) ? trim(mb_strtolower($context)) : null;
foreach ($this->terms() as $candidate) {
$aliases = array_map(
static fn (string $alias): string => trim(mb_strtolower($alias)),
$candidate->legacyAliases,
);
if (! in_array($normalized, $aliases, true)) {
continue;
}
if ($normalizedContext !== null && $candidate->allowedContexts !== []) {
$contexts = array_map(
static fn (string $allowedContext): string => trim(mb_strtolower($allowedContext)),
$candidate->allowedContexts,
);
if (! in_array($normalizedContext, $contexts, true)) {
continue;
}
}
return $candidate;
}
return null;
}
public function canonicalName(string $term): ?string
{
return $this->term($term)?->termKey;
}
public function isCanonical(string $term): bool
{
$resolved = $this->term($term);
if (! $resolved instanceof PlatformVocabularyTerm) {
return false;
}
return trim(mb_strtolower($term)) === trim(mb_strtolower($resolved->termKey));
}
public function ownership(string $term): ?string
{
return $this->term($term)?->ownerLayer;
}
/**
* @return list<string>
*/
public function canonicalTerms(): array
{
return array_values(array_map(
static fn (PlatformVocabularyTerm $term): string => $term->termKey,
$this->terms(),
));
}
/**
* @return array<string, array{
* term_key: string,
* canonical_label: string,
* canonical_description: string,
* boundary_classification: string,
* owner_layer: string,
* allowed_contexts: list<string>,
* legacy_aliases: list<string>,
* alias_retirement_path: ?string,
* forbidden_platform_aliases: list<string>
* }>
*/
public function termInventory(): array
{
return collect($this->terms())
->mapWithKeys(static fn (PlatformVocabularyTerm $term): array => [
$term->termKey => $term->toArray(),
])
->all();
}
/**
* @return array<string, list<string>>
*/
public function legacyAliases(): array
{
return collect($this->terms())
->filter(static fn (PlatformVocabularyTerm $term): bool => $term->legacyAliases !== [])
->mapWithKeys(static fn (PlatformVocabularyTerm $term): array => [
$term->termKey => $term->legacyAliases,
])
->all();
}
/**
* @return array<string, array{
* canonical_name: string,
* legacy_aliases: list<string>,
* retirement_path: ?string,
* forbidden_platform_aliases: list<string>
* }>
*/
public function aliasRetirementInventory(): array
{
return collect($this->terms())
->filter(static fn (PlatformVocabularyTerm $term): bool => $term->legacyAliases !== [])
->mapWithKeys(static fn (PlatformVocabularyTerm $term): array => [
$term->termKey => [
'canonical_name' => $term->termKey,
'legacy_aliases' => $term->legacyAliases,
'retirement_path' => $term->aliasRetirementPath,
'forbidden_platform_aliases' => $term->forbiddenPlatformAliases,
],
])
->all();
}
public function boundaryClassification(string $term): ?string
{
return $this->term($term)?->boundaryClassification;
}
/**
* @return list<string>
*/
public function allowedBoundaryClassifications(): array
{
return [
self::BOUNDARY_PLATFORM_CORE,
self::BOUNDARY_CROSS_DOMAIN_GOVERNANCE,
self::BOUNDARY_INTUNE_SPECIFIC,
];
}
/**
* @return list<RegistryOwnershipDescriptor>
*/
public function registries(): array
{
return array_values(array_map(
static fn (array $descriptor): RegistryOwnershipDescriptor => RegistryOwnershipDescriptor::fromArray($descriptor),
$this->configuredRegistries(),
));
}
public function registry(string $registryKey): ?RegistryOwnershipDescriptor
{
$normalized = trim(mb_strtolower($registryKey));
foreach ($this->registries() as $descriptor) {
if (trim(mb_strtolower($descriptor->registryKey)) === $normalized) {
return $descriptor;
}
}
return null;
}
/**
* @return array<string, array{
* registry_key: string,
* boundary_classification: string,
* owner_layer: string,
* source_class_or_file: string,
* canonical_nouns: list<string>,
* allowed_consumers: list<string>,
* compatibility_notes: ?string
* }>
*/
public function registryInventory(): array
{
return collect($this->registries())
->mapWithKeys(static fn (RegistryOwnershipDescriptor $descriptor): array => [
$descriptor->registryKey => $descriptor->toArray(),
])
->all();
}
/**
* @return array<string, array{
* owner_namespace: string,
* boundary_classification: string,
* owner_layer: string,
* compatibility_notes: ?string
* }>
*/
public function reasonNamespaceInventory(): array
{
return $this->configuredReasonNamespaces();
}
/**
* @return array{
* owner_namespace: string,
* boundary_classification: string,
* owner_layer: string,
* compatibility_notes: ?string
* }|null
*/
public function reasonNamespace(string $ownerNamespace): ?array
{
$normalized = trim(mb_strtolower($ownerNamespace));
foreach ($this->reasonNamespaceInventory() as $descriptor) {
$candidate = is_string($descriptor['owner_namespace'] ?? null)
? trim(mb_strtolower((string) $descriptor['owner_namespace']))
: null;
if ($candidate === $normalized) {
return $descriptor;
}
}
return null;
}
public function classifyReasonNamespace(string $ownerNamespace): ?string
{
return $this->reasonNamespace($ownerNamespace)['boundary_classification'] ?? null;
}
public function classifyOperationType(string $operationType): ?string
{
if (trim($operationType) === '') {
return null;
}
return $this->registry('operation_catalog')?->boundaryClassification;
}
/**
* @return array<string, array<string, mixed>>
*/
private function configuredTerms(): array
{
$configured = config('tenantpilot.platform_vocabulary.terms');
if (is_array($configured) && $configured !== []) {
return $configured;
}
return [
'governed_subject' => [
'term_key' => 'governed_subject',
'canonical_label' => 'Governed subject',
'canonical_description' => 'The platform-facing noun for a governed object across compare, snapshot, evidence, and review surfaces.',
'boundary_classification' => self::BOUNDARY_PLATFORM_CORE,
'owner_layer' => self::OWNER_PLATFORM_CORE,
'allowed_contexts' => ['compare', 'snapshot', 'evidence', 'review', 'reporting'],
'legacy_aliases' => ['policy_type'],
'alias_retirement_path' => 'Retire false-universal policy_type wording from platform-owned summaries and descriptors while preserving Intune-owned storage.',
'forbidden_platform_aliases' => ['policy_type'],
],
'domain_key' => [
'term_key' => 'domain_key',
'canonical_label' => 'Governance domain',
'canonical_description' => 'The canonical domain discriminator for cross-domain and platform-near contracts.',
'boundary_classification' => self::BOUNDARY_CROSS_DOMAIN_GOVERNANCE,
'owner_layer' => self::OWNER_PLATFORM_CORE,
'allowed_contexts' => ['governance', 'compare', 'snapshot', 'reporting'],
'legacy_aliases' => [],
'alias_retirement_path' => null,
'forbidden_platform_aliases' => [],
],
'subject_class' => [
'term_key' => 'subject_class',
'canonical_label' => 'Subject class',
'canonical_description' => 'The canonical subject-class discriminator for governed-subject contracts.',
'boundary_classification' => self::BOUNDARY_CROSS_DOMAIN_GOVERNANCE,
'owner_layer' => self::OWNER_PLATFORM_CORE,
'allowed_contexts' => ['governance', 'compare', 'snapshot'],
'legacy_aliases' => [],
'alias_retirement_path' => null,
'forbidden_platform_aliases' => [],
],
'subject_type_key' => [
'term_key' => 'subject_type_key',
'canonical_label' => 'Governed subject key',
'canonical_description' => 'The domain-owned subject-family key used by platform-near descriptors.',
'boundary_classification' => self::BOUNDARY_CROSS_DOMAIN_GOVERNANCE,
'owner_layer' => self::OWNER_PLATFORM_CORE,
'allowed_contexts' => ['governance', 'compare', 'snapshot', 'evidence'],
'legacy_aliases' => ['policy_type'],
'alias_retirement_path' => 'Prefer subject_type_key on platform-near payloads and keep policy_type only where the owning model is Intune-specific.',
'forbidden_platform_aliases' => ['policy_type'],
],
'subject_type_label' => [
'term_key' => 'subject_type_label',
'canonical_label' => 'Governed subject label',
'canonical_description' => 'The operator-facing label for a governed subject family.',
'boundary_classification' => self::BOUNDARY_CROSS_DOMAIN_GOVERNANCE,
'owner_layer' => self::OWNER_PLATFORM_CORE,
'allowed_contexts' => ['snapshot', 'evidence', 'compare', 'review'],
'legacy_aliases' => [],
'alias_retirement_path' => null,
'forbidden_platform_aliases' => [],
],
'resource_type' => [
'term_key' => 'resource_type',
'canonical_label' => 'Resource type',
'canonical_description' => 'Optional resource-shaped noun for platform-facing summaries when a governed subject also needs a resource family label.',
'boundary_classification' => self::BOUNDARY_PLATFORM_CORE,
'owner_layer' => self::OWNER_PLATFORM_CORE,
'allowed_contexts' => ['reporting', 'review'],
'legacy_aliases' => [],
'alias_retirement_path' => null,
'forbidden_platform_aliases' => [],
],
'operation_type' => [
'term_key' => 'operation_type',
'canonical_label' => 'Operation type',
'canonical_description' => 'The canonical platform operation identifier shown on monitoring and launch surfaces.',
'boundary_classification' => self::BOUNDARY_PLATFORM_CORE,
'owner_layer' => self::OWNER_PLATFORM_CORE,
'allowed_contexts' => ['monitoring', 'reporting', 'launch_surfaces'],
'legacy_aliases' => ['type'],
'alias_retirement_path' => 'Expose canonical operation_type on read models while operation_runs.type remains a compatibility storage seam during rollout.',
'forbidden_platform_aliases' => [],
],
'platform_reason_family' => [
'term_key' => 'platform_reason_family',
'canonical_label' => 'Platform reason family',
'canonical_description' => 'The cross-domain platform family that explains the top-level cause category without erasing domain ownership.',
'boundary_classification' => self::BOUNDARY_PLATFORM_CORE,
'owner_layer' => self::OWNER_PLATFORM_CORE,
'allowed_contexts' => ['reason_translation', 'monitoring', 'review', 'reporting'],
'legacy_aliases' => [],
'alias_retirement_path' => null,
'forbidden_platform_aliases' => [],
],
'reason_owner.owner_namespace' => [
'term_key' => 'reason_owner.owner_namespace',
'canonical_label' => 'Reason owner namespace',
'canonical_description' => 'The explicit namespace that marks whether a translated reason is provider-owned, governance-owned, access-owned, or runtime-owned.',
'boundary_classification' => self::BOUNDARY_PLATFORM_CORE,
'owner_layer' => self::OWNER_PLATFORM_CORE,
'allowed_contexts' => ['reason_translation', 'review', 'reporting'],
'legacy_aliases' => [],
'alias_retirement_path' => null,
'forbidden_platform_aliases' => [],
],
'reason_code' => [
'term_key' => 'reason_code',
'canonical_label' => 'Reason code',
'canonical_description' => 'The original domain-owned or provider-owned reason identifier preserved for diagnostics and translation lookup.',
'boundary_classification' => self::BOUNDARY_PLATFORM_CORE,
'owner_layer' => self::OWNER_PLATFORM_CORE,
'allowed_contexts' => ['reason_translation', 'diagnostics'],
'legacy_aliases' => [],
'alias_retirement_path' => null,
'forbidden_platform_aliases' => [],
],
'registry_key' => [
'term_key' => 'registry_key',
'canonical_label' => 'Registry key',
'canonical_description' => 'The stable identifier for a contributor-facing registry or catalog.',
'boundary_classification' => self::BOUNDARY_PLATFORM_CORE,
'owner_layer' => self::OWNER_PLATFORM_CORE,
'allowed_contexts' => ['governance', 'contributor_guidance'],
'legacy_aliases' => [],
'alias_retirement_path' => null,
'forbidden_platform_aliases' => [],
],
'boundary_classification' => [
'term_key' => 'boundary_classification',
'canonical_label' => 'Boundary classification',
'canonical_description' => 'The explicit classification that marks a term or registry as platform_core, cross_domain_governance, or intune_specific.',
'boundary_classification' => self::BOUNDARY_PLATFORM_CORE,
'owner_layer' => self::OWNER_PLATFORM_CORE,
'allowed_contexts' => ['governance', 'contributor_guidance'],
'legacy_aliases' => [],
'alias_retirement_path' => null,
'forbidden_platform_aliases' => [],
],
'policy_type' => [
'term_key' => 'policy_type',
'canonical_label' => 'Intune policy type',
'canonical_description' => 'The Intune-specific discriminator that remains valid on adapter-owned or Intune-owned models but must not be treated as a universal platform noun.',
'boundary_classification' => self::BOUNDARY_INTUNE_SPECIFIC,
'owner_layer' => self::OWNER_DOMAIN_OWNED,
'allowed_contexts' => ['intune_adapter', 'intune_inventory', 'intune_backup'],
'legacy_aliases' => [],
'alias_retirement_path' => null,
'forbidden_platform_aliases' => ['governed_subject'],
],
];
}
/**
* @return array<string, array<string, mixed>>
*/
private function configuredRegistries(): array
{
$configured = config('tenantpilot.platform_vocabulary.registries');
if (is_array($configured) && $configured !== []) {
return $configured;
}
return [
'governance_subject_taxonomy_registry' => [
'registry_key' => 'governance_subject_taxonomy_registry',
'boundary_classification' => self::BOUNDARY_CROSS_DOMAIN_GOVERNANCE,
'owner_layer' => self::OWNER_PLATFORM_CORE,
'source_class_or_file' => GovernanceSubjectTaxonomyRegistry::class,
'canonical_nouns' => ['domain_key', 'subject_class', 'subject_type_key', 'subject_type_label'],
'allowed_consumers' => ['baseline_scope', 'compare', 'snapshot', 'review'],
'compatibility_notes' => 'Provides the governed-subject source of truth, resolves legacy policy-type payloads, and preserves inactive or future-domain entries without exposing them as active operator choices.',
],
'operation_catalog' => [
'registry_key' => 'operation_catalog',
'boundary_classification' => self::BOUNDARY_PLATFORM_CORE,
'owner_layer' => self::OWNER_PLATFORM_CORE,
'source_class_or_file' => OperationCatalog::class,
'canonical_nouns' => ['operation_type'],
'allowed_consumers' => ['monitoring', 'reporting', 'launch_surfaces', 'audit'],
'compatibility_notes' => 'Resolves canonical operation meaning from historical storage values without treating every stored raw string as equally canonical.',
],
'provider_reason_codes' => [
'registry_key' => 'provider_reason_codes',
'boundary_classification' => self::BOUNDARY_INTUNE_SPECIFIC,
'owner_layer' => self::OWNER_PROVIDER_OWNED,
'source_class_or_file' => ProviderReasonCodes::class,
'canonical_nouns' => ['reason_code'],
'allowed_consumers' => ['reason_translation'],
'compatibility_notes' => 'Provider-owned reason codes remain namespaced domain details and become platform-safe only through the reason-translation boundary.',
],
'inventory_policy_type_catalog' => [
'registry_key' => 'inventory_policy_type_catalog',
'boundary_classification' => self::BOUNDARY_INTUNE_SPECIFIC,
'owner_layer' => self::OWNER_DOMAIN_OWNED,
'source_class_or_file' => InventoryPolicyTypeMeta::class,
'canonical_nouns' => ['policy_type'],
'allowed_consumers' => ['intune_adapter', 'inventory', 'backup'],
'compatibility_notes' => 'The supported Intune policy-type list is not a universal platform registry and must be wrapped before reuse on platform-near summaries.',
],
];
}
/**
* @return array<string, array{
* owner_namespace: string,
* boundary_classification: string,
* owner_layer: string,
* compatibility_notes: ?string
* }>
*/
private function configuredReasonNamespaces(): array
{
$configured = config('tenantpilot.platform_vocabulary.reason_namespaces');
if (is_array($configured) && $configured !== []) {
return $configured;
}
return [
'tenant_operability' => [
'owner_namespace' => 'tenant_operability',
'boundary_classification' => self::BOUNDARY_PLATFORM_CORE,
'owner_layer' => self::OWNER_PLATFORM_CORE,
'compatibility_notes' => 'Tenant operability reasons are platform-core guardrails for workspace and tenant context.',
],
'execution_denial' => [
'owner_namespace' => 'execution_denial',
'boundary_classification' => self::BOUNDARY_PLATFORM_CORE,
'owner_layer' => self::OWNER_PLATFORM_CORE,
'compatibility_notes' => 'Execution denial reasons remain platform-core run-legitimacy semantics.',
],
'operation_lifecycle' => [
'owner_namespace' => 'operation_lifecycle',
'boundary_classification' => self::BOUNDARY_PLATFORM_CORE,
'owner_layer' => self::OWNER_PLATFORM_CORE,
'compatibility_notes' => 'Lifecycle reconciliation reasons remain platform-core monitoring semantics.',
],
'governance.baseline_compare' => [
'owner_namespace' => 'governance.baseline_compare',
'boundary_classification' => self::BOUNDARY_CROSS_DOMAIN_GOVERNANCE,
'owner_layer' => self::OWNER_DOMAIN_OWNED,
'compatibility_notes' => 'Baseline-compare reason codes are governance-owned details translated through the platform boundary.',
],
'governance.artifact_truth' => [
'owner_namespace' => 'governance.artifact_truth',
'boundary_classification' => self::BOUNDARY_CROSS_DOMAIN_GOVERNANCE,
'owner_layer' => self::OWNER_DOMAIN_OWNED,
'compatibility_notes' => 'Artifact-truth reason codes remain governance-owned and are surfaced through platform-safe summaries.',
],
'provider.microsoft_graph' => [
'owner_namespace' => 'provider.microsoft_graph',
'boundary_classification' => self::BOUNDARY_INTUNE_SPECIFIC,
'owner_layer' => self::OWNER_PROVIDER_OWNED,
'compatibility_notes' => 'Microsoft Graph provider reasons remain provider-owned and Intune-specific.',
],
'provider.intune_rbac' => [
'owner_namespace' => 'provider.intune_rbac',
'boundary_classification' => self::BOUNDARY_INTUNE_SPECIFIC,
'owner_layer' => self::OWNER_PROVIDER_OWNED,
'compatibility_notes' => 'Provider-owned Intune RBAC reasons remain Intune-specific.',
],
'rbac.intune' => [
'owner_namespace' => 'rbac.intune',
'boundary_classification' => self::BOUNDARY_INTUNE_SPECIFIC,
'owner_layer' => self::OWNER_DOMAIN_OWNED,
'compatibility_notes' => 'RBAC detail remains Intune-specific domain context.',
],
'reason_translation.fallback' => [
'owner_namespace' => 'reason_translation.fallback',
'boundary_classification' => self::BOUNDARY_PLATFORM_CORE,
'owner_layer' => self::OWNER_PLATFORM_CORE,
'compatibility_notes' => 'Fallback translation remains a platform-core compatibility seam until a domain owner is known.',
],
];
}
}

View File

@ -0,0 +1,110 @@
<?php
declare(strict_types=1);
namespace App\Support\Governance;
use InvalidArgumentException;
final readonly class PlatformVocabularyTerm
{
/**
* @param list<string> $allowedContexts
* @param list<string> $legacyAliases
* @param list<string> $forbiddenPlatformAliases
*/
public function __construct(
public string $termKey,
public string $canonicalLabel,
public string $canonicalDescription,
public string $boundaryClassification,
public string $ownerLayer,
public array $allowedContexts = [],
public array $legacyAliases = [],
public ?string $aliasRetirementPath = null,
public array $forbiddenPlatformAliases = [],
) {
if (trim($this->termKey) === '' || trim($this->canonicalLabel) === '' || trim($this->canonicalDescription) === '') {
throw new InvalidArgumentException('Platform vocabulary terms require a key, label, and description.');
}
if ($this->legacyAliases !== [] && blank($this->aliasRetirementPath)) {
throw new InvalidArgumentException('Platform vocabulary terms with legacy aliases must declare an alias retirement path.');
}
}
/**
* @param array<string, mixed> $data
*/
public static function fromArray(array $data): self
{
return new self(
termKey: (string) ($data['term_key'] ?? ''),
canonicalLabel: (string) ($data['canonical_label'] ?? ''),
canonicalDescription: (string) ($data['canonical_description'] ?? ''),
boundaryClassification: (string) ($data['boundary_classification'] ?? ''),
ownerLayer: (string) ($data['owner_layer'] ?? ''),
allowedContexts: self::stringList($data['allowed_contexts'] ?? []),
legacyAliases: self::stringList($data['legacy_aliases'] ?? []),
aliasRetirementPath: is_string($data['alias_retirement_path'] ?? null)
? trim((string) $data['alias_retirement_path'])
: null,
forbiddenPlatformAliases: self::stringList($data['forbidden_platform_aliases'] ?? []),
);
}
public function matches(string $term): bool
{
$normalized = trim(mb_strtolower($term));
if ($normalized === '') {
return false;
}
return in_array($normalized, array_map(
static fn (string $candidate): string => trim(mb_strtolower($candidate)),
array_merge([$this->termKey], $this->legacyAliases),
), true);
}
/**
* @return array{
* term_key: string,
* canonical_label: string,
* canonical_description: string,
* boundary_classification: string,
* owner_layer: string,
* allowed_contexts: list<string>,
* legacy_aliases: list<string>,
* alias_retirement_path: ?string,
* forbidden_platform_aliases: list<string>
* }
*/
public function toArray(): array
{
return [
'term_key' => $this->termKey,
'canonical_label' => $this->canonicalLabel,
'canonical_description' => $this->canonicalDescription,
'boundary_classification' => $this->boundaryClassification,
'owner_layer' => $this->ownerLayer,
'allowed_contexts' => $this->allowedContexts,
'legacy_aliases' => $this->legacyAliases,
'alias_retirement_path' => $this->aliasRetirementPath,
'forbidden_platform_aliases' => $this->forbiddenPlatformAliases,
];
}
/**
* @param iterable<mixed> $values
* @return list<string>
*/
private static function stringList(iterable $values): array
{
return collect($values)
->filter(static fn (mixed $value): bool => is_string($value) && trim($value) !== '')
->map(static fn (string $value): string => trim($value))
->values()
->all();
}
}

View File

@ -0,0 +1,87 @@
<?php
declare(strict_types=1);
namespace App\Support\Governance;
use InvalidArgumentException;
final readonly class RegistryOwnershipDescriptor
{
/**
* @param list<string> $canonicalNouns
* @param list<string> $allowedConsumers
*/
public function __construct(
public string $registryKey,
public string $boundaryClassification,
public string $ownerLayer,
public string $sourceClassOrFile,
public array $canonicalNouns,
public array $allowedConsumers,
public ?string $compatibilityNotes = null,
) {
if (trim($this->registryKey) === '' || trim($this->sourceClassOrFile) === '') {
throw new InvalidArgumentException('Registry ownership descriptors require a registry key and source reference.');
}
if ($this->canonicalNouns === [] || $this->allowedConsumers === []) {
throw new InvalidArgumentException('Registry ownership descriptors require canonical nouns and allowed consumers.');
}
}
/**
* @param array<string, mixed> $data
*/
public static function fromArray(array $data): self
{
return new self(
registryKey: (string) ($data['registry_key'] ?? ''),
boundaryClassification: (string) ($data['boundary_classification'] ?? ''),
ownerLayer: (string) ($data['owner_layer'] ?? ''),
sourceClassOrFile: (string) ($data['source_class_or_file'] ?? ''),
canonicalNouns: self::stringList($data['canonical_nouns'] ?? []),
allowedConsumers: self::stringList($data['allowed_consumers'] ?? []),
compatibilityNotes: is_string($data['compatibility_notes'] ?? null)
? trim((string) $data['compatibility_notes'])
: null,
);
}
/**
* @return array{
* registry_key: string,
* boundary_classification: string,
* owner_layer: string,
* source_class_or_file: string,
* canonical_nouns: list<string>,
* allowed_consumers: list<string>,
* compatibility_notes: ?string
* }
*/
public function toArray(): array
{
return [
'registry_key' => $this->registryKey,
'boundary_classification' => $this->boundaryClassification,
'owner_layer' => $this->ownerLayer,
'source_class_or_file' => $this->sourceClassOrFile,
'canonical_nouns' => $this->canonicalNouns,
'allowed_consumers' => $this->allowedConsumers,
'compatibility_notes' => $this->compatibilityNotes,
];
}
/**
* @param iterable<mixed> $values
* @return list<string>
*/
private static function stringList(iterable $values): array
{
return collect($values)
->filter(static fn (mixed $value): bool => is_string($value) && trim($value) !== '')
->map(static fn (string $value): string => trim($value))
->values()
->all();
}
}

View File

@ -0,0 +1,45 @@
<?php
declare(strict_types=1);
namespace App\Support\Governance;
final readonly class SubjectDescriptorNormalizationResult
{
/**
* @param list<string> $warnings
*/
public function __construct(
public PlatformSubjectDescriptor $descriptor,
public string $sourceSurface,
public bool $usedLegacyAlias = false,
public array $warnings = [],
) {}
/**
* @return array{
* descriptor: array{
* domain_key: string,
* subject_class: string,
* subject_type_key: string,
* subject_type_label: string,
* platform_noun: string,
* display_label: string,
* legacy_policy_type: ?string,
* owner_layer: string
* },
* source_surface: string,
* used_legacy_alias: bool,
* warnings: list<string>
* }
*/
public function toArray(): array
{
return [
'descriptor' => $this->descriptor->toArray(),
'source_surface' => $this->sourceSurface,
'used_legacy_alias' => $this->usedLegacyAlias,
'warnings' => $this->warnings,
];
}
}

View File

@ -1,7 +1,11 @@
<?php
declare(strict_types=1);
namespace App\Support;
use App\Support\Governance\PlatformVocabularyGlossary;
use App\Support\Governance\RegistryOwnershipDescriptor;
use App\Support\OpsUx\OperationSummaryKeys;
final class OperationCatalog
@ -13,51 +17,34 @@ final class OperationCatalog
*/
public static function labels(): array
{
return [
'policy.sync' => 'Policy sync',
'policy.sync_one' => 'Policy sync',
'policy.capture_snapshot' => 'Policy snapshot',
'policy.delete' => 'Delete policies',
'policy.unignore' => 'Restore policies',
'policy.export' => 'Export policies to backup',
'provider.connection.check' => 'Provider connection check',
'inventory_sync' => 'Inventory sync',
'compliance.snapshot' => 'Compliance snapshot',
'provider.inventory.sync' => 'Inventory sync',
'provider.compliance.snapshot' => 'Compliance snapshot',
'entra_group_sync' => 'Directory groups sync',
'backup_set.add_policies' => 'Backup set update',
'backup_set.remove_policies' => 'Backup set update',
'backup_set.delete' => 'Archive backup sets',
'backup_set.restore' => 'Restore backup sets',
'backup_set.force_delete' => 'Delete backup sets',
'backup_schedule_run' => 'Backup schedule run',
'backup_schedule_retention' => 'Backup schedule retention',
'backup_schedule_purge' => 'Backup schedule purge',
'restore.execute' => 'Restore execution',
'assignments.fetch' => 'Assignment fetch',
'assignments.restore' => 'Assignment restore',
'ops.reconcile_adapter_runs' => 'Reconcile adapter runs',
'directory_role_definitions.sync' => 'Role definitions sync',
'restore_run.delete' => 'Delete restore runs',
'restore_run.restore' => 'Restore restore runs',
'restore_run.force_delete' => 'Force delete restore runs',
'tenant.sync' => 'Tenant sync',
'policy_version.prune' => 'Prune policy versions',
'policy_version.restore' => 'Restore policy versions',
'policy_version.force_delete' => 'Delete policy versions',
'alerts.evaluate' => 'Alerts evaluation',
'alerts.deliver' => 'Alerts delivery',
'baseline_capture' => 'Baseline capture',
'baseline_compare' => 'Baseline compare',
'permission_posture_check' => 'Permission posture check',
'entra.admin_roles.scan' => 'Entra admin roles scan',
'tenant.review_pack.generate' => 'Review pack generation',
'tenant.review.compose' => 'Review composition',
'tenant.evidence.snapshot.generate' => 'Evidence snapshot generation',
'rbac.health_check' => 'RBAC health check',
'findings.lifecycle.backfill' => 'Findings lifecycle backfill',
];
$labels = [];
foreach (self::operationAliases() as $alias) {
$labels[$alias->rawValue] = self::canonicalDefinitions()[$alias->canonicalCode]->displayLabel;
}
return $labels;
}
/**
* @return array<string, array{
* canonical_code: string,
* domain_key: ?string,
* artifact_family: ?string,
* display_label: string,
* supports_operator_explanation: bool,
* expected_duration_seconds: ?int
* }>
*/
public static function canonicalInventory(): array
{
$inventory = [];
foreach (self::canonicalDefinitions() as $canonicalCode => $definition) {
$inventory[$canonicalCode] = $definition->toArray();
}
return $inventory;
}
public static function label(string $operationType): string
@ -68,34 +55,12 @@ public static function label(string $operationType): string
return 'Operation';
}
return self::labels()[$operationType] ?? 'Unknown operation';
return self::resolve($operationType)->canonical->displayLabel;
}
public static function expectedDurationSeconds(string $operationType): ?int
{
return match (trim($operationType)) {
'policy.sync', 'policy.sync_one' => 90,
'provider.connection.check' => 30,
'policy.export' => 120,
'inventory_sync' => 180,
'compliance.snapshot' => 180,
'provider.inventory.sync' => 180,
'provider.compliance.snapshot' => 180,
'entra_group_sync' => 120,
'assignments.fetch', 'assignments.restore' => 60,
'ops.reconcile_adapter_runs' => 120,
'alerts.evaluate', 'alerts.deliver' => 120,
'baseline_capture' => 120,
'baseline_compare' => 120,
'permission_posture_check' => 30,
'entra.admin_roles.scan' => 60,
'tenant.review_pack.generate' => 60,
'tenant.review.compose' => 60,
'tenant.evidence.snapshot.generate' => 120,
'rbac.health_check' => 30,
'findings.lifecycle.backfill' => 300,
default => null,
};
return self::resolve($operationType)->canonical->expectedDurationSeconds;
}
/**
@ -108,13 +73,7 @@ public static function allowedSummaryKeys(): array
public static function governanceArtifactFamily(string $operationType): ?string
{
return match (trim($operationType)) {
'baseline_capture' => 'baseline_snapshot',
'tenant.evidence.snapshot.generate' => 'evidence_snapshot',
'tenant.review.compose' => 'tenant_review',
'tenant.review_pack.generate' => 'review_pack',
default => null,
};
return self::resolve($operationType)->canonical->artifactFamily;
}
public static function isGovernanceArtifactOperation(string $operationType): bool
@ -124,9 +83,227 @@ public static function isGovernanceArtifactOperation(string $operationType): boo
public static function supportsOperatorExplanation(string $operationType): bool
{
$operationType = trim($operationType);
return self::resolve($operationType)->canonical->supportsOperatorExplanation;
}
return self::isGovernanceArtifactOperation($operationType)
|| $operationType === 'baseline_compare';
public static function canonicalCode(string $operationType): string
{
return self::resolve($operationType)->canonical->canonicalCode;
}
/**
* @return list<string>
*/
public static function canonicalNouns(): array
{
return ['operation_type'];
}
public static function ownershipDescriptor(?PlatformVocabularyGlossary $glossary = null): RegistryOwnershipDescriptor
{
$glossary ??= app(PlatformVocabularyGlossary::class);
return $glossary->registry('operation_catalog')
?? RegistryOwnershipDescriptor::fromArray([
'registry_key' => 'operation_catalog',
'boundary_classification' => PlatformVocabularyGlossary::BOUNDARY_PLATFORM_CORE,
'owner_layer' => PlatformVocabularyGlossary::OWNER_PLATFORM_CORE,
'source_class_or_file' => self::class,
'canonical_nouns' => self::canonicalNouns(),
'allowed_consumers' => ['monitoring', 'reporting', 'launch_surfaces', 'audit'],
'compatibility_notes' => 'Resolves canonical operation meaning from historical storage values without treating every stored raw string as equally canonical.',
]);
}
public static function boundaryClassification(?PlatformVocabularyGlossary $glossary = null): string
{
return self::ownershipDescriptor($glossary)->boundaryClassification;
}
/**
* @return list<string>
*/
public static function rawValuesForCanonical(string $canonicalCode): array
{
return array_values(array_map(
static fn (OperationTypeAlias $alias): string => $alias->rawValue,
array_filter(
self::operationAliases(),
static fn (OperationTypeAlias $alias): bool => $alias->canonicalCode === trim($canonicalCode),
),
));
}
/**
* @param iterable<mixed>|null $types
* @return array<string, string>
*/
public static function filterOptions(?iterable $types = null): array
{
$values = collect($types ?? array_keys(self::labels()))
->filter(static fn (mixed $type): bool => is_string($type) && trim($type) !== '')
->map(static fn (string $type): string => trim($type))
->mapWithKeys(static fn (string $type): array => [self::canonicalCode($type) => self::label($type)])
->sortBy(static fn (string $label): string => $label)
->all();
return $values;
}
/**
* @return array<string, array{
* raw_value: string,
* canonical_name: string,
* alias_status: string,
* write_allowed: bool,
* deprecation_note: ?string,
* retirement_path: ?string
* }>
*/
public static function aliasInventory(): array
{
$inventory = [];
foreach (self::operationAliases() as $alias) {
$inventory[$alias->rawValue] = $alias->retirementMetadata();
}
return $inventory;
}
public static function resolve(string $operationType): OperationTypeResolution
{
$operationType = trim($operationType);
$aliases = self::operationAliases();
$matchedAlias = collect($aliases)
->first(static fn (OperationTypeAlias $alias): bool => $alias->rawValue === $operationType);
if ($matchedAlias instanceof OperationTypeAlias) {
return new OperationTypeResolution(
rawValue: $operationType,
canonical: self::canonicalDefinitions()[$matchedAlias->canonicalCode],
aliasesConsidered: array_values(array_filter(
$aliases,
static fn (OperationTypeAlias $alias): bool => $alias->canonicalCode === $matchedAlias->canonicalCode,
)),
aliasStatus: $matchedAlias->aliasStatus,
wasLegacyAlias: $matchedAlias->aliasStatus !== 'canonical',
);
}
return new OperationTypeResolution(
rawValue: $operationType,
canonical: new CanonicalOperationType(
canonicalCode: $operationType,
domainKey: null,
artifactFamily: null,
displayLabel: 'Unknown operation',
supportsOperatorExplanation: false,
expectedDurationSeconds: null,
),
aliasesConsidered: [],
aliasStatus: 'unknown',
wasLegacyAlias: false,
);
}
/**
* @return array<string, CanonicalOperationType>
*/
private static function canonicalDefinitions(): array
{
return [
'policy.sync' => new CanonicalOperationType('policy.sync', 'intune', null, 'Policy sync', false, 90),
'policy.snapshot' => new CanonicalOperationType('policy.snapshot', 'intune', null, 'Policy snapshot', false, 120),
'policy.delete' => new CanonicalOperationType('policy.delete', 'intune', null, 'Delete policies'),
'policy.restore' => new CanonicalOperationType('policy.restore', 'intune', null, 'Restore policies'),
'policy.export' => new CanonicalOperationType('policy.export', 'intune', null, 'Export policies to backup', false, 120),
'provider.connection.check' => new CanonicalOperationType('provider.connection.check', 'intune', null, 'Provider connection check', false, 30),
'inventory.sync' => new CanonicalOperationType('inventory.sync', 'intune', null, 'Inventory sync', false, 180),
'compliance.snapshot' => new CanonicalOperationType('compliance.snapshot', 'intune', null, 'Compliance snapshot', false, 180),
'directory.groups.sync' => new CanonicalOperationType('directory.groups.sync', 'entra', null, 'Directory groups sync', false, 120),
'backup_set.update' => new CanonicalOperationType('backup_set.update', 'intune', null, 'Backup set update'),
'backup_set.archive' => new CanonicalOperationType('backup_set.archive', 'intune', null, 'Archive backup sets'),
'backup_set.restore' => new CanonicalOperationType('backup_set.restore', 'intune', null, 'Restore backup sets'),
'backup_set.delete' => new CanonicalOperationType('backup_set.delete', 'intune', null, 'Delete backup sets'),
'backup.schedule.execute' => new CanonicalOperationType('backup.schedule.execute', 'intune', null, 'Backup schedule run'),
'backup.schedule.retention' => new CanonicalOperationType('backup.schedule.retention', 'intune', null, 'Backup schedule retention'),
'backup.schedule.purge' => new CanonicalOperationType('backup.schedule.purge', 'intune', null, 'Backup schedule purge'),
'restore.execute' => new CanonicalOperationType('restore.execute', 'intune', null, 'Restore execution'),
'assignments.fetch' => new CanonicalOperationType('assignments.fetch', 'intune', null, 'Assignment fetch', false, 60),
'assignments.restore' => new CanonicalOperationType('assignments.restore', 'intune', null, 'Assignment restore', false, 60),
'ops.reconcile_adapter_runs' => new CanonicalOperationType('ops.reconcile_adapter_runs', 'platform_foundation', null, 'Reconcile adapter runs', false, 120),
'directory.role_definitions.sync' => new CanonicalOperationType('directory.role_definitions.sync', 'entra', null, 'Role definitions sync'),
'restore_run.delete' => new CanonicalOperationType('restore_run.delete', 'intune', null, 'Delete restore runs'),
'restore_run.restore' => new CanonicalOperationType('restore_run.restore', 'intune', null, 'Restore restore runs'),
'restore_run.force_delete' => new CanonicalOperationType('restore_run.force_delete', 'intune', null, 'Force delete restore runs'),
'tenant.sync' => new CanonicalOperationType('tenant.sync', 'platform_foundation', null, 'Tenant sync'),
'policy_version.prune' => new CanonicalOperationType('policy_version.prune', 'intune', null, 'Prune policy versions'),
'policy_version.restore' => new CanonicalOperationType('policy_version.restore', 'intune', null, 'Restore policy versions'),
'policy_version.force_delete' => new CanonicalOperationType('policy_version.force_delete', 'intune', null, 'Delete policy versions'),
'alerts.evaluate' => new CanonicalOperationType('alerts.evaluate', 'platform_foundation', null, 'Alerts evaluation', false, 120),
'alerts.deliver' => new CanonicalOperationType('alerts.deliver', 'platform_foundation', null, 'Alerts delivery', false, 120),
'baseline.capture' => new CanonicalOperationType('baseline.capture', 'platform_foundation', 'baseline_snapshot', 'Baseline capture', true, 120),
'baseline.compare' => new CanonicalOperationType('baseline.compare', 'platform_foundation', null, 'Baseline compare', true, 120),
'permission.posture.check' => new CanonicalOperationType('permission.posture.check', 'platform_foundation', null, 'Permission posture check', false, 30),
'entra.admin_roles.scan' => new CanonicalOperationType('entra.admin_roles.scan', 'entra', null, 'Entra admin roles scan', false, 60),
'tenant.review_pack.generate' => new CanonicalOperationType('tenant.review_pack.generate', 'platform_foundation', 'review_pack', 'Review pack generation', true, 60),
'tenant.review.compose' => new CanonicalOperationType('tenant.review.compose', 'platform_foundation', 'tenant_review', 'Review composition', true, 60),
'tenant.evidence.snapshot.generate' => new CanonicalOperationType('tenant.evidence.snapshot.generate', 'platform_foundation', 'evidence_snapshot', 'Evidence snapshot generation', true, 120),
'rbac.health_check' => new CanonicalOperationType('rbac.health_check', 'intune', null, 'RBAC health check', false, 30),
'findings.lifecycle.backfill' => new CanonicalOperationType('findings.lifecycle.backfill', 'platform_foundation', null, 'Findings lifecycle backfill', false, 300),
];
}
/**
* @return list<OperationTypeAlias>
*/
private static function operationAliases(): array
{
return [
new OperationTypeAlias('policy.sync', 'policy.sync', 'canonical', true),
new OperationTypeAlias('policy.sync_one', 'policy.sync', 'legacy_alias', true, 'Legacy single-policy sync values resolve to the canonical policy.sync operation.', 'Prefer policy.sync on platform-owned read paths.'),
new OperationTypeAlias('policy.capture_snapshot', 'policy.snapshot', 'canonical', true),
new OperationTypeAlias('policy.delete', 'policy.delete', 'canonical', true),
new OperationTypeAlias('policy.unignore', 'policy.restore', 'legacy_alias', true, 'Legacy policy.unignore values resolve to policy.restore for operator-facing wording.', 'Prefer policy.restore on new platform-owned read models.'),
new OperationTypeAlias('policy.export', 'policy.export', 'canonical', true),
new OperationTypeAlias('provider.connection.check', 'provider.connection.check', 'canonical', true),
new OperationTypeAlias('inventory_sync', 'inventory.sync', 'legacy_alias', true, 'Legacy inventory_sync storage values resolve to the canonical inventory.sync operation.', 'Preserve stored values during rollout while showing inventory.sync semantics on read paths.'),
new OperationTypeAlias('provider.inventory.sync', 'inventory.sync', 'legacy_alias', false, 'Provider-prefixed historical inventory sync values share the same operator meaning as inventory sync.', 'Avoid emitting provider.inventory.sync on new platform-owned surfaces.'),
new OperationTypeAlias('compliance.snapshot', 'compliance.snapshot', 'canonical', true),
new OperationTypeAlias('provider.compliance.snapshot', 'compliance.snapshot', 'legacy_alias', false, 'Provider-prefixed compliance snapshot values resolve to the canonical compliance.snapshot operation.', 'Avoid emitting provider.compliance.snapshot on new platform-owned surfaces.'),
new OperationTypeAlias('entra_group_sync', 'directory.groups.sync', 'legacy_alias', true, 'Historical entra_group_sync values resolve to directory.groups.sync.', 'Prefer directory.groups.sync on new platform-owned read models.'),
new OperationTypeAlias('backup_set.add_policies', 'backup_set.update', 'canonical', true),
new OperationTypeAlias('backup_set.remove_policies', 'backup_set.update', 'legacy_alias', true, 'Removal and addition both resolve to the same backup-set update operator meaning.', 'Use backup_set.update for canonical reporting buckets.'),
new OperationTypeAlias('backup_set.delete', 'backup_set.archive', 'canonical', true),
new OperationTypeAlias('backup_set.restore', 'backup_set.restore', 'canonical', true),
new OperationTypeAlias('backup_set.force_delete', 'backup_set.delete', 'legacy_alias', true, 'Force-delete wording is normalized to the canonical delete label.', 'Use backup_set.delete for new platform-owned summaries.'),
new OperationTypeAlias('backup_schedule_run', 'backup.schedule.execute', 'legacy_alias', true, 'Historical backup_schedule_run values resolve to backup.schedule.execute.', 'Prefer backup.schedule.execute on canonical read paths.'),
new OperationTypeAlias('backup_schedule_retention', 'backup.schedule.retention', 'legacy_alias', true, 'Legacy backup schedule retention values resolve to backup.schedule.retention.', 'Prefer dotted canonical backup schedule naming on new read paths.'),
new OperationTypeAlias('backup_schedule_purge', 'backup.schedule.purge', 'legacy_alias', true, 'Legacy backup schedule purge values resolve to backup.schedule.purge.', 'Prefer dotted canonical backup schedule naming on new read paths.'),
new OperationTypeAlias('restore.execute', 'restore.execute', 'canonical', true),
new OperationTypeAlias('assignments.fetch', 'assignments.fetch', 'canonical', true),
new OperationTypeAlias('assignments.restore', 'assignments.restore', 'canonical', true),
new OperationTypeAlias('ops.reconcile_adapter_runs', 'ops.reconcile_adapter_runs', 'canonical', true),
new OperationTypeAlias('directory_role_definitions.sync', 'directory.role_definitions.sync', 'legacy_alias', true, 'Legacy directory_role_definitions.sync values resolve to directory.role_definitions.sync.', 'Prefer dotted role-definition naming on new read paths.'),
new OperationTypeAlias('restore_run.delete', 'restore_run.delete', 'canonical', true),
new OperationTypeAlias('restore_run.restore', 'restore_run.restore', 'canonical', true),
new OperationTypeAlias('restore_run.force_delete', 'restore_run.force_delete', 'canonical', true),
new OperationTypeAlias('tenant.sync', 'tenant.sync', 'canonical', true),
new OperationTypeAlias('policy_version.prune', 'policy_version.prune', 'canonical', true),
new OperationTypeAlias('policy_version.restore', 'policy_version.restore', 'canonical', true),
new OperationTypeAlias('policy_version.force_delete', 'policy_version.force_delete', 'canonical', true),
new OperationTypeAlias('alerts.evaluate', 'alerts.evaluate', 'canonical', true),
new OperationTypeAlias('alerts.deliver', 'alerts.deliver', 'canonical', true),
new OperationTypeAlias('baseline_capture', 'baseline.capture', 'legacy_alias', true, 'Historical baseline_capture values resolve to baseline.capture.', 'Prefer baseline.capture on canonical read paths.'),
new OperationTypeAlias('baseline_compare', 'baseline.compare', 'legacy_alias', true, 'Historical baseline_compare values resolve to baseline.compare.', 'Prefer baseline.compare on canonical read paths.'),
new OperationTypeAlias('permission_posture_check', 'permission.posture.check', 'legacy_alias', true, 'Historical permission_posture_check values resolve to permission.posture.check.', 'Prefer dotted permission posture naming on new read paths.'),
new OperationTypeAlias('entra.admin_roles.scan', 'entra.admin_roles.scan', 'canonical', true),
new OperationTypeAlias('tenant.review_pack.generate', 'tenant.review_pack.generate', 'canonical', true),
new OperationTypeAlias('tenant.review.compose', 'tenant.review.compose', 'canonical', true),
new OperationTypeAlias('tenant.evidence.snapshot.generate', 'tenant.evidence.snapshot.generate', 'canonical', true),
new OperationTypeAlias('rbac.health_check', 'rbac.health_check', 'canonical', true),
new OperationTypeAlias('findings.lifecycle.backfill', 'findings.lifecycle.backfill', 'canonical', true),
];
}
}

View File

@ -27,4 +27,26 @@ public static function values(): array
{
return array_map(static fn (self $case): string => $case->value, self::cases());
}
public function canonicalCode(): string
{
return match ($this) {
self::BaselineCapture => 'baseline.capture',
self::BaselineCompare => 'baseline.compare',
self::InventorySync => 'inventory.sync',
self::PolicySync, self::PolicySyncOne => 'policy.sync',
self::DirectoryGroupsSync => 'directory.groups.sync',
self::BackupScheduleExecute => 'backup.schedule.execute',
self::BackupScheduleRetention => 'backup.schedule.retention',
self::BackupSchedulePurge => 'backup.schedule.purge',
self::DirectoryRoleDefinitionsSync => 'directory.role_definitions.sync',
self::RestoreExecute => 'restore.execute',
self::EntraAdminRolesScan => 'entra.admin_roles.scan',
self::ReviewPackGenerate => 'tenant.review_pack.generate',
self::TenantReviewCompose => 'tenant.review.compose',
self::EvidenceSnapshotGenerate => 'tenant.evidence.snapshot.generate',
self::RbacHealthCheck => 'rbac.health_check',
default => $this->value,
};
}
}

View File

@ -0,0 +1,72 @@
<?php
declare(strict_types=1);
namespace App\Support;
use InvalidArgumentException;
final readonly class OperationTypeAlias
{
public function __construct(
public string $rawValue,
public string $canonicalCode,
public string $aliasStatus,
public bool $writeAllowed,
public ?string $deprecationNote = null,
public ?string $retirementPath = null,
) {
if (trim($this->rawValue) === '' || trim($this->canonicalCode) === '') {
throw new InvalidArgumentException('Operation type aliases require a raw value and canonical code.');
}
}
/**
* @return array{
* raw_value: string,
* canonical_code: string,
* alias_status: string,
* write_allowed: bool,
* deprecation_note: ?string,
* retirement_path: ?string
* }
*/
public function toArray(): array
{
return [
'raw_value' => $this->rawValue,
'canonical_code' => $this->canonicalCode,
'alias_status' => $this->aliasStatus,
'write_allowed' => $this->writeAllowed,
'deprecation_note' => $this->deprecationNote,
'retirement_path' => $this->retirementPath,
];
}
public function canonicalName(): string
{
return $this->canonicalCode;
}
/**
* @return array{
* raw_value: string,
* canonical_name: string,
* alias_status: string,
* write_allowed: bool,
* deprecation_note: ?string,
* retirement_path: ?string
* }
*/
public function retirementMetadata(): array
{
return [
'raw_value' => $this->rawValue,
'canonical_name' => $this->canonicalName(),
'alias_status' => $this->aliasStatus,
'write_allowed' => $this->writeAllowed,
'deprecation_note' => $this->deprecationNote,
'retirement_path' => $this->retirementPath,
];
}
}

View File

@ -0,0 +1,56 @@
<?php
declare(strict_types=1);
namespace App\Support;
final readonly class OperationTypeResolution
{
/**
* @param list<OperationTypeAlias> $aliasesConsidered
*/
public function __construct(
public string $rawValue,
public CanonicalOperationType $canonical,
public array $aliasesConsidered,
public string $aliasStatus,
public bool $wasLegacyAlias,
) {}
/**
* @return array{
* raw_value: string,
* canonical: array{
* canonical_code: string,
* domain_key: ?string,
* artifact_family: ?string,
* display_label: string,
* supports_operator_explanation: bool,
* expected_duration_seconds: ?int
* },
* aliases_considered: list<array{
* raw_value: string,
* canonical_code: string,
* alias_status: string,
* write_allowed: bool,
* deprecation_note: ?string,
* retirement_path: ?string
* }>,
* alias_status: string,
* was_legacy_alias: bool
* }
*/
public function toArray(): array
{
return [
'raw_value' => $this->rawValue,
'canonical' => $this->canonical->toArray(),
'aliases_considered' => array_map(
static fn (OperationTypeAlias $alias): array => $alias->toArray(),
$this->aliasesConsidered,
),
'alias_status' => $this->aliasStatus,
'was_legacy_alias' => $this->wasLegacyAlias,
];
}
}

View File

@ -5,6 +5,8 @@
namespace App\Support\Operations;
use App\Support\ReasonTranslation\NextStepOption;
use App\Support\ReasonTranslation\PlatformReasonFamily;
use App\Support\ReasonTranslation\ReasonOwnershipDescriptor;
use App\Support\ReasonTranslation\ReasonResolutionEnvelope;
enum ExecutionDenialReasonCode: string
@ -125,6 +127,12 @@ public function toReasonResolutionEnvelope(string $surface = 'detail', array $co
nextSteps: $this->nextSteps(),
showNoActionNeeded: false,
diagnosticCodeLabel: $this->value,
reasonOwnership: new ReasonOwnershipDescriptor(
ownerLayer: 'platform_core',
ownerNamespace: 'execution_denial',
reasonCode: $this->value,
platformReasonFamily: PlatformReasonFamily::Authorization,
),
);
}
}

View File

@ -5,6 +5,8 @@
namespace App\Support\Operations;
use App\Support\ReasonTranslation\NextStepOption;
use App\Support\ReasonTranslation\PlatformReasonFamily;
use App\Support\ReasonTranslation\ReasonOwnershipDescriptor;
use App\Support\ReasonTranslation\ReasonResolutionEnvelope;
enum LifecycleReconciliationReason: string
@ -78,6 +80,12 @@ public function toReasonResolutionEnvelope(string $surface = 'detail', array $co
nextSteps: $this->nextSteps(),
showNoActionNeeded: false,
diagnosticCodeLabel: $this->value,
reasonOwnership: new ReasonOwnershipDescriptor(
ownerLayer: 'platform_core',
ownerNamespace: 'operation_lifecycle',
reasonCode: $this->value,
platformReasonFamily: PlatformReasonFamily::Execution,
),
);
}
}

View File

@ -2,6 +2,10 @@
namespace App\Support\Providers;
use App\Support\Governance\PlatformVocabularyGlossary;
use App\Support\ReasonTranslation\PlatformReasonFamily;
use App\Support\ReasonTranslation\ReasonOwnershipDescriptor;
final class ProviderReasonCodes
{
public const string ProviderConnectionMissing = 'provider_connection_missing';
@ -92,4 +96,65 @@ public static function isKnown(string $reasonCode): bool
{
return in_array($reasonCode, self::all(), true) || str_starts_with($reasonCode, 'ext.');
}
public static function registryKey(): string
{
return 'provider_reason_codes';
}
/**
* @return list<string>
*/
public static function canonicalNouns(): array
{
return ['reason_code'];
}
public static function ownerLayer(string $reasonCode): string
{
return PlatformVocabularyGlossary::OWNER_PROVIDER_OWNED;
}
public static function boundaryClassification(string $reasonCode): string
{
return PlatformVocabularyGlossary::BOUNDARY_INTUNE_SPECIFIC;
}
public static function ownerNamespace(string $reasonCode): string
{
return str_starts_with($reasonCode, 'intune_rbac.')
? 'provider.intune_rbac'
: 'provider.microsoft_graph';
}
public static function platformReasonFamily(string $reasonCode): PlatformReasonFamily
{
return match ($reasonCode) {
self::ProviderPermissionMissing,
self::ProviderPermissionDenied,
self::IntuneRbacPermissionMissing => PlatformReasonFamily::Authorization,
self::NetworkUnreachable,
self::RateLimited,
self::ProviderPermissionRefreshFailed,
self::ProviderAuthFailed => PlatformReasonFamily::Availability,
self::ProviderConnectionTypeInvalid,
self::TenantTargetMismatch,
self::ProviderConnectionReviewRequired => PlatformReasonFamily::Compatibility,
default => PlatformReasonFamily::Prerequisite,
};
}
public static function ownershipDescriptor(string $reasonCode): ?ReasonOwnershipDescriptor
{
if (! self::isKnown($reasonCode)) {
return null;
}
return new ReasonOwnershipDescriptor(
ownerLayer: self::ownerLayer($reasonCode),
ownerNamespace: self::ownerNamespace($reasonCode),
reasonCode: $reasonCode,
platformReasonFamily: self::platformReasonFamily($reasonCode),
);
}
}

View File

@ -248,6 +248,7 @@ private function envelope(
nextSteps: $nextSteps,
showNoActionNeeded: false,
diagnosticCodeLabel: $reasonCode,
reasonOwnership: ProviderReasonCodes::ownershipDescriptor($reasonCode),
);
}

View File

@ -2,7 +2,10 @@
namespace App\Support;
use App\Support\Governance\PlatformVocabularyGlossary;
use App\Support\ReasonTranslation\NextStepOption;
use App\Support\ReasonTranslation\PlatformReasonFamily;
use App\Support\ReasonTranslation\ReasonOwnershipDescriptor;
use App\Support\ReasonTranslation\ReasonResolutionEnvelope;
enum RbacReason: string
@ -60,6 +63,26 @@ public function actionability(): string
};
}
public function ownerLayer(): string
{
return PlatformVocabularyGlossary::OWNER_DOMAIN_OWNED;
}
public function ownerNamespace(): string
{
return 'rbac.intune';
}
public function platformReasonFamily(): PlatformReasonFamily
{
return PlatformReasonFamily::Authorization;
}
public function boundaryClassification(): string
{
return PlatformVocabularyGlossary::BOUNDARY_INTUNE_SPECIFIC;
}
/**
* @return array<int, NextStepOption>
*/
@ -92,6 +115,12 @@ public function toReasonResolutionEnvelope(string $surface = 'detail', array $co
nextSteps: $this->nextSteps(),
showNoActionNeeded: $this->actionability() === 'non_actionable',
diagnosticCodeLabel: $this->value,
reasonOwnership: new ReasonOwnershipDescriptor(
ownerLayer: $this->ownerLayer(),
ownerNamespace: $this->ownerNamespace(),
reasonCode: $this->value,
platformReasonFamily: $this->platformReasonFamily(),
),
);
}
}

View File

@ -0,0 +1,27 @@
<?php
declare(strict_types=1);
namespace App\Support\ReasonTranslation;
enum PlatformReasonFamily: string
{
case Authorization = 'authorization';
case Prerequisite = 'prerequisite';
case Compatibility = 'compatibility';
case Coverage = 'coverage';
case Availability = 'availability';
case Execution = 'execution';
public function label(): string
{
return match ($this) {
self::Authorization => 'Authorization',
self::Prerequisite => 'Prerequisite',
self::Compatibility => 'Compatibility',
self::Coverage => 'Coverage',
self::Availability => 'Availability',
self::Execution => 'Execution',
};
}
}

View File

@ -0,0 +1,62 @@
<?php
declare(strict_types=1);
namespace App\Support\ReasonTranslation;
final readonly class ReasonOwnershipDescriptor
{
public function __construct(
public string $ownerLayer,
public string $ownerNamespace,
public string $reasonCode,
public PlatformReasonFamily $platformReasonFamily,
) {}
/**
* @param array<string, mixed> $data
*/
public static function fromArray(array $data): ?self
{
$family = is_string($data['platform_reason_family'] ?? null)
? PlatformReasonFamily::tryFrom((string) $data['platform_reason_family'])
: null;
if (! $family instanceof PlatformReasonFamily) {
return null;
}
$ownerLayer = is_string($data['owner_layer'] ?? null) ? trim((string) $data['owner_layer']) : '';
$ownerNamespace = is_string($data['owner_namespace'] ?? null) ? trim((string) $data['owner_namespace']) : '';
$reasonCode = is_string($data['reason_code'] ?? null) ? trim((string) $data['reason_code']) : '';
if ($ownerLayer === '' || $ownerNamespace === '' || $reasonCode === '') {
return null;
}
return new self(
ownerLayer: $ownerLayer,
ownerNamespace: $ownerNamespace,
reasonCode: $reasonCode,
platformReasonFamily: $family,
);
}
/**
* @return array{
* owner_layer: string,
* owner_namespace: string,
* reason_code: string,
* platform_reason_family: string
* }
*/
public function toArray(): array
{
return [
'owner_layer' => $this->ownerLayer,
'owner_namespace' => $this->ownerNamespace,
'reason_code' => $this->reasonCode,
'platform_reason_family' => $this->platformReasonFamily->value,
];
}
}

View File

@ -13,6 +13,7 @@
use App\Support\Providers\ProviderReasonTranslator;
use App\Support\RbacReason;
use App\Support\Tenants\TenantOperabilityReasonCode;
use Illuminate\Support\Str;
final class ReasonPresenter
{
@ -209,6 +210,93 @@ public function trustImpact(?ReasonResolutionEnvelope $envelope): ?string
return $envelope?->trustImpact;
}
public function ownerLayer(?ReasonResolutionEnvelope $envelope): ?string
{
return $envelope?->ownerLayer();
}
public function ownerNamespace(?ReasonResolutionEnvelope $envelope): ?string
{
return $envelope?->ownerNamespace();
}
public function platformReasonFamily(?ReasonResolutionEnvelope $envelope): ?string
{
return $envelope?->platformReasonFamily();
}
public function platformReasonFamilyLabel(?ReasonResolutionEnvelope $envelope): ?string
{
return $envelope?->platformReasonFamilyEnum()?->label();
}
public function ownerLabel(?ReasonResolutionEnvelope $envelope): ?string
{
$ownerNamespace = $envelope?->ownerNamespace();
if (! is_string($ownerNamespace) || trim($ownerNamespace) === '') {
return null;
}
return match (true) {
str_starts_with($ownerNamespace, 'provider.') => 'Provider-owned detail',
str_starts_with($ownerNamespace, 'governance.') => 'Governance detail',
$ownerNamespace === 'rbac.intune' => 'Intune RBAC detail',
$ownerNamespace === 'tenant_operability',
$ownerNamespace === 'execution_denial',
$ownerNamespace === 'operation_lifecycle',
$ownerNamespace === 'reason_translation.fallback' => 'Platform core',
default => match ($envelope?->ownerLayer()) {
'provider_owned' => 'Provider-owned detail',
'domain_owned' => 'Domain-owned detail',
'platform_core' => 'Platform core',
'compatibility_alias' => 'Compatibility alias',
'compatibility_only' => 'Compatibility-only detail',
default => Str::of((string) $envelope?->ownerLayer())->replace('_', ' ')->headline()->toString(),
},
};
}
/**
* @return array{
* owner_label: string,
* owner_layer: string,
* owner_namespace: string,
* boundary_classification: ?string,
* boundary_label: ?string,
* family: string,
* family_label: string,
* diagnostic_code: string
* }|null
*/
public function semantics(?ReasonResolutionEnvelope $envelope): ?array
{
if (! $envelope instanceof ReasonResolutionEnvelope) {
return null;
}
$boundary = $this->reasonTranslator->boundaryClassificationForEnvelope($envelope);
$family = $envelope->platformReasonFamilyEnum();
$ownerLabel = $this->ownerLabel($envelope);
if (! is_string($ownerLabel) || $family === null) {
return null;
}
return [
'owner_label' => $ownerLabel,
'owner_layer' => (string) $envelope->ownerLayer(),
'owner_namespace' => (string) $envelope->ownerNamespace(),
'boundary_classification' => $boundary,
'boundary_label' => is_string($boundary)
? Str::of($boundary)->replace('_', ' ')->headline()->toString()
: null,
'family' => $family->value,
'family_label' => $family->label(),
'diagnostic_code' => $envelope->diagnosticCode(),
];
}
public function absencePattern(?ReasonResolutionEnvelope $envelope): ?string
{
return $envelope?->absencePattern;

View File

@ -22,6 +22,7 @@ public function __construct(
public ?string $diagnosticCodeLabel = null,
public string $trustImpact = TrustworthinessLevel::LimitedConfidence->value,
public ?string $absencePattern = null,
public ?ReasonOwnershipDescriptor $reasonOwnership = null,
) {
if (trim($this->internalCode) === '') {
throw new InvalidArgumentException('Reason envelopes must preserve an internal code.');
@ -97,6 +98,14 @@ public static function fromArray(array $data): ?self
$absencePattern = is_string($data['absence_pattern'] ?? null)
? trim((string) $data['absence_pattern'])
: (is_string($data['absencePattern'] ?? null) ? trim((string) $data['absencePattern']) : null);
$reasonOwnership = is_array($data['reason_owner'] ?? null)
? ReasonOwnershipDescriptor::fromArray($data['reason_owner'])
: (is_array($data['reasonOwnership'] ?? null) ? ReasonOwnershipDescriptor::fromArray($data['reasonOwnership']) : ReasonOwnershipDescriptor::fromArray([
'owner_layer' => $data['owner_layer'] ?? $data['ownerLayer'] ?? null,
'owner_namespace' => $data['owner_namespace'] ?? $data['ownerNamespace'] ?? null,
'reason_code' => $data['reason_code'] ?? $internalCode,
'platform_reason_family' => $data['platform_reason_family'] ?? $data['platformReasonFamily'] ?? null,
]));
if ($internalCode === '' || $operatorLabel === '' || $shortExplanation === '' || $actionability === '') {
return null;
@ -112,6 +121,7 @@ public static function fromArray(array $data): ?self
diagnosticCodeLabel: $diagnosticCodeLabel !== '' ? $diagnosticCodeLabel : null,
trustImpact: $trustImpact !== '' ? $trustImpact : TrustworthinessLevel::LimitedConfidence->value,
absencePattern: $absencePattern !== '' ? $absencePattern : null,
reasonOwnership: $reasonOwnership,
);
}
@ -130,6 +140,23 @@ public function withNextSteps(array $nextSteps): self
diagnosticCodeLabel: $this->diagnosticCodeLabel,
trustImpact: $this->trustImpact,
absencePattern: $this->absencePattern,
reasonOwnership: $this->reasonOwnership,
);
}
public function withReasonOwnership(?ReasonOwnershipDescriptor $reasonOwnership): self
{
return new self(
internalCode: $this->internalCode,
operatorLabel: $this->operatorLabel,
shortExplanation: $this->shortExplanation,
actionability: $this->actionability,
nextSteps: $this->nextSteps,
showNoActionNeeded: $this->showNoActionNeeded,
diagnosticCodeLabel: $this->diagnosticCodeLabel,
trustImpact: $this->trustImpact,
absencePattern: $this->absencePattern,
reasonOwnership: $reasonOwnership,
);
}
@ -181,6 +208,26 @@ public function diagnosticCode(): string
: $this->internalCode;
}
public function ownerLayer(): ?string
{
return $this->reasonOwnership?->ownerLayer;
}
public function ownerNamespace(): ?string
{
return $this->reasonOwnership?->ownerNamespace;
}
public function platformReasonFamily(): ?string
{
return $this->reasonOwnership?->platformReasonFamily->value;
}
public function platformReasonFamilyEnum(): ?PlatformReasonFamily
{
return $this->reasonOwnership?->platformReasonFamily;
}
/**
* @return array<int, array{label: string, url: string}>
*/
@ -209,9 +256,18 @@ public function toLegacyNextSteps(): array
* scope: string
* }>,
* show_no_action_needed: bool,
* diagnostic_code_label: string
* diagnostic_code_label: string,
* trust_impact: string,
* absence_pattern: ?string
* absence_pattern: ?string,
* reason_owner: ?array{
* owner_layer: string,
* owner_namespace: string,
* reason_code: string,
* platform_reason_family: string
* },
* owner_layer: ?string,
* owner_namespace: ?string,
* platform_reason_family: ?string
* }
*/
public function toArray(): array
@ -229,6 +285,10 @@ public function toArray(): array
'diagnostic_code_label' => $this->diagnosticCode(),
'trust_impact' => $this->trustImpact,
'absence_pattern' => $this->absencePattern,
'reason_owner' => $this->reasonOwnership?->toArray(),
'owner_layer' => $this->ownerLayer(),
'owner_namespace' => $this->ownerNamespace(),
'platform_reason_family' => $this->platformReasonFamily(),
];
}
}

View File

@ -6,6 +6,7 @@
use App\Support\Baselines\BaselineCompareReasonCode;
use App\Support\Baselines\BaselineReasonCodes;
use App\Support\Governance\PlatformVocabularyGlossary;
use App\Support\Operations\ExecutionDenialReasonCode;
use App\Support\Operations\LifecycleReconciliationReason;
use App\Support\Providers\ProviderReasonCodes;
@ -27,6 +28,7 @@ final class ReasonTranslator
public function __construct(
private readonly ProviderReasonTranslator $providerReasonTranslator,
private readonly FallbackReasonTranslator $fallbackReasonTranslator,
private readonly PlatformVocabularyGlossary $glossary,
) {}
/**
@ -44,7 +46,7 @@ public function translate(
return null;
}
return match (true) {
$envelope = match (true) {
$artifactKey === ProviderReasonTranslator::ARTIFACT_KEY,
$artifactKey === null && $this->providerReasonTranslator->canTranslate($reasonCode) => $this->providerReasonTranslator->translate($reasonCode, $surface, $context),
$artifactKey === self::GOVERNANCE_ARTIFACT_TRUTH_ARTIFACT && BaselineCompareReasonCode::tryFrom($reasonCode) instanceof BaselineCompareReasonCode => $this->translateBaselineCompareReason($reasonCode),
@ -62,6 +64,36 @@ public function translate(
$artifactKey === null && ProviderReasonCodes::isKnown($reasonCode) => $this->providerReasonTranslator->translate($reasonCode, $surface, $context),
default => $this->fallbackTranslate($reasonCode, $artifactKey, $surface, $context),
};
return $this->withOwnership($envelope, $reasonCode, $artifactKey);
}
/**
* @param array<string, mixed> $context
*/
public function boundaryClassification(
?string $reasonCode,
?string $artifactKey = null,
string $surface = 'detail',
array $context = [],
): ?string {
return $this->boundaryClassificationForEnvelope(
$this->translate($reasonCode, $artifactKey, $surface, $context),
);
}
public function boundaryClassificationForEnvelope(?ReasonResolutionEnvelope $envelope): ?string
{
return $this->boundaryClassificationForNamespace($envelope?->ownerNamespace());
}
public function boundaryClassificationForNamespace(?string $ownerNamespace): ?string
{
if (! is_string($ownerNamespace) || trim($ownerNamespace) === '') {
return null;
}
return $this->glossary->classifyReasonNamespace($ownerNamespace);
}
/**
@ -182,6 +214,12 @@ private function translateBaselineReason(string $reasonCode): ReasonResolutionEn
'prerequisite_missing',
'Refresh the page and select a valid snapshot for this baseline profile.',
],
BaselineReasonCodes::COMPARE_MIXED_SCOPE => [
'Mixed compare scope',
'The selected governed subjects span multiple compare strategy families, so TenantPilot will not start one misleading combined compare run.',
'prerequisite_missing',
'Narrow the governed subject selection so one compare strategy family owns the requested scope.',
],
default => [
'Baseline workflow blocked',
'TenantPilot recorded a baseline precondition that prevents this workflow from continuing safely.',
@ -236,6 +274,24 @@ private function translateBaselineCompareReason(string $reasonCode): ReasonResol
'prerequisite_missing',
'Resume or rerun evidence capture before relying on this compare result.',
],
BaselineCompareReasonCode::UnsupportedSubjects => [
'Unsupported subjects remained',
'The comparison finished, but one or more in-scope subjects are not currently supported by the selected compare strategy.',
'prerequisite_missing',
'Narrow scope or wait for support before treating zero visible findings as complete.',
],
BaselineCompareReasonCode::AmbiguousSubjects => [
'Subject identity stayed ambiguous',
'The comparison finished, but one or more in-scope subjects could not be matched cleanly enough to produce a trustworthy result.',
'prerequisite_missing',
'Review the ambiguous subject mapping before relying on this compare result.',
],
BaselineCompareReasonCode::StrategyFailed => [
'Strategy processing failed',
'The comparison finished without a fully usable result because strategy-owned subject processing failed for one or more in-scope subjects.',
'retryable_transient',
'Inspect the compare run diagnostics and retry once the subject-processing failure is addressed.',
],
BaselineCompareReasonCode::RolloutDisabled => [
'Compare rollout disabled',
'The comparison path was limited by rollout configuration, so the result is not decision-grade.',
@ -248,6 +304,24 @@ private function translateBaselineCompareReason(string $reasonCode): ReasonResol
'prerequisite_missing',
'Review scope selection and baseline inputs before comparing again.',
],
BaselineCompareReasonCode::OverdueFindingsRemain => [
'Overdue findings remain',
'The latest compare did not produce new drift, but overdue findings still require attention.',
'prerequisite_missing',
'Review and resolve the overdue findings before treating this posture as healthy.',
],
BaselineCompareReasonCode::GovernanceExpiring => [
'Accepted-risk governance is expiring',
'Accepted-risk coverage is still valid, but renewal is approaching and needs review.',
'prerequisite_missing',
'Review the expiring governance before it lapses.',
],
BaselineCompareReasonCode::GovernanceLapsed => [
'Accepted-risk governance lapsed',
'Accepted-risk coverage has lapsed, so the current posture still needs follow-up.',
'prerequisite_missing',
'Restore valid governance or move the affected findings back into active remediation.',
],
};
return new ReasonResolutionEnvelope(
@ -263,4 +337,101 @@ private function translateBaselineCompareReason(string $reasonCode): ReasonResol
absencePattern: $enum->absencePattern(),
);
}
private function withOwnership(
?ReasonResolutionEnvelope $envelope,
string $reasonCode,
?string $artifactKey,
): ?ReasonResolutionEnvelope {
if (! $envelope instanceof ReasonResolutionEnvelope) {
return null;
}
if ($envelope->reasonOwnership instanceof ReasonOwnershipDescriptor) {
return $envelope;
}
$ownership = match (true) {
$artifactKey === ProviderReasonTranslator::ARTIFACT_KEY,
$artifactKey === null && ProviderReasonCodes::isKnown($reasonCode) => ProviderReasonCodes::ownershipDescriptor($reasonCode),
$artifactKey === self::RBAC_ARTIFACT,
$artifactKey === null && RbacReason::tryFrom($reasonCode) instanceof RbacReason => new ReasonOwnershipDescriptor(
ownerLayer: 'domain_owned',
ownerNamespace: 'rbac.intune',
reasonCode: $reasonCode,
platformReasonFamily: PlatformReasonFamily::Authorization,
),
$artifactKey === self::TENANT_OPERABILITY_ARTIFACT,
$artifactKey === null && TenantOperabilityReasonCode::tryFrom($reasonCode) instanceof TenantOperabilityReasonCode => new ReasonOwnershipDescriptor(
ownerLayer: 'platform_core',
ownerNamespace: 'tenant_operability',
reasonCode: $reasonCode,
platformReasonFamily: PlatformReasonFamily::Availability,
),
$artifactKey === self::EXECUTION_DENIAL_ARTIFACT,
$artifactKey === null && ExecutionDenialReasonCode::tryFrom($reasonCode) instanceof ExecutionDenialReasonCode => new ReasonOwnershipDescriptor(
ownerLayer: 'platform_core',
ownerNamespace: 'execution_denial',
reasonCode: $reasonCode,
platformReasonFamily: PlatformReasonFamily::Authorization,
),
$artifactKey === null && LifecycleReconciliationReason::tryFrom($reasonCode) instanceof LifecycleReconciliationReason => new ReasonOwnershipDescriptor(
ownerLayer: 'platform_core',
ownerNamespace: 'operation_lifecycle',
reasonCode: $reasonCode,
platformReasonFamily: PlatformReasonFamily::Execution,
),
$artifactKey === self::GOVERNANCE_ARTIFACT_TRUTH_ARTIFACT && BaselineCompareReasonCode::tryFrom($reasonCode) instanceof BaselineCompareReasonCode,
$artifactKey === null && BaselineCompareReasonCode::tryFrom($reasonCode) instanceof BaselineCompareReasonCode => new ReasonOwnershipDescriptor(
ownerLayer: 'domain_owned',
ownerNamespace: 'governance.baseline_compare',
reasonCode: $reasonCode,
platformReasonFamily: $this->baselineCompareFamily($reasonCode),
),
$artifactKey === self::GOVERNANCE_ARTIFACT_TRUTH_ARTIFACT && BaselineReasonCodes::isKnown($reasonCode),
$artifactKey === null && BaselineReasonCodes::isKnown($reasonCode) => new ReasonOwnershipDescriptor(
ownerLayer: 'domain_owned',
ownerNamespace: 'governance.artifact_truth',
reasonCode: $reasonCode,
platformReasonFamily: $this->baselineReasonFamily($reasonCode),
),
default => new ReasonOwnershipDescriptor(
ownerLayer: 'platform_core',
ownerNamespace: 'reason_translation.fallback',
reasonCode: $reasonCode,
platformReasonFamily: PlatformReasonFamily::Compatibility,
),
};
return $envelope->withReasonOwnership($ownership);
}
private function baselineCompareFamily(string $reasonCode): PlatformReasonFamily
{
return match (BaselineCompareReasonCode::tryFrom($reasonCode)) {
BaselineCompareReasonCode::CoverageUnproven,
BaselineCompareReasonCode::EvidenceCaptureIncomplete,
BaselineCompareReasonCode::UnsupportedSubjects,
BaselineCompareReasonCode::AmbiguousSubjects,
BaselineCompareReasonCode::NoSubjectsInScope,
BaselineCompareReasonCode::NoDriftDetected => PlatformReasonFamily::Coverage,
BaselineCompareReasonCode::StrategyFailed => PlatformReasonFamily::Execution,
BaselineCompareReasonCode::RolloutDisabled => PlatformReasonFamily::Compatibility,
BaselineCompareReasonCode::OverdueFindingsRemain,
BaselineCompareReasonCode::GovernanceExpiring,
BaselineCompareReasonCode::GovernanceLapsed => PlatformReasonFamily::Prerequisite,
default => PlatformReasonFamily::Compatibility,
};
}
private function baselineReasonFamily(string $reasonCode): PlatformReasonFamily
{
return match ($reasonCode) {
BaselineReasonCodes::COMPARE_MIXED_SCOPE,
BaselineReasonCodes::CAPTURE_ROLLOUT_DISABLED,
BaselineReasonCodes::COMPARE_ROLLOUT_DISABLED => PlatformReasonFamily::Compatibility,
BaselineReasonCodes::SNAPSHOT_CAPTURE_FAILED => PlatformReasonFamily::Execution,
default => PlatformReasonFamily::Prerequisite,
};
}
}

View File

@ -4,7 +4,10 @@
namespace App\Support\Tenants;
use App\Support\Governance\PlatformVocabularyGlossary;
use App\Support\ReasonTranslation\NextStepOption;
use App\Support\ReasonTranslation\PlatformReasonFamily;
use App\Support\ReasonTranslation\ReasonOwnershipDescriptor;
use App\Support\ReasonTranslation\ReasonResolutionEnvelope;
enum TenantOperabilityReasonCode: string
@ -61,6 +64,26 @@ public function actionability(): string
};
}
public function ownerLayer(): string
{
return PlatformVocabularyGlossary::OWNER_PLATFORM_CORE;
}
public function ownerNamespace(): string
{
return 'tenant_operability';
}
public function platformReasonFamily(): PlatformReasonFamily
{
return PlatformReasonFamily::Availability;
}
public function boundaryClassification(): string
{
return PlatformVocabularyGlossary::BOUNDARY_PLATFORM_CORE;
}
/**
* @return array<int, NextStepOption>
*/
@ -102,6 +125,12 @@ public function toReasonResolutionEnvelope(string $surface = 'detail', array $co
nextSteps: $this->nextSteps(),
showNoActionNeeded: $this->actionability() === 'non_actionable',
diagnosticCodeLabel: $this->value,
reasonOwnership: new ReasonOwnershipDescriptor(
ownerLayer: $this->ownerLayer(),
ownerNamespace: $this->ownerNamespace(),
reasonCode: $this->value,
platformReasonFamily: $this->platformReasonFamily(),
),
);
}
}

View File

@ -5,6 +5,8 @@
namespace App\Support\Ui\GovernanceArtifactTruth;
use App\Support\ReasonTranslation\NextStepOption;
use App\Support\ReasonTranslation\PlatformReasonFamily;
use App\Support\ReasonTranslation\ReasonOwnershipDescriptor;
use App\Support\ReasonTranslation\ReasonResolutionEnvelope;
use App\Support\Ui\OperatorExplanation\TrustworthinessLevel;
@ -22,6 +24,9 @@ public function __construct(
public string $trustImpact,
public ?string $absencePattern,
public array $nextSteps = [],
public ?string $ownerLayer = null,
public ?string $ownerNamespace = null,
public ?string $platformReasonFamily = null,
) {}
public static function fromReasonResolutionEnvelope(
@ -44,11 +49,32 @@ public static function fromReasonResolutionEnvelope(
static fn (NextStepOption $nextStep): string => $nextStep->label,
$reason->nextSteps,
)),
ownerLayer: $reason->ownerLayer(),
ownerNamespace: $reason->ownerNamespace(),
platformReasonFamily: $reason->platformReasonFamily(),
);
}
public function toReasonResolutionEnvelope(): ReasonResolutionEnvelope
{
$reasonOwnership = null;
$family = is_string($this->platformReasonFamily)
? PlatformReasonFamily::tryFrom($this->platformReasonFamily)
: null;
if (is_string($this->ownerLayer)
&& trim($this->ownerLayer) !== ''
&& is_string($this->ownerNamespace)
&& trim($this->ownerNamespace) !== ''
&& $family instanceof PlatformReasonFamily) {
$reasonOwnership = new ReasonOwnershipDescriptor(
ownerLayer: trim($this->ownerLayer),
ownerNamespace: trim($this->ownerNamespace),
reasonCode: $this->reasonCode ?? 'artifact_truth_reason',
platformReasonFamily: $family,
);
}
return new ReasonResolutionEnvelope(
internalCode: $this->reasonCode ?? 'artifact_truth_reason',
operatorLabel: $this->operatorLabel ?? 'Operator attention required',
@ -61,6 +87,7 @@ public function toReasonResolutionEnvelope(): ReasonResolutionEnvelope
diagnosticCodeLabel: $this->diagnosticCode,
trustImpact: $this->trustImpact !== '' ? $this->trustImpact : TrustworthinessLevel::LimitedConfidence->value,
absencePattern: $this->absencePattern,
reasonOwnership: $reasonOwnership,
);
}
@ -73,7 +100,10 @@ public function toReasonResolutionEnvelope(): ReasonResolutionEnvelope
* diagnosticCode: ?string,
* trustImpact: string,
* absencePattern: ?string,
* nextSteps: array<int, string>
* nextSteps: array<int, string>,
* ownerLayer: ?string,
* ownerNamespace: ?string,
* platformReasonFamily: ?string
* }
*/
public function toArray(): array
@ -87,6 +117,9 @@ public function toArray(): array
'trustImpact' => $this->trustImpact,
'absencePattern' => $this->absencePattern,
'nextSteps' => $this->nextSteps,
'ownerLayer' => $this->ownerLayer,
'ownerNamespace' => $this->ownerNamespace,
'platformReasonFamily' => $this->platformReasonFamily,
];
}
}

View File

@ -597,6 +597,248 @@
],
],
'platform_vocabulary' => [
'terms' => [
'governed_subject' => [
'term_key' => 'governed_subject',
'canonical_label' => 'Governed subject',
'canonical_description' => 'The platform-facing noun for a governed object across compare, snapshot, evidence, and review surfaces.',
'boundary_classification' => 'platform_core',
'owner_layer' => 'platform_core',
'allowed_contexts' => ['compare', 'snapshot', 'evidence', 'review', 'reporting'],
'legacy_aliases' => ['policy_type'],
'alias_retirement_path' => 'Retire false-universal policy_type wording from platform-owned summaries and descriptors while preserving Intune-owned storage.',
'forbidden_platform_aliases' => ['policy_type'],
],
'domain_key' => [
'term_key' => 'domain_key',
'canonical_label' => 'Governance domain',
'canonical_description' => 'The canonical domain discriminator for cross-domain and platform-near contracts.',
'boundary_classification' => 'cross_domain_governance',
'owner_layer' => 'platform_core',
'allowed_contexts' => ['governance', 'compare', 'snapshot', 'reporting'],
'legacy_aliases' => [],
'alias_retirement_path' => null,
'forbidden_platform_aliases' => [],
],
'subject_class' => [
'term_key' => 'subject_class',
'canonical_label' => 'Subject class',
'canonical_description' => 'The canonical subject-class discriminator for governed-subject contracts.',
'boundary_classification' => 'cross_domain_governance',
'owner_layer' => 'platform_core',
'allowed_contexts' => ['governance', 'compare', 'snapshot'],
'legacy_aliases' => [],
'alias_retirement_path' => null,
'forbidden_platform_aliases' => [],
],
'subject_type_key' => [
'term_key' => 'subject_type_key',
'canonical_label' => 'Governed subject key',
'canonical_description' => 'The domain-owned subject-family key used by platform-near descriptors.',
'boundary_classification' => 'cross_domain_governance',
'owner_layer' => 'platform_core',
'allowed_contexts' => ['governance', 'compare', 'snapshot', 'evidence'],
'legacy_aliases' => ['policy_type'],
'alias_retirement_path' => 'Prefer subject_type_key on platform-near payloads and keep policy_type only where the owning model is Intune-specific.',
'forbidden_platform_aliases' => ['policy_type'],
],
'subject_type_label' => [
'term_key' => 'subject_type_label',
'canonical_label' => 'Governed subject label',
'canonical_description' => 'The operator-facing label for a governed subject family.',
'boundary_classification' => 'cross_domain_governance',
'owner_layer' => 'platform_core',
'allowed_contexts' => ['snapshot', 'evidence', 'compare', 'review'],
'legacy_aliases' => [],
'alias_retirement_path' => null,
'forbidden_platform_aliases' => [],
],
'resource_type' => [
'term_key' => 'resource_type',
'canonical_label' => 'Resource type',
'canonical_description' => 'Optional resource-shaped noun for platform-facing summaries when a governed subject also needs a resource family label.',
'boundary_classification' => 'platform_core',
'owner_layer' => 'platform_core',
'allowed_contexts' => ['reporting', 'review'],
'legacy_aliases' => [],
'alias_retirement_path' => null,
'forbidden_platform_aliases' => [],
],
'operation_type' => [
'term_key' => 'operation_type',
'canonical_label' => 'Operation type',
'canonical_description' => 'The canonical platform operation identifier shown on monitoring and launch surfaces.',
'boundary_classification' => 'platform_core',
'owner_layer' => 'platform_core',
'allowed_contexts' => ['monitoring', 'reporting', 'launch_surfaces'],
'legacy_aliases' => ['type'],
'alias_retirement_path' => 'Expose canonical operation_type on read models while operation_runs.type remains a compatibility storage seam during rollout.',
'forbidden_platform_aliases' => [],
],
'platform_reason_family' => [
'term_key' => 'platform_reason_family',
'canonical_label' => 'Platform reason family',
'canonical_description' => 'The cross-domain platform family that explains the top-level cause category without erasing domain ownership.',
'boundary_classification' => 'platform_core',
'owner_layer' => 'platform_core',
'allowed_contexts' => ['reason_translation', 'monitoring', 'review', 'reporting'],
'legacy_aliases' => [],
'alias_retirement_path' => null,
'forbidden_platform_aliases' => [],
],
'reason_owner.owner_namespace' => [
'term_key' => 'reason_owner.owner_namespace',
'canonical_label' => 'Reason owner namespace',
'canonical_description' => 'The explicit namespace that marks whether a translated reason is provider-owned, governance-owned, access-owned, or runtime-owned.',
'boundary_classification' => 'platform_core',
'owner_layer' => 'platform_core',
'allowed_contexts' => ['reason_translation', 'review', 'reporting'],
'legacy_aliases' => [],
'alias_retirement_path' => null,
'forbidden_platform_aliases' => [],
],
'reason_code' => [
'term_key' => 'reason_code',
'canonical_label' => 'Reason code',
'canonical_description' => 'The original domain-owned or provider-owned reason identifier preserved for diagnostics and translation lookup.',
'boundary_classification' => 'platform_core',
'owner_layer' => 'platform_core',
'allowed_contexts' => ['reason_translation', 'diagnostics'],
'legacy_aliases' => [],
'alias_retirement_path' => null,
'forbidden_platform_aliases' => [],
],
'registry_key' => [
'term_key' => 'registry_key',
'canonical_label' => 'Registry key',
'canonical_description' => 'The stable identifier for a contributor-facing registry or catalog.',
'boundary_classification' => 'platform_core',
'owner_layer' => 'platform_core',
'allowed_contexts' => ['governance', 'contributor_guidance'],
'legacy_aliases' => [],
'alias_retirement_path' => null,
'forbidden_platform_aliases' => [],
],
'boundary_classification' => [
'term_key' => 'boundary_classification',
'canonical_label' => 'Boundary classification',
'canonical_description' => 'The explicit classification that marks a term or registry as platform_core, cross_domain_governance, or intune_specific.',
'boundary_classification' => 'platform_core',
'owner_layer' => 'platform_core',
'allowed_contexts' => ['governance', 'contributor_guidance'],
'legacy_aliases' => [],
'alias_retirement_path' => null,
'forbidden_platform_aliases' => [],
],
'policy_type' => [
'term_key' => 'policy_type',
'canonical_label' => 'Intune policy type',
'canonical_description' => 'The Intune-specific discriminator that remains valid on adapter-owned or Intune-owned models but must not be treated as a universal platform noun.',
'boundary_classification' => 'intune_specific',
'owner_layer' => 'domain_owned',
'allowed_contexts' => ['intune_adapter', 'intune_inventory', 'intune_backup'],
'legacy_aliases' => [],
'alias_retirement_path' => null,
'forbidden_platform_aliases' => ['governed_subject'],
],
],
'reason_namespaces' => [
'tenant_operability' => [
'owner_namespace' => 'tenant_operability',
'boundary_classification' => 'platform_core',
'owner_layer' => 'platform_core',
'compatibility_notes' => 'Tenant operability reasons are platform-core guardrails for workspace and tenant context.',
],
'execution_denial' => [
'owner_namespace' => 'execution_denial',
'boundary_classification' => 'platform_core',
'owner_layer' => 'platform_core',
'compatibility_notes' => 'Execution denial reasons remain platform-core run-legitimacy semantics.',
],
'operation_lifecycle' => [
'owner_namespace' => 'operation_lifecycle',
'boundary_classification' => 'platform_core',
'owner_layer' => 'platform_core',
'compatibility_notes' => 'Lifecycle reconciliation reasons remain platform-core monitoring semantics.',
],
'governance.baseline_compare' => [
'owner_namespace' => 'governance.baseline_compare',
'boundary_classification' => 'cross_domain_governance',
'owner_layer' => 'domain_owned',
'compatibility_notes' => 'Baseline-compare reason codes are governance-owned details translated through the platform boundary.',
],
'governance.artifact_truth' => [
'owner_namespace' => 'governance.artifact_truth',
'boundary_classification' => 'cross_domain_governance',
'owner_layer' => 'domain_owned',
'compatibility_notes' => 'Artifact-truth reason codes remain governance-owned and are surfaced through platform-safe summaries.',
],
'provider.microsoft_graph' => [
'owner_namespace' => 'provider.microsoft_graph',
'boundary_classification' => 'intune_specific',
'owner_layer' => 'provider_owned',
'compatibility_notes' => 'Microsoft Graph provider reasons remain provider-owned and Intune-specific.',
],
'provider.intune_rbac' => [
'owner_namespace' => 'provider.intune_rbac',
'boundary_classification' => 'intune_specific',
'owner_layer' => 'provider_owned',
'compatibility_notes' => 'Provider-owned Intune RBAC reasons remain Intune-specific.',
],
'rbac.intune' => [
'owner_namespace' => 'rbac.intune',
'boundary_classification' => 'intune_specific',
'owner_layer' => 'domain_owned',
'compatibility_notes' => 'RBAC detail remains Intune-specific domain context.',
],
'reason_translation.fallback' => [
'owner_namespace' => 'reason_translation.fallback',
'boundary_classification' => 'platform_core',
'owner_layer' => 'platform_core',
'compatibility_notes' => 'Fallback translation remains a platform-core compatibility seam until a domain owner is known.',
],
],
'registries' => [
'governance_subject_taxonomy_registry' => [
'registry_key' => 'governance_subject_taxonomy_registry',
'boundary_classification' => 'cross_domain_governance',
'owner_layer' => 'platform_core',
'source_class_or_file' => App\Support\Governance\GovernanceSubjectTaxonomyRegistry::class,
'canonical_nouns' => ['domain_key', 'subject_class', 'subject_type_key', 'subject_type_label'],
'allowed_consumers' => ['baseline_scope', 'compare', 'snapshot', 'review'],
'compatibility_notes' => 'Provides the governed-subject source of truth, resolves legacy policy-type payloads, and preserves inactive or future-domain entries without exposing them as active operator choices.',
],
'operation_catalog' => [
'registry_key' => 'operation_catalog',
'boundary_classification' => 'platform_core',
'owner_layer' => 'platform_core',
'source_class_or_file' => App\Support\OperationCatalog::class,
'canonical_nouns' => ['operation_type'],
'allowed_consumers' => ['monitoring', 'reporting', 'launch_surfaces', 'audit'],
'compatibility_notes' => 'Resolves canonical operation meaning from historical storage values without treating every stored raw string as equally canonical.',
],
'provider_reason_codes' => [
'registry_key' => 'provider_reason_codes',
'boundary_classification' => 'intune_specific',
'owner_layer' => 'provider_owned',
'source_class_or_file' => App\Support\Providers\ProviderReasonCodes::class,
'canonical_nouns' => ['reason_code'],
'allowed_consumers' => ['reason_translation'],
'compatibility_notes' => 'Provider-owned reason codes remain namespaced domain details and become platform-safe only through the reason-translation boundary.',
],
'inventory_policy_type_catalog' => [
'registry_key' => 'inventory_policy_type_catalog',
'boundary_classification' => 'intune_specific',
'owner_layer' => 'domain_owned',
'source_class_or_file' => App\Support\Inventory\InventoryPolicyTypeMeta::class,
'canonical_nouns' => ['policy_type'],
'allowed_consumers' => ['intune_adapter', 'inventory', 'backup'],
'compatibility_notes' => 'The supported Intune policy-type list is not a universal platform registry and must be wrapped before reuse on platform-near summaries.',
],
],
],
'hardening' => [
'intune_write_gate' => [
'enabled' => (bool) env('TENANTPILOT_INTUNE_WRITE_GATE_ENABLED', true),

View File

@ -30,10 +30,10 @@
'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, subject class, outcome, next action, or subject key before falling back to raw diagnostics.',
'evidence_gap_details_description' => 'Search recorded gap subjects by reason, governed subject, 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, 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_search_help' => 'Filter matches across reason, governed subject, subject class, outcome, next action, and subject key.',
'evidence_gap_bucket_help_ambiguous_match' => 'Multiple inventory records matched the same policy subject — inspect the mapping to confirm the correct pairing.',
'evidence_gap_bucket_help_policy_record_missing' => 'The expected policy record was not found in the baseline snapshot — verify the policy still exists in the tenant.',
'evidence_gap_bucket_help_inventory_record_missing' => 'No inventory record could be matched for these subjects — confirm the inventory sync is current.',
@ -57,7 +57,7 @@
'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_policy_type' => 'Governed subject',
'evidence_gap_subject_class' => 'Subject class',
'evidence_gap_outcome' => 'Outcome',
'evidence_gap_next_action' => 'Next action',

View File

@ -33,7 +33,7 @@
@endphp
<x-filament::section
:heading="$group['label'] ?? ($group['policyType'] ?? 'Policy type')"
:heading="$group['subjectDescriptor']['display_label'] ?? ($group['label'] ?? ($group['policyType'] ?? 'Governed subject'))"
:description="$group['coverageHint'] ?? null"
collapsible
:collapsed="(bool) ($group['initiallyCollapsed'] ?? true)"
@ -84,7 +84,7 @@
{{ $item['label'] ?? 'Snapshot item' }}
</div>
<div class="text-xs text-gray-500 dark:text-gray-400">
{{ $item['typeLabel'] ?? 'Policy type' }}
{{ $item['typeLabel'] ?? 'Governed subject family' }}
</div>
</div>

View File

@ -22,14 +22,14 @@
<div class="space-y-3">
@if ($rows === [])
<div class="rounded-lg border border-dashed border-gray-300 px-4 py-6 text-sm text-gray-600 dark:border-gray-700 dark:text-gray-300">
No captured policy types are available in this snapshot.
No captured governed subjects are available in this snapshot.
</div>
@else
<div class="overflow-x-auto rounded-lg border border-gray-200 dark:border-gray-700">
<table class="min-w-full divide-y divide-gray-200 text-left text-sm dark:divide-gray-700">
<thead class="bg-gray-50 dark:bg-gray-900/50">
<tr class="text-xs uppercase tracking-wide text-gray-500 dark:text-gray-400">
<th class="px-4 py-3">Policy type</th>
<th class="px-4 py-3">Governed subject</th>
<th class="px-4 py-3">Items</th>
<th class="px-4 py-3">Fidelity</th>
<th class="px-4 py-3">Coverage state</th>
@ -47,7 +47,7 @@
<tr class="align-top">
<td class="px-4 py-3 font-medium text-gray-900 dark:text-white">
{{ $row['label'] ?? ($row['policyType'] ?? 'Policy type') }}
{{ $row['governedSubjectLabel'] ?? ($row['label'] ?? ($row['policyType'] ?? 'Governed subject')) }}
</td>
<td class="px-4 py-3 text-gray-700 dark:text-gray-200">
{{ (int) ($row['itemCount'] ?? 0) }}

View File

@ -17,7 +17,7 @@
<div class="space-y-3">
@foreach ($groupPayloads as $group)
@php
$label = $group['label'] ?? 'Policy type';
$label = $group['payload']['subject_descriptor']['display_label'] ?? ($group['label'] ?? 'Governed subject');
$payload = is_array($group['payload'] ?? null) ? $group['payload'] : [];
@endphp

View File

@ -8,6 +8,7 @@
$contextLinks = is_array($state['context_links'] ?? null) ? $state['context_links'] : [];
$publishBlockers = is_array($state['publish_blockers'] ?? null) ? $state['publish_blockers'] : [];
$operatorExplanation = is_array($state['operator_explanation'] ?? null) ? $state['operator_explanation'] : [];
$reasonSemantics = is_array($state['reason_semantics'] ?? null) ? $state['reason_semantics'] : [];
@endphp
<div class="space-y-4">
@ -31,6 +32,20 @@
</div>
@endif
@if ($reasonSemantics !== [])
<dl class="grid grid-cols-1 gap-3 md:grid-cols-2">
<div class="rounded-md border border-gray-100 bg-gray-50 px-3 py-2 dark:border-gray-800 dark:bg-gray-950/60">
<dt class="text-[11px] font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">Reason owner</dt>
<dd class="mt-1 text-sm text-gray-900 dark:text-gray-100">{{ $reasonSemantics['owner_label'] ?? 'Platform core' }}</dd>
</div>
<div class="rounded-md border border-gray-100 bg-gray-50 px-3 py-2 dark:border-gray-800 dark:bg-gray-950/60">
<dt class="text-[11px] font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">Platform reason family</dt>
<dd class="mt-1 text-sm text-gray-900 dark:text-gray-100">{{ $reasonSemantics['family_label'] ?? 'Compatibility' }}</dd>
</div>
</dl>
@endif
<dl class="grid grid-cols-2 gap-3 xl:grid-cols-4">
@foreach ($metrics as $metric)
@php

View File

@ -8,6 +8,7 @@
$duplicateNamePoliciesCountValue = (int) ($duplicateNamePoliciesCount ?? 0);
$duplicateNameSubjectsCountValue = (int) ($duplicateNameSubjectsCount ?? 0);
$explanation = is_array($operatorExplanation ?? null) ? $operatorExplanation : null;
$reasonSemantics = is_array($reasonSemantics ?? null) ? $reasonSemantics : null;
$summary = is_array($summaryAssessment ?? null) ? $summaryAssessment : null;
$matrixNavigationContext = is_array($navigationContext ?? null) ? $navigationContext : null;
$arrivedFromCompareMatrix = str_starts_with((string) ($matrixNavigationContext['source_surface'] ?? ''), 'baseline_compare_matrix');
@ -117,6 +118,24 @@
@endif
</div>
@if ($reasonSemantics !== null)
<dl class="grid gap-3 md:grid-cols-2">
<div class="rounded-lg border border-gray-200 bg-gray-50 px-4 py-3 dark:border-gray-800 dark:bg-gray-950/50">
<dt class="text-[11px] font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">Reason owner</dt>
<dd class="mt-1 text-sm font-medium text-gray-900 dark:text-white">
{{ $reasonSemantics['owner_label'] ?? 'Platform core' }}
</dd>
</div>
<div class="rounded-lg border border-gray-200 bg-gray-50 px-4 py-3 dark:border-gray-800 dark:bg-gray-950/50">
<dt class="text-[11px] font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">Platform reason family</dt>
<dd class="mt-1 text-sm font-medium text-gray-900 dark:text-white">
{{ $reasonSemantics['family_label'] ?? 'Compatibility' }}
</dd>
</div>
</dl>
@endif
<dl class="grid gap-3 md:grid-cols-2 xl:grid-cols-4">
<div class="rounded-lg border border-gray-200 bg-gray-50 px-4 py-3 dark:border-gray-800 dark:bg-gray-950/50">
<dt class="text-[11px] font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">Execution outcome</dt>

View File

@ -340,7 +340,7 @@
@if ($policyTypeOptions !== [])
<div class="mt-3 flex flex-wrap gap-2">
<x-filament::badge color="gray" size="sm">
{{ count($policyTypeOptions) }} searchable policy types
{{ count($policyTypeOptions) }} searchable governed subjects
</x-filament::badge>
@if ($hiddenAssignedTenantCount > 0)
<x-filament::badge color="gray" size="sm">
@ -507,7 +507,7 @@
{{ $result['displayName'] ?? $result['subjectKey'] ?? 'Subject' }}
</div>
<div class="text-sm text-gray-500 dark:text-gray-400">
{{ $result['policyType'] ?? 'Unknown policy type' }}
{{ $result['governedSubjectLabel'] ?? ($result['policyType'] ?? 'Unknown governed subject') }}
</div>
@if (filled($result['baselineExternalId'] ?? null))
<div class="text-xs text-gray-500 dark:text-gray-400">
@ -715,7 +715,7 @@
{{ $subject['displayName'] ?? $subject['subjectKey'] ?? 'Subject' }}
</div>
<div class="text-xs text-gray-500 dark:text-gray-400">
{{ $subject['policyType'] ?? 'Unknown policy type' }}
{{ $subject['governedSubjectLabel'] ?? ($subject['policyType'] ?? 'Unknown governed subject') }}
</div>
@if (filled($subject['baselineExternalId'] ?? null))
<div class="text-xs text-gray-500 dark:text-gray-400">

View File

@ -11,6 +11,8 @@
/** @var bool $canManage */
/** @var ?string $downloadUrl */
/** @var ?string $failedReason */
/** @var ?string $failedReasonDetail */
/** @var ?array<string, mixed> $failedReasonSemantics */
/** @var ?string $reviewUrl */
$badgeSpec = $statusEnum ? BadgeCatalog::spec(BadgeDomain::ReviewPackStatus, $statusEnum->value) : null;
@ -133,11 +135,25 @@
</div>
@if ($failedReason)
<div class="text-sm text-danger-600 dark:text-danger-400">
<div class="text-sm font-medium text-danger-600 dark:text-danger-400">
{{ $failedReason }}
</div>
@endif
@if ($failedReasonDetail)
<div class="text-xs text-gray-500 dark:text-gray-400">
{{ $failedReasonDetail }}
</div>
@endif
@if (is_array($failedReasonSemantics ?? null))
<div class="text-xs text-gray-500 dark:text-gray-400">
Reason owner: {{ $failedReasonSemantics['owner_label'] ?? 'Platform core' }}
&middot;
Platform reason family: {{ $failedReasonSemantics['family_label'] ?? 'Compatibility' }}
</div>
@endif
<div class="text-xs text-gray-400 dark:text-gray-500">
{{ $pack->updated_at?->diffForHumans() ?? '—' }}
</div>

View File

@ -0,0 +1,28 @@
<?php
declare(strict_types=1);
use App\Support\Governance\PlatformVocabularyGlossary;
use App\Support\OperationCatalog;
it('keeps touched registry ownership metadata inside the allowed three-way boundary classification', function (): void {
$classifications = collect(app(PlatformVocabularyGlossary::class)->registries())
->map(static fn ($descriptor): string => $descriptor->boundaryClassification)
->unique()
->values()
->all();
expect($classifications)->toEqualCanonicalizing([
PlatformVocabularyGlossary::BOUNDARY_CROSS_DOMAIN_GOVERNANCE,
PlatformVocabularyGlossary::BOUNDARY_PLATFORM_CORE,
PlatformVocabularyGlossary::BOUNDARY_INTUNE_SPECIFIC,
]);
});
it('guards the false-universal policy_type alias behind explicit context-aware vocabulary helpers', function (): void {
$glossary = app(PlatformVocabularyGlossary::class);
expect($glossary->term('policy_type')?->boundaryClassification)->toBe(PlatformVocabularyGlossary::BOUNDARY_INTUNE_SPECIFIC)
->and($glossary->resolveAlias('policy_type', 'compare')?->termKey)->toBe('governed_subject')
->and(OperationCatalog::canonicalCode('baseline_capture'))->toBe('baseline.capture');
});

View File

@ -2,6 +2,8 @@
declare(strict_types=1);
use App\Support\Baselines\BaselineCompareReasonCode;
use App\Support\Governance\PlatformVocabularyGlossary;
use App\Support\Operations\ExecutionDenialReasonCode;
use App\Support\Providers\ProviderReasonCodes;
use App\Support\RbacReason;
@ -32,3 +34,24 @@
->and(TenantOperabilityReasonCode::TenantAlreadyArchived->toReasonResolutionEnvelope()->guidanceText())->toBe('No action needed.')
->and(RbacReason::ManualAssignmentRequired->toReasonResolutionEnvelope()->operatorLabel)->toBe('Manual role assignment required');
});
it('keeps primary-surface reason ownership inside the allowed three-way boundary classification', function (): void {
$translator = app(ReasonTranslator::class);
$classifications = collect([
$translator->boundaryClassification(ExecutionDenialReasonCode::MissingCapability->value, ReasonTranslator::EXECUTION_DENIAL_ARTIFACT),
$translator->boundaryClassification(ProviderReasonCodes::ProviderConsentMissing),
$translator->boundaryClassification(TenantOperabilityReasonCode::RememberedContextStale->value, ReasonTranslator::TENANT_OPERABILITY_ARTIFACT),
$translator->boundaryClassification(RbacReason::ManualAssignmentRequired->value, ReasonTranslator::RBAC_ARTIFACT),
$translator->boundaryClassification(BaselineCompareReasonCode::CoverageUnproven->value, ReasonTranslator::GOVERNANCE_ARTIFACT_TRUTH_ARTIFACT),
])
->filter()
->unique()
->values()
->all();
expect($classifications)->toEqualCanonicalizing([
PlatformVocabularyGlossary::BOUNDARY_PLATFORM_CORE,
PlatformVocabularyGlossary::BOUNDARY_CROSS_DOMAIN_GOVERNANCE,
PlatformVocabularyGlossary::BOUNDARY_INTUNE_SPECIFIC,
]);
});

View File

@ -42,7 +42,9 @@
->assertSuccessful()
->assertSee('Permission required')
->assertSee('The initiating actor no longer has the capability required for this queued run.')
->assertSee('Review workspace or tenant access before retrying.');
->assertSee('Review workspace or tenant access before retrying.')
->assertDontSee('execution_denial')
->assertDontSee('platform_core');
});
it('returns not found before any translated guidance can leak to non-members', function (): void {

View File

@ -2,17 +2,29 @@
declare(strict_types=1);
require_once __DIR__.'/Support/FakeCompareStrategy.php';
use App\Jobs\CompareBaselineToTenantJob;
use App\Models\BaselineProfile;
use App\Models\BaselineSnapshot;
use App\Models\BaselineSnapshotItem;
use App\Models\BaselineTenantAssignment;
use App\Models\InventoryItem;
use App\Models\OperationRun;
use App\Services\Baselines\BaselineCompareService;
use App\Services\Baselines\BaselineSnapshotIdentity;
use App\Services\Intune\AuditLogger;
use App\Services\OperationRunService;
use App\Support\Baselines\BaselineCompareReasonCode;
use App\Support\Baselines\BaselineReasonCodes;
use App\Support\Baselines\Compare\CompareStrategyRegistry;
use App\Support\Governance\GovernanceSubjectTaxonomyRegistry;
use App\Support\OperationRunOutcome;
use App\Support\OperationRunStatus;
use App\Support\OperationRunType;
use Illuminate\Support\Facades\Queue;
use Tests\Feature\Baselines\Support\FailingCompareStrategy;
use Tests\Feature\Baselines\Support\FakeGovernanceSubjectTaxonomyRegistry;
it('blocks compare execution when the queued snapshot is incomplete', function (): void {
[$user, $tenant] = createUserWithTenant(role: 'owner');
@ -113,3 +125,86 @@
->and(data_get($context, 'baseline_compare.latest_attempted_snapshot_id'))->toBe((int) $currentSnapshot->getKey())
->and(data_get($run->failure_summary, '0.reason_code'))->toBe(BaselineReasonCodes::COMPARE_SNAPSHOT_SUPERSEDED);
});
it('marks compare runs as partially succeeded when strategy-owned processing fails before subject classification completes', function (): void {
Queue::fake();
[$user, $tenant] = createUserWithTenant(role: 'owner');
app()->instance(GovernanceSubjectTaxonomyRegistry::class, new FakeGovernanceSubjectTaxonomyRegistry);
app()->instance(CompareStrategyRegistry::class, new CompareStrategyRegistry([
app(FailingCompareStrategy::class),
]));
$profile = BaselineProfile::factory()->active()->create([
'workspace_id' => (int) $tenant->workspace_id,
'scope_jsonb' => [
'version' => 2,
'entries' => [[
'domain_key' => 'entra',
'subject_class' => 'control',
'subject_type_keys' => ['conditionalAccessPolicy'],
'filters' => [],
]],
],
]);
$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()]);
BaselineTenantAssignment::factory()->create([
'workspace_id' => (int) $tenant->workspace_id,
'tenant_id' => (int) $tenant->getKey(),
'baseline_profile_id' => (int) $profile->getKey(),
]);
BaselineSnapshotItem::factory()->create([
'baseline_snapshot_id' => (int) $snapshot->getKey(),
'subject_type' => 'control',
'subject_external_id' => 'conditional-access-policy-1',
'subject_key' => 'conditional-access-policy-1',
'policy_type' => 'conditionalAccessPolicy',
'baseline_hash' => hash('sha256', 'baseline'),
'meta_jsonb' => ['display_name' => 'Conditional Access Policy'],
]);
$inventorySyncRun = createInventorySyncOperationRunWithCoverage($tenant, ['conditionalAccessPolicy' => 'succeeded']);
InventoryItem::factory()->create([
'tenant_id' => (int) $tenant->getKey(),
'workspace_id' => (int) $tenant->workspace_id,
'external_id' => 'conditional-access-policy-1',
'policy_type' => 'conditionalAccessPolicy',
'display_name' => 'Conditional Access Policy',
'meta_jsonb' => ['display_name' => 'Conditional Access Policy'],
'last_seen_operation_run_id' => (int) $inventorySyncRun->getKey(),
'last_seen_at' => now(),
]);
$result = app(BaselineCompareService::class)->startCompare($tenant, $user);
expect($result['ok'])->toBeTrue();
/** @var OperationRun $run */
$run = $result['run'];
(new CompareBaselineToTenantJob($run))->handle(
app(BaselineSnapshotIdentity::class),
app(AuditLogger::class),
app(OperationRunService::class),
);
$run->refresh();
expect($run->outcome)->toBe(OperationRunOutcome::PartiallySucceeded->value)
->and(data_get($run->context, 'baseline_compare.reason_code'))->toBe(BaselineCompareReasonCode::StrategyFailed->value)
->and(data_get($run->context, 'baseline_compare.strategy.execution_diagnostics.failed'))->toBeTrue()
->and(data_get($run->context, 'baseline_compare.strategy.execution_diagnostics.exception_class'))->toBe(RuntimeException::class)
->and(data_get($run->context, 'baseline_compare.strategy.state_counts'))->toBe([])
->and(data_get($run->context, 'baseline_compare.evidence_gaps.by_reason.strategy_failed'))->toBe(1);
});

View File

@ -215,7 +215,7 @@
->and($cellsByTenant[(int) $ambiguousTenant->getKey()]['reasonSummary'] ?? null)->toBe('Ambiguous Match')
->and($cellsByTenant[(int) $notComparedTenant->getKey()]['state'] ?? null)->toBe('not_compared')
->and($cellsByTenant[(int) $notComparedTenant->getKey()]['attentionLevel'] ?? null)->toBe('refresh_recommended')
->and($cellsByTenant[(int) $notComparedTenant->getKey()]['reasonSummary'] ?? null)->toContain('Policy type coverage was not proven')
->and($cellsByTenant[(int) $notComparedTenant->getKey()]['reasonSummary'] ?? null)->toContain('Governed subject coverage was not proven')
->and($cellsByTenant[(int) $staleTenant->getKey()]['state'] ?? null)->toBe('stale_result')
->and($cellsByTenant[(int) $staleTenant->getKey()]['freshnessState'] ?? null)->toBe('stale')
->and($cellsByTenant[(int) $staleTenant->getKey()]['trustLevel'] ?? null)->toBe('limited_confidence')

View File

@ -2,16 +2,24 @@
declare(strict_types=1);
require_once __DIR__.'/Support/FakeCompareStrategy.php';
use App\Filament\Pages\BaselineCompareMatrix;
use App\Jobs\CompareBaselineToTenantJob;
use App\Models\OperationRun;
use App\Models\Tenant;
use App\Services\Baselines\BaselineCompareService;
use App\Support\Baselines\BaselineReasonCodes;
use App\Support\Baselines\Compare\CompareStrategyRegistry;
use App\Support\Baselines\Compare\IntuneCompareStrategy;
use App\Support\Governance\GovernanceSubjectTaxonomyRegistry;
use App\Support\OperationRunOutcome;
use App\Support\OperationRunStatus;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Queue;
use Livewire\Livewire;
use Tests\Feature\Baselines\Support\FakeCompareStrategy;
use Tests\Feature\Baselines\Support\FakeGovernanceSubjectTaxonomyRegistry;
use Tests\Feature\Concerns\BuildsBaselineCompareMatrixFixtures;
uses(RefreshDatabase::class, BuildsBaselineCompareMatrixFixtures::class);
@ -93,3 +101,82 @@
->whereNull('tenant_id')
->count())->toBe(0);
});
it('blocks visible assignment fanout when the baseline scope spans multiple compare strategy families', function (): void {
Queue::fake();
$fixture = $this->makeBaselineCompareMatrixFixture();
app()->instance(GovernanceSubjectTaxonomyRegistry::class, new FakeGovernanceSubjectTaxonomyRegistry);
app()->instance(CompareStrategyRegistry::class, new CompareStrategyRegistry([
app(IntuneCompareStrategy::class),
app(FakeCompareStrategy::class),
]));
$fixture['profile']->update([
'scope_jsonb' => [
'version' => 2,
'entries' => [
[
'domain_key' => 'intune',
'subject_class' => 'policy',
'subject_type_keys' => ['deviceConfiguration'],
'filters' => [],
],
[
'domain_key' => 'entra',
'subject_class' => 'control',
'subject_type_keys' => ['conditionalAccessPolicy'],
'filters' => [],
],
],
],
]);
$result = app(BaselineCompareService::class)->startCompareForVisibleAssignments($fixture['profile'], $fixture['user']);
expect($result['visibleAssignedTenantCount'])->toBe(2)
->and($result['queuedCount'])->toBe(0)
->and($result['alreadyQueuedCount'])->toBe(0)
->and($result['blockedCount'])->toBe(2)
->and(collect($result['targets'])->pluck('reasonCode')->unique()->values()->all())
->toBe([BaselineReasonCodes::COMPARE_MIXED_SCOPE]);
Queue::assertNotPushed(CompareBaselineToTenantJob::class);
});
it('blocks visible assignment fanout when the baseline scope has no compatible compare strategy family', function (): void {
Queue::fake();
$fixture = $this->makeBaselineCompareMatrixFixture();
app()->instance(GovernanceSubjectTaxonomyRegistry::class, new FakeGovernanceSubjectTaxonomyRegistry);
app()->instance(CompareStrategyRegistry::class, new CompareStrategyRegistry([
app(IntuneCompareStrategy::class),
]));
$fixture['profile']->update([
'scope_jsonb' => [
'version' => 2,
'entries' => [
[
'domain_key' => 'entra',
'subject_class' => 'control',
'subject_type_keys' => ['conditionalAccessPolicy'],
'filters' => [],
],
],
],
]);
$result = app(BaselineCompareService::class)->startCompareForVisibleAssignments($fixture['profile'], $fixture['user']);
expect($result['visibleAssignedTenantCount'])->toBe(2)
->and($result['queuedCount'])->toBe(0)
->and($result['alreadyQueuedCount'])->toBe(0)
->and($result['blockedCount'])->toBe(2)
->and(collect($result['targets'])->pluck('reasonCode')->unique()->values()->all())
->toBe([BaselineReasonCodes::COMPARE_UNSUPPORTED_SCOPE]);
Queue::assertNotPushed(CompareBaselineToTenantJob::class);
});

View File

@ -1,15 +1,22 @@
<?php
require_once __DIR__.'/Support/FakeCompareStrategy.php';
use App\Jobs\CompareBaselineToTenantJob;
use App\Models\BaselineProfile;
use App\Models\BaselineSnapshot;
use App\Models\BaselineTenantAssignment;
use App\Models\OperationRun;
use App\Services\Baselines\BaselineCompareService;
use App\Support\Baselines\Compare\CompareStrategyRegistry;
use App\Support\Baselines\BaselineProfileStatus;
use App\Support\Baselines\BaselineReasonCodes;
use App\Support\Baselines\Compare\IntuneCompareStrategy;
use App\Support\Governance\GovernanceSubjectTaxonomyRegistry;
use App\Support\OperationRunType;
use Illuminate\Support\Facades\Queue;
use Tests\Feature\Baselines\Support\FakeCompareStrategy;
use Tests\Feature\Baselines\Support\FakeGovernanceSubjectTaxonomyRegistry;
// --- T040: Compare precondition 422 tests ---
@ -217,6 +224,158 @@
Queue::assertPushed(CompareBaselineToTenantJob::class);
});
it('rejects compare when canonical scope spans multiple compare strategy families', function (): void {
Queue::fake();
[$user, $tenant] = createUserWithTenant(role: 'owner');
app()->instance(GovernanceSubjectTaxonomyRegistry::class, new FakeGovernanceSubjectTaxonomyRegistry);
app()->instance(CompareStrategyRegistry::class, new CompareStrategyRegistry([
app(IntuneCompareStrategy::class),
app(FakeCompareStrategy::class),
]));
$profile = BaselineProfile::factory()->active()->create([
'workspace_id' => $tenant->workspace_id,
'scope_jsonb' => [
'version' => 2,
'entries' => [
[
'domain_key' => 'intune',
'subject_class' => 'policy',
'subject_type_keys' => ['deviceConfiguration'],
'filters' => [],
],
[
'domain_key' => 'entra',
'subject_class' => 'control',
'subject_type_keys' => ['conditionalAccessPolicy'],
'filters' => [],
],
],
],
]);
$snapshot = BaselineSnapshot::factory()->create([
'workspace_id' => $tenant->workspace_id,
'baseline_profile_id' => $profile->getKey(),
]);
$profile->update(['active_snapshot_id' => $snapshot->getKey()]);
BaselineTenantAssignment::create([
'workspace_id' => (int) $tenant->workspace_id,
'tenant_id' => (int) $tenant->getKey(),
'baseline_profile_id' => (int) $profile->getKey(),
]);
$service = app(BaselineCompareService::class);
$result = $service->startCompare($tenant, $user);
expect($result['ok'])->toBeFalse();
expect($result['reason_code'])->toBe(BaselineReasonCodes::COMPARE_MIXED_SCOPE);
Queue::assertNotPushed(CompareBaselineToTenantJob::class);
expect(OperationRun::query()->where('type', OperationRunType::BaselineCompare->value)->count())->toBe(0);
});
it('rejects compare when canonical scope uses an inactive subject type', function (): void {
Queue::fake();
[$user, $tenant] = createUserWithTenant(role: 'owner');
$profile = BaselineProfile::factory()->active()->create([
'workspace_id' => $tenant->workspace_id,
'scope_jsonb' => ['policy_types' => ['deviceConfiguration'], 'foundation_types' => []],
]);
BaselineProfile::query()
->whereKey($profile->getKey())
->update([
'scope_jsonb' => json_encode([
'version' => 2,
'entries' => [
[
'domain_key' => 'platform_foundation',
'subject_class' => 'configuration_resource',
'subject_type_keys' => ['intuneRoleAssignment'],
'filters' => [],
],
],
], JSON_THROW_ON_ERROR),
]);
$snapshot = BaselineSnapshot::factory()->create([
'workspace_id' => $tenant->workspace_id,
'baseline_profile_id' => $profile->getKey(),
]);
$profile->update(['active_snapshot_id' => $snapshot->getKey()]);
BaselineTenantAssignment::create([
'workspace_id' => (int) $tenant->workspace_id,
'tenant_id' => (int) $tenant->getKey(),
'baseline_profile_id' => (int) $profile->getKey(),
]);
$service = app(BaselineCompareService::class);
$result = $service->startCompare($tenant, $user);
expect($result['ok'])->toBeFalse();
expect($result['reason_code'])->toBe(BaselineReasonCodes::COMPARE_INVALID_SCOPE);
Queue::assertNotPushed(CompareBaselineToTenantJob::class);
expect(OperationRun::query()->where('type', OperationRunType::BaselineCompare->value)->count())->toBe(0);
});
it('rejects compare when canonical scope has no compatible compare strategy', function (): void {
Queue::fake();
[$user, $tenant] = createUserWithTenant(role: 'owner');
app()->instance(GovernanceSubjectTaxonomyRegistry::class, new FakeGovernanceSubjectTaxonomyRegistry);
app()->instance(CompareStrategyRegistry::class, new CompareStrategyRegistry([
app(IntuneCompareStrategy::class),
]));
$profile = BaselineProfile::factory()->active()->create([
'workspace_id' => $tenant->workspace_id,
'scope_jsonb' => [
'version' => 2,
'entries' => [
[
'domain_key' => 'entra',
'subject_class' => 'control',
'subject_type_keys' => ['conditionalAccessPolicy'],
'filters' => [],
],
],
],
]);
$snapshot = BaselineSnapshot::factory()->create([
'workspace_id' => $tenant->workspace_id,
'baseline_profile_id' => $profile->getKey(),
]);
$profile->update(['active_snapshot_id' => $snapshot->getKey()]);
BaselineTenantAssignment::create([
'workspace_id' => (int) $tenant->workspace_id,
'tenant_id' => (int) $tenant->getKey(),
'baseline_profile_id' => (int) $profile->getKey(),
]);
$service = app(BaselineCompareService::class);
$result = $service->startCompare($tenant, $user);
expect($result['ok'])->toBeFalse();
expect($result['reason_code'])->toBe(BaselineReasonCodes::COMPARE_UNSUPPORTED_SCOPE);
Queue::assertNotPushed(CompareBaselineToTenantJob::class);
expect(OperationRun::query()->where('type', OperationRunType::BaselineCompare->value)->count())->toBe(0);
});
it('uses an explicit snapshot override instead of baseline_profiles.active_snapshot_id when provided', function () {
Queue::fake();

View File

@ -0,0 +1,160 @@
<?php
declare(strict_types=1);
require_once __DIR__.'/Support/FakeCompareStrategy.php';
use App\Jobs\CompareBaselineToTenantJob;
use App\Models\BaselineProfile;
use App\Models\BaselineSnapshot;
use App\Models\BaselineTenantAssignment;
use App\Models\Finding;
use App\Models\InventoryItem;
use App\Models\OperationRun;
use App\Services\Baselines\BaselineCompareService;
use App\Services\Baselines\BaselineSnapshotIdentity;
use App\Services\Intune\AuditLogger;
use App\Services\OperationRunService;
use App\Support\Baselines\Compare\CompareStrategyRegistry;
use App\Support\OperationRunOutcome;
use App\Support\OperationRunStatus;
use App\Support\OperationRunType;
use App\Support\Governance\GovernanceSubjectTaxonomyRegistry;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Queue;
use Tests\Feature\Baselines\Support\FakeCompareStrategy;
use Tests\Feature\Baselines\Support\FakeGovernanceSubjectTaxonomyRegistry;
uses(RefreshDatabase::class);
it('runs a future-domain compare strategy through the shared lifecycle without implicit intune fallback', function (): void {
Queue::fake();
[$user, $tenant] = createUserWithTenant(role: 'owner');
app()->instance(GovernanceSubjectTaxonomyRegistry::class, new FakeGovernanceSubjectTaxonomyRegistry);
app()->instance(CompareStrategyRegistry::class, new CompareStrategyRegistry([
app(FakeCompareStrategy::class),
]));
$profile = BaselineProfile::factory()->active()->create([
'workspace_id' => (int) $tenant->workspace_id,
'scope_jsonb' => [
'version' => 2,
'entries' => [[
'domain_key' => 'entra',
'subject_class' => 'control',
'subject_type_keys' => ['conditionalAccessPolicy'],
'filters' => [],
]],
],
]);
$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()]);
BaselineTenantAssignment::factory()->create([
'workspace_id' => (int) $tenant->workspace_id,
'tenant_id' => (int) $tenant->getKey(),
'baseline_profile_id' => (int) $profile->getKey(),
]);
$displayName = 'Conditional Access Global Block';
$externalId = 'conditional-access-policy-1';
$subjectKey = app(BaselineSnapshotIdentity::class)->subjectKey(
policyType: 'conditionalAccessPolicy',
displayName: $displayName,
subjectExternalId: $externalId,
) ?? $externalId;
$baselineMeta = [
'display_name' => $displayName,
'conditions' => ['users' => ['includeGuestsOrExternalUsers' => true]],
];
$currentMeta = [
'display_name' => $displayName,
'conditions' => ['users' => ['includeGuestsOrExternalUsers' => false]],
];
$baselineHash = app(BaselineSnapshotIdentity::class)->hashItemContent(
policyType: 'conditionalAccessPolicy',
subjectExternalId: $externalId,
metaJsonb: $baselineMeta,
);
\App\Models\BaselineSnapshotItem::factory()->create([
'baseline_snapshot_id' => (int) $snapshot->getKey(),
'subject_type' => 'control',
'subject_external_id' => $externalId,
'subject_key' => $subjectKey,
'policy_type' => 'conditionalAccessPolicy',
'baseline_hash' => $baselineHash,
'meta_jsonb' => $baselineMeta,
]);
$inventorySyncRun = OperationRun::factory()->create([
'tenant_id' => (int) $tenant->getKey(),
'workspace_id' => (int) $tenant->workspace_id,
'type' => OperationRunType::InventorySync->value,
'status' => OperationRunStatus::Completed->value,
'outcome' => OperationRunOutcome::Succeeded->value,
'completed_at' => now(),
'context' => [
'inventory' => [
'coverage' => [
'policy_types' => [
'conditionalAccessPolicy' => ['status' => 'succeeded'],
],
'foundation_types' => [],
],
],
],
]);
InventoryItem::factory()->create([
'tenant_id' => (int) $tenant->getKey(),
'workspace_id' => (int) $tenant->workspace_id,
'external_id' => $externalId,
'policy_type' => 'conditionalAccessPolicy',
'display_name' => $displayName,
'meta_jsonb' => $currentMeta,
'last_seen_operation_run_id' => (int) $inventorySyncRun->getKey(),
'last_seen_at' => now(),
]);
$service = app(BaselineCompareService::class);
$result = $service->startCompare($tenant, $user);
expect($result['ok'] ?? false)->toBeTrue();
Queue::assertPushed(CompareBaselineToTenantJob::class);
/** @var OperationRun $run */
$run = $result['run'];
(new CompareBaselineToTenantJob($run))->handle(
app(\App\Services\Baselines\BaselineSnapshotIdentity::class),
app(AuditLogger::class),
app(OperationRunService::class),
);
$run->refresh();
expect($run->status)->toBe(OperationRunStatus::Completed->value)
->and($run->outcome)->toBe(OperationRunOutcome::Succeeded->value)
->and(data_get($run->context, 'baseline_compare.strategy.key'))->toBe('future_control')
->and(data_get($run->context, 'baseline_compare.strategy.selection_state'))->toBe('supported')
->and(data_get($run->context, 'findings.counts_by_change_type.different_version'))->toBe(1)
->and(data_get($run->context, 'result.findings_total'))->toBe(1);
$finding = Finding::query()->where('tenant_id', (int) $tenant->getKey())->first();
expect($finding)->not->toBeNull()
->and($finding?->subject_type)->toBe('control')
->and(data_get($finding?->evidence_jsonb, 'summary.kind'))->toBe('control_snapshot')
->and(data_get($finding?->evidence_jsonb, 'policy_type'))->toBe('conditionalAccessPolicy');
});

View File

@ -0,0 +1,454 @@
<?php
declare(strict_types=1);
namespace Tests\Feature\Baselines\Support;
use App\Models\Tenant;
use App\Services\Baselines\Evidence\EvidenceProvenance;
use App\Services\Baselines\Evidence\ResolvedEvidence;
use App\Support\Baselines\Compare\CompareFindingCandidate;
use App\Support\Baselines\Compare\CompareOrchestrationContext;
use App\Support\Baselines\Compare\CompareState;
use App\Support\Baselines\Compare\CompareStrategy;
use App\Support\Baselines\Compare\CompareStrategyCapability;
use App\Support\Baselines\Compare\CompareStrategyKey;
use App\Support\Baselines\Compare\CompareSubjectIdentity;
use App\Support\Baselines\Compare\CompareSubjectProjection;
use App\Support\Baselines\Compare\CompareSubjectResult;
use App\Support\Baselines\OperatorActionCategory;
use App\Support\Baselines\ResolutionOutcome;
use App\Support\Baselines\ResolutionPath;
use App\Support\Baselines\SubjectClass;
use App\Support\Governance\GovernanceDomainKey;
use App\Support\Governance\GovernanceSubjectClass;
use App\Support\Governance\GovernanceSubjectTaxonomyRegistry;
use App\Support\Governance\GovernanceSubjectType;
final class FakeCompareStrategy implements CompareStrategy
{
public function key(): CompareStrategyKey
{
return CompareStrategyKey::from('future_control');
}
public function capabilities(): array
{
return [
new CompareStrategyCapability(
strategyKey: $this->key(),
domainKeys: [GovernanceDomainKey::Entra->value],
subjectClasses: [GovernanceSubjectClass::Control->value],
subjectTypeKeys: ['conditionalAccessPolicy'],
),
];
}
public function compare(
CompareOrchestrationContext $context,
Tenant $tenant,
array $baselineItems,
array $currentItems,
array $resolvedCurrentEvidence,
array $severityMapping,
): array {
$subjectResults = [];
foreach ($baselineItems as $key => $baselineItem) {
$currentItem = $currentItems[$key] ?? null;
if (! is_array($currentItem)) {
$subjectResults[] = $this->driftResult(
context: $context,
baselineItem: $baselineItem,
currentItem: null,
currentEvidence: null,
changeType: 'missing_policy',
severity: 'high',
);
continue;
}
$currentEvidence = $resolvedCurrentEvidence[$key] ?? null;
if (! $currentEvidence instanceof ResolvedEvidence) {
$subjectResults[] = $this->gapResult(
policyType: (string) $baselineItem['policy_type'],
subjectKey: (string) $baselineItem['subject_key'],
externalSubjectId: (string) $baselineItem['subject_external_id'],
operatorLabel: (string) (($currentItem['meta_jsonb']['display_name'] ?? $baselineItem['meta_jsonb']['display_name'] ?? $baselineItem['subject_key']) ?: $baselineItem['subject_key']),
compareState: CompareState::Incomplete,
reasonCode: 'missing_current',
baselineAvailability: 'available',
currentStateAvailability: 'unknown',
trustLevel: 'unusable',
evidenceQuality: 'missing',
);
continue;
}
$baselineMeta = is_array($baselineItem['meta_jsonb'] ?? null) ? $baselineItem['meta_jsonb'] : [];
$currentMeta = is_array($currentItem['meta_jsonb'] ?? null) ? $currentItem['meta_jsonb'] : [];
if ($this->metaFingerprint($baselineMeta) !== $this->metaFingerprint($currentMeta)) {
$subjectResults[] = $this->driftResult(
context: $context,
baselineItem: $baselineItem,
currentItem: $currentItem,
currentEvidence: $currentEvidence,
changeType: 'different_version',
severity: 'medium',
);
continue;
}
$subjectResults[] = new CompareSubjectResult(
subjectIdentity: $this->identity(
policyType: (string) $baselineItem['policy_type'],
externalSubjectId: (string) $baselineItem['subject_external_id'],
subjectKey: (string) $baselineItem['subject_key'],
),
projection: $this->projection(
policyType: (string) $baselineItem['policy_type'],
operatorLabel: (string) (($currentItem['meta_jsonb']['display_name'] ?? $baselineItem['meta_jsonb']['display_name'] ?? $baselineItem['subject_key']) ?: $baselineItem['subject_key']),
),
baselineAvailability: 'available',
currentStateAvailability: 'available',
compareState: CompareState::NoDrift,
trustLevel: 'trustworthy',
evidenceQuality: $currentEvidence->fidelity,
);
}
foreach ($currentItems as $key => $currentItem) {
if (array_key_exists($key, $baselineItems)) {
continue;
}
$currentEvidence = $resolvedCurrentEvidence[$key] ?? null;
if (! $currentEvidence instanceof ResolvedEvidence) {
$subjectResults[] = $this->gapResult(
policyType: (string) $currentItem['policy_type'],
subjectKey: (string) $currentItem['subject_key'],
externalSubjectId: (string) $currentItem['subject_external_id'],
operatorLabel: (string) (($currentItem['meta_jsonb']['display_name'] ?? $currentItem['subject_key']) ?: $currentItem['subject_key']),
compareState: CompareState::Incomplete,
reasonCode: 'missing_current',
baselineAvailability: 'missing',
currentStateAvailability: 'unknown',
trustLevel: 'unusable',
evidenceQuality: 'missing',
);
continue;
}
$subjectResults[] = $this->driftResult(
context: $context,
baselineItem: null,
currentItem: $currentItem,
currentEvidence: $currentEvidence,
changeType: 'unexpected_policy',
severity: 'low',
);
}
return [
'subject_results' => $subjectResults,
'diagnostics' => [
'strategy_family' => 'future_control',
'state_counts' => [
'drift' => count(array_filter($subjectResults, static fn (CompareSubjectResult $result): bool => $result->compareState === CompareState::Drift)),
],
],
];
}
/**
* @param array<string, mixed> $meta
*/
private function metaFingerprint(array $meta): string
{
unset($meta['display_name'], $meta['category'], $meta['platform']);
return hash('sha256', json_encode($this->sortRecursive($meta), JSON_THROW_ON_ERROR | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE));
}
/**
* @param array<string, mixed> $value
* @return array<string, mixed>
*/
private function sortRecursive(array $value): array
{
foreach ($value as $key => $nestedValue) {
if (! is_array($nestedValue)) {
continue;
}
$value[$key] = $this->sortRecursive($nestedValue);
}
ksort($value, SORT_STRING);
return $value;
}
private function driftResult(
CompareOrchestrationContext $context,
?array $baselineItem,
?array $currentItem,
?ResolvedEvidence $currentEvidence,
string $changeType,
string $severity,
): CompareSubjectResult {
$source = $baselineItem ?? $currentItem ?? [];
$policyType = (string) ($source['policy_type'] ?? 'conditionalAccessPolicy');
$subjectKey = (string) ($source['subject_key'] ?? 'unknown');
$externalSubjectId = (string) ($source['subject_external_id'] ?? 'unknown');
$operatorLabel = (string) ((($currentItem['meta_jsonb']['display_name'] ?? null) ?: ($baselineItem['meta_jsonb']['display_name'] ?? null) ?: $subjectKey) ?: $subjectKey);
$fidelity = $currentEvidence?->fidelity ?? EvidenceProvenance::FidelityMeta;
$baselineProvenance = EvidenceProvenance::build(
fidelity: EvidenceProvenance::FidelityMeta,
source: EvidenceProvenance::SourceInventory,
observedAt: null,
observedOperationRunId: null,
);
$currentProvenance = $currentEvidence?->tenantProvenance() ?? EvidenceProvenance::build(
fidelity: $fidelity,
source: EvidenceProvenance::SourceInventory,
observedAt: null,
observedOperationRunId: $context->inventorySyncRunId(),
);
return new CompareSubjectResult(
subjectIdentity: $this->identity($policyType, $externalSubjectId, $subjectKey),
projection: $this->projection($policyType, $operatorLabel),
baselineAvailability: $baselineItem === null ? 'missing' : 'available',
currentStateAvailability: $currentItem === null ? 'missing' : 'available',
compareState: CompareState::Drift,
trustLevel: $fidelity === EvidenceProvenance::FidelityContent ? 'trustworthy' : 'limited_confidence',
evidenceQuality: $fidelity,
severityRecommendation: $severity,
findingCandidate: new CompareFindingCandidate(
changeType: $changeType,
severity: $severity,
fingerprintBasis: [
'policy_type' => $policyType,
'subject_key' => $subjectKey,
'change_type' => $changeType,
],
evidencePayload: [
'change_type' => $changeType,
'policy_type' => $policyType,
'subject_key' => $subjectKey,
'display_name' => $operatorLabel,
'summary' => ['kind' => 'control_snapshot'],
'baseline' => [
'hash' => $baselineItem['baseline_hash'] ?? null,
'provenance' => $baselineProvenance,
],
'current' => [
'hash' => $currentEvidence?->hash,
'provenance' => $currentProvenance,
],
'fidelity' => $fidelity,
'provenance' => [
'baseline_profile_id' => $context->baselineProfileId,
'baseline_snapshot_id' => $context->baselineSnapshotId,
'compare_operation_run_id' => $context->operationRunId,
'inventory_sync_run_id' => $context->inventorySyncRunId(),
],
],
),
diagnostics: [
'strategy_key' => $this->key()->value,
],
);
}
private function gapResult(
string $policyType,
string $subjectKey,
string $externalSubjectId,
string $operatorLabel,
CompareState $compareState,
string $reasonCode,
string $baselineAvailability,
string $currentStateAvailability,
string $trustLevel,
string $evidenceQuality,
): CompareSubjectResult {
return new CompareSubjectResult(
subjectIdentity: $this->identity($policyType, $externalSubjectId, $subjectKey),
projection: $this->projection($policyType, $operatorLabel),
baselineAvailability: $baselineAvailability,
currentStateAvailability: $currentStateAvailability,
compareState: $compareState,
trustLevel: $trustLevel,
evidenceQuality: $evidenceQuality,
diagnostics: [
'reason_code' => $reasonCode,
'gap_record' => [
'policy_type' => $policyType,
'subject_key' => $subjectKey,
'subject_class' => SubjectClass::Derived->value,
'resolution_path' => ResolutionPath::Derived->value,
'resolution_outcome' => ResolutionOutcome::CaptureFailed->value,
'operator_action_category' => OperatorActionCategory::RunInventorySync->value,
'structural' => false,
'retryable' => $reasonCode === 'missing_current',
'reason_code' => $reasonCode,
'search_text' => strtolower(implode(' ', [$policyType, $subjectKey, $reasonCode])),
],
],
);
}
private function identity(string $policyType, string $externalSubjectId, string $subjectKey): CompareSubjectIdentity
{
return new CompareSubjectIdentity(
domainKey: GovernanceDomainKey::Entra->value,
subjectClass: GovernanceSubjectClass::Control->value,
subjectTypeKey: $policyType,
externalSubjectId: $externalSubjectId,
subjectKey: $subjectKey,
);
}
private function projection(string $policyType, string $operatorLabel): CompareSubjectProjection
{
return new CompareSubjectProjection(
platformSubjectClass: 'control',
domainKey: GovernanceDomainKey::Entra->value,
subjectTypeKey: $policyType,
operatorLabel: $operatorLabel,
summaryKind: 'control_snapshot',
);
}
}
final class FailingCompareStrategy implements CompareStrategy
{
public function key(): CompareStrategyKey
{
return CompareStrategyKey::from('failing_control');
}
public function capabilities(): array
{
return [
new CompareStrategyCapability(
strategyKey: $this->key(),
domainKeys: [GovernanceDomainKey::Entra->value],
subjectClasses: [GovernanceSubjectClass::Control->value],
subjectTypeKeys: ['conditionalAccessPolicy'],
),
];
}
public function compare(
CompareOrchestrationContext $context,
Tenant $tenant,
array $baselineItems,
array $currentItems,
array $resolvedCurrentEvidence,
array $severityMapping,
): array {
throw new \RuntimeException('Synthetic strategy failure for compare testing.');
}
}
final class FakeGovernanceSubjectTaxonomyRegistry
{
private readonly GovernanceSubjectTaxonomyRegistry $inner;
public function __construct()
{
$this->inner = new GovernanceSubjectTaxonomyRegistry;
}
public function all(): array
{
return array_values(array_merge($this->inner->all(), [
new GovernanceSubjectType(
domainKey: GovernanceDomainKey::Entra,
subjectClass: GovernanceSubjectClass::Control,
subjectTypeKey: 'conditionalAccessPolicy',
label: 'Conditional Access Policy',
description: 'Synthetic test-only future domain control',
captureSupported: true,
compareSupported: true,
inventorySupported: true,
active: true,
supportMode: 'supported',
legacyBucket: null,
),
]));
}
public function active(): array
{
return array_values(array_filter(
$this->all(),
static fn (GovernanceSubjectType $subjectType): bool => $subjectType->active,
));
}
public function activeLegacyBucketKeys(string $legacyBucket): array
{
$subjectTypes = array_filter(
$this->active(),
static fn (GovernanceSubjectType $subjectType): bool => $subjectType->legacyBucket === $legacyBucket,
);
$keys = array_map(
static fn (GovernanceSubjectType $subjectType): string => $subjectType->subjectTypeKey,
$subjectTypes,
);
sort($keys, SORT_STRING);
return array_values(array_unique($keys));
}
public function find(string $domainKey, string $subjectTypeKey): ?GovernanceSubjectType
{
foreach ($this->all() as $subjectType) {
if ($subjectType->domainKey->value !== trim($domainKey)) {
continue;
}
if ($subjectType->subjectTypeKey !== trim($subjectTypeKey)) {
continue;
}
return $subjectType;
}
return null;
}
public function isKnownDomain(string $domainKey): bool
{
return $this->inner->isKnownDomain($domainKey);
}
public function allowsSubjectClass(string $domainKey, string $subjectClass): bool
{
return $this->inner->allowsSubjectClass($domainKey, $subjectClass);
}
public function supportsFilters(string $domainKey, string $subjectClass): bool
{
return $this->inner->supportsFilters($domainKey, $subjectClass);
}
public function groupLabel(string $domainKey, string $subjectClass): string
{
return $this->inner->groupLabel($domainKey, $subjectClass);
}
}

View File

@ -80,7 +80,7 @@ function baselineCompareEvidenceGapBuckets(): array
expect($table->isSearchable())->toBeTrue();
expect($table->getDefaultSortColumn())->toBe('reason_label');
expect($table->getColumn('reason_label')?->isSearchable())->toBeTrue();
expect($table->getColumn('policy_type')?->isSearchable())->toBeTrue();
expect($table->getColumn('governed_subject_label')?->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();
@ -90,7 +90,7 @@ function baselineCompareEvidenceGapBuckets(): array
->assertSee('WiFi-Corp-Profile')
->assertSee('Deleted-Policy-ABC')
->assertSee('Reason')
->assertSee('Policy type')
->assertSee('Governed subject')
->assertSee('Subject class')
->assertSee('Outcome')
->assertSee('Next action')
@ -119,6 +119,7 @@ function baselineCompareEvidenceGapBuckets(): array
->assertSee('Retired-Compliance-Policy')
->assertDontSee('VPN-Always-On')
->filterTable('policy_type', 'deviceCompliancePolicy')
->assertSee('Device Compliance')
->assertSee('Retired-Compliance-Policy')
->assertDontSee('Deleted-Policy-ABC')
->filterTable('operator_action_category', 'run_policy_sync_or_backup')

View File

@ -9,6 +9,7 @@
use App\Models\OperationRun;
use App\Support\Baselines\BaselineCompareReasonCode;
use App\Support\Baselines\BaselineCompareStats;
use App\Support\ReasonTranslation\ReasonPresenter;
use App\Support\Ui\OperatorExplanation\ExplanationFamily;
use Filament\Facades\Filament;
use Livewire\Livewire;
@ -74,14 +75,22 @@
$stats = BaselineCompareStats::forTenant($tenant);
$explanation = $stats->operatorExplanation();
$summary = $stats->summaryAssessment();
$reasonSemantics = app(ReasonPresenter::class)->semantics(
app(ReasonPresenter::class)->forArtifactTruth(BaselineCompareReasonCode::CoverageUnproven->value, 'artifact_truth'),
);
expect($explanation->family)->toBe(ExplanationFamily::SuppressedOutput);
expect($reasonSemantics)->not->toBeNull();
Livewire::actingAs($user)
->test(BaselineCompareLanding::class)
->assertSee($summary->headline)
->assertSee($explanation->trustworthinessLabel())
->assertSee($summary->nextActionLabel())
->assertSee('Reason owner')
->assertSee($reasonSemantics['owner_label'])
->assertSee('Platform reason family')
->assertSee($reasonSemantics['family_label'])
->assertSee('Findings shown')
->assertSee('Evidence gaps');
});

View File

@ -1,5 +1,7 @@
<?php
require_once dirname(__DIR__).'/Baselines/Support/FakeCompareStrategy.php';
use App\Filament\Pages\BaselineCompareLanding;
use App\Jobs\CompareBaselineToTenantJob;
use App\Livewire\BulkOperationProgress;
@ -7,6 +9,9 @@
use App\Models\BaselineSnapshot;
use App\Models\BaselineTenantAssignment;
use App\Models\OperationRun;
use App\Support\Baselines\Compare\CompareStrategyRegistry;
use App\Support\Baselines\Compare\IntuneCompareStrategy;
use App\Support\Governance\GovernanceSubjectTaxonomyRegistry;
use App\Support\OperationRunOutcome;
use App\Support\OperationRunStatus;
use App\Support\OperationRunType;
@ -15,6 +20,8 @@
use Filament\Facades\Filament;
use Illuminate\Support\Facades\Queue;
use Livewire\Livewire;
use Tests\Feature\Baselines\Support\FakeCompareStrategy;
use Tests\Feature\Baselines\Support\FakeGovernanceSubjectTaxonomyRegistry;
it('redirects unauthenticated users (302)', function (): void {
[$user, $tenant] = createUserWithTenant(role: 'owner');
@ -186,6 +193,64 @@
expect(OperationRun::query()->where('type', 'baseline_compare')->count())->toBe(0);
});
it('shows mixed-strategy compare rejection truth on the tenant landing surface', function (): void {
Queue::fake();
[$user, $tenant] = createUserWithTenant(role: 'owner');
$this->actingAs($user);
$tenant->makeCurrent();
Filament::setTenant($tenant, true);
app()->instance(GovernanceSubjectTaxonomyRegistry::class, new FakeGovernanceSubjectTaxonomyRegistry);
app()->instance(CompareStrategyRegistry::class, new CompareStrategyRegistry([
app(IntuneCompareStrategy::class),
app(FakeCompareStrategy::class),
]));
$profile = BaselineProfile::factory()->active()->create([
'workspace_id' => (int) $tenant->workspace_id,
'scope_jsonb' => [
'version' => 2,
'entries' => [
[
'domain_key' => 'intune',
'subject_class' => 'policy',
'subject_type_keys' => ['deviceConfiguration'],
'filters' => [],
],
[
'domain_key' => 'entra',
'subject_class' => 'control',
'subject_type_keys' => ['conditionalAccessPolicy'],
'filters' => [],
],
],
],
]);
$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(),
]);
Livewire::test(BaselineCompareLanding::class)
->callAction('compareNow')
->assertNotified('Cannot start comparison')
->assertStatus(200);
Queue::assertNotPushed(CompareBaselineToTenantJob::class);
expect(OperationRun::query()->where('type', 'baseline_compare')->count())->toBe(0);
});
it('can refresh stats without calling mount directly', function (): void {
[$user, $tenant] = createUserWithTenant(role: 'owner');
$this->actingAs($user);

View File

@ -182,3 +182,75 @@
->assertDontSee('Evidence gap details')
->assertSee('Baseline compare evidence');
});
it('includes strategy diagnostics in the landing evidence payload when strategy-owned compare processing fails', function (): void {
[$user, $tenant] = createUserWithTenant(role: 'owner');
$this->actingAs($user);
$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,
'completed_at' => now(),
'context' => [
'baseline_profile_id' => (int) $profile->getKey(),
'baseline_snapshot_id' => (int) $snapshot->getKey(),
'baseline_compare' => [
'reason_code' => BaselineCompareReasonCode::StrategyFailed->value,
'coverage' => [
'effective_types' => ['deviceConfiguration'],
'covered_types' => ['deviceConfiguration'],
'uncovered_types' => [],
'proof' => true,
],
'fidelity' => 'meta',
'strategy' => [
'key' => 'intune_policy',
'selection_state' => 'supported',
'operator_reason' => 'Compare strategy resolved successfully.',
'execution_diagnostics' => [
'failed' => true,
'exception_class' => RuntimeException::class,
],
'state_counts' => [
'failed' => 1,
],
],
'evidence_gaps' => [
'count' => 1,
'by_reason' => [
'strategy_failed' => 1,
],
],
],
],
]);
Livewire::test(BaselineCompareLanding::class)
->assertSee('Baseline compare evidence')
->assertSee('intune_policy')
->assertSee('strategy_failed')
->assertSee('RuntimeException');
});

View File

@ -2,12 +2,20 @@
declare(strict_types=1);
require_once dirname(__DIR__).'/Baselines/Support/FakeCompareStrategy.php';
use App\Filament\Pages\BaselineCompareMatrix;
use App\Filament\Resources\BaselineProfileResource;
use App\Models\User;
use App\Models\WorkspaceMembership;
use App\Support\Baselines\Compare\CompareStrategyRegistry;
use App\Support\Baselines\Compare\IntuneCompareStrategy;
use App\Support\Governance\GovernanceSubjectTaxonomyRegistry;
use Filament\Actions\Action;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Livewire\Livewire;
use Tests\Feature\Baselines\Support\FakeCompareStrategy;
use Tests\Feature\Baselines\Support\FakeGovernanceSubjectTaxonomyRegistry;
use Tests\Feature\Concerns\BuildsBaselineCompareMatrixFixtures;
uses(RefreshDatabase::class, BuildsBaselineCompareMatrixFixtures::class);
@ -199,6 +207,44 @@
->assertSee('Capture a complete baseline snapshot before using the compare matrix.');
});
it('disables compare-assigned-tenants when the scope spans multiple compare strategy families', function (): void {
$fixture = $this->makeBaselineCompareMatrixFixture();
app()->instance(GovernanceSubjectTaxonomyRegistry::class, new FakeGovernanceSubjectTaxonomyRegistry);
app()->instance(CompareStrategyRegistry::class, new CompareStrategyRegistry([
app(IntuneCompareStrategy::class),
app(FakeCompareStrategy::class),
]));
$fixture['profile']->update([
'scope_jsonb' => [
'version' => 2,
'entries' => [
[
'domain_key' => 'intune',
'subject_class' => 'policy',
'subject_type_keys' => ['deviceConfiguration'],
'filters' => [],
],
[
'domain_key' => 'entra',
'subject_class' => 'control',
'subject_type_keys' => ['conditionalAccessPolicy'],
'filters' => [],
],
],
],
]);
$this->setAdminWorkspaceContext($fixture['user'], $fixture['workspace']);
Livewire::actingAs($fixture['user'])
->test(BaselineCompareMatrix::class, ['record' => $fixture['profile']->getKey()])
->assertActionVisible('compareAssignedTenants')
->assertActionDisabled('compareAssignedTenants')
->assertActionExists('compareAssignedTenants', fn (Action $action): bool => $action->getTooltip() === 'The selected governed subjects span multiple compare strategy families and must be narrowed before comparing assigned tenants.');
});
it('renders an empty state when the baseline profile has no assigned tenants', function (): void {
$fixture = $this->makeBaselineCompareMatrixFixture();

View File

@ -1,5 +1,7 @@
<?php
require_once dirname(__DIR__).'/Baselines/Support/FakeCompareStrategy.php';
use App\Filament\Resources\BaselineProfileResource;
use App\Filament\Resources\BaselineProfileResource\Pages\ViewBaselineProfile;
use App\Jobs\CompareBaselineToTenantJob;
@ -8,6 +10,9 @@
use App\Models\BaselineTenantAssignment;
use App\Models\OperationRun;
use App\Support\Baselines\BaselineCaptureMode;
use App\Support\Baselines\Compare\CompareStrategyRegistry;
use App\Support\Baselines\Compare\IntuneCompareStrategy;
use App\Support\Governance\GovernanceSubjectTaxonomyRegistry;
use App\Support\Workspaces\WorkspaceContext;
use Filament\Actions\Action;
use Filament\Actions\ActionGroup;
@ -15,6 +20,8 @@
use Illuminate\Support\Facades\Queue;
use Livewire\Features\SupportTesting\Testable;
use Livewire\Livewire;
use Tests\Feature\Baselines\Support\FakeCompareStrategy;
use Tests\Feature\Baselines\Support\FakeGovernanceSubjectTaxonomyRegistry;
function baselineProfileHeaderActions(Testable $component): array
{
@ -149,6 +156,64 @@ function baselineProfileHeaderActions(Testable $component): array
expect(OperationRun::query()->where('type', 'baseline_compare')->count())->toBe(0);
});
it('shows mixed-strategy compare rejection truth on the workspace start surface', function (): void {
Queue::fake();
[$user, $tenant] = createUserWithTenant(role: 'owner');
app()->instance(GovernanceSubjectTaxonomyRegistry::class, new FakeGovernanceSubjectTaxonomyRegistry);
app()->instance(CompareStrategyRegistry::class, new CompareStrategyRegistry([
app(IntuneCompareStrategy::class),
app(FakeCompareStrategy::class),
]));
$profile = BaselineProfile::factory()->active()->create([
'workspace_id' => (int) $tenant->workspace_id,
'scope_jsonb' => [
'version' => 2,
'entries' => [
[
'domain_key' => 'intune',
'subject_class' => 'policy',
'subject_type_keys' => ['deviceConfiguration'],
'filters' => [],
],
[
'domain_key' => 'entra',
'subject_class' => 'control',
'subject_type_keys' => ['conditionalAccessPolicy'],
'filters' => [],
],
],
],
]);
$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(),
]);
session()->put(WorkspaceContext::SESSION_KEY, (int) $tenant->workspace_id);
Livewire::actingAs($user)
->test(ViewBaselineProfile::class, ['record' => $profile->getKey()])
->assertSee('Mixed strategy scope')
->callAction('compareNow', data: ['target_tenant_id' => (int) $tenant->getKey()])
->assertNotified('Cannot start comparison')
->assertStatus(200);
Queue::assertNotPushed(CompareBaselineToTenantJob::class);
expect(OperationRun::query()->where('type', 'baseline_compare')->count())->toBe(0);
});
it('moves compare-matrix navigation into related context while keeping compare-assigned-tenants secondary', function (): void {
[$user, $tenant] = createUserWithTenant(role: 'owner');
$this->actingAs($user);

View File

@ -48,7 +48,7 @@
$this->actingAs($user)
->get(BaselineSnapshotResource::getUrl('view', ['record' => $snapshot], panel: 'admin'))
->assertOk()
->assertSeeInOrder(['Artifact truth', 'Coverage summary', 'Captured policy types', 'Technical detail'])
->assertSeeInOrder(['Artifact truth', 'Coverage summary', 'Captured governed subjects', 'Technical detail'])
->assertSee('Reference only')
->assertSee('Inventory metadata')
->assertSee('Metadata-only evidence was captured for this item.')
@ -111,7 +111,7 @@ public function render(BaselineSnapshotItem $item): RenderedSnapshotItem
->get(BaselineSnapshotResource::getUrl('view', ['record' => $snapshot], panel: 'admin'))
->assertOk()
->assertSee('Technical detail')
->assertSee('Structured rendering failed for this policy type. Fallback metadata is shown instead.')
->assertSee('Structured rendering failed for this governed subject family. Fallback metadata is shown instead.')
->assertSee('Bitlocker Require')
->assertSee('A fallback renderer is being used for this item.');
});

View File

@ -8,7 +8,7 @@
use App\Models\BaselineSnapshot;
use App\Models\BaselineSnapshotItem;
it('renders the baseline snapshot detail page as summary-first with grouped policy browsing', function (): void {
it('renders the baseline snapshot detail page as summary-first with grouped governed-subject browsing', function (): void {
[$user, $tenant] = createUserWithTenant(role: 'readonly');
$profile = BaselineProfile::factory()->active()->create([
@ -93,13 +93,14 @@
->assertSee('Capture timing')
->assertSee('Related context')
->assertSee(BaselineProfileResource::getUrl('view', ['record' => $profile], panel: 'admin'), false)
->assertSeeInOrder(['Security Baseline', 'Coverage summary', 'Captured policy types', 'Technical detail'])
->assertSeeInOrder(['Security Baseline', 'Coverage summary', 'Captured governed subjects', 'Technical detail'])
->assertSee('Security Reader')
->assertSee('Bitlocker Require')
->assertSee('Mystery Policy')
->assertSee('Intune RBAC Role Definition')
->assertSee('Device Compliance')
->assertSee('Mystery Policy Type')
->assertSee('Governed subject')
->assertDontSee('Intune RBAC Role Definition References');
$this->actingAs($user)

View File

@ -77,7 +77,7 @@
$this->get(BaselineSnapshotResource::getUrl('view', ['record' => $snapshot], panel: 'admin'))
->assertOk()
->assertSeeInOrder(['Security Baseline', 'Captured policy types', 'Technical detail']);
->assertSeeInOrder(['Security Baseline', 'Captured governed subjects', 'Technical detail']);
$this->get(BackupSetResource::getUrl('view', ['record' => $backupSet], tenant: $tenant))
->assertOk()

View File

@ -151,6 +151,73 @@ function visibleLivewireText(Testable $component): string
->and(mb_strpos($pageText, 'Decision'))->toBeLessThan(mb_strpos($pageText, 'Artifact truth details'));
});
it('shows strategy diagnostics and operator-safe failure meaning for strategy-owned compare failures', function (): void {
[$user, $tenant] = createUserWithTenant(role: 'owner');
$run = OperationRun::factory()->create([
'tenant_id' => (int) $tenant->getKey(),
'workspace_id' => (int) $tenant->workspace_id,
'type' => 'baseline_compare',
'status' => 'completed',
'outcome' => 'partially_succeeded',
'context' => [
'baseline_compare' => [
'reason_code' => BaselineCompareReasonCode::StrategyFailed->value,
'coverage' => [
'proof' => true,
'effective_types' => ['conditionalAccessPolicy'],
'covered_types' => ['conditionalAccessPolicy'],
'uncovered_types' => [],
],
'evidence_gaps' => [
'count' => 1,
'by_reason' => [
'strategy_failed' => 1,
],
],
'strategy' => [
'key' => 'intune_policy',
'selection_state' => 'supported',
'operator_reason' => 'Compare strategy resolved successfully.',
'execution_diagnostics' => [
'failed' => true,
'exception_class' => RuntimeException::class,
],
'state_counts' => [
'failed' => 2,
],
],
],
],
'summary_counts' => [
'total' => 0,
'processed' => 0,
'errors_recorded' => 1,
],
'completed_at' => now(),
]);
$truth = app(ArtifactTruthPresenter::class)->forOperationRun($run->fresh());
$explanation = $truth->operatorExplanation;
Filament::setTenant(null, true);
$this->withSession([WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id]);
session([WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id]);
Livewire::actingAs($user)
->test(TenantlessOperationRunViewer::class, ['run' => $run])
->assertSee($explanation?->headline ?? '')
->assertSee($explanation?->nextActionText ?? '')
->assertSee('Compare strategy')
->assertSee('Intune Policy')
->assertSee('Strategy selection')
->assertSee('Supported')
->assertSee('Strategy subject states')
->assertSee('Failed 2')
->assertSee('Baseline compare evidence')
->assertSee('RuntimeException');
});
it('deduplicates repeated artifact truth explanation text for follow-up runs without a usable artifact', function (): void {
[$user, $tenant] = createUserWithTenant(role: 'owner');

View File

@ -9,8 +9,10 @@
use App\Models\Workspace;
use App\Models\WorkspaceMembership;
use App\Support\Inventory\InventoryCoverage as InventoryCoveragePayload;
use App\Support\Operations\ExecutionDenialReasonCode;
use App\Support\OperationRunOutcome;
use App\Support\OperationRunStatus;
use App\Support\ReasonTranslation\ReasonPresenter;
use App\Support\Workspaces\WorkspaceContext;
use Filament\Facades\Filament;
use Illuminate\Testing\TestResponse;
@ -303,6 +305,47 @@ function baselineCompareGapContext(array $overrides = []): array
->assertSee('Adapter reconciler');
});
it('renders explicit reason-owner and platform-family semantics for blocked runs', function (): void {
[$user, $tenant] = createUserWithTenant(role: 'owner');
Filament::setTenant(null, true);
$run = OperationRun::factory()->create([
'workspace_id' => (int) $tenant->workspace_id,
'tenant_id' => (int) $tenant->getKey(),
'type' => 'inventory_sync',
'status' => OperationRunStatus::Completed->value,
'outcome' => OperationRunOutcome::Blocked->value,
'context' => [
'reason_code' => ExecutionDenialReasonCode::MissingCapability->value,
'execution_legitimacy' => [
'reason_code' => ExecutionDenialReasonCode::MissingCapability->value,
],
],
'failure_summary' => [[
'code' => 'operation.blocked',
'reason_code' => ExecutionDenialReasonCode::MissingCapability->value,
'message' => 'Operation blocked because the initiating actor no longer has the required capability.',
]],
]);
$reasonSemantics = app(ReasonPresenter::class)->semantics(
app(ReasonPresenter::class)->forOperationRun($run, 'run_detail'),
);
expect($reasonSemantics)->not->toBeNull();
$this->actingAs($user)
->withSession([WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id])
->get(route('admin.operations.view', ['run' => (int) $run->getKey()]))
->assertOk()
->assertSee('Explanation semantics')
->assertSee('Reason owner')
->assertSee($reasonSemantics['owner_label'])
->assertSee('Platform reason family')
->assertSee($reasonSemantics['family_label']);
});
it('renders evidence gap details section for baseline compare runs with gap subjects', function (): void {
[$user, $tenant] = createUserWithTenant(role: 'owner');
@ -340,7 +383,7 @@ function baselineCompareGapContext(array $overrides = []): array
->assertSee('2 affected')
->assertSee('WiFi-Corp-Profile')
->assertSee('Deleted-Policy-ABC')
->assertSee('Policy type')
->assertSee('Governed subject')
->assertSee('Subject class')
->assertSee('Outcome')
->assertSee('Next action')

View File

@ -160,11 +160,49 @@ function operationRunFilterIndicatorLabels($component): array
expect($filter)->not->toBeNull();
expect($filter?->getOptions())->toBe([
'inventory_sync' => 'Inventory sync',
'inventory.sync' => 'Inventory sync',
'policy.sync' => 'Policy sync',
]);
});
it('filters legacy and provider-prefixed inventory runs through one canonical operation selection', function (): void {
[$user, $tenant] = createUserWithTenant(role: 'owner');
$legacyRun = OperationRun::factory()->create([
'tenant_id' => (int) $tenant->getKey(),
'workspace_id' => (int) $tenant->workspace_id,
'type' => 'inventory_sync',
'status' => OperationRunStatus::Completed->value,
'outcome' => OperationRunOutcome::Succeeded->value,
]);
$providerPrefixedRun = OperationRun::factory()->create([
'tenant_id' => (int) $tenant->getKey(),
'workspace_id' => (int) $tenant->workspace_id,
'type' => 'provider.inventory.sync',
'status' => OperationRunStatus::Completed->value,
'outcome' => OperationRunOutcome::Succeeded->value,
]);
$otherRun = OperationRun::factory()->create([
'tenant_id' => (int) $tenant->getKey(),
'workspace_id' => (int) $tenant->workspace_id,
'type' => 'policy.sync',
'status' => OperationRunStatus::Completed->value,
'outcome' => OperationRunOutcome::Succeeded->value,
]);
Filament::setTenant($tenant, true);
$this->withSession([WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id]);
session([WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id]);
Livewire::actingAs($user)
->test(Operations::class)
->filterTable('type', 'inventory.sync')
->assertCanSeeTableRecords([$legacyRun, $providerPrefixedRun])
->assertCanNotSeeTableRecords([$otherRun]);
});
it('clears tenant-sensitive persisted filters when the canonical tenant context changes', function (): void {
$tenantA = Tenant::factory()->create();
[$user, $tenantA] = createUserWithTenant(tenant: $tenantA, role: 'owner');

View File

@ -61,3 +61,21 @@
->assertSee('Back to Operations')
->assertDontSee('← Back to Archived Tenant');
});
it('resolves legacy operation values to canonical operation metadata on read paths', function (): void {
[$user, $tenant] = createUserWithTenant(role: 'owner');
$this->actingAs($user);
$run = OperationRun::factory()->for($tenant)->create([
'workspace_id' => (int) $tenant->workspace_id,
'type' => 'backup_schedule_run',
]);
expect($run->canonicalOperationType())->toBe('backup.schedule.execute');
$this->withSession([WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id])
->get(OperationRunLinks::tenantlessView($run))
->assertOk()
->assertSee('Backup schedule run')
->assertDontSee('backup_schedule_run');
});

View File

@ -4,6 +4,7 @@
use App\Support\Baselines\BaselineCompareReasonCode;
use App\Support\Baselines\BaselineReasonCodes;
use App\Support\Governance\PlatformVocabularyGlossary;
use App\Support\ReasonTranslation\ReasonPresenter;
use App\Support\Ui\OperatorExplanation\TrustworthinessLevel;
@ -40,3 +41,15 @@
'missing_input',
],
]);
it('keeps governance reason ownership and family semantics explicit', function (): void {
$presenter = app(ReasonPresenter::class);
$semantics = $presenter->semantics(
$presenter->forArtifactTruth(BaselineCompareReasonCode::CoverageUnproven->value, 'artifact_truth'),
);
expect($semantics)->not->toBeNull()
->and($semantics['owner_label'] ?? null)->toBe('Governance detail')
->and($semantics['family_label'] ?? null)->toBe('Coverage')
->and($semantics['boundary_classification'] ?? null)->toBe(PlatformVocabularyGlossary::BOUNDARY_CROSS_DOMAIN_GOVERNANCE);
});

View File

@ -11,6 +11,8 @@
use App\Models\Tenant;
use App\Services\Evidence\EvidenceSnapshotService;
use App\Support\Evidence\EvidenceSnapshotStatus;
use App\Support\Providers\ProviderReasonCodes;
use App\Support\ReasonTranslation\ReasonPresenter;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Storage;
use Livewire\Livewire;
@ -184,6 +186,51 @@ function seedWidgetReviewPackSnapshot(Tenant $tenant): EvidenceSnapshot
->assertDontSee('wire:poll.10s', escape: false);
});
it('renders translated reason semantics for failed review-pack runs when available', function (): void {
$tenant = Tenant::factory()->create();
[$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'owner');
$run = OperationRun::factory()->create([
'tenant_id' => (int) $tenant->getKey(),
'workspace_id' => (int) $tenant->workspace_id,
'type' => 'tenant.review_pack.generate',
'status' => 'completed',
'outcome' => 'failed',
'context' => [
'reason_code' => ProviderReasonCodes::ProviderPermissionMissing,
],
'failure_summary' => [[
'code' => 'operation.failed',
'reason_code' => ProviderReasonCodes::ProviderPermissionMissing,
'message' => 'The provider app is missing a required permission.',
]],
]);
ReviewPack::factory()->failed()->create([
'tenant_id' => (int) $tenant->getKey(),
'workspace_id' => (int) $tenant->workspace_id,
'initiated_by_user_id' => (int) $user->getKey(),
'operation_run_id' => (int) $run->getKey(),
]);
$reasonSemantics = app(ReasonPresenter::class)->semantics(
app(ReasonPresenter::class)->forOperationRun($run, 'review_pack_widget'),
);
$reasonEnvelope = app(ReasonPresenter::class)->forOperationRun($run, 'review_pack_widget');
expect($reasonEnvelope)->not->toBeNull()
->and($reasonSemantics)->not->toBeNull();
setTenantPanelContext($tenant);
Livewire::actingAs($user)
->test(TenantReviewPackCard::class, ['record' => $tenant])
->assertSee($reasonEnvelope->operatorLabel)
->assertSee($reasonEnvelope->shortExplanation)
->assertSee($reasonSemantics['owner_label'])
->assertSee($reasonSemantics['family_label']);
});
// ─── Expired State ───────────────────────────────────────────
it('shows generate action for an expired pack', function (): void {

View File

@ -5,6 +5,7 @@
use App\Filament\Pages\Reviews\ReviewRegister;
use App\Filament\Resources\TenantReviewResource;
use App\Models\Tenant;
use App\Support\ReasonTranslation\ReasonPresenter;
use App\Support\Ui\GovernanceArtifactTruth\ArtifactTruthPresenter;
use App\Support\Workspaces\WorkspaceContext;
use Livewire\Livewire;
@ -32,6 +33,9 @@
$truth = app(ArtifactTruthPresenter::class)->forTenantReview($review);
$explanation = $truth->operatorExplanation;
$reasonSemantics = app(ReasonPresenter::class)->semantics($truth->reason?->toReasonResolutionEnvelope());
expect($reasonSemantics)->not->toBeNull();
setTenantPanelContext($tenant);
@ -39,7 +43,11 @@
->get(TenantReviewResource::tenantScopedUrl('view', ['record' => $review], $tenant))
->assertOk()
->assertSee($explanation?->headline ?? '')
->assertSee($explanation?->nextActionText ?? '');
->assertSee($explanation?->nextActionText ?? '')
->assertSee('Reason owner')
->assertSee($reasonSemantics['owner_label'])
->assertSee('Platform reason family')
->assertSee($reasonSemantics['family_label']);
setAdminPanelContext();
session()->put(WorkspaceContext::SESSION_KEY, (int) $tenant->workspace_id);

View File

@ -0,0 +1,217 @@
<?php
declare(strict_types=1);
use App\Models\Tenant;
use App\Support\Baselines\BaselineScope;
use App\Support\Baselines\Compare\CompareOrchestrationContext;
use App\Support\Baselines\Compare\CompareStrategy;
use App\Support\Baselines\Compare\CompareStrategyCapability;
use App\Support\Baselines\Compare\CompareStrategyKey;
use App\Support\Baselines\Compare\CompareStrategyRegistry;
use App\Support\Governance\GovernanceDomainKey;
use App\Support\Governance\GovernanceSubjectClass;
it('selects a single compatible strategy family for a canonical scope entry', function (): void {
$registry = new CompareStrategyRegistry([
compareStrategyStub(
key: 'intune_policy',
capabilities: [
new CompareStrategyCapability(
strategyKey: CompareStrategyKey::intunePolicy(),
domainKeys: [GovernanceDomainKey::Intune->value, GovernanceDomainKey::PlatformFoundation->value],
subjectClasses: [
GovernanceSubjectClass::Policy->value,
GovernanceSubjectClass::ConfigurationResource->value,
],
),
],
),
]);
$scope = BaselineScope::fromJsonb([
'version' => 2,
'entries' => [
[
'domain_key' => GovernanceDomainKey::Intune->value,
'subject_class' => GovernanceSubjectClass::Policy->value,
'subject_type_keys' => ['deviceConfiguration'],
'filters' => [],
],
[
'domain_key' => GovernanceDomainKey::PlatformFoundation->value,
'subject_class' => GovernanceSubjectClass::ConfigurationResource->value,
'subject_type_keys' => ['assignmentFilter'],
'filters' => [],
],
],
]);
$selection = $registry->select($scope);
expect($selection->isSupported())->toBeTrue()
->and($selection->strategyKey?->value)->toBe('intune_policy')
->and($selection->matchedScopeEntries)->toHaveCount(2)
->and($selection->rejectedScopeEntries)->toBe([]);
});
it('rejects canonical scope entries when no strategy supports them', function (): void {
$registry = new CompareStrategyRegistry([
compareStrategyStub(
key: 'intune_policy',
capabilities: [
new CompareStrategyCapability(
strategyKey: CompareStrategyKey::intunePolicy(),
domainKeys: [GovernanceDomainKey::Intune->value],
subjectClasses: [GovernanceSubjectClass::Policy->value],
),
],
),
]);
$scope = new BaselineScope(
entries: [[
'domain_key' => GovernanceDomainKey::Entra->value,
'subject_class' => GovernanceSubjectClass::Control->value,
'subject_type_keys' => ['conditionalAccessPolicy'],
'filters' => [],
]],
version: 2,
);
$selection = $registry->select($scope);
expect($selection->isUnsupported())->toBeTrue()
->and($selection->strategyKey)->toBeNull()
->and($selection->matchedScopeEntries)->toBe([])
->and($selection->rejectedScopeEntries)->toHaveCount(1);
});
it('marks scope as mixed when multiple strategy families are required', function (): void {
$registry = new CompareStrategyRegistry([
compareStrategyStub(
key: 'intune_policy',
capabilities: [
new CompareStrategyCapability(
strategyKey: CompareStrategyKey::intunePolicy(),
domainKeys: [GovernanceDomainKey::Intune->value],
subjectClasses: [GovernanceSubjectClass::Policy->value],
),
],
),
compareStrategyStub(
key: 'future_control',
capabilities: [
new CompareStrategyCapability(
strategyKey: CompareStrategyKey::from('future_control'),
domainKeys: [GovernanceDomainKey::Entra->value],
subjectClasses: [GovernanceSubjectClass::Control->value],
),
],
),
]);
$scope = new BaselineScope(
entries: [
[
'domain_key' => GovernanceDomainKey::Intune->value,
'subject_class' => GovernanceSubjectClass::Policy->value,
'subject_type_keys' => ['deviceConfiguration'],
'filters' => [],
],
[
'domain_key' => GovernanceDomainKey::Entra->value,
'subject_class' => GovernanceSubjectClass::Control->value,
'subject_type_keys' => ['conditionalAccessPolicy'],
'filters' => [],
],
],
version: 2,
);
$selection = $registry->select($scope);
expect($selection->isMixed())->toBeTrue()
->and($selection->strategyKey)->toBeNull()
->and($selection->matchedScopeEntries)->toHaveCount(2)
->and($selection->diagnostics['matched_strategy_keys'] ?? [])->toEqual(['future_control', 'intune_policy']);
});
it('supports deterministic future-domain selection without implicit intune fallback', function (): void {
$registry = new CompareStrategyRegistry([
compareStrategyStub(
key: 'future_control',
capabilities: [
new CompareStrategyCapability(
strategyKey: CompareStrategyKey::from('future_control'),
domainKeys: [GovernanceDomainKey::Entra->value],
subjectClasses: [GovernanceSubjectClass::Control->value],
),
],
),
]);
$scope = new BaselineScope(
entries: [[
'domain_key' => GovernanceDomainKey::Entra->value,
'subject_class' => GovernanceSubjectClass::Control->value,
'subject_type_keys' => ['conditionalAccessPolicy'],
'filters' => [],
]],
version: 2,
);
$selection = $registry->select($scope);
expect($selection->isSupported())->toBeTrue()
->and($selection->strategyKey?->value)->toBe('future_control')
->and($registry->resolve('future_control'))->toBeInstanceOf(CompareStrategy::class);
});
it('throws when resolving an unknown strategy key', function (): void {
$registry = new CompareStrategyRegistry([]);
expect(fn (): CompareStrategy => $registry->resolve('missing_strategy'))
->toThrow(InvalidArgumentException::class, 'Unknown compare strategy');
});
/**
* @param list<CompareStrategyCapability> $capabilities
*/
function compareStrategyStub(string $key, array $capabilities): CompareStrategy
{
return new class($key, $capabilities) implements CompareStrategy
{
/**
* @param list<CompareStrategyCapability> $capabilities
*/
public function __construct(
private readonly string $keyValue,
private readonly array $capabilities,
) {}
public function key(): CompareStrategyKey
{
return CompareStrategyKey::from($this->keyValue);
}
public function capabilities(): array
{
return $this->capabilities;
}
public function compare(
CompareOrchestrationContext $context,
Tenant $tenant,
array $baselineItems,
array $currentItems,
array $resolvedCurrentEvidence,
array $severityMapping,
): array {
return [
'subject_results' => [],
'diagnostics' => [],
];
}
};
}

View File

@ -0,0 +1,122 @@
<?php
declare(strict_types=1);
use App\Support\Baselines\Compare\CompareFindingCandidate;
use App\Support\Baselines\Compare\CompareState;
use App\Support\Baselines\Compare\CompareSubjectIdentity;
use App\Support\Baselines\Compare\CompareSubjectProjection;
use App\Support\Baselines\Compare\CompareSubjectResult;
it('serializes compare subject results with structured finding and diagnostics payloads', function (): void {
$result = new CompareSubjectResult(
subjectIdentity: new CompareSubjectIdentity(
domainKey: 'entra',
subjectClass: 'control',
subjectTypeKey: 'conditionalAccessPolicy',
externalSubjectId: 'cap-1',
subjectKey: 'cap-1',
),
projection: new CompareSubjectProjection(
platformSubjectClass: 'control',
domainKey: 'entra',
subjectTypeKey: 'conditionalAccessPolicy',
operatorLabel: 'Conditional Access Policy',
summaryKind: 'control_snapshot',
additionalLabels: ['family' => 'identity'],
),
baselineAvailability: 'available',
currentStateAvailability: 'available',
compareState: CompareState::Drift,
trustLevel: 'limited_confidence',
evidenceQuality: 'meta',
severityRecommendation: 'medium',
findingCandidate: new CompareFindingCandidate(
changeType: 'different_version',
severity: 'medium',
fingerprintBasis: [
'policy_type' => 'conditionalAccessPolicy',
'subject_key' => 'cap-1',
'change_type' => 'different_version',
],
evidencePayload: [
'summary' => ['kind' => 'control_snapshot'],
],
),
diagnostics: [
'strategy_key' => 'future_control',
],
);
$payload = $result->toArray();
expect($result->hasFindingCandidate())->toBeTrue()
->and($result->isGapState())->toBeFalse()
->and($payload['compare_state'])->toBe(CompareState::Drift->value)
->and($payload['baseline_availability'])->toBe('available')
->and($payload['current_state_availability'])->toBe('available')
->and($payload['projection']['platform_subject_class'])->toBe('control')
->and($payload['projection']['summary_kind'])->toBe('control_snapshot')
->and($payload['finding_candidate']['change_type'])->toBe('different_version')
->and($payload['finding_candidate']['severity'])->toBe('medium')
->and($payload['diagnostics']['strategy_key'])->toBe('future_control');
});
it('requires a finding candidate for drift results', function (): void {
expect(fn (): CompareSubjectResult => new CompareSubjectResult(
subjectIdentity: new CompareSubjectIdentity('intune', 'policy', 'deviceConfiguration', 'policy-1', 'policy-1'),
projection: new CompareSubjectProjection('policy', 'intune', 'deviceConfiguration', 'Policy 1'),
baselineAvailability: 'available',
currentStateAvailability: 'missing',
compareState: CompareState::Drift,
trustLevel: 'limited_confidence',
evidenceQuality: 'meta',
))->toThrow(InvalidArgumentException::class, 'require a finding candidate');
});
it('rejects non-drift results that still try to write findings', function (): void {
expect(fn (): CompareSubjectResult => new CompareSubjectResult(
subjectIdentity: new CompareSubjectIdentity('entra', 'control', 'conditionalAccessPolicy', 'cap-1', 'cap-1'),
projection: new CompareSubjectProjection('control', 'entra', 'conditionalAccessPolicy', 'CAP 1'),
baselineAvailability: 'available',
currentStateAvailability: 'unknown',
compareState: CompareState::Incomplete,
trustLevel: 'unusable',
evidenceQuality: 'missing',
findingCandidate: new CompareFindingCandidate(
changeType: 'different_version',
severity: 'high',
fingerprintBasis: ['subject_key' => 'cap-1'],
evidencePayload: [],
),
))->toThrow(InvalidArgumentException::class, 'Only drift compare subject results');
});
it('treats unsupported, incomplete, ambiguous, and failed states as gap states', function (): void {
$states = [
CompareState::Unsupported,
CompareState::Incomplete,
CompareState::Ambiguous,
CompareState::Failed,
];
foreach ($states as $state) {
$result = new CompareSubjectResult(
subjectIdentity: new CompareSubjectIdentity('entra', 'control', 'conditionalAccessPolicy', 'cap-1', 'cap-1'),
projection: new CompareSubjectProjection('control', 'entra', 'conditionalAccessPolicy', 'CAP 1'),
baselineAvailability: 'available',
currentStateAvailability: 'unknown',
compareState: $state,
trustLevel: 'unusable',
evidenceQuality: 'missing',
diagnostics: [
'reason_code' => 'strategy_failed',
'gap_record' => ['reason_code' => 'strategy_failed'],
],
);
expect($result->isGapState())->toBeTrue()
->and($result->gapReasonCode())->toBe('strategy_failed')
->and($result->gapRecord())->toBe(['reason_code' => 'strategy_failed']);
}
});

View File

@ -42,4 +42,12 @@
->and(collect($registry->active())->contains(
static fn ($subjectType): bool => $subjectType->domainKey === GovernanceDomainKey::Entra,
))->toBeFalse();
});
it('finds subject types by subject key across legacy buckets and exposes contributor ownership metadata', function (): void {
$registry = app(GovernanceSubjectTaxonomyRegistry::class);
expect($registry->findBySubjectTypeKey('deviceConfiguration')?->domainKey)->toBe(GovernanceDomainKey::Intune)
->and($registry->findBySubjectTypeKey('assignmentFilter', 'foundation_types')?->subjectClass)->toBe(GovernanceSubjectClass::ConfigurationResource)
->and($registry->ownershipDescriptor()->canonicalNouns)->toBe(['domain_key', 'subject_class', 'subject_type_key', 'subject_type_label']);
});

View File

@ -108,6 +108,7 @@
->and(data_get($rendered, 'snapshot.overallFidelity'))->toBe('partial')
->and(data_get($rendered, 'snapshot.overallGapCount'))->toBe(1)
->and($rendered['summaryRows'])->toHaveCount(3)
->and(data_get($rendered, 'summaryRows.0.subjectDescriptor.platform_noun'))->toBe('Governed subject')
->and(collect($rendered['groups'])->pluck('label')->all())
->toContain('Intune RBAC Role Definition', 'Device Compliance', 'Mystery Policy Type')
->and(data_get($rendered, 'technicalDetail.defaultCollapsed'))->toBeTrue();

View File

@ -0,0 +1,45 @@
<?php
declare(strict_types=1);
use App\Support\Governance\GovernanceDomainKey;
use App\Support\Governance\GovernanceSubjectClass;
use App\Support\Governance\PlatformSubjectDescriptorNormalizer;
it('normalizes legacy policy_type payloads into governed-subject descriptors', function (): void {
$result = app(PlatformSubjectDescriptorNormalizer::class)->fromArray([
'policy_type' => 'deviceConfiguration',
], 'baseline_compare');
expect($result->usedLegacyAlias)->toBeTrue()
->and($result->descriptor->domainKey)->toBe(GovernanceDomainKey::Intune->value)
->and($result->descriptor->subjectClass)->toBe(GovernanceSubjectClass::Policy->value)
->and($result->descriptor->subjectTypeKey)->toBe('deviceConfiguration')
->and($result->descriptor->platformNoun)->toBe('Governed subject');
});
it('builds governed-subject descriptors for canonical baseline scope entries', function (): void {
$descriptors = app(PlatformSubjectDescriptorNormalizer::class)->descriptorsForScopeEntries([
[
'domain_key' => GovernanceDomainKey::PlatformFoundation->value,
'subject_class' => GovernanceSubjectClass::ConfigurationResource->value,
'subject_type_keys' => ['assignmentFilter'],
'filters' => [],
],
]);
expect($descriptors)->toHaveCount(1)
->and(data_get($descriptors, '0.descriptor.subject_type_key'))->toBe('assignmentFilter')
->and(data_get($descriptors, '0.descriptor.domain_key'))->toBe(GovernanceDomainKey::PlatformFoundation->value)
->and(data_get($descriptors, '0.source_surface'))->toBe('baseline_scope');
});
it('falls back to compatibility-only descriptors for unknown subject types', function (): void {
$result = app(PlatformSubjectDescriptorNormalizer::class)->fromArray([
'policy_type' => 'mysterySubject',
], 'snapshot');
expect($result->usedLegacyAlias)->toBeTrue()
->and($result->descriptor->subjectTypeKey)->toBe('mysterySubject')
->and($result->warnings)->not->toBeEmpty();
});

View File

@ -0,0 +1,38 @@
<?php
declare(strict_types=1);
use App\Support\Governance\PlatformVocabularyGlossary;
it('publishes canonical term inventory with boundary classifications and retirement metadata', function (): void {
$glossary = app(PlatformVocabularyGlossary::class);
expect($glossary->canonicalTerms())
->toContain('governed_subject', 'operation_type', 'policy_type')
->and($glossary->term('governed_subject')?->boundaryClassification)->toBe(PlatformVocabularyGlossary::BOUNDARY_PLATFORM_CORE)
->and($glossary->term('governed_subject')?->legacyAliases)->toContain('policy_type')
->and($glossary->term('governed_subject')?->aliasRetirementPath)->toContain('policy_type');
});
it('prefers exact Intune-specific terms while still resolving platform aliases by context', function (): void {
$glossary = app(PlatformVocabularyGlossary::class);
expect($glossary->term('policy_type')?->boundaryClassification)->toBe(PlatformVocabularyGlossary::BOUNDARY_INTUNE_SPECIFIC)
->and($glossary->resolveAlias('policy_type', 'compare')?->termKey)->toBe('governed_subject')
->and($glossary->resolveAlias('policy_type', 'intune_adapter'))->toBeNull();
});
it('publishes contributor-facing registry, alias, and reason-namespace inventories', function (): void {
$glossary = app(PlatformVocabularyGlossary::class);
$termInventory = $glossary->termInventory();
$aliasInventory = $glossary->aliasRetirementInventory();
$registryInventory = $glossary->registryInventory();
$reasonNamespaceInventory = $glossary->reasonNamespaceInventory();
expect($termInventory['operation_type']['boundary_classification'] ?? null)->toBe(PlatformVocabularyGlossary::BOUNDARY_PLATFORM_CORE)
->and($aliasInventory['governed_subject']['canonical_name'] ?? null)->toBe('governed_subject')
->and($aliasInventory['governed_subject']['legacy_aliases'] ?? [])->toContain('policy_type')
->and($registryInventory['operation_catalog']['canonical_nouns'] ?? [])->toContain('operation_type')
->and($reasonNamespaceInventory['governance.baseline_compare']['boundary_classification'] ?? null)->toBe(PlatformVocabularyGlossary::BOUNDARY_CROSS_DOMAIN_GOVERNANCE)
->and($reasonNamespaceInventory['rbac.intune']['boundary_classification'] ?? null)->toBe(PlatformVocabularyGlossary::BOUNDARY_INTUNE_SPECIFIC);
});

Some files were not shown because too many files have changed in this diff Show More