Compare commits
2 Commits
dev
...
202-govern
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a2ed2e97db | ||
|
|
b5b1b465ea |
4
.github/agents/copilot-instructions.md
vendored
4
.github/agents/copilot-instructions.md
vendored
@ -180,8 +180,6 @@ ## 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 (feat/005-bulk-operations)
|
||||
|
||||
@ -216,8 +214,8 @@ ## Code Style
|
||||
PHP 8.4.15: Follow standard conventions
|
||||
|
||||
## Recent Changes
|
||||
- 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
8
.github/skills/giteaflow/SKILL.md
vendored
@ -1,8 +0,0 @@
|
||||
---
|
||||
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
|
||||
@ -381,23 +381,17 @@ 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 = 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,
|
||||
};
|
||||
$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,
|
||||
};
|
||||
|
||||
Notification::make()
|
||||
->title('Cannot start comparison')
|
||||
|
||||
@ -14,8 +14,6 @@
|
||||
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;
|
||||
@ -248,22 +246,7 @@ protected function getHeaderActions(): array
|
||||
->requireCapability(Capabilities::WORKSPACE_BASELINES_MANAGE)
|
||||
->preserveDisabled()
|
||||
->tooltip('You need workspace baseline manage access to compare the visible assigned set.')
|
||||
->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();
|
||||
});
|
||||
->apply();
|
||||
|
||||
return [
|
||||
Action::make('backToBaselineProfile')
|
||||
@ -633,41 +616,9 @@ 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();
|
||||
@ -688,15 +639,6 @@ 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);
|
||||
@ -719,7 +661,7 @@ private function compareAssignedTenants(): void
|
||||
} else {
|
||||
Notification::make()
|
||||
->title('No baseline compares were started')
|
||||
->body($blockedReasonMessage !== null ? $blockedReasonMessage.' '.$summary : $summary)
|
||||
->body($summary)
|
||||
->warning()
|
||||
->send();
|
||||
}
|
||||
|
||||
@ -25,7 +25,6 @@
|
||||
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;
|
||||
@ -808,7 +807,6 @@ 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',
|
||||
};
|
||||
@ -820,7 +818,6 @@ 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',
|
||||
};
|
||||
@ -832,7 +829,6 @@ 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',
|
||||
};
|
||||
@ -842,7 +838,6 @@ 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.',
|
||||
};
|
||||
@ -878,13 +873,7 @@ private static function compareAvailabilityReason(BaselineProfile $profile): ?st
|
||||
return BaselineReasonCodes::COMPARE_INVALID_SCOPE;
|
||||
}
|
||||
|
||||
$selection = app(CompareStrategyRegistry::class)->select($scope);
|
||||
|
||||
if ($selection->isMixed()) {
|
||||
return BaselineReasonCodes::COMPARE_MIXED_SCOPE;
|
||||
}
|
||||
|
||||
if (! $selection->isSupported()) {
|
||||
if (! $scope->operationEligibility('compare')['ok']) {
|
||||
return BaselineReasonCodes::COMPARE_UNSUPPORTED_SCOPE;
|
||||
}
|
||||
|
||||
|
||||
@ -250,23 +250,19 @@ 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 = 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),
|
||||
};
|
||||
$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),
|
||||
};
|
||||
|
||||
Notification::make()
|
||||
->title('Cannot start comparison')
|
||||
|
||||
@ -957,49 +957,6 @@ 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',
|
||||
@ -1052,9 +1009,6 @@ 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,
|
||||
|
||||
@ -42,13 +42,6 @@
|
||||
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;
|
||||
@ -109,7 +102,6 @@ public function handle(
|
||||
?BaselineContentCapturePhase $contentCapturePhase = null,
|
||||
?BaselineFullContentRolloutGate $rolloutGate = null,
|
||||
?ContentEvidenceProvider $contentEvidenceProvider = null,
|
||||
?CompareStrategyRegistry $compareStrategyRegistry = null,
|
||||
): void {
|
||||
$settingsResolver ??= app(SettingsResolver::class);
|
||||
$baselineAutoCloseService ??= app(BaselineAutoCloseService::class);
|
||||
@ -119,7 +111,6 @@ 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.'));
|
||||
@ -348,44 +339,6 @@ 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;
|
||||
@ -506,79 +459,32 @@ public function handle(
|
||||
resolvedMetaEvidence: $resolvedCurrentMetaEvidence,
|
||||
);
|
||||
|
||||
$strategy = $compareStrategyRegistry->resolve($strategySelection->strategyKey);
|
||||
$orchestrationContext = new CompareOrchestrationContext(
|
||||
workspaceId: (int) $workspace->getKey(),
|
||||
tenantId: (int) $tenant->getKey(),
|
||||
$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,
|
||||
baselineProfileId: (int) $profile->getKey(),
|
||||
baselineSnapshotId: (int) $snapshot->getKey(),
|
||||
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'] : [],
|
||||
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,
|
||||
);
|
||||
|
||||
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']
|
||||
: [];
|
||||
$driftResults = $computeResult['drift'];
|
||||
$driftGaps = $computeResult['evidence_gaps'];
|
||||
$rbacRoleDefinitionSummary = $computeResult['rbac_role_definitions'];
|
||||
|
||||
$upsertResult = $this->upsertFindings(
|
||||
$tenant,
|
||||
@ -596,7 +502,7 @@ public function handle(
|
||||
$gapSubjects = $this->collectGapSubjects(
|
||||
ambiguousKeys: $ambiguousKeys,
|
||||
phaseGapSubjects: $phaseGapSubjects ?? [],
|
||||
driftGapSubjects: $strategyGapSubjects,
|
||||
driftGapSubjects: $computeResult['evidence_gap_subjects'] ?? [],
|
||||
);
|
||||
|
||||
$summaryCounts = [
|
||||
@ -659,9 +565,6 @@ 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,
|
||||
};
|
||||
@ -674,11 +577,6 @@ 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,
|
||||
@ -1096,193 +994,6 @@ 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".
|
||||
*
|
||||
@ -1358,13 +1069,13 @@ private function loadCurrentInventory(
|
||||
'subject_external_id' => (string) $inventoryItem->external_id,
|
||||
'subject_key' => $subjectKey,
|
||||
'policy_type' => (string) $inventoryItem->policy_type,
|
||||
'meta_jsonb' => array_replace($metaJsonb, [
|
||||
'display_name' => $metaJsonb['display_name'] ?? $inventoryItem->display_name,
|
||||
'category' => $metaJsonb['category'] ?? $inventoryItem->category,
|
||||
'platform' => $metaJsonb['platform'] ?? $inventoryItem->platform,
|
||||
'meta_jsonb' => [
|
||||
'display_name' => $inventoryItem->display_name,
|
||||
'category' => $inventoryItem->category,
|
||||
'platform' => $inventoryItem->platform,
|
||||
'is_built_in' => $metaJsonb['is_built_in'] ?? null,
|
||||
'role_permission_count' => $metaJsonb['role_permission_count'] ?? null,
|
||||
]),
|
||||
],
|
||||
];
|
||||
}
|
||||
});
|
||||
|
||||
@ -19,9 +19,6 @@
|
||||
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;
|
||||
@ -34,7 +31,6 @@ public function __construct(
|
||||
private readonly BaselineSnapshotTruthResolver $snapshotTruthResolver,
|
||||
private readonly BaselineSupportCapabilityGuard $capabilityGuard,
|
||||
private readonly CapabilityResolver $capabilityResolver,
|
||||
private readonly CompareStrategyRegistry $compareStrategyRegistry,
|
||||
) {}
|
||||
|
||||
/**
|
||||
@ -113,7 +109,9 @@ public function startCompareForProfile(
|
||||
$snapshotId = (int) $snapshot->getKey();
|
||||
|
||||
try {
|
||||
$profileScope = $profile->normalizedScope();
|
||||
$profileScope = BaselineScope::fromJsonb(
|
||||
is_array($profile->scope_jsonb) ? $profile->scope_jsonb : null,
|
||||
);
|
||||
$overrideScope = $assignment->override_scope_jsonb !== null
|
||||
? BaselineScope::fromJsonb(
|
||||
is_array($assignment->override_scope_jsonb) ? $assignment->override_scope_jsonb : null,
|
||||
@ -129,10 +127,10 @@ public function startCompareForProfile(
|
||||
return $this->failedStart(BaselineReasonCodes::COMPARE_INVALID_SCOPE);
|
||||
}
|
||||
|
||||
$selection = $this->compareStrategyRegistry->select($effectiveScope);
|
||||
$eligibility = $effectiveScope->operationEligibility('compare', $this->capabilityGuard);
|
||||
|
||||
if (! $selection->isSupported()) {
|
||||
return $this->failedStart($this->selectionFailureReasonCode($selection));
|
||||
if (! $eligibility['ok']) {
|
||||
return $this->failedStart(BaselineReasonCodes::COMPARE_UNSUPPORTED_SCOPE);
|
||||
}
|
||||
|
||||
$captureMode = $profile->capture_mode instanceof BaselineCaptureMode
|
||||
@ -148,9 +146,6 @@ 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(
|
||||
@ -280,36 +275,6 @@ 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>}
|
||||
*/
|
||||
|
||||
@ -151,7 +151,6 @@ 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,
|
||||
|
||||
@ -12,9 +12,6 @@ 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';
|
||||
@ -27,9 +24,6 @@ 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.',
|
||||
@ -44,13 +38,10 @@ 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,
|
||||
};
|
||||
}
|
||||
@ -61,12 +52,9 @@ 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,
|
||||
};
|
||||
@ -78,12 +66,9 @@ 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',
|
||||
};
|
||||
|
||||
@ -122,7 +122,9 @@ public static function forTenant(?Tenant $tenant): self
|
||||
$snapshotReasonMessage = self::missingSnapshotMessage($snapshotReasonCode);
|
||||
|
||||
try {
|
||||
$profileScope = $profile->normalizedScope();
|
||||
$profileScope = BaselineScope::fromJsonb(
|
||||
is_array($profile->scope_jsonb) ? $profile->scope_jsonb : null,
|
||||
);
|
||||
$overrideScope = $assignment->override_scope_jsonb !== null
|
||||
? BaselineScope::fromJsonb(
|
||||
is_array($assignment->override_scope_jsonb) ? $assignment->override_scope_jsonb : null,
|
||||
|
||||
@ -54,8 +54,6 @@ 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';
|
||||
@ -89,7 +87,6 @@ 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,
|
||||
@ -124,7 +121,6 @@ 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,
|
||||
@ -154,7 +150,6 @@ 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',
|
||||
|
||||
@ -187,19 +187,9 @@ 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,
|
||||
));
|
||||
}
|
||||
|
||||
@ -311,7 +301,7 @@ public function summaryGroups(?GovernanceSubjectTaxonomyRegistry $registry = nul
|
||||
public function toEffectiveScopeContext(?BaselineSupportCapabilityGuard $guard = null, ?string $operation = null): array
|
||||
{
|
||||
$expanded = $this->expandDefaults();
|
||||
$allTypes = $expanded->allTypes();
|
||||
$allTypes = self::uniqueSorted(array_merge($expanded->policyTypes, $expanded->foundationTypes));
|
||||
|
||||
$context = [
|
||||
'canonical_scope' => $expanded->toStoredJsonb(),
|
||||
|
||||
@ -1,46 +0,0 @@
|
||||
<?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,
|
||||
];
|
||||
}
|
||||
}
|
||||
@ -1,82 +0,0 @@
|
||||
<?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,
|
||||
];
|
||||
}
|
||||
}
|
||||
@ -1,15 +0,0 @@
|
||||
<?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';
|
||||
}
|
||||
@ -1,34 +0,0 @@
|
||||
<?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;
|
||||
}
|
||||
@ -1,84 +0,0 @@
|
||||
<?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,
|
||||
];
|
||||
}
|
||||
}
|
||||
@ -1,44 +0,0 @@
|
||||
<?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;
|
||||
}
|
||||
}
|
||||
@ -1,202 +0,0 @@
|
||||
<?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),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@ -1,129 +0,0 @@
|
||||
<?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,
|
||||
];
|
||||
}
|
||||
}
|
||||
@ -1,42 +0,0 @@
|
||||
<?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,
|
||||
];
|
||||
}
|
||||
}
|
||||
@ -1,48 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Support\Baselines\Compare;
|
||||
|
||||
use InvalidArgumentException;
|
||||
|
||||
final class CompareSubjectProjection
|
||||
{
|
||||
/**
|
||||
* @param array<string, string> $additionalLabels
|
||||
*/
|
||||
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 = [],
|
||||
) {
|
||||
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>
|
||||
* }
|
||||
*/
|
||||
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,
|
||||
];
|
||||
}
|
||||
}
|
||||
@ -1,119 +0,0 @@
|
||||
<?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
@ -1,12 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Support\Baselines\Compare;
|
||||
|
||||
enum StrategySelectionState: string
|
||||
{
|
||||
case Supported = 'supported';
|
||||
case Unsupported = 'unsupported';
|
||||
case Mixed = 'mixed';
|
||||
}
|
||||
@ -182,12 +182,6 @@ 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.',
|
||||
@ -242,24 +236,6 @@ 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.',
|
||||
@ -272,24 +248,6 @@ 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(
|
||||
|
||||
@ -2,29 +2,17 @@
|
||||
|
||||
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');
|
||||
@ -125,86 +113,3 @@
|
||||
->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);
|
||||
});
|
||||
|
||||
@ -2,24 +2,16 @@
|
||||
|
||||
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);
|
||||
@ -101,82 +93,3 @@
|
||||
->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);
|
||||
});
|
||||
|
||||
@ -1,22 +1,15 @@
|
||||
<?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 ---
|
||||
|
||||
@ -224,158 +217,6 @@
|
||||
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();
|
||||
|
||||
|
||||
@ -1,160 +0,0 @@
|
||||
<?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');
|
||||
});
|
||||
@ -1,454 +0,0 @@
|
||||
<?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);
|
||||
}
|
||||
}
|
||||
@ -1,7 +1,5 @@
|
||||
<?php
|
||||
|
||||
require_once dirname(__DIR__).'/Baselines/Support/FakeCompareStrategy.php';
|
||||
|
||||
use App\Filament\Pages\BaselineCompareLanding;
|
||||
use App\Jobs\CompareBaselineToTenantJob;
|
||||
use App\Livewire\BulkOperationProgress;
|
||||
@ -9,9 +7,6 @@
|
||||
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;
|
||||
@ -20,8 +15,6 @@
|
||||
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');
|
||||
@ -193,64 +186,6 @@
|
||||
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);
|
||||
|
||||
@ -182,75 +182,3 @@
|
||||
->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');
|
||||
});
|
||||
|
||||
@ -2,20 +2,12 @@
|
||||
|
||||
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);
|
||||
@ -207,44 +199,6 @@
|
||||
->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();
|
||||
|
||||
|
||||
@ -1,7 +1,5 @@
|
||||
<?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;
|
||||
@ -10,9 +8,6 @@
|
||||
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;
|
||||
@ -20,8 +15,6 @@
|
||||
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
|
||||
{
|
||||
@ -156,64 +149,6 @@ 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);
|
||||
|
||||
@ -151,73 +151,6 @@ 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');
|
||||
|
||||
|
||||
@ -1,217 +0,0 @@
|
||||
<?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' => [],
|
||||
];
|
||||
}
|
||||
};
|
||||
}
|
||||
@ -1,122 +0,0 @@
|
||||
<?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']);
|
||||
}
|
||||
});
|
||||
@ -1,35 +0,0 @@
|
||||
# Specification Quality Checklist: Baseline Compare Engine Strategy Extraction
|
||||
|
||||
**Purpose**: Validate specification completeness and quality before proceeding to planning
|
||||
**Created**: 2026-04-13
|
||||
**Feature**: [spec.md](../spec.md)
|
||||
|
||||
## Content Quality
|
||||
|
||||
- [x] No implementation details (languages, frameworks, APIs)
|
||||
- [x] Focused on user value and business needs
|
||||
- [x] Written for non-technical stakeholders
|
||||
- [x] All mandatory sections completed
|
||||
|
||||
## Requirement Completeness
|
||||
|
||||
- [x] No [NEEDS CLARIFICATION] markers remain
|
||||
- [x] Requirements are testable and unambiguous
|
||||
- [x] Success criteria are measurable
|
||||
- [x] Success criteria are technology-agnostic (no implementation details)
|
||||
- [x] All acceptance scenarios are defined
|
||||
- [x] Edge cases are identified
|
||||
- [x] Scope is clearly bounded
|
||||
- [x] Dependencies and assumptions identified
|
||||
|
||||
## Feature Readiness
|
||||
|
||||
- [x] All functional requirements have clear acceptance criteria
|
||||
- [x] User scenarios cover primary flows
|
||||
- [x] Feature meets measurable outcomes defined in Success Criteria
|
||||
- [x] No implementation details leak into specification
|
||||
|
||||
## Notes
|
||||
|
||||
- Validated against the draft spec on 2026-04-13 after a wording pass on success criteria to keep them outcome-focused.
|
||||
- No clarification markers remain. The spec is ready for `/speckit.plan`.
|
||||
@ -1,442 +0,0 @@
|
||||
openapi: 3.1.0
|
||||
info:
|
||||
title: Baseline Compare Strategy Extraction Internal Contract
|
||||
version: 0.1.0
|
||||
summary: Internal logical contract for compare strategy selection, compare launch validation, and strategy-owned subject results
|
||||
description: |
|
||||
This contract is an internal planning artifact for Spec 203. The affected
|
||||
compare surfaces still render through Filament and Livewire, and compare
|
||||
execution continues to run through the existing Laravel services and jobs.
|
||||
The paths below are logical boundary identifiers for existing service, job,
|
||||
and surface entry points only; they do not imply new HTTP controllers or
|
||||
routes.
|
||||
x-logical-artifact: true
|
||||
x-baseline-compare-strategy-consumers:
|
||||
- surface: baseline.compare.start
|
||||
sourceFiles:
|
||||
- apps/platform/app/Services/Baselines/BaselineCompareService.php
|
||||
mustConsume:
|
||||
- canonical_scope_v2
|
||||
- deterministic_strategy_selection
|
||||
- unsupported_or_mixed_scope_rejection
|
||||
- strategy_key_recorded_in_run_context
|
||||
- surface: baseline.compare.fanout
|
||||
sourceFiles:
|
||||
- apps/platform/app/Services/Baselines/BaselineCompareService.php
|
||||
- apps/platform/app/Filament/Pages/BaselineCompareMatrix.php
|
||||
- apps/platform/app/Filament/Resources/BaselineProfileResource/Pages/ViewBaselineProfile.php
|
||||
mustConsume:
|
||||
- visible_set_strategy_validation
|
||||
- single_strategy_per_run_rule
|
||||
- tenant_owned_compare_runs_only
|
||||
- surface: baseline.compare.execution
|
||||
sourceFiles:
|
||||
- apps/platform/app/Jobs/CompareBaselineToTenantJob.php
|
||||
- apps/platform/app/Services/Baselines/CurrentStateHashResolver.php
|
||||
- apps/platform/app/Services/Drift/DriftHasher.php
|
||||
mustConsume:
|
||||
- compare_orchestration_context
|
||||
- compare_subject_result
|
||||
- strategy_provided_subject_projection
|
||||
- surface: baseline.compare.review
|
||||
sourceFiles:
|
||||
- apps/platform/app/Filament/Pages/BaselineCompareLanding.php
|
||||
- apps/platform/app/Support/Baselines/BaselineCompareSummaryAssessor.php
|
||||
- apps/platform/app/Support/Baselines/BaselineCompareExplanationRegistry.php
|
||||
mustRender:
|
||||
- unsupported_scope_truth
|
||||
- incomplete_or_ambiguous_truth
|
||||
- failed_strategy_truth
|
||||
paths:
|
||||
/internal/tenants/{tenant}/baseline-profiles/{profile}/compare/validate:
|
||||
post:
|
||||
summary: Resolve one compatible compare strategy family before compare is enqueued
|
||||
operationId: validateBaselineCompareStrategySelection
|
||||
parameters:
|
||||
- name: tenant
|
||||
in: path
|
||||
required: true
|
||||
schema:
|
||||
type: integer
|
||||
- name: profile
|
||||
in: path
|
||||
required: true
|
||||
schema:
|
||||
type: integer
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/CompareValidationRequest'
|
||||
responses:
|
||||
'200':
|
||||
description: One compatible strategy family was selected for the requested compare scope
|
||||
content:
|
||||
application/vnd.tenantpilot.baseline-compare-strategy-selection+json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/CompareStrategySelection'
|
||||
'422':
|
||||
description: Scope is unsupported or requires more than one strategy family
|
||||
content:
|
||||
application/vnd.tenantpilot.baseline-compare-strategy-errors+json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/CompareStrategySelection'
|
||||
'403':
|
||||
description: Actor is in scope but lacks capability to start compare
|
||||
'404':
|
||||
description: Tenant or baseline profile is outside actor scope
|
||||
/internal/tenants/{tenant}/baseline-profiles/{profile}/compare:
|
||||
post:
|
||||
summary: Start baseline compare using one selected strategy family
|
||||
operationId: startBaselineCompareWithStrategySelection
|
||||
parameters:
|
||||
- name: tenant
|
||||
in: path
|
||||
required: true
|
||||
schema:
|
||||
type: integer
|
||||
- name: profile
|
||||
in: path
|
||||
required: true
|
||||
schema:
|
||||
type: integer
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/CompareValidationRequest'
|
||||
responses:
|
||||
'202':
|
||||
description: Compare accepted with a deterministic strategy family recorded in the existing run context
|
||||
content:
|
||||
application/vnd.tenantpilot.baseline-compare-run+json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/CompareLaunchEnvelope'
|
||||
'422':
|
||||
description: Scope is unsupported or mixed and compare was not started
|
||||
'403':
|
||||
description: Actor is in scope but lacks capability to start compare
|
||||
'404':
|
||||
description: Tenant or baseline profile is outside actor scope
|
||||
/internal/workspaces/{workspace}/baseline-profiles/{profile}/compare-visible-assignments:
|
||||
post:
|
||||
summary: Start compare for the visible assigned tenant set only when one strategy family supports the shared scope
|
||||
operationId: startVisibleAssignmentCompareWithStrategySelection
|
||||
parameters:
|
||||
- name: workspace
|
||||
in: path
|
||||
required: true
|
||||
schema:
|
||||
type: integer
|
||||
- name: profile
|
||||
in: path
|
||||
required: true
|
||||
schema:
|
||||
type: integer
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/VisibleAssignmentCompareRequest'
|
||||
responses:
|
||||
'202':
|
||||
description: Visible tenant compare fan-out accepted with the same selected strategy family for each tenant-owned run
|
||||
content:
|
||||
application/vnd.tenantpilot.baseline-compare-fanout+json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/VisibleAssignmentCompareEnvelope'
|
||||
'422':
|
||||
description: Visible set scope is unsupported or mixed and no tenant compare runs were started
|
||||
'403':
|
||||
description: Actor is in scope but lacks workspace baseline manage capability
|
||||
'404':
|
||||
description: Workspace or baseline profile is outside actor scope
|
||||
/internal/baseline-compare/subjects/classify:
|
||||
post:
|
||||
summary: Logical strategy-owned boundary for classifying one compare subject inside the compare job
|
||||
operationId: classifyCompareSubject
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/CompareSubjectInput'
|
||||
responses:
|
||||
'200':
|
||||
description: Structured compare-subject result consumable by platform orchestration
|
||||
content:
|
||||
application/vnd.tenantpilot.compare-subject-result+json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/CompareSubjectResult'
|
||||
components:
|
||||
schemas:
|
||||
CompareStrategyKey:
|
||||
type: string
|
||||
examples:
|
||||
- intune_policy
|
||||
- entra_configuration
|
||||
StrategySelectionState:
|
||||
type: string
|
||||
enum:
|
||||
- supported
|
||||
- unsupported
|
||||
- mixed
|
||||
ScopeEntryReference:
|
||||
type: object
|
||||
additionalProperties: false
|
||||
required:
|
||||
- domain_key
|
||||
- subject_class
|
||||
- subject_type_keys
|
||||
properties:
|
||||
domain_key:
|
||||
type: string
|
||||
subject_class:
|
||||
type: string
|
||||
subject_type_keys:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
CompareStrategyCapability:
|
||||
type: object
|
||||
additionalProperties: false
|
||||
required:
|
||||
- strategy_key
|
||||
- domain_keys
|
||||
- subject_classes
|
||||
- compare_supported
|
||||
- active
|
||||
properties:
|
||||
strategy_key:
|
||||
$ref: '#/components/schemas/CompareStrategyKey'
|
||||
domain_keys:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
subject_classes:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
subject_type_keys:
|
||||
oneOf:
|
||||
- type: string
|
||||
enum:
|
||||
- all
|
||||
- type: array
|
||||
items:
|
||||
type: string
|
||||
compare_supported:
|
||||
type: boolean
|
||||
active:
|
||||
type: boolean
|
||||
CompareStrategySelection:
|
||||
type: object
|
||||
additionalProperties: false
|
||||
required:
|
||||
- selection_state
|
||||
- matched_scope_entries
|
||||
- rejected_scope_entries
|
||||
- operator_reason
|
||||
- diagnostics
|
||||
properties:
|
||||
selection_state:
|
||||
$ref: '#/components/schemas/StrategySelectionState'
|
||||
strategy_key:
|
||||
oneOf:
|
||||
- $ref: '#/components/schemas/CompareStrategyKey'
|
||||
- type: 'null'
|
||||
matched_scope_entries:
|
||||
type: array
|
||||
items:
|
||||
$ref: '#/components/schemas/ScopeEntryReference'
|
||||
rejected_scope_entries:
|
||||
type: array
|
||||
items:
|
||||
$ref: '#/components/schemas/ScopeEntryReference'
|
||||
operator_reason:
|
||||
type: string
|
||||
diagnostics:
|
||||
type: object
|
||||
additionalProperties: true
|
||||
CompareValidationRequest:
|
||||
type: object
|
||||
additionalProperties: false
|
||||
required:
|
||||
- baseline_profile_id
|
||||
- normalized_scope
|
||||
properties:
|
||||
baseline_profile_id:
|
||||
type: integer
|
||||
baseline_snapshot_id:
|
||||
type:
|
||||
- integer
|
||||
- 'null'
|
||||
normalized_scope:
|
||||
type: object
|
||||
additionalProperties: true
|
||||
CompareLaunchEnvelope:
|
||||
type: object
|
||||
additionalProperties: false
|
||||
required:
|
||||
- operation_run_id
|
||||
- strategy_selection
|
||||
properties:
|
||||
operation_run_id:
|
||||
type: integer
|
||||
strategy_selection:
|
||||
$ref: '#/components/schemas/CompareStrategySelection'
|
||||
VisibleAssignmentCompareRequest:
|
||||
type: object
|
||||
additionalProperties: false
|
||||
required:
|
||||
- baseline_profile_id
|
||||
- normalized_scope
|
||||
- visible_tenant_ids
|
||||
properties:
|
||||
baseline_profile_id:
|
||||
type: integer
|
||||
normalized_scope:
|
||||
type: object
|
||||
additionalProperties: true
|
||||
visible_tenant_ids:
|
||||
type: array
|
||||
items:
|
||||
type: integer
|
||||
VisibleAssignmentCompareEnvelope:
|
||||
type: object
|
||||
additionalProperties: false
|
||||
required:
|
||||
- strategy_selection
|
||||
- started_runs
|
||||
- skipped_tenants
|
||||
properties:
|
||||
strategy_selection:
|
||||
$ref: '#/components/schemas/CompareStrategySelection'
|
||||
started_runs:
|
||||
type: array
|
||||
items:
|
||||
type: integer
|
||||
skipped_tenants:
|
||||
type: array
|
||||
items:
|
||||
type: integer
|
||||
CompareSubjectInput:
|
||||
type: object
|
||||
additionalProperties: false
|
||||
required:
|
||||
- strategy_key
|
||||
- subject_identity
|
||||
- baseline_state
|
||||
- current_state
|
||||
properties:
|
||||
strategy_key:
|
||||
$ref: '#/components/schemas/CompareStrategyKey'
|
||||
subject_identity:
|
||||
type: object
|
||||
additionalProperties: true
|
||||
baseline_state:
|
||||
type: object
|
||||
additionalProperties: true
|
||||
current_state:
|
||||
oneOf:
|
||||
- type: object
|
||||
additionalProperties: true
|
||||
- type: 'null'
|
||||
CompareSubjectState:
|
||||
type: string
|
||||
enum:
|
||||
- no_drift
|
||||
- drift
|
||||
- unsupported
|
||||
- incomplete
|
||||
- ambiguous
|
||||
- failed
|
||||
CompareSubjectProjection:
|
||||
type: object
|
||||
additionalProperties: false
|
||||
required:
|
||||
- platform_subject_class
|
||||
- domain_key
|
||||
- subject_type_key
|
||||
- operator_label
|
||||
properties:
|
||||
platform_subject_class:
|
||||
type: string
|
||||
domain_key:
|
||||
type: string
|
||||
subject_type_key:
|
||||
type: string
|
||||
operator_label:
|
||||
type: string
|
||||
summary_kind:
|
||||
type:
|
||||
- string
|
||||
- 'null'
|
||||
additional_labels:
|
||||
type: object
|
||||
additionalProperties:
|
||||
type: string
|
||||
CompareFindingCandidate:
|
||||
type: object
|
||||
additionalProperties: false
|
||||
required:
|
||||
- change_type
|
||||
- severity
|
||||
- fingerprint_basis
|
||||
- evidence_payload
|
||||
- auto_close_eligible
|
||||
properties:
|
||||
change_type:
|
||||
type: string
|
||||
severity:
|
||||
type: string
|
||||
fingerprint_basis:
|
||||
type: object
|
||||
additionalProperties: true
|
||||
evidence_payload:
|
||||
type: object
|
||||
additionalProperties: true
|
||||
auto_close_eligible:
|
||||
type: boolean
|
||||
CompareSubjectResult:
|
||||
type: object
|
||||
additionalProperties: false
|
||||
required:
|
||||
- subject_identity
|
||||
- projection
|
||||
- baseline_availability
|
||||
- current_state_availability
|
||||
- compare_state
|
||||
- trust_level
|
||||
- evidence_quality
|
||||
- diagnostics
|
||||
properties:
|
||||
subject_identity:
|
||||
type: object
|
||||
additionalProperties: true
|
||||
projection:
|
||||
$ref: '#/components/schemas/CompareSubjectProjection'
|
||||
baseline_availability:
|
||||
type: string
|
||||
current_state_availability:
|
||||
type: string
|
||||
compare_state:
|
||||
$ref: '#/components/schemas/CompareSubjectState'
|
||||
trust_level:
|
||||
type: string
|
||||
evidence_quality:
|
||||
type: string
|
||||
severity_recommendation:
|
||||
type:
|
||||
- string
|
||||
- 'null'
|
||||
finding_candidate:
|
||||
oneOf:
|
||||
- $ref: '#/components/schemas/CompareFindingCandidate'
|
||||
- type: 'null'
|
||||
diagnostics:
|
||||
type: object
|
||||
additionalProperties: true
|
||||
@ -1,224 +0,0 @@
|
||||
# Data Model: Baseline Compare Engine Strategy Extraction
|
||||
|
||||
## Overview
|
||||
|
||||
This feature introduces no new top-level persisted entity. It reuses the existing baseline compare start path, compare run persistence, finding lifecycle, and evidence storage, but inserts a new internal strategy boundary between platform compare orchestration and domain-specific compare logic.
|
||||
|
||||
## Existing Persisted Truth Reused Without Change
|
||||
|
||||
### Workspace-owned baseline truth
|
||||
|
||||
- `baseline_profiles`
|
||||
- `baseline_snapshots`
|
||||
- `baseline_snapshot_items`
|
||||
- Canonical Baseline Scope V2 from Spec 202
|
||||
|
||||
These remain the reference truth that compare reads.
|
||||
|
||||
### Tenant-owned operational truth
|
||||
|
||||
- `operation_runs` for `baseline_compare`
|
||||
- existing drift findings and recurrence tracking
|
||||
- existing evidence and compare diagnostics stored in current finding and run payloads
|
||||
|
||||
These remain the long-lived operator truth written by compare.
|
||||
|
||||
### Existing subject and evidence inputs
|
||||
|
||||
- inventory-backed current state
|
||||
- policy version and baseline snapshot evidence used by the current Intune path
|
||||
- compare coverage and trust context already stored in the run and summary layers
|
||||
|
||||
This feature changes how domain logic is organized, not where those truths live.
|
||||
|
||||
## New Internal Contracts
|
||||
|
||||
### CompareStrategyKey
|
||||
|
||||
**Type**: string enum or equivalent value object
|
||||
**Purpose**: identify one compare strategy family
|
||||
|
||||
| Value | Status | Notes |
|
||||
|------|--------|-------|
|
||||
| `intune_policy` | active | First explicit compare strategy family |
|
||||
| future values | reserved | Additional families may be added later without changing the platform orchestration shape |
|
||||
|
||||
### CompareStrategyCapability
|
||||
|
||||
**Type**: derived registry record
|
||||
**Purpose**: declare which canonical scope families a strategy supports
|
||||
|
||||
| Field | Type | Notes |
|
||||
|------|------|-------|
|
||||
| `strategy_key` | string | `CompareStrategyKey` value |
|
||||
| `domain_keys` | array<string> | Supported governance domains |
|
||||
| `subject_classes` | array<string> | Supported canonical subject classes |
|
||||
| `subject_type_keys` | array<string> or `all` | Optional narrowing to known subject families |
|
||||
| `compare_supported` | boolean | Whether compare may be started for the declared family |
|
||||
| `active` | boolean | Whether the strategy is currently available |
|
||||
|
||||
### StrategySelectionState
|
||||
|
||||
**Type**: internal enum
|
||||
**Purpose**: capture compare preflight compatibility outcome
|
||||
|
||||
| Value | Meaning |
|
||||
|------|---------|
|
||||
| `supported` | Exactly one compatible strategy family matches the requested scope |
|
||||
| `unsupported` | No compatible strategy supports the requested scope |
|
||||
| `mixed` | More than one strategy family would be required by the requested scope |
|
||||
|
||||
This is an orchestration contract, not a new top-level persisted domain status family.
|
||||
|
||||
### CompareStrategySelection
|
||||
|
||||
**Type**: internal orchestration record
|
||||
**Purpose**: represent preflight strategy resolution for a compare start
|
||||
|
||||
| Field | Type | Notes |
|
||||
|------|------|-------|
|
||||
| `selection_state` | `StrategySelectionState` | Required |
|
||||
| `strategy_key` | string or `null` | Present only when `selection_state = supported` |
|
||||
| `matched_scope_entries` | array<object> | Canonical scope entries accepted by the selected strategy |
|
||||
| `rejected_scope_entries` | array<object> | Scope entries rejected as unsupported or mixed |
|
||||
| `operator_reason` | string | Short operator-safe explanation |
|
||||
| `diagnostics` | array<string, mixed> | Secondary detail for logs and run detail |
|
||||
|
||||
### CompareOrchestrationContext
|
||||
|
||||
**Type**: internal execution record
|
||||
**Purpose**: represent the platform-owned inputs to one compare run
|
||||
|
||||
| Field | Type | Notes |
|
||||
|------|------|-------|
|
||||
| `workspace_id` | integer | Required |
|
||||
| `tenant_id` | integer | Required for tenant-owned compare runs |
|
||||
| `baseline_profile_id` | integer | Required |
|
||||
| `baseline_snapshot_id` | integer | Required reference snapshot |
|
||||
| `operation_run_id` | integer | Required |
|
||||
| `normalized_scope` | canonical scope document | Required |
|
||||
| `strategy_selection` | `CompareStrategySelection` | Required |
|
||||
| `coverage_context` | array<string, mixed> | Existing compare coverage truth |
|
||||
| `launch_context` | array<string, mixed> | Existing start-surface context such as tenant or matrix origin |
|
||||
|
||||
### CompareSubjectIdentity
|
||||
|
||||
**Type**: internal value object
|
||||
**Purpose**: represent one subject being compared without assuming policy-only semantics
|
||||
|
||||
| Field | Type | Notes |
|
||||
|------|------|-------|
|
||||
| `domain_key` | string | Canonical governance domain |
|
||||
| `subject_class` | string | Canonical subject class |
|
||||
| `subject_type_key` | string | Domain-owned subject family discriminator |
|
||||
| `external_subject_id` | string | Stable external identifier where available |
|
||||
| `subject_key` | string | Stable compare key used for deduplication and finding identity |
|
||||
|
||||
### CompareSubjectProjection
|
||||
|
||||
**Type**: internal value object
|
||||
**Purpose**: provide the platform with operator-safe subject metadata for findings and summaries
|
||||
|
||||
| Field | Type | Notes |
|
||||
|------|------|-------|
|
||||
| `platform_subject_class` | string | Canonical class for platform writers |
|
||||
| `domain_key` | string | Canonical governance domain |
|
||||
| `subject_type_key` | string | Strategy-owned subject family |
|
||||
| `operator_label` | string | Operator-facing subject label |
|
||||
| `summary_kind` | string or `null` | Optional summary discriminator |
|
||||
| `additional_labels` | array<string, string> | Optional secondary labels for diagnostics or detail surfaces |
|
||||
|
||||
### CompareState
|
||||
|
||||
**Type**: internal enum
|
||||
**Purpose**: represent per-subject compare outcome
|
||||
|
||||
| Value | Meaning |
|
||||
|------|---------|
|
||||
| `no_drift` | Subject compared successfully and no drift was confirmed |
|
||||
| `drift` | Subject compared successfully and drift was confirmed |
|
||||
| `unsupported` | Subject or family is not supported by the selected strategy |
|
||||
| `incomplete` | Evidence was insufficient to classify the subject fully |
|
||||
| `ambiguous` | Identity or evidence ambiguity prevented a trustworthy compare decision |
|
||||
| `failed` | Processing failed for this subject |
|
||||
|
||||
These values map to existing compare truth and do not imply a new top-level persisted run state family.
|
||||
|
||||
### CompareSubjectResult
|
||||
|
||||
**Type**: internal orchestration contract
|
||||
**Purpose**: one structured output row from a compare strategy back to platform orchestration
|
||||
|
||||
| Field | Type | Notes |
|
||||
|------|------|-------|
|
||||
| `subject_identity` | `CompareSubjectIdentity` | Required |
|
||||
| `projection` | `CompareSubjectProjection` | Required |
|
||||
| `baseline_availability` | string | Required; e.g. `available`, `missing`, `not_applicable` |
|
||||
| `current_state_availability` | string | Required; e.g. `available`, `missing`, `unknown` |
|
||||
| `compare_state` | `CompareState` | Required |
|
||||
| `trust_level` | string | Required; maps to current trust semantics |
|
||||
| `evidence_quality` | string | Required; maps to existing evidence completeness semantics |
|
||||
| `severity_recommendation` | string or `null` | Optional |
|
||||
| `finding_candidate` | `CompareFindingCandidate` or `null` | Present when a finding should be written or updated |
|
||||
| `diagnostics` | array<string, mixed> | Secondary detail for run context and troubleshooting |
|
||||
|
||||
### CompareFindingCandidate
|
||||
|
||||
**Type**: internal value object
|
||||
**Purpose**: provide the unified finding writer with strategy-neutral mutation input
|
||||
|
||||
| Field | Type | Notes |
|
||||
|------|------|-------|
|
||||
| `change_type` | string | Existing finding change type or equivalent classification |
|
||||
| `severity` | string | Existing severity family |
|
||||
| `fingerprint_basis` | array<string, mixed> | Stable values used by finding recurrence logic |
|
||||
| `evidence_payload` | array<string, mixed> | Strategy-owned evidence detail |
|
||||
| `auto_close_eligible` | boolean | Whether absence in the current run should close the finding |
|
||||
|
||||
## Relationships
|
||||
|
||||
- One `CompareStrategyCapability` belongs to one `CompareStrategyKey`.
|
||||
- One `CompareStrategySelection` is produced for each compare start attempt.
|
||||
- One `CompareOrchestrationContext` contains exactly one supported `CompareStrategySelection` when a compare run is allowed to start.
|
||||
- One strategy processes many `CompareSubjectIdentity` values and emits many `CompareSubjectResult` values for one `CompareOrchestrationContext`.
|
||||
- One `CompareSubjectResult` may yield zero or one `CompareFindingCandidate`.
|
||||
- Existing finding and summary writers consume `CompareSubjectResult` and `CompareFindingCandidate` without needing to inspect strategy internals directly.
|
||||
|
||||
## Validation Rules
|
||||
|
||||
### Strategy selection
|
||||
|
||||
1. Canonical scope must already be normalized to Baseline Scope V2.
|
||||
2. Exactly one active strategy family must support all included scope entries.
|
||||
3. Inactive or unsupported subject types fail selection before compare is enqueued.
|
||||
4. Mixed strategy families fail selection before compare is enqueued.
|
||||
5. No implicit fallback to `intune_policy` is allowed.
|
||||
|
||||
### Compare-subject result contract
|
||||
|
||||
1. Every processed subject must return one `CompareSubjectResult`.
|
||||
2. Every result must include identity, projection, availability, compare state, trust, evidence quality, and diagnostics.
|
||||
3. `finding_candidate` may be omitted only when the compare state does not warrant finding persistence.
|
||||
4. The platform must not need raw strategy-private fields to build summaries, findings, or run diagnostics.
|
||||
|
||||
## Transition Rules
|
||||
|
||||
### Compare start to strategy selection
|
||||
|
||||
1. Receive canonical scope from the baseline profile plus any compare narrowing already allowed by the current workflow.
|
||||
2. Resolve the compatible strategy family from the registry.
|
||||
3. Reject unsupported or mixed scope before creating subject work.
|
||||
4. Persist the selected strategy family and compatibility diagnostics into existing compare run context only when a run is created.
|
||||
|
||||
### Strategy selection to subject processing
|
||||
|
||||
1. Build `CompareOrchestrationContext` from existing baseline, tenant, snapshot, and run truth.
|
||||
2. Hand domain-specific subject discovery and classification work to the selected strategy.
|
||||
3. Reuse generic current-state and hashing helpers where they remain strategy-neutral.
|
||||
|
||||
### Subject results to existing run and finding truth
|
||||
|
||||
1. Aggregate `CompareSubjectResult` values into existing compare summary counts and trust semantics.
|
||||
2. Write or update findings through the existing unified finding lifecycle using `CompareFindingCandidate`.
|
||||
3. Persist strategy diagnostics only as secondary run or evidence detail inside existing persistence shapes.
|
||||
4. Preserve existing run outcome ownership and current operator-visible compare semantics.
|
||||
@ -1,225 +0,0 @@
|
||||
# Implementation Plan: Baseline Compare Engine Strategy Extraction
|
||||
|
||||
**Branch**: `203-baseline-compare-strategy` | **Date**: 2026-04-13 | **Spec**: `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/specs/203-baseline-compare-strategy/spec.md`
|
||||
**Input**: Feature specification from `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/specs/203-baseline-compare-strategy/spec.md`
|
||||
|
||||
**Note**: This plan keeps the existing baseline compare workflow, `OperationRun` model, and finding lifecycle intact while extracting the current Intune-specific compare behavior behind one explicit strategy seam.
|
||||
|
||||
## Summary
|
||||
|
||||
Keep `BaselineCompareService` and `CompareBaselineToTenantJob` as the platform compare orchestration flow, add one deterministic compare-strategy registry keyed by canonical Baseline Scope V2, validate single-strategy compatibility before enqueue, extract current Intune-specific subject processing into an explicit `IntuneCompareStrategy`, and feed a strategy-neutral per-subject compare-result contract into the existing summary, finding, trust, and run-outcome writers. No new `OperationRun` type, no new top-level compare persistence model, and no multi-strategy-per-run orchestration are planned.
|
||||
|
||||
## Technical Context
|
||||
|
||||
**Language/Version**: PHP 8.4.15
|
||||
**Primary Dependencies**: Laravel 12, Filament v5, Livewire v4, Pest v4, Laravel Sail, existing `BaselineCompareService`, `CompareBaselineToTenantJob`, `SubjectResolver`, `CurrentStateHashResolver`, `DriftHasher`, `BaselineCompareSummaryAssessor`, and finding lifecycle services
|
||||
**Storage**: PostgreSQL via existing baseline snapshots, baseline snapshot items, `operation_runs`, findings, and baseline scope JSON; no new top-level tables planned
|
||||
**Testing**: Pest unit, feature, Filament Livewire, and existing browser smoke coverage run through Laravel Sail
|
||||
**Target Platform**: Laravel web application under `apps/platform` with queue-backed compare execution in Sail/Docker
|
||||
**Project Type**: web application in a monorepo (`apps/platform` plus `apps/website`)
|
||||
**Performance Goals**: Keep compare start enqueue-only, keep strategy selection and compatibility validation in-process and cheap, add no extra remote calls on start surfaces, and preserve current compare throughput and matrix rendering behavior
|
||||
**Constraints**: Single-strategy-per-run only, no new compare UI surface, no new `OperationRun` type, no new plugin framework, no silent Intune fallback, preserve current Intune compare behavior, and keep current finding lifecycle and trust semantics
|
||||
**Scale/Scope**: One baseline compare start service, one compare job, existing workspace and tenant compare surfaces, one explicit Intune strategy, one strategy registry, one internal compare-result contract, and focused regression extensions across compare feature and Filament suites
|
||||
|
||||
## Constitution Check
|
||||
|
||||
*GATE: Passed before Phase 0 research. Re-checked after Phase 1 design and still passing with one explicit proportionality justification recorded below.*
|
||||
|
||||
| Principle | Pre-Research | Post-Design | Notes |
|
||||
|-----------|--------------|-------------|-------|
|
||||
| Inventory-first / snapshots-second | PASS | PASS | Compare continues to read existing inventory-backed current state and workspace-owned baseline snapshots; no new source of compare truth is introduced. |
|
||||
| Read/write separation | PASS | PASS | Compare launch surfaces remain simulation-only start actions; compare still writes only existing run, finding, and audit truth. |
|
||||
| Graph contract path | PASS | PASS | The feature introduces no new Microsoft Graph path; the extracted Intune strategy continues to consume existing contract-backed evidence sources. |
|
||||
| Deterministic capabilities | PASS | PASS | Strategy capability matching is explicit, testable, and driven by canonical scope plus registry metadata. |
|
||||
| Workspace + tenant isolation | PASS | PASS | Workspace baseline surfaces and tenant compare surfaces keep current isolation boundaries and deny-as-not-found semantics. |
|
||||
| RBAC-UX authorization semantics | PASS | PASS | Existing capability checks remain authoritative for compare starts and drilldowns; unsupported or mixed scope is a compare truth outcome, not an auth shortcut. |
|
||||
| Run observability / Ops-UX | PASS | PASS | Existing `baseline_compare` runs remain the only run truth; status/outcome ownership, summary-count rules, and terminal notification behavior remain unchanged. |
|
||||
| Data minimization | PASS | PASS | No new persisted compare artifact is added; strategy-neutral context extends existing run/finding payloads only where necessary. |
|
||||
| Proportionality / anti-bloat | PASS | PASS | One narrow strategy seam is justified by a current compare-truth problem and recorded in Complexity Tracking; no broader workflow framework is added. |
|
||||
| No premature abstraction | JUSTIFIED | JUSTIFIED | This is a deliberate, narrow exception: one strategy contract and registry are introduced before a second production domain exists because Spec 202 makes unsupported or mixed compare scope a current-release truth problem. The alternative thin wrapper is recorded and rejected below. |
|
||||
| Persisted truth / behavioral state | PASS | PASS | The new compare-result states remain internal orchestration semantics and map to existing operator-visible compare outcomes; no new top-level persisted state family is introduced. |
|
||||
| UI semantics / few layers | PASS | PASS | Existing compare surfaces remain the same; the new compare-result contract feeds current summaries and explanations instead of creating a new UI interpretation framework. |
|
||||
| Filament v5 / Livewire v4 compliance | PASS | PASS | The affected compare surfaces remain Filament v5 + Livewire v4 surfaces with no legacy API introduction. |
|
||||
| Provider registration location | PASS | PASS | No panel or provider change is required; Laravel 11+ provider registration remains in `bootstrap/providers.php`. |
|
||||
| Global search hard rule | PASS | PASS | No globally searchable resource is added or changed by this feature. |
|
||||
| Destructive action safety | PASS | PASS | No new destructive action is introduced. Existing archive actions stay confirmed; compare start actions remain confirmed and capability-gated. |
|
||||
| Asset strategy | PASS | PASS | No new assets or panel registrations are required; existing Filament asset deployment remains unchanged. |
|
||||
|
||||
## Filament-Specific Compliance Notes
|
||||
|
||||
- **Livewire v4.0+ compliance**: The touched compare surfaces remain on Filament v5 + Livewire v4 and no deprecated Filament API is introduced by the plan.
|
||||
- **Provider registration location**: No new panel or provider is required; `bootstrap/providers.php` remains the only relevant provider registration location.
|
||||
- **Global search**: No new searchable resource is added. Existing compare pages remain standalone pages and current global-search behavior is unaffected.
|
||||
- **Destructive actions**: This feature adds no new destructive action. Existing destructive actions on baseline surfaces remain unchanged and must keep `->requiresConfirmation()`.
|
||||
- **Asset strategy**: No panel-only or shared asset registration is needed. Deployment handling of `cd apps/platform && php artisan filament:assets` remains unchanged.
|
||||
- **Testing plan**: Extend compare feature tests, Filament start-surface tests, and focused matrix or landing coverage for unsupported or mixed-scope truth. Keep existing compare smoke coverage green.
|
||||
|
||||
## Phase 0 Research
|
||||
|
||||
Research outcomes are captured in `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/specs/203-baseline-compare-strategy/research.md`.
|
||||
|
||||
Key decisions:
|
||||
|
||||
- Keep `BaselineCompareService` as the compare start orchestration layer and `CompareBaselineToTenantJob` as the async executor instead of redesigning the run lifecycle.
|
||||
- Add a deterministic compare-strategy registry that resolves one compatible strategy family from canonical Baseline Scope V2 before a run is enqueued.
|
||||
- Extract the strategy seam at the current subject-processing boundary inside `CompareBaselineToTenantJob` rather than replacing current queue, finding, or summary flows.
|
||||
- Reuse existing generic helpers such as `DriftHasher`, `CurrentStateHashResolver`, finding lifecycle services, and summary assessors; move Intune-shaped normalizer selection, policy-type special cases, and subject-projection detail behind `IntuneCompareStrategy`.
|
||||
- Model the per-subject compare output as a structured internal contract instead of raw ad-hoc arrays or a new persisted compare-result table.
|
||||
- Keep workspace fan-out compare as repeated tenant-owned runs with the same single-strategy validation and no new workspace umbrella run.
|
||||
|
||||
## Phase 1 Design
|
||||
|
||||
Design artifacts are created under `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/specs/203-baseline-compare-strategy/`:
|
||||
|
||||
- `research.md`: architecture decisions and rejected alternatives for strategy extraction
|
||||
- `data-model.md`: internal compare strategy, strategy selection, compare-subject result, and projection contracts
|
||||
- `contracts/baseline-compare-strategy.logical.openapi.yaml`: logical internal contract for compare validation, compare launch, fan-out launch, and strategy-owned subject classification
|
||||
- `quickstart.md`: implementation and verification sequence for the feature
|
||||
|
||||
Design decisions:
|
||||
|
||||
- Keep compare launch orchestration in `BaselineCompareService` and run execution in `CompareBaselineToTenantJob`.
|
||||
- Introduce one narrow compare-support namespace under `app/Support/Baselines/Compare/` for the strategy contract, registry, selection result, and compare-subject result value objects.
|
||||
- Keep Intune-specific section normalizers and policy-type branching inside the explicit Intune strategy rather than in platform orchestration.
|
||||
- Preserve existing finding persistence, run summary assessment, and explanation surfaces by feeding them strategy-neutral projection metadata instead of raw strategy internals.
|
||||
- Reject unsupported or mixed strategy scope before subject processing, and keep strategy failure truth visible through existing compare landing and canonical run-detail surfaces.
|
||||
|
||||
## Project Structure
|
||||
|
||||
### Documentation (this feature)
|
||||
|
||||
```text
|
||||
specs/203-baseline-compare-strategy/
|
||||
├── plan.md
|
||||
├── research.md
|
||||
├── data-model.md
|
||||
├── quickstart.md
|
||||
├── spec.md
|
||||
├── contracts/
|
||||
│ └── baseline-compare-strategy.logical.openapi.yaml
|
||||
└── checklists/
|
||||
└── requirements.md
|
||||
```
|
||||
|
||||
### Source Code (repository root)
|
||||
|
||||
```text
|
||||
apps/platform/
|
||||
├── app/
|
||||
│ ├── Filament/
|
||||
│ │ ├── Pages/
|
||||
│ │ │ ├── BaselineCompareLanding.php
|
||||
│ │ │ └── BaselineCompareMatrix.php
|
||||
│ │ └── Resources/
|
||||
│ │ └── BaselineProfileResource/
|
||||
│ │ └── Pages/
|
||||
│ │ └── ViewBaselineProfile.php
|
||||
│ ├── Jobs/
|
||||
│ │ └── CompareBaselineToTenantJob.php
|
||||
│ ├── Services/
|
||||
│ │ ├── Baselines/
|
||||
│ │ │ ├── BaselineCompareService.php
|
||||
│ │ │ ├── CurrentStateHashResolver.php
|
||||
│ │ │ └── Evidence/
|
||||
│ │ └── Drift/
|
||||
│ │ ├── DriftHasher.php
|
||||
│ │ └── Normalizers/
|
||||
│ └── Support/
|
||||
│ └── Baselines/
|
||||
│ ├── BaselineCompareExplanationRegistry.php
|
||||
│ ├── BaselineCompareSummaryAssessor.php
|
||||
│ ├── ResolutionOutcome.php
|
||||
│ ├── SubjectClass.php
|
||||
│ ├── SubjectResolver.php
|
||||
│ └── Compare/
|
||||
│ └── [new strategy contract, registry, and result objects]
|
||||
└── tests/
|
||||
├── Feature/
|
||||
│ ├── Baselines/
|
||||
│ │ ├── BaselineComparePreconditionsTest.php
|
||||
│ │ ├── BaselineCompareExecutionGuardTest.php
|
||||
│ │ ├── BaselineCompareFindingsTest.php
|
||||
│ │ ├── BaselineCompareMatrixCompareAllActionTest.php
|
||||
│ │ ├── BaselineCompareRbacRoleDefinitionsTest.php
|
||||
│ │ ├── BaselineCompareGapClassificationTest.php
|
||||
│ │ └── [new strategy-selection and unsupported-scope coverage]
|
||||
│ ├── BaselineDriftEngine/
|
||||
│ │ ├── CompareContentEvidenceTest.php
|
||||
│ │ └── CompareFidelityMismatchTest.php
|
||||
│ └── Filament/
|
||||
│ ├── BaselineProfileCompareStartSurfaceTest.php
|
||||
│ ├── BaselineCompareLandingStartSurfaceTest.php
|
||||
│ ├── BaselineCompareMatrixPageTest.php
|
||||
│ └── [new unsupported or mixed-scope surface tests]
|
||||
└── Browser/
|
||||
└── [existing compare-related smoke tests remain green]
|
||||
```
|
||||
|
||||
**Structure Decision**: Keep the change inside the existing baseline compare service, compare job, and compare surfaces. Add one narrow `app/Support/Baselines/Compare` namespace for the strategy contract, registry, selection, and result objects, but avoid a wider plugin or provider framework.
|
||||
|
||||
## Complexity Tracking
|
||||
|
||||
| Violation | Why Needed | Simpler Alternative Rejected Because |
|
||||
|-----------|------------|-------------------------------------|
|
||||
| Compare strategy contract and registry before a second production domain exists | Canonical Baseline Scope V2 makes unsupported or mixed-domain compare a current-release truth problem. The platform must stop treating Intune policy semantics as the hidden universal default now. | A thin wrapper around `CompareBaselineToTenantJob` would leave Intune assumptions in the platform core and would not make preflight rejection or future-domain participation honest. |
|
||||
| Structured compare-subject result contract | Existing summary, finding, and run writers need domain-neutral compare output to avoid reverse-engineering Intune-shaped evidence arrays. | Passing raw arrays through the job would recreate hidden policy-only defaults and force downstream orchestration to keep inferring domain meaning from Intune payload shapes. |
|
||||
|
||||
## Proportionality Review
|
||||
|
||||
- **Current operator problem**: Compare truth is currently reliable only because platform orchestration and Intune-specific logic are still mixed. As canonical scope broadens, unsupported or mixed compare scope cannot be rejected honestly without a clean boundary.
|
||||
- **Existing structure is insufficient because**: The current compare job still makes policy-shaped assumptions during subject processing and evidence projection, so the platform cannot safely claim one canonical compare workflow across future subject families.
|
||||
- **Narrowest correct implementation**: Keep the current start service, queue job, run model, finding model, and compare surfaces. Introduce one compare strategy contract, one deterministic resolver, and one strategy-neutral subject-result contract, then move only the Intune-specific processing behind the explicit strategy boundary.
|
||||
- **Ownership cost created**: One new compare-support namespace, stricter contract discipline, additional strategy-resolution and regression coverage, and ongoing review to keep platform orchestration free of reintroduced Intune assumptions.
|
||||
- **Alternative intentionally rejected**: A superficial wrapper around the current job or a broad compare plugin framework. The wrapper would not solve the current truth boundary, and the framework would import speculative complexity before a second real production domain exists.
|
||||
- **Release truth**: current-release compare correctness and future-domain readiness at the specific compare seam, not generalized platform totalization
|
||||
|
||||
## Implementation Strategy
|
||||
|
||||
### Phase A - Strategy Selection and Compare Preconditions
|
||||
|
||||
- Add the compare strategy capability contract and deterministic registry keyed by canonical Baseline Scope V2.
|
||||
- Validate one compatible strategy family at compare start in `BaselineCompareService` for both tenant compare and workspace fan-out compare.
|
||||
- Return explicit unsupported or mixed-scope outcomes before a run is enqueued.
|
||||
|
||||
### Phase B - Intune Strategy Extraction
|
||||
|
||||
- Extract the current Intune-specific subject-processing logic from `CompareBaselineToTenantJob` into `IntuneCompareStrategy`.
|
||||
- Move Intune-only normalizer selection, policy-type special cases, and subject projection details behind the new strategy boundary.
|
||||
- Keep generic helpers such as `CurrentStateHashResolver`, `DriftHasher`, and finding lifecycle orchestration reusable by the job.
|
||||
|
||||
### Phase C - Strategy-Neutral Result Projection
|
||||
|
||||
- Replace raw per-subject drift arrays with a structured compare-subject result contract.
|
||||
- Feed existing finding, summary, and trust writers from strategy-provided subject projection metadata rather than universal policy defaults.
|
||||
- Keep existing run outcome, summary counts, and explanation surfaces stable.
|
||||
|
||||
### Phase D - Surface Truth Hardening
|
||||
|
||||
- Update compare start surfaces to explain unsupported or mixed-scope preconditions through existing helper text, confirmation copy, or start-result messaging.
|
||||
- Keep compare landing and canonical run detail truthful for unsupported, incomplete, ambiguous, and failed strategy outcomes without adding a new page.
|
||||
|
||||
### Phase E - Regression and Verification
|
||||
|
||||
- Extend compare precondition, finding, summary, and matrix compare-all coverage for strategy resolution and unsupported or mixed-scope behavior.
|
||||
- Keep current Intune compare classification, finding recurrence, and evidence contract coverage green to detect silent behavior drift.
|
||||
- Validate the touched compare start surfaces and run-detail messaging with focused Filament tests and existing smoke tests.
|
||||
|
||||
## Risk Assessment
|
||||
|
||||
| Risk | Impact | Likelihood | Mitigation |
|
||||
|------|--------|------------|------------|
|
||||
| The strategy seam stays superficial and platform code still depends on Intune assumptions | High | Medium | Extract policy-type branching, section normalizer selection, and strategy-owned projection metadata out of the job body; review the remaining orchestration path for universal policy defaults. |
|
||||
| Intune compare behavior regresses during extraction | High | Medium | Extend existing compare feature tests before and during extraction; keep current finding, gap, and summary suites green as the acceptance bar. |
|
||||
| The compare-subject result contract is too weak | High | Medium | Define explicit projection, diagnostics, and availability fields in Phase 1 design and require summary/finding writers to consume them without reading strategy internals directly. |
|
||||
| Unsupported or mixed-scope rejection happens too late | Medium | Medium | Resolve and validate one strategy family in `BaselineCompareService` before run creation and reuse the same logic for workspace fan-out compare. |
|
||||
| The change expands into a general compare framework | Medium | Low | Limit the new namespace and contract to baseline compare only, keep single-strategy-per-run, and defer any multi-strategy orchestration or second-domain UI work. |
|
||||
|
||||
## Test Strategy
|
||||
|
||||
- Extend `tests/Feature/Baselines/BaselineComparePreconditionsTest.php` for unsupported strategy, mixed-strategy, and inactive subject-type rejection before run creation.
|
||||
- Extend `tests/Feature/Baselines/BaselineCompareMatrixCompareAllActionTest.php` to ensure workspace fan-out compare applies the same single-strategy validation and truthful rejection behavior.
|
||||
- Extend `tests/Feature/Baselines/BaselineCompareFindingsTest.php`, `BaselineCompareGapClassificationTest.php`, and `BaselineCompareRbacRoleDefinitionsTest.php` to prove Intune finding projection and classification behavior stay stable through the extracted strategy.
|
||||
- Extend `tests/Feature/Baselines/BaselineCompareExecutionGuardTest.php` and `BaselineCompareSummaryAssessmentTest.php` to keep `OperationRun` outcome, summary, and trust semantics unchanged.
|
||||
- Add focused unit coverage for the compare strategy registry, strategy selection result, and compare-subject result mapping.
|
||||
- Extend `tests/Feature/Filament/BaselineProfileCompareStartSurfaceTest.php`, `BaselineCompareLandingStartSurfaceTest.php`, and `BaselineCompareMatrixPageTest.php` for unsupported or mixed-scope start-surface truth and no-regression launch behavior.
|
||||
- Keep existing compare browser smoke coverage green to detect accidental surface regressions in the matrix or landing experience.
|
||||
@ -1,116 +0,0 @@
|
||||
# Quickstart: Baseline Compare Engine Strategy Extraction
|
||||
|
||||
## Goal
|
||||
|
||||
Extract the current Intune-shaped compare processing behind one explicit compare strategy while preserving the existing baseline compare run lifecycle, finding lifecycle, trust semantics, and operator-facing compare story.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
1. Work on branch `203-baseline-compare-strategy`.
|
||||
2. Ensure the platform containers are available:
|
||||
|
||||
```bash
|
||||
cd apps/platform && ./vendor/bin/sail up -d
|
||||
```
|
||||
|
||||
3. Keep Spec 202's canonical scope contract available because strategy selection depends on Baseline Scope V2.
|
||||
|
||||
## Recommended Implementation Order
|
||||
|
||||
### 1. Lock the current compare behavior with focused regression tests
|
||||
|
||||
Run the existing compare-focused suite before extracting anything:
|
||||
|
||||
```bash
|
||||
cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Baselines/BaselineComparePreconditionsTest.php
|
||||
cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Baselines/BaselineCompareFindingsTest.php
|
||||
cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Baselines/BaselineCompareGapClassificationTest.php
|
||||
cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Baselines/BaselineCompareRbacRoleDefinitionsTest.php
|
||||
```
|
||||
|
||||
Add any missing tests for unsupported scope, mixed-strategy scope, and current Intune compare classification parity before moving major compare logic.
|
||||
|
||||
### 2. Introduce the compare strategy contract and selection result
|
||||
|
||||
Add the narrow compare-support namespace under `app/Support/Baselines/Compare/` with:
|
||||
|
||||
- compare strategy contract
|
||||
- strategy capability registry
|
||||
- strategy selection result
|
||||
- compare subject result contract
|
||||
|
||||
Keep these objects internal and derived. Do not add a new table or new `OperationRun` type.
|
||||
|
||||
### 3. Wire strategy validation into compare start surfaces
|
||||
|
||||
Update `BaselineCompareService` so both:
|
||||
|
||||
- tenant compare start
|
||||
- workspace compare-matrix fan-out compare
|
||||
|
||||
resolve one compatible strategy family from canonical scope before any run is enqueued.
|
||||
|
||||
Unsupported or mixed-scope requests should fail clearly before subject work begins.
|
||||
|
||||
### 4. Extract the current Intune compare implementation behind `IntuneCompareStrategy`
|
||||
|
||||
Move the current Intune-shaped subject-processing logic out of the core path in `CompareBaselineToTenantJob`, including:
|
||||
|
||||
- policy-type-specific normalizer selection
|
||||
- section or evidence shaping that assumes Intune policy structure
|
||||
- special-case subject handling such as RBAC role-definition compare rules
|
||||
- strategy-owned subject projection metadata
|
||||
|
||||
Keep generic helpers such as `CurrentStateHashResolver`, `DriftHasher`, and finding lifecycle orchestration reusable by the job.
|
||||
|
||||
### 5. Feed existing finding and summary writers from the new result contract
|
||||
|
||||
Replace raw per-subject drift arrays with the structured compare-subject result contract where orchestration needs:
|
||||
|
||||
- summary aggregation
|
||||
- finding write or update
|
||||
- diagnostics persistence
|
||||
- operator-safe degraded or failed state explanation
|
||||
|
||||
Do not create a new compare-result table.
|
||||
|
||||
### 6. Harden existing compare surfaces
|
||||
|
||||
Update the existing compare launch and review surfaces so they remain truthful for:
|
||||
|
||||
- unsupported scope
|
||||
- mixed-strategy scope
|
||||
- incomplete evidence
|
||||
- ambiguous identity
|
||||
- strategy failure
|
||||
|
||||
This work should stay within the existing baseline profile detail, compare matrix, tenant compare landing, and canonical run-detail surfaces.
|
||||
|
||||
## Focused Verification
|
||||
|
||||
Run the most relevant suites after each phase:
|
||||
|
||||
```bash
|
||||
cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Baselines/BaselineComparePreconditionsTest.php
|
||||
cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Baselines/BaselineCompareExecutionGuardTest.php
|
||||
cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Baselines/BaselineCompareFindingsTest.php
|
||||
cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Baselines/BaselineCompareMatrixCompareAllActionTest.php
|
||||
cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Baselines/BaselineCompareRbacRoleDefinitionsTest.php
|
||||
cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Filament/BaselineProfileCompareStartSurfaceTest.php
|
||||
cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Filament/BaselineCompareLandingStartSurfaceTest.php
|
||||
cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Filament/BaselineCompareMatrixPageTest.php
|
||||
```
|
||||
|
||||
If the compare landing or matrix messaging changes materially, keep existing browser smoke coverage green as a final confidence pass.
|
||||
|
||||
## Final Validation
|
||||
|
||||
1. Run formatting:
|
||||
|
||||
```bash
|
||||
cd apps/platform && ./vendor/bin/sail bin pint --dirty --format agent
|
||||
```
|
||||
|
||||
2. Re-run the focused compare test pack.
|
||||
3. Confirm that unsupported or mixed-scope compare requests fail before enqueue.
|
||||
4. Confirm that the current Intune compare path still produces the same operator-visible finding, summary, and trust outcomes.
|
||||
@ -1,89 +0,0 @@
|
||||
# Research: Baseline Compare Engine Strategy Extraction
|
||||
|
||||
## Decision: Keep `BaselineCompareService` and `CompareBaselineToTenantJob` as the platform compare orchestration path
|
||||
|
||||
### Rationale
|
||||
|
||||
The current compare workflow already has the right platform-owned responsibilities in the right places: `BaselineCompareService` performs start-surface preconditions, run setup, and workspace fan-out orchestration, while `CompareBaselineToTenantJob` owns queued execution, result persistence, and run completion. Replacing that lifecycle would expand scope and risk without improving the core problem this spec is trying to solve.
|
||||
|
||||
### Alternatives considered
|
||||
|
||||
- Introduce a brand-new compare orchestrator service and new queue workflow: rejected because it would duplicate stable run and finding behavior before proving the narrower extraction seam.
|
||||
- Push more orchestration into Filament pages: rejected because start surfaces must stay enqueue-only and server-side compare truth must remain outside page code.
|
||||
|
||||
## Decision: Resolve one compare strategy family from canonical Baseline Scope V2 before a run is enqueued
|
||||
|
||||
### Rationale
|
||||
|
||||
Spec 202 makes canonical scope explicit. That gives the compare start path a deterministic input contract for strategy selection. Resolving one strategy family before enqueue is the narrowest way to reject unsupported or mixed scope truthfully and enforce the single-strategy-per-run rule before any subject processing begins.
|
||||
|
||||
### Alternatives considered
|
||||
|
||||
- Resolve strategy lazily inside the compare job after the run starts: rejected because unsupported or mixed scope would then produce avoidable queued work and less honest operator feedback.
|
||||
- Allow the compare job to pick the first compatible strategy silently: rejected because it would recreate the same hidden platform default that this spec is trying to remove.
|
||||
|
||||
## Decision: Extract the strategy seam at the subject-processing boundary inside `CompareBaselineToTenantJob`
|
||||
|
||||
### Rationale
|
||||
|
||||
The narrowest real domain seam in the current code is where the compare job turns baseline items, current-state evidence, and subject resolution into drift classification, diagnostics, and finding projection data. That is where Intune-specific normalizer selection, policy-type special cases, and evidence shaping currently live. Extracting there preserves the existing queue lifecycle and reuse of generic helpers while removing domain assumptions from the platform core.
|
||||
|
||||
### Alternatives considered
|
||||
|
||||
- Extract only a thin wrapper around the whole job: rejected because the job body would still contain platform-visible Intune assumptions.
|
||||
- Extract at the `BaselineCompareService` level only: rejected because the service already behaves like platform orchestration; the Intune-shaped logic mostly lives deeper in execution.
|
||||
|
||||
## Decision: Reuse current generic helpers and move only Intune-shaped logic behind `IntuneCompareStrategy`
|
||||
|
||||
### Rationale
|
||||
|
||||
Exploration shows that several current compare helpers are already strategy-neutral enough to survive the extraction: `CurrentStateHashResolver`, `DriftHasher`, finding lifecycle handling, and summary/trust assessment logic. The most Intune-shaped code is the section normalizer selection, policy-type branching, summary-kind choice, RBAC role-definition special cases, and subject projection detail. Reusing the generic helpers keeps the extraction narrow and avoids replacing proven infrastructure.
|
||||
|
||||
### Alternatives considered
|
||||
|
||||
- Rebuild all compare helpers under a new generic compare package: rejected because it would replatform too much stable code for little value.
|
||||
- Leave Intune-specific normalizers and evidence shaping in the job while adding only a nominal strategy interface: rejected because the platform would still own hidden Intune behavior.
|
||||
|
||||
## Decision: Model compare subject output as an explicit internal result contract, not as raw arrays and not as a new persisted entity
|
||||
|
||||
### Rationale
|
||||
|
||||
Current compare processing already passes around raw drift arrays and evidence payloads. That shape is too weak for a true domain boundary because it forces downstream orchestration to infer meaning from Intune-shaped keys. A structured internal compare-subject result contract is the narrowest way to make summaries, findings, and diagnostics consume strategy-neutral data. It remains internal and derived, so it does not create a new persisted compare truth layer.
|
||||
|
||||
### Alternatives considered
|
||||
|
||||
- Keep raw drift arrays as the only contract: rejected because the platform would keep reverse-engineering domain logic from strategy internals.
|
||||
- Create a new persisted compare result table: rejected because existing `OperationRun`, finding, and evidence persistence already hold the long-lived product truth.
|
||||
|
||||
## Decision: Keep workspace fan-out compare as repeated tenant-owned runs with the same strategy validation
|
||||
|
||||
### Rationale
|
||||
|
||||
The workspace compare matrix already starts normal tenant-owned compare runs for the visible assignment set and explicitly avoids a workspace umbrella run. The extraction should preserve that model and apply the same strategy validation used by tenant-local compare starts. That keeps operator semantics stable and avoids inventing new batch-run persistence.
|
||||
|
||||
### Alternatives considered
|
||||
|
||||
- Add a new workspace umbrella compare run for fan-out: rejected because it would add new operational truth that this spec does not need.
|
||||
- Validate fan-out compare differently from tenant compare: rejected because it would create inconsistent compare semantics between two existing launch surfaces.
|
||||
|
||||
## Decision: Surface unsupported or mixed strategy failures through existing compare launch and review semantics
|
||||
|
||||
### Rationale
|
||||
|
||||
This feature is not a UI redesign. The existing baseline detail, compare matrix, tenant compare landing, and canonical run-detail surfaces already own the operator story. The right change is to make those surfaces show truthful compatibility or failure meaning through existing alerts, helper text, summaries, and diagnostics instead of inventing a new compare-administration page.
|
||||
|
||||
### Alternatives considered
|
||||
|
||||
- Add a dedicated strategy diagnostics page: rejected because it would add UI breadth for a workflow that already has a natural home.
|
||||
- Hide unsupported or mixed scope behind generic precondition failures: rejected because the operator needs explicit truth about why compare could not run.
|
||||
|
||||
## Decision: Treat the new compare strategy seam as a deliberate, narrow proportionality exception to the default anti-abstraction bias
|
||||
|
||||
### Rationale
|
||||
|
||||
The constitution normally rejects new strategy systems before two concrete production cases exist. This feature qualifies for a narrow exception because Baseline Scope V2 is already shipping a broader compare-input contract, and the current compare engine still embeds one domain's assumptions in the platform core. Without the seam, current-release compare truth becomes less honest the moment scope extends beyond the hidden Intune default.
|
||||
|
||||
### Alternatives considered
|
||||
|
||||
- Defer the seam until the second production domain exists: rejected because that would either block safe expansion or force a future domain to copy or distort the current compare engine.
|
||||
- Build a broad multi-domain compare framework now: rejected because it would over-correct and import far more complexity than the current release needs.
|
||||
@ -1,304 +0,0 @@
|
||||
# Feature Specification: Baseline Compare Engine Strategy Extraction
|
||||
|
||||
**Feature Branch**: `203-baseline-compare-strategy`
|
||||
**Created**: 2026-04-13
|
||||
**Status**: Draft
|
||||
**Input**: User description: "Spec 203 - Baseline Compare Engine Strategy Extraction"
|
||||
|
||||
## Spec Candidate Check *(mandatory - SPEC-GATE-001)*
|
||||
|
||||
- **Problem**: Baseline compare already works for Intune-backed policy subjects, but the platform still mixes compare orchestration with Intune-specific subject resolution, normalization, and finding projection, so compare cannot expand safely beyond its first domain.
|
||||
- **Today's failure**: Baseline scope can become broader through Spec 202, but the compare path still behaves as if every governed subject is an Intune policy flow. That keeps future-domain work trapped behind hidden Intune assumptions and risks misleading compare truth when the platform vocabulary grows.
|
||||
- **User-visible improvement**: Operators keep one baseline compare story, one run model, one finding lifecycle, and one trust or outcome model, while unsupported or mixed-domain scope is rejected honestly and current Intune compare behavior stays stable.
|
||||
- **Smallest enterprise-capable version**: Extract one platform compare orchestration layer, one explicit compare strategy contract, one deterministic strategy resolver, one stable internal compare-result contract, and one explicit Intune compare strategy without adding a second real governance domain or redesigning compare UI.
|
||||
- **Explicit non-goals**: No second compare-capable domain, no multi-strategy-per-run orchestration, no broad backup/restore or inventory generalization, no repo-wide Intune rename, no new compare plugin framework, and no wholesale redesign of findings persistence.
|
||||
- **Permanent complexity imported**: One compare strategy contract, one deterministic resolver, one structured per-subject compare-result contract, one explicit Intune strategy boundary, compatibility validation at compare entrypoints, and focused regression coverage.
|
||||
- **Why now**: Spec 202 creates the canonical scope vocabulary. Without this extraction, compare remains the strongest Intune-shaped bottleneck and will either block the first non-Intune compare domain or force the platform to carry misleading universal policy semantics longer.
|
||||
- **Why not local**: A thin wrapper around the current monolith would preserve the same hidden Intune assumptions in the platform core, leaving future domains to fork the compare path or misuse Intune-shaped artifacts.
|
||||
- **Approval class**: Core Enterprise
|
||||
- **Red flags triggered**: New abstraction risk and future-domain preparation risk. Defense: compare is already a production-real workflow, the new seam is justified by current compare truth rather than speculative breadth, the first release stays single-strategy-per-run, and existing persistence and UI are preserved wherever possible.
|
||||
- **Score**: Nutzen: 2 | Dringlichkeit: 2 | Scope: 2 | Komplexitaet: 1 | Produktnaehe: 1 | Wiederverwendung: 2 | **Gesamt: 10/12**
|
||||
- **Decision**: approve
|
||||
|
||||
## Spec Scope Fields *(mandatory)*
|
||||
|
||||
- **Scope**: workspace, tenant, canonical-view
|
||||
- **Primary Routes**:
|
||||
- `/admin/baseline-profiles/{record}` as the existing workspace baseline detail and compare launch surface
|
||||
- `/admin/baseline-profiles/{record}/compare-matrix` as the existing workspace compare matrix and visible-set fan-out surface
|
||||
- `/admin/t/{tenant}/baseline-compare` as the tenant compare landing and result surface
|
||||
- `/admin/operations/{run}` as the canonical compare-run detail surface
|
||||
- **Data Ownership**:
|
||||
- Workspace-owned baseline profiles, baseline snapshots, and canonical Baseline Scope V2 remain the reference truth that compare reads.
|
||||
- Tenant-owned compare runs, compare findings, and related diagnostics remain the persisted operational truth written by compare.
|
||||
- This feature does not require a new top-level persisted compare result artifact. The new compare-result contract is internal to orchestration and may be reflected only through existing run and finding persistence.
|
||||
- **RBAC**:
|
||||
- Existing workspace baseline view and manage capabilities remain authoritative for workspace baseline detail and compare-matrix launch surfaces.
|
||||
- Existing tenant compare and finding capabilities remain authoritative for tenant compare and compare follow-up surfaces.
|
||||
- This feature changes orchestration and compare eligibility, not membership boundaries. Non-members remain `404`, entitled members without the required capability remain `403`, and unsupported strategy or mixed-scope failures are truthful compare-state outcomes rather than authorization shortcuts.
|
||||
|
||||
For canonical-view specs, the spec MUST define:
|
||||
|
||||
- **Default filter behavior when tenant-context is active**: Canonical compare-run detail keeps current tenant context in related links when the operator arrived from a tenant route, but the run viewer itself remains explicit to the referenced run and does not widen visibility beyond the entitled tenant.
|
||||
- **Explicit entitlement checks preventing cross-tenant leakage**: Canonical run-detail and compare drilldowns must continue to enforce workspace entitlement first and tenant entitlement second. Unsupported or failed strategy details must never reveal hidden tenant identities, hidden scope entries, or cross-tenant compare metadata.
|
||||
|
||||
## Decision-First Surface Role *(mandatory when operator-facing surfaces are changed)*
|
||||
|
||||
| Surface | Decision Role | Human-in-the-loop Moment | Immediately Visible for First Decision | On-Demand Detail / Evidence | Why This Is Primary or Why Not | Workflow Alignment | Attention-load Reduction |
|
||||
|---|---|---|---|---|---|---|---|
|
||||
| Baseline profile detail and compare matrix launch controls | Secondary Context Surface | Decide whether compare can start for the selected baseline scope and visible tenant set | Compare readiness, compatible strategy family, and unsupported or mixed-scope reason when compare cannot start | Deeper domain diagnostics, subject-family detail, and run drilldowns | Not primary because these surfaces prepare compare work rather than serving as the final tenant review surface | Follows baseline definition and workspace compare launch workflow | Prevents operators from starting misleading compare runs and then reconstructing why they failed |
|
||||
| Tenant baseline compare landing | Primary Decision Surface | Decide whether the tenant currently matches the baseline, needs follow-up, or needs data or support remediation | Compare summary, trust or completeness state, and explicit unsupported or incomplete meaning when compare could not produce a trustworthy result | Subject-level evidence gaps, detailed diagnostics, and related run history | Primary because this is where tenant compare truth becomes actionable for the operator | Follows the tenant review workflow after compare execution | Keeps unsupported, ambiguous, and incomplete states distinct from calm no-drift results |
|
||||
| Canonical compare run detail | Tertiary Evidence / Diagnostics Surface | Inspect why a compare run failed, degraded, or refused to process a given scope | Run outcome, summary counts, high-level compare reason, and next-step guidance | Strategy diagnostics, subject-level diagnostics, and detailed processing context | Not primary because it explains the run after the decision surface, rather than serving as the default governance queue | Follows monitoring and troubleshooting workflow | Keeps raw orchestration or strategy detail secondary until the operator deliberately opens evidence |
|
||||
|
||||
## UI/UX Surface Classification *(mandatory when operator-facing surfaces are changed)*
|
||||
|
||||
| Surface | Action Surface Class | Surface Type | Likely Next Operator Action | Primary Inspect/Open Model | Row Click | Secondary Actions Placement | Destructive Actions Placement | Canonical Collection Route | Canonical Detail Route | Scope Signals | Canonical Noun | Critical Truth Visible by Default | Exception Type / Justification |
|
||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
||||
| Baseline profile detail | Detail / Workflow hub | Workspace baseline detail | Start compare for one tenant or review compare matrix readiness | Explicit view page | not applicable | Existing header actions and contextual status sections | Existing archive action remains in its current confirmed placement | `/admin/baseline-profiles` | `/admin/baseline-profiles/{record}` | Active workspace, baseline profile, canonical scope, compare readiness | Baseline profile / baseline compare | Whether the baseline scope is compare-compatible and whether launch would stay truthful | none |
|
||||
| Baseline compare matrix | Matrix / Workspace report | Compare launch and drilldown hub | Compare assigned tenants or inspect tenant follow-up | Explicit page controls and drilldowns | forbidden | Header toolbar and matrix support surfaces | none | `/admin/baseline-profiles/{record}/compare-matrix` | Same route with focused state, plus tenant compare or run drilldowns | Active workspace, baseline profile, visible tenant set, compare compatibility | Baseline compare matrix | Whether the visible assigned set can be compared through one compatible strategy family | matrix surface remains a narrow custom layout exception |
|
||||
| Tenant baseline compare landing | Decision / Review | Tenant compare landing | Compare now or inspect the latest compare truth | Explicit tenant page | forbidden | Header action and contextual compare summary panels | none | `/admin/t/{tenant}/baseline-compare` | Same route | Active workspace, tenant context, baseline context, compare trust state | Baseline compare | Whether the latest tenant compare result is trustworthy, unsupported, incomplete, or drifted | none |
|
||||
| Canonical compare run detail | Evidence / Diagnostics | Operation run detail | Inspect run outcome and root cause | Explicit run detail view | forbidden | Existing navigation and diagnostics sections | none | `/admin/operations/{run}` | Same route | Workspace context, tenant context when applicable, run outcome, compare summary | Baseline compare run | Why the compare run completed, degraded, or failed and what follow-up is appropriate | canonical evidence detail |
|
||||
|
||||
## Operator Surface Contract *(mandatory when operator-facing surfaces are changed)*
|
||||
|
||||
| Surface | Primary Persona | Decision / Operator Action Supported | Surface Type | Primary Operator Question | Default-visible Information | Diagnostics-only Information | Status Dimensions Used | Mutation Scope | Primary Actions | Dangerous Actions |
|
||||
|---|---|---|---|---|---|---|---|---|---|---|
|
||||
| Baseline profile detail and compare matrix launch controls | Workspace baseline manager | Decide whether compare should start for a baseline scope | Secondary context and workflow hub | Can this baseline scope be compared honestly for the intended target set right now? | Scope summary, compatibility readiness, and unsupported or mixed-scope explanation | Detailed domain or subject-family diagnostics | compare readiness, compatibility, rollout or support constraints | `simulation only` for compare starts | Compare now, Compare assigned tenants, Open compare matrix | Existing archive action only |
|
||||
| Tenant baseline compare landing | Tenant operator | Decide whether tenant state requires follow-up or whether compare truth is incomplete or unsupported | Tenant review surface | Can I trust this compare result, and what kind of follow-up is appropriate? | Compare summary, drift status, trust or completeness, and operator-safe unsupported or incomplete meaning | Subject-level diagnostics and detailed evidence-gap context | compare outcome, trust, evidence completeness, actionability | `simulation only` | Compare now, inspect findings or related evidence | none |
|
||||
| Canonical compare run detail | Workspace operator or entitled tenant operator | Inspect run truth and diagnose failure or degraded compare behavior | Canonical evidence detail | Why did this compare run succeed, degrade, or fail? | Run outcome, summary counts, and high-level reason | Per-subject diagnostics, strategy diagnostics, and detailed processing notes | execution outcome, compare completeness, diagnostics severity | read-only | View run context and navigate to related compare surfaces | none |
|
||||
|
||||
## Proportionality Review *(mandatory when structural complexity is introduced)*
|
||||
|
||||
- **New source of truth?**: no
|
||||
- **New persisted entity/table/artifact?**: no
|
||||
- **New abstraction?**: yes
|
||||
- **New enum/state/reason family?**: yes, but only where compare-result classification or diagnostics must distinguish operator-visible outcomes that already exist implicitly
|
||||
- **New cross-domain UI framework/taxonomy?**: no
|
||||
- **Current operator problem**: The platform can only guarantee compare truth today because Intune-specific logic is embedded in the same path that owns run lifecycle, findings, and trust semantics. That shape blocks safe scope expansion and makes unsupported future-domain selections hard to reject honestly.
|
||||
- **Existing structure is insufficient because**: Current compare orchestration still assumes Intune-style subject resolution and policy-shaped findings at platform level. As Baseline Scope V2 becomes broader, the platform lacks a clean boundary that lets domain logic vary without duplicating or distorting the compare workflow.
|
||||
- **Narrowest correct implementation**: Introduce one explicit compare-strategy contract, one deterministic resolver, one strategy-neutral per-subject compare-result contract, and extract the existing Intune compare path behind that boundary. Keep compare single-strategy-per-run, keep current persistence and UI, and avoid generalizing backup, restore, inventory, or multi-domain presentation.
|
||||
- **Ownership cost**: Ongoing contract discipline, regression coverage, compare-entrypoint validation, and care to keep strategy outputs structured enough that the platform core does not re-grow hidden domain assumptions.
|
||||
- **Alternative intentionally rejected**: A superficial wrapper around the existing monolith, or a broad universal compare plugin framework. The wrapper would not remove the current bottleneck, and the plugin framework would over-abstract before the second real compare domain exists.
|
||||
- **Release truth**: current-release platform correctness and controlled future-domain enablement, not speculative ecosystem totalization
|
||||
|
||||
## User Scenarios & Testing *(mandatory)*
|
||||
|
||||
### User Story 1 - Preserve current Intune compare through an explicit boundary (Priority: P1)
|
||||
|
||||
As a tenant or workspace operator, I want existing Intune baseline compare behavior to keep working after the extraction so that the platform gains a safer architecture without changing the compare truth I already rely on.
|
||||
|
||||
**Why this priority**: The extraction is only shippable if current Intune compare stays functionally intact. A cleaner abstraction that changes compare truth would be a regression, not an improvement.
|
||||
|
||||
**Independent Test**: Run the current Intune compare paths from the tenant landing and workspace fan-out surfaces, then confirm that drift, no-drift, incomplete, evidence-gap, finding, and summary behavior matches current expectations through the new orchestration path.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** a baseline scope that is fully supported by the current Intune compare path, **When** an operator starts compare after this feature lands, **Then** the compare still runs successfully and produces the same core run, summary, and finding semantics through the explicit Intune strategy boundary.
|
||||
2. **Given** an Intune compare case that currently resolves to no drift, drift, ambiguous, or incomplete truth, **When** the extracted compare path evaluates it, **Then** the operator sees the same category of outcome and the same follow-up meaning as before.
|
||||
|
||||
---
|
||||
|
||||
### User Story 2 - Reject unsupported or mixed-domain compare scope honestly (Priority: P1)
|
||||
|
||||
As a workspace baseline manager, I want compare to reject unsupported or mixed-domain scope before work starts so that I do not launch misleading runs that quietly fall back to Intune assumptions.
|
||||
|
||||
**Why this priority**: Honest failure is part of the product value. The compare engine must not imply broader support than the platform actually has.
|
||||
|
||||
**Independent Test**: Attempt compare with canonical scope entries that have no matching strategy or that span more than one strategy family, and verify that the launch surfaces and run semantics fail clearly before compare proceeds.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** a canonical baseline scope whose subject family has no compatible compare strategy, **When** an operator starts compare, **Then** the system refuses to start the compare as supported work and explains the unsupported scope clearly.
|
||||
2. **Given** a canonical baseline scope that spans more than one strategy family, **When** an operator starts compare, **Then** the system rejects the run as incompatible rather than partially processing or silently choosing one family.
|
||||
|
||||
---
|
||||
|
||||
### User Story 3 - Keep one platform compare story while allowing domain-specific logic (Priority: P2)
|
||||
|
||||
As a product team preparing the next governance domain, I want a new domain compare path to plug into the existing compare lifecycle so that future expansion does not require copying the current Intune monolith or pretending every governed subject is a policy.
|
||||
|
||||
**Why this priority**: This is the strategic reason for the feature. Without it, the platform keeps paying an increasing cost for Intune-first assumptions.
|
||||
|
||||
**Independent Test**: Register a non-Intune test strategy against canonical scope and verify that the compare entrypoint can resolve it, execute through the same orchestration lifecycle, and produce structured compare outputs without policy-only platform defaults.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** a registered compare capability for a different domain family, **When** the compare entrypoint receives matching canonical scope, **Then** the platform can orchestrate the run through the same lifecycle and summary flow without reusing Intune-only assumptions.
|
||||
2. **Given** the strategy returns structured subject results, **When** the platform writes summaries and findings, **Then** it does not have to invent missing domain meaning from raw strategy internals or policy-only defaults.
|
||||
|
||||
---
|
||||
|
||||
### User Story 4 - Preserve truthful degraded and failed states (Priority: P2)
|
||||
|
||||
As an operator reviewing compare truth, I want unsupported, incomplete, ambiguous, and failed conditions to remain distinct so that the new boundary does not blur trust semantics.
|
||||
|
||||
**Why this priority**: The product's operator value depends on calm but accurate truth. Regression here would undermine confidence faster than a small functional bug.
|
||||
|
||||
**Independent Test**: Exercise strategy failure, unsupported subject family, missing baseline state, missing current state, and ambiguous subject matching cases, then verify that run detail and compare landing surfaces keep those meanings distinct from no drift.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** a compare strategy fails after the run has started, **When** the operator opens the compare landing or run detail, **Then** the product shows a truthful failed or degraded outcome with meaningful diagnostics rather than collapsing into no-drift or silent partial success.
|
||||
2. **Given** a subject cannot be compared because evidence is incomplete or identity is ambiguous, **When** the compare completes, **Then** the operator can distinguish that state from unsupported scope and from confirmed no-drift.
|
||||
|
||||
### Edge Cases
|
||||
|
||||
- A canonical scope contains only supported Intune subject families but includes an inactive subject type that should be rejected before compare starts.
|
||||
- A canonical scope contains entries that individually look valid but resolve to different strategy families when combined.
|
||||
- A strategy matches the requested family at run start but later returns a subject result marked unsupported or indeterminate for a subset of discovered subjects.
|
||||
- Baseline state exists for a subject but current state does not, and the strategy must distinguish evidence absence from true missing-drift semantics.
|
||||
- Strategy diagnostics are available for troubleshooting, but default compare surfaces must not expose raw internal detail as the primary explanation.
|
||||
- Workspace fan-out compare surfaces select a visible tenant set whose baseline scope is incompatible with the single-strategy-per-run rule.
|
||||
|
||||
## Requirements *(mandatory)*
|
||||
|
||||
**Constitution alignment (required):** This feature reuses existing baseline compare run and finding flows. It does not introduce a new provider path or a new long-running workflow type, but it does introduce a new compare abstraction because current compare truth already needs a clean boundary between platform orchestration and domain logic.
|
||||
|
||||
**Constitution alignment (PROP-001 / ABSTR-001 / PERSIST-001 / STATE-001 / BLOAT-001):** The abstraction is justified by current-release compare truth, not speculative framework-building. A narrower wrapper would keep hidden Intune assumptions inside platform compare. No new top-level persistence, no general workflow plugin system, and no multi-strategy-per-run architecture are allowed in this release.
|
||||
|
||||
**Constitution alignment (OPS-UX):** Existing `baseline_compare` runs remain the canonical operational truth. Toasts remain intent-only, progress remains on existing run surfaces, and terminal notification behavior remains initiator-aware. Run status and outcome transitions remain service-owned. Any compare summary counts added or updated by this feature must stay numeric-only and lifecycle-safe. Strategy failure or unsupported-scope outcomes must remain visible through the same run truth rather than a parallel status system.
|
||||
|
||||
**Constitution alignment (RBAC-UX):** The feature spans workspace baseline surfaces under `/admin`, tenant compare surfaces under `/admin/t/{tenant}/...`, and canonical compare-run detail under `/admin/operations/{run}`. Membership boundaries remain unchanged: non-members receive `404`, in-scope members missing capability receive `403`. Server-side authorization remains required for compare starts and any related follow-up mutations. Unsupported or mixed-strategy rejection is compare eligibility truth, not an authorization bypass.
|
||||
|
||||
**Constitution alignment (OPS-EX-AUTH-001):** Not applicable beyond reaffirming that compare surfaces do not use the auth-handshake exception.
|
||||
|
||||
**Constitution alignment (BADGE-001):** This feature does not introduce a new badge framework. Existing centralized compare, trust, freshness, and outcome semantics remain authoritative. Any new unsupported or strategy-failure label must be centralized rather than page-local.
|
||||
|
||||
**Constitution alignment (UI-FIL-001):** The touched compare surfaces continue to use existing Filament pages, actions, sections, alerts, and shared status primitives. Unsupported or mixed-scope explanations must reuse those shared affordances rather than creating a local diagnostic UI framework.
|
||||
|
||||
**Constitution alignment (UI-NAMING-001):** Primary operator-facing labels remain `Baseline Compare`, `Compare now`, `Compare assigned tenants`, `Compare matrix`, `Baseline compare`, `Open operation`, and similar existing nouns. Internal architecture terms such as `strategy`, `registry`, or `resolver` stay out of primary operator copy unless they appear in secondary diagnostic detail where no simpler truthful wording exists.
|
||||
|
||||
**Constitution alignment (DECIDE-001):** The feature does not introduce a new primary operator surface. It reinforces the existing decision flow by making compare readiness, unsupported scope, incomplete evidence, and failure truth visible in the correct existing surfaces rather than hidden inside one domain-specific backend path.
|
||||
|
||||
**Constitution alignment (UI-CONST-001 / UI-SURF-001 / ACTSURF-001 / UI-HARD-001 / UI-EX-001 / UI-REVIEW-001 / HDR-001):** Existing inspect and drilldown models remain unchanged. Baseline detail, compare matrix, compare landing, and canonical run detail keep their current navigation patterns. The only material surface change is clearer compare eligibility and degraded-state truth. No new destructive action is introduced, and no existing destructive placement changes.
|
||||
|
||||
**Constitution alignment (ACTSURF-001 - action hierarchy):** `Compare now` and `Compare assigned tenants` remain the primary launch actions on their existing surfaces. Compare compatibility explanations belong in disabled helper text, confirmation copy, or immediate result messaging, not in mixed catch-all action groups. Navigation to detailed diagnostics remains secondary to the launch and review actions.
|
||||
|
||||
**Constitution alignment (OPSURF-001):** Default-visible compare content must stay operator-first. Launch surfaces should show whether compare can run truthfully. Review surfaces should show whether the latest result is trustworthy, unsupported, incomplete, or failed. Raw diagnostics and strategy-specific detail remain explicitly secondary.
|
||||
|
||||
**Constitution alignment (UI-SEM-001 / LAYER-001 / TEST-TRUTH-001):** The new compare-result contract is a business-truth boundary, not a UI semantics framework. The feature must remove hidden assumptions rather than add a second interpretive layer. Tests must prove behavior, outcome, and lifecycle correctness across the new boundary rather than merely proving indirection exists.
|
||||
|
||||
**Constitution alignment (Filament Action Surfaces):** The Action Surface Contract remains satisfied. Each affected surface keeps one primary inspect or open model, no redundant `View` action is introduced, empty action groups are not introduced, and destructive actions remain unchanged. The UI Action Matrix below records the affected surfaces.
|
||||
|
||||
**Constitution alignment (UX-001 - Layout & Information Architecture):** No new compare page is introduced. Existing compare pages, alerts, summaries, and run-detail sections must surface the new eligibility and failure truth without adding parallel layouts or naked diagnostic blocks.
|
||||
|
||||
### Functional Requirements
|
||||
|
||||
- **FR-203-001 Compare strategy contract**: The platform MUST define one explicit compare capability contract that allows domain-specific logic to participate in compare without taking ownership of the platform run lifecycle.
|
||||
- **FR-203-002 Strategy capability declaration**: Each compare strategy MUST declare the canonical domain, subject-class, and subject-type families it supports for compare.
|
||||
- **FR-203-003 Deterministic strategy resolution**: The compare entrypoint MUST resolve exactly one compatible strategy family for a run from canonical Baseline Scope V2 and MUST do so deterministically.
|
||||
- **FR-203-004 Single-strategy-per-run rule**: A compare run MUST reject scope that spans more than one compatible strategy family instead of partially processing or silently choosing one family.
|
||||
- **FR-203-005 No implicit Intune fallback**: If no compatible strategy exists for the requested scope, the compare path MUST fail or degrade explicitly and MUST NOT silently fall back to the current Intune logic.
|
||||
- **FR-203-006 Platform orchestration ownership**: The platform compare layer MUST own compare start, normalized scope intake, strategy resolution, run status and outcome transitions, summary aggregation, unified finding write flow, audit hooks, persistence coordination, and failure handling.
|
||||
- **FR-203-007 Strategy ownership boundary**: A compare strategy MUST own subject discovery, baseline subject materialization, current-state materialization, domain normalization, fingerprinting, equivalence or drift determination, domain-specific evidence-gap classification, and domain-specific finding enrichment.
|
||||
- **FR-203-008 Explicit strategy inputs**: A strategy MUST receive the current workspace, tenant, baseline reference, normalized scope, and compare-run context plus any required dependencies through explicit platform context rather than hidden global lookups.
|
||||
- **FR-203-009 Stable compare-result contract**: The platform MUST consume a structured per-subject compare result that represents, at minimum, subject identity, domain key, subject class, subject type key, baseline availability, current-state availability, compare state, trust or confidence, evidence quality or gaps, recommended severity when relevant, finding candidate data, and diagnostics or reason codes.
|
||||
- **FR-203-010 Strategy-provided subject projection**: Strategies MUST provide the subject metadata required for findings and summaries, including stable subject identity, domain key, subject-type meaning, stable subject key, and operator-facing label data, so that the platform does not invent policy-shaped defaults.
|
||||
- **FR-203-011 Unified platform outputs**: Regardless of strategy, compare MUST continue to produce one canonical run model, one canonical compare summary model, one unified finding lifecycle, and one unified trust or outcome story.
|
||||
- **FR-203-012 Explicit Intune strategy**: The current Intune compare behavior MUST run through one explicit Intune compare strategy rather than through implicit platform-default logic.
|
||||
- **FR-203-013 Intune behavior preservation**: The Intune extraction MUST preserve existing drift, no-drift, incomplete, ambiguous, evidence-gap, summary, and finding projection behavior unless a separate spec explicitly changes those semantics.
|
||||
- **FR-203-014 Remove policy-only platform assumptions**: Platform compare code touched by this feature MUST no longer assume that every subject is a policy, that policy-version evidence is universal, or that Intune normalization rules are universal compare behavior.
|
||||
- **FR-203-015 Distinct result states**: The compare layer MUST keep no-drift, drift found, subject unsupported, evidence incomplete, ambiguous or indeterminate, and compare failed as distinct operator-visible outcomes.
|
||||
- **FR-203-016 Truthful strategy failure handling**: If a strategy fails after compare has started, the platform MUST preserve truthful run outcome, useful diagnostics, and clear follow-up meaning rather than implying successful or complete compare.
|
||||
- **FR-203-017 Stable persistence footprint**: The feature SHOULD reuse current compare and finding persistence wherever possible and MUST NOT introduce a new top-level compare artifact unless removing hardcoded platform assumptions cannot be done otherwise.
|
||||
- **FR-203-018 Strategy-neutral stored context**: Any compare summary or run-context data added or changed by this feature MUST avoid implying that every compared subject is a policy unless that meaning is explicitly strategy-owned metadata.
|
||||
- **FR-203-019 Pre-flight scope validation**: The compare entrypoint MUST validate that scope is canonical V2, that all included subject families are supported by the selected strategy, and that inactive or unsupported subject types are rejected before subject processing begins.
|
||||
- **FR-203-020 Workspace fan-out parity**: Workspace compare launch surfaces that fan out tenant compares MUST apply the same compatibility validation and single-strategy rule as tenant-local compare starts.
|
||||
- **FR-203-021 Operator-safe unsupported messaging**: If a compare cannot run because scope is unsupported or mixed, the operator MUST receive a clear explanation and the system MUST not imply that compare completed or that no drift was found.
|
||||
- **FR-203-022 Diagnostics without reverse engineering**: The strategy result contract MUST be strong enough that the platform can write summaries, findings, and diagnostics without reverse-engineering raw strategy internals or reintroducing hidden domain assumptions.
|
||||
- **FR-203-023 Future-domain plausibility**: The resulting compare boundary MUST make it possible to add a future non-Intune compare strategy by registering one strategy family and supplying the structured compare-result contract, without cloning the platform orchestration path.
|
||||
- **FR-203-024 Automated regression coverage**: Automated coverage MUST prove strategy resolution, unsupported scope rejection, mixed-strategy rejection, Intune strategy capability matching, unchanged Intune compare classification and finding semantics, and preservation of canonical run outcome and trust behavior through the new boundary.
|
||||
- **FR-203-025 Enqueue-only compare starts**: Tenant and workspace compare start surfaces MUST remain enqueue-only, MUST NOT perform inline remote work, and MUST keep strategy selection and compatibility validation in-process before the run is queued.
|
||||
|
||||
## UI Action Matrix *(mandatory when Filament is changed)*
|
||||
|
||||
| Surface | Location | Header Actions | Inspect Affordance (List/Table) | Row Actions (max 2 visible) | Bulk Actions (grouped) | Empty-State CTA(s) | View Header Actions | Create/Edit Save+Cancel | Audit log? | Notes / Exemptions |
|
||||
|---|---|---|---|---|---|---|---|---|---|---|
|
||||
| Baseline profile view page | Existing workspace baseline detail surface | Existing `Compare now`, `Compare assigned tenants`, and `Open compare matrix` actions remain; unsupported or mixed-scope compare must surface through existing confirmation or helper text patterns | Explicit baseline detail page | none added | none | Existing baseline detail empty states remain | Existing view-page actions remain | n/a | Existing compare-start audit semantics remain | No new action type; compare launch truth becomes more explicit |
|
||||
| Baseline compare matrix page | Existing workspace compare matrix surface | Existing `Compare assigned tenants` remains the primary launch action; compatibility explanation must be visible before misleading fan-out starts | Explicit matrix controls and drilldowns | none added | none | Existing matrix empty states remain | n/a | n/a | Existing compare-start audit semantics remain | Matrix layout and drilldowns remain unchanged; only launch truth is clarified |
|
||||
| Tenant baseline compare landing | Existing tenant compare landing surface | Existing `Compare now` remains capability-gated and confirmed; unsupported or mixed-scope compare must not present as a successful start | Explicit tenant compare page | none added | none | Existing compare prerequisites and empty states remain | Existing page actions remain | n/a | Existing compare-start and compare-run audit semantics remain | No new destructive action; existing launch action keeps confirmation |
|
||||
| Canonical compare run detail | Existing compare run detail surface | Existing navigation actions remain | Explicit operation run detail | none added | none | Existing no-run or no-data states remain | Existing run-detail actions remain | n/a | Existing run-backed audit semantics remain | New strategy diagnostics remain secondary evidence, not a new action plane |
|
||||
|
||||
### Key Entities *(include if feature involves data)*
|
||||
|
||||
- **Compare Strategy**: The domain-owned compare capability that knows how to discover subjects, materialize baseline and current state, compare them, and enrich results for one compatible subject family.
|
||||
- **Compare Strategy Registry**: The deterministic mapping that selects one compare strategy family from canonical Baseline Scope V2 for a run.
|
||||
- **Compare Subject Result**: The structured internal result for one processed subject, including identity, classification, compare state, trust, evidence quality, finding candidate data, and diagnostics.
|
||||
- **Compare Orchestration Context**: The platform-owned run context that coordinates scope intake, strategy resolution, execution, summary aggregation, finding persistence, and truthful run outcome.
|
||||
- **Intune Compare Strategy**: The first explicit compare strategy that owns the current Intune-specific compare behavior without forcing those assumptions into the platform core.
|
||||
|
||||
## Success Criteria *(mandatory)*
|
||||
|
||||
### Measurable Outcomes
|
||||
|
||||
- **SC-001**: In automated regression coverage, 100% of the current Intune compare scenarios selected for the feature produce the same operator-visible classification, finding, and summary outcome after the Intune compare logic is isolated behind the new compare boundary.
|
||||
- **SC-002**: In automated launch coverage, unsupported or mixed-strategy scope is rejected before subject processing in 100% of covered cases and is never reported as successful compare or no-drift.
|
||||
- **SC-003**: In automated validation, one non-Intune compare-capable subject family can pass through the same compare lifecycle without being modeled as an Intune policy flow.
|
||||
- **SC-004**: On the existing compare landing and run-detail surfaces, operators can distinguish no drift, drift, unsupported, incomplete, ambiguous, and failed outcomes without opening raw diagnostics.
|
||||
- **SC-005**: Post-implementation review can identify one platform-owned compare lifecycle and one isolated Intune compare boundary, with no remaining universal policy default required at compare entrypoints.
|
||||
- **SC-006**: Focused regression coverage proves that compare start surfaces stay enqueue-only, introduce no inline remote work, and preserve current compare throughput expectations for the targeted start paths.
|
||||
|
||||
## Rollout Strategy
|
||||
|
||||
### Phase 1 - Introduce strategy contract and resolution
|
||||
|
||||
- Define the compare strategy contract and the deterministic strategy registry.
|
||||
- Route the compare entrypoint through strategy resolution while keeping the current Intune path functionally intact.
|
||||
|
||||
### Phase 2 - Extract the Intune strategy
|
||||
|
||||
- Move the current Intune compare logic behind the explicit Intune strategy boundary.
|
||||
- Preserve existing compare launches, summaries, findings, and operator-visible semantics.
|
||||
|
||||
### Phase 3 - Remove platform hardcoding
|
||||
|
||||
- Eliminate platform-level policy-only assumptions from orchestration, finding projection defaults, and compare-result handling.
|
||||
- Ensure compare summaries and finding writes consume strategy-provided metadata instead of Intune defaults.
|
||||
|
||||
### Phase 4 - Harden diagnostics and rejection paths
|
||||
|
||||
- Make unsupported and mixed-strategy scope rejection explicit on the existing compare launch surfaces.
|
||||
- Ensure strategy failure and degraded cases stay truthful on compare landing and run-detail surfaces.
|
||||
|
||||
## Non-Goals
|
||||
|
||||
- Implementing a second real governance domain
|
||||
- Supporting multi-strategy compare in one run
|
||||
- Redesigning the compare UI as a multi-domain surface
|
||||
- Renaming every Intune-specific class, field, or model
|
||||
- Replacing the current finding lifecycle or evidence domain with a new universal model
|
||||
- Generalizing backup, restore, or inventory workflows as part of this feature
|
||||
- Building a broad compare plugin framework for unrelated workflows
|
||||
|
||||
## Assumptions
|
||||
|
||||
- Spec 202 provides the canonical Baseline Scope V2 input contract and subject-family vocabulary needed for deterministic strategy resolution.
|
||||
- Existing Intune compare behavior is the first production-real strategy and should be preserved rather than diluted into a lowest-common-denominator compare model.
|
||||
- Single-strategy-per-run is sufficient for the current release and keeps compare truth easier to explain than mixed-domain orchestration would.
|
||||
- Existing compare runs and findings can absorb the necessary strategy-neutral context without requiring a new top-level compare persistence model.
|
||||
- Workspace compare fan-out continues to start tenant-owned compare work and does not need a new workspace umbrella run in this feature.
|
||||
|
||||
## Dependencies
|
||||
|
||||
- Spec 202 - Governance Subject Taxonomy and Baseline Scope V2
|
||||
- Existing workspace baseline detail and compare-matrix surfaces
|
||||
- Existing tenant baseline compare landing and canonical operation run detail surfaces
|
||||
- Existing compare summary, trust, finding, and audit semantics
|
||||
- Current Intune compare logic as the baseline behavior to preserve
|
||||
|
||||
## Risks
|
||||
|
||||
- The extraction could become a thin wrapper around the current monolith without really removing platform-level Intune assumptions.
|
||||
- Intune compare behavior could regress during the move behind the explicit boundary if regression coverage is too weak.
|
||||
- The compare-result contract could stay too weak, forcing the platform layer to rebuild hidden domain assumptions from raw strategy outputs.
|
||||
- Unsupported or mixed-scope rejection could be surfaced too late, after partial compare work has already started.
|
||||
- Future-domain preparation could expand into a broader framework unless the single-strategy-per-run boundary is enforced.
|
||||
|
||||
## Definition of Done
|
||||
|
||||
- The compare engine has one platform-owned orchestration path and one explicit Intune compare strategy.
|
||||
- Current Intune compare behavior still works and is regression-protected through the new boundary.
|
||||
- Compare entrypoints resolve strategy from canonical Baseline Scope V2 rather than implicit Intune assumptions.
|
||||
- Unsupported or mixed-strategy scope is rejected clearly and truthfully.
|
||||
- Platform compare code touched by this feature no longer treats policy semantics as the universal compare default.
|
||||
- Existing operator surfaces keep one consistent run, finding, trust, and outcome story while strategy-specific detail remains secondary.
|
||||
@ -1,246 +0,0 @@
|
||||
# Tasks: Baseline Compare Engine Strategy Extraction
|
||||
|
||||
**Input**: Design documents from `/specs/203-baseline-compare-strategy/`
|
||||
**Prerequisites**: `plan.md`, `spec.md`, `research.md`, `data-model.md`, `contracts/baseline-compare-strategy.logical.openapi.yaml`, `quickstart.md`
|
||||
|
||||
**Tests**: Required. This feature changes runtime compare orchestration, existing Filament start surfaces, and canonical `baseline_compare` run truth, so Pest unit, feature, Filament, and existing browser smoke coverage must be added or extended.
|
||||
**Operations**: This feature reuses the existing `baseline_compare` `OperationRun` lifecycle only. No new run type, queued notification channel, or alternate monitoring surface should be introduced.
|
||||
**RBAC**: Existing workspace and tenant compare capabilities remain authoritative. Tasks must preserve `404` vs `403` semantics while treating unsupported or mixed scope as compare truth rather than authorization.
|
||||
**Operator Surfaces**: The affected surfaces are the existing baseline profile detail, baseline compare matrix, tenant baseline compare landing, and canonical operation run detail.
|
||||
**Filament UI Action Surfaces**: Existing `Compare now` and `Compare assigned tenants` actions remain the primary launch actions. No new destructive action is added.
|
||||
**Proportionality**: Add only the narrow compare-support namespace under `apps/platform/app/Support/Baselines/Compare/` and avoid a broader compare plugin framework.
|
||||
|
||||
**Organization**: Tasks are grouped by user story so each slice stays independently testable. Recommended delivery order is `US1 -> US2 -> US3 -> US4`, with `US1` as the MVP cut after the shared compare-contract foundation is in place.
|
||||
|
||||
## Phase 1: Setup (Shared Infrastructure)
|
||||
|
||||
**Purpose**: Prepare focused test seams for compare strategy selection, compare-result contracts, and future-domain proof coverage.
|
||||
|
||||
- [X] T001 Create the compare strategy registry unit test scaffold in `apps/platform/tests/Unit/Baselines/CompareStrategyRegistryTest.php`
|
||||
- [X] T002 [P] Create the compare subject result contract unit test scaffold in `apps/platform/tests/Unit/Baselines/CompareSubjectResultContractTest.php`
|
||||
- [X] T003 [P] Create the future-domain compare strategy support fixture and feature test scaffold in `apps/platform/tests/Feature/Baselines/Support/FakeCompareStrategy.php` and `apps/platform/tests/Feature/Baselines/BaselineCompareStrategySelectionTest.php`
|
||||
|
||||
**Checkpoint**: Dedicated Spec 203 test entry points exist and the compare extraction can proceed without mixing this slice into unrelated suites.
|
||||
|
||||
---
|
||||
|
||||
## Phase 2: Foundational (Blocking Prerequisites)
|
||||
|
||||
**Purpose**: Establish the shared compare strategy, selection, and subject-result contracts before any story-specific behavior lands.
|
||||
|
||||
**CRITICAL**: No user story work should start before this phase is complete.
|
||||
|
||||
- [X] T004 [P] Add foundational capability, selection-state, and result-contract expectations in `apps/platform/tests/Unit/Baselines/CompareStrategyRegistryTest.php` and `apps/platform/tests/Unit/Baselines/CompareSubjectResultContractTest.php`
|
||||
- [X] T005 [P] Add shared preflight selection and single-strategy handoff coverage in `apps/platform/tests/Feature/Baselines/BaselineComparePreconditionsTest.php` and `apps/platform/tests/Feature/Baselines/BaselineCompareMatrixCompareAllActionTest.php`
|
||||
- [X] T006 [P] Extend compare run lifecycle and summary-count guard coverage in `apps/platform/tests/Feature/Guards/OperationLifecycleOpsUxGuardTest.php`, `apps/platform/tests/Feature/OpsUx/OperationSummaryKeysSpecTest.php`, and `apps/platform/tests/Feature/OpsUx/SummaryCountsWhitelistTest.php`
|
||||
- [X] T007 Implement compare selection enums and subject-result value objects in `apps/platform/app/Support/Baselines/Compare/CompareStrategyKey.php`, `apps/platform/app/Support/Baselines/Compare/StrategySelectionState.php`, `apps/platform/app/Support/Baselines/Compare/CompareState.php`, `apps/platform/app/Support/Baselines/Compare/CompareSubjectIdentity.php`, `apps/platform/app/Support/Baselines/Compare/CompareSubjectProjection.php`, `apps/platform/app/Support/Baselines/Compare/CompareFindingCandidate.php`, and `apps/platform/app/Support/Baselines/Compare/CompareSubjectResult.php`
|
||||
- [X] T008 Implement the compare strategy contract, capability record, selection record, orchestration context, and registry in `apps/platform/app/Support/Baselines/Compare/CompareStrategy.php`, `apps/platform/app/Support/Baselines/Compare/CompareStrategyCapability.php`, `apps/platform/app/Support/Baselines/Compare/CompareStrategySelection.php`, `apps/platform/app/Support/Baselines/Compare/CompareOrchestrationContext.php`, and `apps/platform/app/Support/Baselines/Compare/CompareStrategyRegistry.php`
|
||||
- [X] T009 Wire shared strategy bootstrap and run-context handoff into `apps/platform/app/Services/Baselines/BaselineCompareService.php` and `apps/platform/app/Jobs/CompareBaselineToTenantJob.php`
|
||||
|
||||
**Checkpoint**: The repo can model one supported compare strategy family, reject non-deterministic selection at the contract layer, preserve Ops-UX lifecycle guards, and hand a structured compare context into the existing compare job.
|
||||
|
||||
---
|
||||
|
||||
## Phase 3: User Story 1 - Preserve current Intune compare through an explicit boundary (Priority: P1) MVP
|
||||
|
||||
**Goal**: Keep the current supported Intune compare behavior stable while moving domain-specific compare logic behind one explicit strategy boundary.
|
||||
|
||||
**Independent Test**: Start supported Intune compare from the existing tenant and workspace surfaces and verify findings, summaries, and trust semantics remain unchanged through the extracted strategy path.
|
||||
|
||||
### Tests for User Story 1
|
||||
|
||||
> **NOTE**: Write these tests first and confirm they fail before implementation.
|
||||
|
||||
- [X] T010 [P] [US1] Extend supported Intune compare classification and finding parity coverage in `apps/platform/tests/Feature/Baselines/BaselineCompareFindingsTest.php`, `apps/platform/tests/Feature/Baselines/BaselineCompareGapClassificationTest.php`, and `apps/platform/tests/Feature/Baselines/BaselineCompareRbacRoleDefinitionsTest.php`
|
||||
- [X] T011 [P] [US1] Extend supported-scope launch, run-outcome, and summary parity coverage in `apps/platform/tests/Feature/Baselines/BaselineCompareExecutionGuardTest.php`, `apps/platform/tests/Feature/Baselines/BaselineCompareSummaryAssessmentTest.php`, `apps/platform/tests/Feature/Filament/BaselineProfileCompareStartSurfaceTest.php`, and `apps/platform/tests/Feature/Filament/BaselineCompareLandingStartSurfaceTest.php`
|
||||
- [X] T012 [P] [US1] Extend explanation, why-no-findings, and evidence-contract parity coverage in `apps/platform/tests/Feature/Baselines/BaselineCompareWhyNoFindingsReasonCodeTest.php`, `apps/platform/tests/Feature/Baselines/BaselineCompareExplanationFallbackTest.php`, `apps/platform/tests/Feature/Baselines/BaselineCompareDriftEvidenceContractTest.php`, and `apps/platform/tests/Feature/Baselines/BaselineCompareDriftEvidenceContractRbacTest.php`
|
||||
|
||||
### Implementation for User Story 1
|
||||
|
||||
- [X] T013 [US1] Implement the explicit `IntuneCompareStrategy` and register its supported capability family in `apps/platform/app/Support/Baselines/Compare/IntuneCompareStrategy.php` and `apps/platform/app/Support/Baselines/Compare/CompareStrategyRegistry.php`
|
||||
- [X] T014 [US1] Route supported compare starts through deterministic strategy selection and record the chosen strategy in `apps/platform/app/Services/Baselines/BaselineCompareService.php` and `apps/platform/app/Jobs/CompareBaselineToTenantJob.php`
|
||||
- [X] T015 [US1] Move Intune-only subject discovery, normalizer selection, RBAC role-definition branching, and projection shaping behind the strategy boundary in `apps/platform/app/Support/Baselines/Compare/IntuneCompareStrategy.php` and `apps/platform/app/Jobs/CompareBaselineToTenantJob.php`
|
||||
- [X] T016 [US1] Feed existing finding, summary, explanation, and badge semantics from `CompareSubjectResult` without changing supported Intune outcomes in `apps/platform/app/Support/Baselines/BaselineCompareStats.php`, `apps/platform/app/Support/Baselines/BaselineCompareSummaryAssessor.php`, `apps/platform/app/Support/Baselines/BaselineCompareExplanationRegistry.php`, `apps/platform/app/Support/Badges/BadgeDomain.php`, and `apps/platform/app/Support/Badges/BadgeCatalog.php`
|
||||
|
||||
**Checkpoint**: Supported Intune compare remains independently functional and regression-protected through the new explicit strategy seam.
|
||||
|
||||
---
|
||||
|
||||
## Phase 4: User Story 2 - Reject unsupported or mixed-domain compare scope honestly (Priority: P1)
|
||||
|
||||
**Goal**: Fail unsupported or mixed compare scope before enqueue so operators never launch misleading compare work.
|
||||
|
||||
**Independent Test**: Attempt tenant and workspace fan-out compare with unsupported, inactive, and mixed-family canonical scope and confirm the start surfaces reject the work before any compare run is started.
|
||||
|
||||
### Tests for User Story 2
|
||||
|
||||
> **NOTE**: Write these tests first and confirm they fail before implementation.
|
||||
|
||||
- [X] T017 [P] [US2] Extend unsupported, mixed-family, and inactive-type compare gating coverage in `apps/platform/tests/Feature/Baselines/BaselineComparePreconditionsTest.php` and `apps/platform/tests/Feature/Baselines/BaselineCompareMatrixCompareAllActionTest.php`
|
||||
- [X] T018 [P] [US2] Extend start-surface rejection truth and authorization continuity in `apps/platform/tests/Feature/Filament/BaselineProfileCompareStartSurfaceTest.php`, `apps/platform/tests/Feature/Filament/BaselineCompareLandingStartSurfaceTest.php`, and `apps/platform/tests/Feature/Filament/BaselineCompareMatrixPageTest.php`
|
||||
|
||||
### Implementation for User Story 2
|
||||
|
||||
- [X] T019 [US2] Enforce supported, unsupported, and mixed selection outcomes before run creation in `apps/platform/app/Services/Baselines/BaselineCompareService.php` and `apps/platform/app/Support/Baselines/Compare/CompareStrategyRegistry.php`
|
||||
- [X] T020 [US2] Add operator-safe compare rejection reason codes and diagnostics mapping in `apps/platform/app/Support/Baselines/BaselineCompareReasonCode.php`, `apps/platform/app/Support/Baselines/BaselineCompareStats.php`, and `apps/platform/app/Support/ReasonTranslation/ReasonTranslator.php`
|
||||
- [X] T021 [US2] Surface truthful unsupported and mixed-scope launch messaging on the existing compare pages in `apps/platform/app/Filament/Resources/BaselineProfileResource.php`, `apps/platform/app/Filament/Resources/BaselineProfileResource/Pages/ViewBaselineProfile.php`, `apps/platform/app/Filament/Pages/BaselineCompareMatrix.php`, and `apps/platform/app/Filament/Pages/BaselineCompareLanding.php`
|
||||
|
||||
**Checkpoint**: Unsupported or mixed compare scope is independently blocked before run creation and explained truthfully on the existing launch surfaces.
|
||||
|
||||
---
|
||||
|
||||
## Phase 5: User Story 3 - Keep one platform compare story while allowing domain-specific logic (Priority: P2)
|
||||
|
||||
**Goal**: Prove that a future non-Intune strategy can participate in the same compare lifecycle without forcing policy-only platform defaults.
|
||||
|
||||
**Independent Test**: Register a non-Intune test strategy against canonical scope and confirm the entrypoint resolves it deterministically, executes the shared lifecycle, and emits structured compare results without Intune fallbacks.
|
||||
|
||||
### Tests for User Story 3
|
||||
|
||||
> **NOTE**: Write these tests first and confirm they fail before implementation.
|
||||
|
||||
- [X] T022 [P] [US3] Add unit coverage for future-domain capability matching, deterministic selection, and no-implicit-fallback behavior in `apps/platform/tests/Unit/Baselines/CompareStrategyRegistryTest.php`
|
||||
- [X] T023 [P] [US3] Add non-Intune strategy lifecycle coverage using `apps/platform/tests/Feature/Baselines/Support/FakeCompareStrategy.php` and `apps/platform/tests/Feature/Baselines/BaselineCompareStrategySelectionTest.php`
|
||||
|
||||
### Implementation for User Story 3
|
||||
|
||||
- [X] T024 [US3] Register strategy-owned domain, subject-class, and subject-type capability declarations without policy-only defaults in `apps/platform/app/Support/Baselines/Compare/CompareStrategyCapability.php`, `apps/platform/app/Support/Baselines/Compare/CompareStrategyRegistry.php`, and `apps/platform/app/Support/Baselines/Compare/IntuneCompareStrategy.php`
|
||||
- [X] T025 [US3] Keep platform summary and explanation aggregation domain-neutral by consuming strategy projections instead of policy defaults in `apps/platform/app/Support/Baselines/BaselineCompareStats.php`, `apps/platform/app/Support/Baselines/BaselineCompareSummaryAssessor.php`, and `apps/platform/app/Support/Baselines/BaselineCompareExplanationRegistry.php`
|
||||
- [X] T026 [US3] Keep the unified finding lifecycle strategy-neutral by consuming `CompareFindingCandidate` and `CompareSubjectProjection` in `apps/platform/app/Jobs/CompareBaselineToTenantJob.php` and `apps/platform/app/Support/Baselines/Compare/CompareSubjectResult.php`
|
||||
|
||||
**Checkpoint**: The compare lifecycle is independently capable of resolving and consuming a non-Intune strategy without cloning the platform orchestration path.
|
||||
|
||||
---
|
||||
|
||||
## Phase 6: User Story 4 - Preserve truthful degraded and failed states (Priority: P2)
|
||||
|
||||
**Goal**: Keep unsupported, incomplete, ambiguous, and failed compare outcomes distinct so the new boundary does not blur operator trust semantics.
|
||||
|
||||
**Independent Test**: Exercise unsupported-subject, incomplete-evidence, ambiguous-match, and strategy-failure cases and verify the compare landing and canonical run detail keep those outcomes distinct from no drift.
|
||||
|
||||
### Tests for User Story 4
|
||||
|
||||
> **NOTE**: Write these tests first and confirm they fail before implementation.
|
||||
|
||||
- [X] T027 [P] [US4] Extend ambiguous, incomplete, unsupported-subject, and strategy-failure coverage in `apps/platform/tests/Feature/Baselines/BaselineCompareAmbiguousMatchGapTest.php`, `apps/platform/tests/Feature/Baselines/BaselineCompareGapClassificationTest.php`, and `apps/platform/tests/Feature/Baselines/BaselineCompareExecutionGuardTest.php`
|
||||
- [X] T028 [P] [US4] Extend operator truth coverage for degraded and failed compare states in `apps/platform/tests/Feature/Filament/BaselineCompareLandingWhyNoFindingsTest.php`, `apps/platform/tests/Feature/Filament/BaselineCompareLandingAdminTenantParityTest.php`, `apps/platform/tests/Feature/Filament/BaselineCompareExplanationSurfaceTest.php`, and `apps/platform/tests/Feature/Filament/OperationRunBaselineTruthSurfaceTest.php`
|
||||
|
||||
### Implementation for User Story 4
|
||||
|
||||
- [X] T029 [US4] Map strategy `unsupported`, `incomplete`, `ambiguous`, and `failed` states to stable compare reasons and summary counts in `apps/platform/app/Support/Baselines/BaselineCompareReasonCode.php`, `apps/platform/app/Support/Baselines/BaselineCompareStats.php`, and `apps/platform/app/Support/Baselines/BaselineCompareSummaryAssessor.php`
|
||||
- [X] T030 [US4] Persist secondary strategy diagnostics and degraded-state evidence without collapsing run outcome truth in `apps/platform/app/Jobs/CompareBaselineToTenantJob.php`, `apps/platform/app/Support/Baselines/BaselineCompareEvidenceGapDetails.php`, and `apps/platform/app/Support/Baselines/BaselineCompareExplanationRegistry.php`
|
||||
- [X] T031 [US4] Update compare landing and canonical run-detail review surfaces for unsupported, incomplete, ambiguous, and failed states in `apps/platform/app/Filament/Pages/BaselineCompareLanding.php`, `apps/platform/app/Filament/Resources/OperationRunResource.php`, and `apps/platform/resources/views/filament/pages/baseline-compare-landing.blade.php`
|
||||
|
||||
**Checkpoint**: Degraded and failed compare states remain independently reviewable and never collapse into calm no-drift semantics.
|
||||
|
||||
---
|
||||
|
||||
## Phase 7: Polish & Cross-Cutting Concerns
|
||||
|
||||
**Purpose**: Lock the slice down with operator-copy review, performance and browser smoke regression guards, and explicit Sail verification.
|
||||
|
||||
- [X] T032 [P] Recheck launch-surface operator copy and naming consistency in `apps/platform/app/Filament/Resources/BaselineProfileResource.php`, `apps/platform/app/Filament/Resources/BaselineProfileResource/Pages/ViewBaselineProfile.php`, and `apps/platform/app/Filament/Pages/BaselineCompareLanding.php`
|
||||
- [X] T033 [P] Recheck matrix and run-detail diagnostic wording plus scope-language consistency in `apps/platform/app/Filament/Pages/BaselineCompareMatrix.php`, `apps/platform/app/Filament/Resources/OperationRunResource.php`, and `apps/platform/resources/views/filament/pages/baseline-compare-landing.blade.php`
|
||||
- [X] T034 [P] Extend compare performance and enqueue-only regression coverage in `apps/platform/tests/Feature/Baselines/BaselineComparePerformanceGuardTest.php` and `apps/platform/tests/Feature/Operations/BaselineQueueRuntimeGuardTest.php`
|
||||
- [X] T035 [P] Extend matrix browser smoke, no-silent-fallback assertions, and final launch-truth regressions in `apps/platform/tests/Browser/Spec190BaselineCompareMatrixSmokeTest.php`, `apps/platform/tests/Feature/Baselines/BaselineCompareExecutionGuardTest.php`, and `apps/platform/tests/Feature/Baselines/BaselineComparePreconditionsTest.php`
|
||||
- [X] T036 Run the focused Sail test pack from `specs/203-baseline-compare-strategy/quickstart.md` against the changed unit, feature, Filament, and browser files
|
||||
- [X] T037 Run formatting and final Ops-UX guard verification in `apps/platform/tests/Feature/Guards/OperationLifecycleOpsUxGuardTest.php`, `apps/platform/tests/Feature/OpsUx/OperationSummaryKeysSpecTest.php`, `apps/platform/tests/Feature/OpsUx/SummaryCountsWhitelistTest.php`, and `apps/platform/tests/Feature/OpsUx/NoQueuedDbNotificationsTest.php`
|
||||
|
||||
---
|
||||
|
||||
## Dependencies & Execution Order
|
||||
|
||||
### Phase Dependencies
|
||||
|
||||
- **Setup (Phase 1)**: No dependencies; can start immediately.
|
||||
- **Foundational (Phase 2)**: Depends on Setup completion; blocks all user stories.
|
||||
- **User Story 1 (Phase 3)**: Depends on Foundational completion; this is the recommended MVP cut.
|
||||
- **User Story 2 (Phase 4)**: Depends on Foundational completion and is easiest to review after US1 proves the supported strategy path stays stable.
|
||||
- **User Story 3 (Phase 5)**: Depends on Foundational completion and on the extracted result contract from US1.
|
||||
- **User Story 4 (Phase 6)**: Depends on Foundational completion and benefits from US1 and US2 because degraded truth builds on the extracted strategy path and explicit rejection semantics.
|
||||
- **Polish (Phase 7)**: Depends on all desired user stories being complete.
|
||||
|
||||
### User Story Dependencies
|
||||
|
||||
- **US1**: No dependencies beyond Foundational.
|
||||
- **US2**: No hard dependency beyond Foundational, but it should follow US1 so the supported path is already stable before rejected paths are hardened.
|
||||
- **US3**: Depends on the shared compare contract from Foundational and the extracted strategy path from US1.
|
||||
- **US4**: Depends on the shared compare contract from Foundational and should follow US1 and US2 so run truth and rejection truth are already explicit.
|
||||
|
||||
### Within Each User Story
|
||||
|
||||
- Write the story tests first and confirm they fail before implementation.
|
||||
- Keep compare start orchestration in `BaselineCompareService.php` and execution in `CompareBaselineToTenantJob.php`; story work should not introduce a parallel compare workflow.
|
||||
- Finish each story's focused verification before moving to the next priority.
|
||||
|
||||
### Parallel Opportunities
|
||||
|
||||
- `T002` and `T003` can run in parallel after `T001`.
|
||||
- `T004`, `T005`, and `T006` can run in parallel before `T007` through `T009`.
|
||||
- Within US1, `T010`, `T011`, and `T012` can run in parallel.
|
||||
- Within US2, `T017` and `T018` can run in parallel.
|
||||
- Within US3, `T022` and `T023` can run in parallel.
|
||||
- Within US4, `T027` and `T028` can run in parallel.
|
||||
- `T032`, `T033`, `T034`, and `T035` can run in parallel once implementation is complete.
|
||||
|
||||
---
|
||||
|
||||
## Parallel Example: User Story 1
|
||||
|
||||
```bash
|
||||
# Parallel test pass for US1
|
||||
T010 Extend supported Intune compare classification and finding parity coverage
|
||||
T011 Extend supported-scope launch, run-outcome, and summary parity coverage
|
||||
T012 Extend explanation, why-no-findings, and evidence-contract parity coverage
|
||||
```
|
||||
|
||||
## Parallel Example: User Story 2
|
||||
|
||||
```bash
|
||||
# Parallel test pass for US2
|
||||
T017 Extend unsupported, mixed-family, and inactive-type compare gating coverage
|
||||
T018 Extend start-surface rejection truth and authorization continuity
|
||||
```
|
||||
|
||||
## Parallel Example: User Story 3
|
||||
|
||||
```bash
|
||||
# Parallel test pass for US3
|
||||
T022 Add unit coverage for future-domain capability matching and deterministic selection
|
||||
T023 Add non-Intune strategy lifecycle coverage with FakeCompareStrategy
|
||||
```
|
||||
|
||||
## Parallel Example: User Story 4
|
||||
|
||||
```bash
|
||||
# Parallel test pass for US4
|
||||
T027 Extend ambiguous, incomplete, unsupported-subject, and strategy-failure coverage
|
||||
T028 Extend operator truth coverage for degraded and failed compare states
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Implementation Strategy
|
||||
|
||||
### MVP First
|
||||
|
||||
1. Finish Setup and Foundational work.
|
||||
2. Deliver US1 to prove Intune compare survives the extraction behind one explicit strategy.
|
||||
3. Validate US1 independently before widening the slice.
|
||||
|
||||
### Incremental Delivery
|
||||
|
||||
1. Add US2 to reject unsupported or mixed scope honestly before enqueue.
|
||||
2. Add US3 to prove future-domain strategy participation without policy-only defaults.
|
||||
3. Add US4 to harden degraded and failed compare truth on the existing review surfaces.
|
||||
4. Finish with copy review, performance and browser smoke guards, and the explicit Sail verification pack from Phase 7.
|
||||
|
||||
### Parallel Team Strategy
|
||||
|
||||
1. One contributor completes Setup and Foundational tasks.
|
||||
2. After Foundation is green:
|
||||
- Contributor A takes US1.
|
||||
- Contributor B prepares US2 test coverage and launch-surface hardening.
|
||||
- Contributor C prepares US3 registry and fake-strategy proof work.
|
||||
- Contributor D prepares US4 degraded-truth coverage and review-surface hardening.
|
||||
3. Merge back for Phase 7 guard, performance, browser smoke, formatting, and focused Sail verification.
|
||||
Loading…
Reference in New Issue
Block a user