Compare commits

...

2 Commits

Author SHA1 Message Date
d644265d30 Spec 203: extract baseline compare strategy (#233)
## Summary
- extract baseline compare orchestration behind an explicit strategy contract and registry
- preserve the current Intune compare path through a dedicated `IntuneCompareStrategy`
- harden compare launch and review surfaces for mixed, unsupported, incomplete, and strategy-failure truth
- add Spec 203 artifacts, focused regression coverage, and future-domain strategy proof tests

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

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

Co-authored-by: Ahmed Darrazi <ahmed.darrazi@live.de>
Reviewed-on: #233
2026-04-13 21:17:04 +00:00
7541b1eb41 Spec 202: implement governance subject taxonomy and baseline scope V2 (#232)
## Summary
- introduce the governance subject taxonomy registry and canonical Baseline Scope V2 normalization and persistence
- update baseline profile Filament surfaces, validation, capture/compare gating, and add the optional scope backfill command with audit logging
- add focused unit, feature, Filament, and browser smoke coverage for save-forward behavior, operation truth, authorization continuity, and invalid-scope rendering
- remove the duplicate legacy spec plan under `specs/001-governance-subject-taxonomy/plan.md`

## Verification
- `cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Browser/Spec202GovernanceSubjectTaxonomySmokeTest.php`
- focused Spec 202 regression pack: `56 passed (300 assertions)`
- `cd apps/platform && ./vendor/bin/sail bin pint --dirty --format agent`

## Notes
- no schema migration required
- no new Filament asset registration required
- branch includes the final browser smoke test coverage for the current feature

Co-authored-by: Ahmed Darrazi <ahmed.darrazi@live.de>
Reviewed-on: #232
2026-04-13 15:33:33 +00:00
76 changed files with 10067 additions and 142 deletions

View File

@ -178,6 +178,10 @@ ## Active Technologies
- PostgreSQL through existing workspace-owned, tenant-owned, and system-visible models; no schema change planned (195-action-surface-closure)
- 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 (196-hard-filament-nativity-cleanup)
- 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)
@ -212,8 +216,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
- 195-action-surface-closure: Added PostgreSQL through existing workspace-owned, tenant-owned, and system-visible models; no schema change planned
<!-- MANUAL ADDITIONS START -->
<!-- MANUAL ADDITIONS END -->

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

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

View File

@ -0,0 +1,204 @@
<?php
declare(strict_types=1);
namespace App\Console\Commands;
use App\Models\BaselineProfile;
use App\Models\Workspace;
use App\Services\Audit\WorkspaceAuditLogger;
use App\Support\Audit\AuditActionId;
use InvalidArgumentException;
use Illuminate\Console\Command;
class BackfillBaselineScopeV2 extends Command
{
protected $signature = 'tenantpilot:baseline-scope-v2:backfill
{--workspace= : Restrict to a workspace id}
{--chunk=100 : Chunk size for large scans}
{--write : Persist canonical V2 scope rows}
{--confirm-write : Required acknowledgement before mutating rows}';
protected $description = 'Preview or commit canonical Baseline Scope V2 backfill for legacy baseline profile rows.';
public function handle(WorkspaceAuditLogger $auditLogger): int
{
$write = (bool) $this->option('write');
if ($write && ! (bool) $this->option('confirm-write')) {
$this->error('Explicit write confirmation required. Re-run with --write --confirm-write.');
return self::FAILURE;
}
$workspaceOption = $this->option('workspace');
if ($workspaceOption !== null && ! is_numeric($workspaceOption)) {
$this->error('The --workspace option must be a numeric workspace id.');
return self::FAILURE;
}
$workspaceId = is_numeric($workspaceOption) ? (int) $workspaceOption : null;
$scan = $this->scanCandidates(
chunkSize: max(1, (int) $this->option('chunk')),
workspaceId: $workspaceId,
);
$mode = $write ? 'commit' : 'preview';
$candidateCount = count($scan['candidates']);
$invalidCount = count($scan['invalid']);
$this->info(sprintf('Mode: %s', $mode));
$this->info('Scope surface: baseline_profiles_only');
$this->info(sprintf('Candidate count: %d', $candidateCount));
foreach ($scan['candidates'] as $candidate) {
$this->line(sprintf(' - %s', $candidate['summary']));
}
if ($invalidCount > 0) {
$this->warn(sprintf('Invalid rows: %d', $invalidCount));
foreach ($scan['invalid'] as $invalidRow) {
$this->warn(sprintf(' - %s', $invalidRow));
}
}
if ($candidateCount === 0) {
$this->info('No baseline profile scope rows require backfill.');
$this->info('Rewritten count: 0');
$this->info('Audit logged: no');
return self::SUCCESS;
}
if ($write && $invalidCount > 0) {
$this->error('Backfill aborted because invalid scope rows were detected. Resolve them before committing.');
return self::FAILURE;
}
if (! $write) {
$this->info('Rewritten count: 0');
$this->info('Audit logged: no');
return self::SUCCESS;
}
$rewrittenCount = 0;
$auditLogged = false;
foreach ($scan['candidates'] as $candidate) {
$profile = BaselineProfile::query()
->with('workspace')
->find($candidate['id']);
if (! $profile instanceof BaselineProfile) {
continue;
}
$before = $profile->rawScopeJsonb();
$after = $profile->canonicalScopeJsonb();
if (! $profile->rewriteScopeToCanonicalV2()) {
continue;
}
$workspace = $profile->workspace;
if ($workspace instanceof Workspace) {
$auditLogger->log(
workspace: $workspace,
action: AuditActionId::BaselineProfileScopeBackfilled,
context: [
'metadata' => [
'source' => 'tenantpilot:baseline-scope-v2:backfill',
'workspace_id' => (int) $profile->workspace_id,
'baseline_profile_id' => (int) $profile->getKey(),
'mode' => 'commit',
'before_scope' => $before,
'after_scope' => $after,
],
],
resourceType: 'baseline_profile',
resourceId: (string) $profile->getKey(),
targetLabel: (string) $profile->name,
summary: sprintf('Baseline profile "%s" scope backfilled to canonical V2.', (string) $profile->name),
);
$auditLogged = true;
}
$rewrittenCount++;
}
$this->info(sprintf('Rewritten count: %d', $rewrittenCount));
$this->info(sprintf('Audit logged: %s', $auditLogged ? 'yes' : 'no'));
return self::SUCCESS;
}
/**
* @return array{candidates: list<array{id: int, summary: string}>, invalid: list<string>}
*/
private function scanCandidates(int $chunkSize, ?int $workspaceId = null): array
{
$candidates = [];
$invalid = [];
BaselineProfile::query()
->when(
$workspaceId !== null,
fn ($query) => $query->where('workspace_id', $workspaceId),
)
->orderBy('id')
->chunkById($chunkSize, function ($profiles) use (&$candidates, &$invalid): void {
foreach ($profiles as $profile) {
if (! $profile instanceof BaselineProfile) {
continue;
}
try {
if (! $profile->requiresScopeSaveForward()) {
continue;
}
$candidates[] = [
'id' => (int) $profile->getKey(),
'summary' => $this->candidateSummary($profile),
];
} catch (InvalidArgumentException $exception) {
$invalid[] = sprintf(
'#%d "%s": %s',
(int) $profile->getKey(),
(string) $profile->name,
$exception->getMessage(),
);
}
}
});
return [
'candidates' => $candidates,
'invalid' => $invalid,
];
}
private function candidateSummary(BaselineProfile $profile): string
{
$groupSummary = collect($profile->normalizedScope()->summaryGroups())
->map(function (array $group): string {
return $group['group_label'].': '.implode(', ', $group['selected_subject_types']);
})
->implode('; ');
return sprintf(
'#%d workspace=%d "%s" => %s',
(int) $profile->getKey(),
(int) $profile->workspace_id,
(string) $profile->name,
$groupSummary,
);
}
}

View File

@ -381,17 +381,23 @@ private function compareNowAction(): Action
if (! ($result['ok'] ?? false)) {
$reasonCode = is_string($result['reason_code'] ?? null) ? (string) $result['reason_code'] : 'unknown';
$translation = is_array($result['reason_translation'] ?? null) ? $result['reason_translation'] : [];
$message = match ($reasonCode) {
\App\Support\Baselines\BaselineReasonCodes::COMPARE_ROLLOUT_DISABLED => 'Full-content baseline compare is currently disabled for controlled rollout.',
\App\Support\Baselines\BaselineReasonCodes::COMPARE_PROFILE_NOT_ACTIVE => 'The assigned baseline profile is not active.',
\App\Support\Baselines\BaselineReasonCodes::COMPARE_NO_ACTIVE_SNAPSHOT,
\App\Support\Baselines\BaselineReasonCodes::COMPARE_NO_CONSUMABLE_SNAPSHOT => 'No complete baseline snapshot is currently available for compare.',
\App\Support\Baselines\BaselineReasonCodes::COMPARE_SNAPSHOT_BUILDING => 'The latest baseline capture is still building. Compare will be available after it completes.',
\App\Support\Baselines\BaselineReasonCodes::COMPARE_SNAPSHOT_INCOMPLETE => 'The latest baseline capture is incomplete. Capture a new baseline before comparing.',
\App\Support\Baselines\BaselineReasonCodes::COMPARE_SNAPSHOT_SUPERSEDED => 'A newer complete baseline snapshot is current. Compare uses the latest complete baseline only.',
default => 'Reason: '.$reasonCode,
};
$message = is_string($translation['short_explanation'] ?? null) && trim((string) $translation['short_explanation']) !== ''
? trim((string) $translation['short_explanation'])
: match ($reasonCode) {
\App\Support\Baselines\BaselineReasonCodes::COMPARE_ROLLOUT_DISABLED => 'Full-content baseline compare is currently disabled for controlled rollout.',
\App\Support\Baselines\BaselineReasonCodes::COMPARE_PROFILE_NOT_ACTIVE => 'The assigned baseline profile is not active.',
\App\Support\Baselines\BaselineReasonCodes::COMPARE_NO_ACTIVE_SNAPSHOT,
\App\Support\Baselines\BaselineReasonCodes::COMPARE_NO_CONSUMABLE_SNAPSHOT => 'No complete baseline snapshot is currently available for compare.',
\App\Support\Baselines\BaselineReasonCodes::COMPARE_SNAPSHOT_BUILDING => 'The latest baseline capture is still building. Compare will be available after it completes.',
\App\Support\Baselines\BaselineReasonCodes::COMPARE_SNAPSHOT_INCOMPLETE => 'The latest baseline capture is incomplete. Capture a new baseline before comparing.',
\App\Support\Baselines\BaselineReasonCodes::COMPARE_SNAPSHOT_SUPERSEDED => 'A newer complete baseline snapshot is current. Compare uses the latest complete baseline only.',
\App\Support\Baselines\BaselineReasonCodes::COMPARE_INVALID_SCOPE => 'The assigned baseline scope is invalid and must be reviewed before compare can start.',
\App\Support\Baselines\BaselineReasonCodes::COMPARE_UNSUPPORTED_SCOPE => 'The selected governed subjects are not supported by any compare strategy.',
\App\Support\Baselines\BaselineReasonCodes::COMPARE_MIXED_SCOPE => 'The selected governed subjects span multiple compare strategy families and must be narrowed before compare can start.',
default => 'Reason: '.$reasonCode,
};
Notification::make()
->title('Cannot start comparison')

View File

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

View File

@ -20,10 +20,12 @@
use App\Support\Badges\BadgeCatalog;
use App\Support\Badges\BadgeDomain;
use App\Support\Badges\BadgeRenderer;
use App\Support\Baselines\BaselineScope;
use App\Support\Baselines\BaselineCaptureMode;
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;
@ -51,9 +53,11 @@
use Filament\Notifications\Notification;
use Filament\Resources\Resource;
use Filament\Schemas\Components\Section;
use Filament\Schemas\Components\Utilities\Get;
use Filament\Schemas\Schema;
use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Table;
use InvalidArgumentException;
use Illuminate\Auth\Access\AuthorizationException;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model;
@ -216,12 +220,14 @@ public static function form(Schema $schema): Schema
->label('Policy types')
->multiple()
->options(self::policyTypeOptions())
->live()
->helperText('Leave empty to include all supported policy types (excluding foundations).')
->native(false),
Select::make('scope_jsonb.foundation_types')
->label('Foundations')
->multiple()
->options(self::foundationTypeOptions())
->live()
->helperText('Leave empty to exclude foundations. Select foundations to include them.')
->native(false),
Placeholder::make('metadata')
@ -232,6 +238,26 @@ public static function form(Schema $schema): Schema
->visible(fn (?BaselineProfile $record): bool => $record !== null),
])
->columns(2),
Section::make('Governed subject summary')
->schema([
Placeholder::make('scope_summary_display')
->label('Scope summary')
->content(function (Get $get): string {
return self::scopeSummaryText(self::formScopePayload($get));
})
->columnSpanFull(),
Placeholder::make('scope_support_readiness_display')
->label('Support readiness')
->content(function (Get $get): string {
return self::scopeSupportReadinessText(self::formScopePayload($get));
}),
Placeholder::make('scope_selection_feedback_display')
->label('Selection feedback')
->content(function (Get $get): string {
return self::scopeSelectionFeedbackText(self::formScopePayload($get)) ?? 'Selections are valid for the current Intune-first workflow.';
}),
])
->columns(2),
]);
}
@ -282,6 +308,17 @@ public static function infolist(Schema $schema): Schema
->columnSpanFull(),
Section::make('Scope')
->schema([
TextEntry::make('governed_subject_summary')
->label('Governed subject summary')
->state(fn (BaselineProfile $record): string => self::scopeSummaryText(self::scopePayload($record)))
->columnSpanFull(),
TextEntry::make('scope_support_readiness')
->label('Support readiness')
->state(fn (BaselineProfile $record): string => self::scopeSupportReadinessText(self::scopePayload($record))),
TextEntry::make('scope_normalization_lineage')
->label('Normalization lineage')
->state(fn (BaselineProfile $record): string => self::scopeNormalizationLineageText($record))
->columnSpanFull(),
TextEntry::make('scope_jsonb.policy_types')
->label('Policy types')
->badge()
@ -502,6 +539,82 @@ public static function compareMatrixUrl(BaselineProfile|int $profile): string
return static::getUrl('compare-matrix', ['record' => $profile], panel: 'admin');
}
/**
* @param array<string, mixed>|null $scopePayload
*/
public static function scopeSummaryText(?array $scopePayload): string
{
try {
$scope = BaselineScope::fromJsonb($scopePayload);
} catch (InvalidArgumentException) {
return 'Invalid governed subject selection.';
}
$groups = collect($scope->summaryGroups());
if ($groups->isEmpty()) {
return 'No governed subjects selected.';
}
return $groups
->map(function (array $group) use ($scopePayload): string {
$selectedSubjectTypes = $group['selected_subject_types'];
if ($group['domain_key'] === 'intune'
&& $group['subject_class'] === 'policy'
&& is_array($scopePayload)
&& (($scopePayload['policy_types'] ?? []) === [])) {
return $group['group_label'].': all supported Intune policy types';
}
if ($selectedSubjectTypes === []) {
return $group['group_label'].': none';
}
return $group['group_label'].': '.implode(', ', $selectedSubjectTypes);
})
->implode('; ');
}
/**
* @param array<string, mixed>|null $scopePayload
*/
public static function scopeSupportReadinessText(?array $scopePayload): string
{
try {
$scope = BaselineScope::fromJsonb($scopePayload);
} catch (InvalidArgumentException) {
return 'Capture: blocked. Compare: blocked.';
}
$capture = $scope->operationEligibility('capture');
$compare = $scope->operationEligibility('compare');
return sprintf(
'Capture: %s. Compare: %s.',
$capture['ok'] ? 'ready' : 'blocked',
$compare['ok'] ? 'ready' : 'blocked',
);
}
/**
* @param array<string, mixed>|null $scopePayload
*/
public static function scopeSelectionFeedbackText(?array $scopePayload): ?string
{
try {
$scope = BaselineScope::fromJsonb($scopePayload);
} catch (InvalidArgumentException $exception) {
return $exception->getMessage();
}
if ($scope->normalizationLineage()['save_forward_required']) {
return 'This Intune-first selection will be saved forward as canonical governed-subject scope V2.';
}
return null;
}
/**
* @return array<string, string>
*/
@ -693,7 +806,12 @@ private static function latestAttemptedSnapshotDescription(BaselineProfile $prof
private static function compareReadinessLabel(BaselineProfile $profile): string
{
return self::compareAvailabilityEnvelope($profile)?->operatorLabel ?? 'Ready';
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',
};
}
private static function compareReadinessColor(BaselineProfile $profile): string
@ -701,6 +819,9 @@ private static function compareReadinessColor(BaselineProfile $profile): string
return match (self::compareAvailabilityReason($profile)) {
null => 'success',
BaselineReasonCodes::COMPARE_PROFILE_NOT_ACTIVE => 'gray',
BaselineReasonCodes::COMPARE_INVALID_SCOPE,
BaselineReasonCodes::COMPARE_MIXED_SCOPE,
BaselineReasonCodes::COMPARE_UNSUPPORTED_SCOPE => 'danger',
default => 'warning',
};
}
@ -710,13 +831,21 @@ private static function compareReadinessIcon(BaselineProfile $profile): ?string
return match (self::compareAvailabilityReason($profile)) {
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',
};
}
private static function profileNextStep(BaselineProfile $profile): string
{
return self::compareAvailabilityEnvelope($profile)?->guidanceText() ?? 'No action needed.';
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.',
};
}
private static function effectiveSnapshot(BaselineProfile $profile): ?BaselineSnapshot
@ -739,6 +868,26 @@ private static function compareAvailabilityReason(BaselineProfile $profile): ?st
return BaselineReasonCodes::COMPARE_PROFILE_NOT_ACTIVE;
}
try {
$scope = BaselineScope::fromJsonb(self::scopePayload($profile));
} catch (InvalidArgumentException) {
return BaselineReasonCodes::COMPARE_INVALID_SCOPE;
}
if ($scope->allTypes() === []) {
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;
}
$resolution = app(BaselineSnapshotTruthResolver::class)->resolveCompareSnapshot($profile);
$reasonCode = $resolution['reason_code'] ?? null;
@ -797,4 +946,37 @@ private static function hasEligibleCompareTarget(BaselineProfile $profile): bool
->get(['id'])
->contains(fn (Tenant $tenant): bool => $resolver->can($user, $tenant, Capabilities::TENANT_SYNC));
}
/**
* @param array<string, mixed>|null $scopePayload
*/
private static function formScopePayload(Get $get): ?array
{
$scopePayload = $get('scope_jsonb');
return is_array($scopePayload) ? $scopePayload : null;
}
/**
* @return array<string, mixed>|null
*/
private static function scopePayload(BaselineProfile $profile): ?array
{
return $profile->rawScopeJsonb();
}
private static function scopeNormalizationLineageText(BaselineProfile $profile): string
{
try {
$lineage = $profile->normalizedScope()->normalizationLineage();
} catch (InvalidArgumentException) {
return 'Stored scope is invalid and must be repaired before capture or compare can continue.';
}
if ($lineage['source_shape'] === 'legacy') {
return 'Legacy Intune buckets are being normalized and will be saved forward as canonical V2 on the next successful save.';
}
return 'Canonical governed-subject scope V2 is already stored for this baseline profile.';
}
}

View File

@ -13,6 +13,8 @@
use App\Support\Workspaces\WorkspaceContext;
use Filament\Notifications\Notification;
use Filament\Resources\Pages\CreateRecord;
use Illuminate\Validation\ValidationException;
use InvalidArgumentException;
class CreateBaselineProfile extends CreateRecord
{
@ -31,7 +33,13 @@ protected function mutateFormDataBeforeCreate(array $data): array
$data['created_by_user_id'] = $user instanceof User ? $user->getKey() : null;
if (isset($data['scope_jsonb'])) {
$data['scope_jsonb'] = BaselineScope::fromJsonb(is_array($data['scope_jsonb']) ? $data['scope_jsonb'] : null)->toJsonb();
try {
$data['scope_jsonb'] = BaselineScope::fromJsonb(is_array($data['scope_jsonb']) ? $data['scope_jsonb'] : null)->toJsonb();
} catch (InvalidArgumentException $exception) {
throw ValidationException::withMessages([
'scope_jsonb.policy_types' => $exception->getMessage(),
]);
}
}
return $data;

View File

@ -11,6 +11,8 @@
use App\Support\Baselines\BaselineScope;
use Filament\Notifications\Notification;
use Filament\Resources\Pages\EditRecord;
use Illuminate\Validation\ValidationException;
use InvalidArgumentException;
class EditBaselineProfile extends EditRecord
{
@ -52,7 +54,13 @@ protected function mutateFormDataBeforeSave(array $data): array
}
if (isset($data['scope_jsonb'])) {
$data['scope_jsonb'] = BaselineScope::fromJsonb(is_array($data['scope_jsonb']) ? $data['scope_jsonb'] : null)->toJsonb();
try {
$data['scope_jsonb'] = BaselineScope::fromJsonb(is_array($data['scope_jsonb']) ? $data['scope_jsonb'] : null)->toJsonb();
} catch (InvalidArgumentException $exception) {
throw ValidationException::withMessages([
'scope_jsonb.policy_types' => $exception->getMessage(),
]);
}
}
return $data;

View File

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

View File

@ -957,6 +957,49 @@ private static function baselineCompareFacts(
);
}
$strategy = data_get($context, 'baseline_compare.strategy');
if (is_array($strategy)) {
$strategyKey = is_string($strategy['key'] ?? null) && trim((string) $strategy['key']) !== ''
? trim((string) $strategy['key'])
: null;
$selectionState = is_string($strategy['selection_state'] ?? null) && trim((string) $strategy['selection_state']) !== ''
? trim((string) $strategy['selection_state'])
: null;
$operatorReason = is_string($strategy['operator_reason'] ?? null) && trim((string) $strategy['operator_reason']) !== ''
? trim((string) $strategy['operator_reason'])
: null;
$stateCounts = is_array($strategy['state_counts'] ?? null)
? array_filter(
array_map(static fn (mixed $count): int => (int) $count, $strategy['state_counts']),
static fn (int $count): bool => $count > 0,
)
: [];
if ($strategyKey !== null) {
$facts[] = $factory->keyFact(
'Compare strategy',
\Illuminate\Support\Str::of($strategyKey)->replace('_', ' ')->headline()->toString(),
);
}
if ($selectionState !== null) {
$facts[] = $factory->keyFact(
'Strategy selection',
\Illuminate\Support\Str::of($selectionState)->replace('_', ' ')->headline()->toString(),
$operatorReason,
);
}
if ($stateCounts !== []) {
$facts[] = $factory->keyFact(
'Strategy subject states',
collect($stateCounts)
->map(static fn (int $count, string $state): string => \Illuminate\Support\Str::of($state)->replace('_', ' ')->headline()->append(' ', (string) $count)->toString())
->implode(', '),
);
}
}
if ((int) ($gapSummary['count'] ?? 0) > 0) {
$facts[] = $factory->keyFact(
'Evidence gap detail',
@ -1009,6 +1052,9 @@ private static function baselineCompareEvidencePayload(OperationRun $record): ar
? (int) data_get($context, 'baseline_compare.evidence_gaps.count')
: null,
'resume_token' => data_get($context, 'baseline_compare.resume_token'),
'strategy' => is_array(data_get($context, 'baseline_compare.strategy'))
? data_get($context, 'baseline_compare.strategy')
: null,
'evidence_capture' => is_array(data_get($context, 'baseline_compare.evidence_capture'))
? data_get($context, 'baseline_compare.evidence_capture')
: null,

View File

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

View File

@ -13,6 +13,7 @@
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
use InvalidArgumentException;
use JsonException;
class BaselineProfile extends Model
@ -62,22 +63,61 @@ protected function scopeJsonb(): Attribute
{
return Attribute::make(
get: function (mixed $value): array {
return BaselineScope::fromJsonb(
$this->decodeScopeJsonb($value)
)->toJsonb();
try {
return $this->normalizedScopeFrom($value)->toJsonb();
} catch (InvalidArgumentException) {
return $this->decodeScopeJsonb($value) ?? ['policy_types' => [], 'foundation_types' => []];
}
},
set: function (mixed $value): string {
$scope = BaselineScope::fromJsonb(is_array($value) ? $value : null)->toJsonb();
$scope = BaselineScope::fromJsonb(is_array($value) ? $value : null)->toStoredJsonb();
try {
return json_encode($scope, JSON_THROW_ON_ERROR);
} catch (JsonException) {
return '{"policy_types":[],"foundation_types":[]}';
return '{"version":2,"entries":[]}';
}
},
);
}
public function normalizedScope(): BaselineScope
{
return $this->normalizedScopeFrom($this->getRawOriginal('scope_jsonb'));
}
/**
* @return array<string, mixed>|null
*/
public function rawScopeJsonb(): ?array
{
return $this->decodeScopeJsonb($this->getRawOriginal('scope_jsonb'));
}
/**
* @return array{version: 2, entries: list<array{domain_key: string, subject_class: string, subject_type_keys: list<string>, filters: array<string, mixed>}>}
*/
public function canonicalScopeJsonb(): array
{
return $this->normalizedScope()->toStoredJsonb();
}
public function requiresScopeSaveForward(): bool
{
return (bool) $this->normalizedScope()->normalizationLineage()['save_forward_required'];
}
public function rewriteScopeToCanonicalV2(): bool
{
$this->scope_jsonb = $this->canonicalScopeJsonb();
if (! $this->isDirty('scope_jsonb')) {
return false;
}
return $this->save();
}
/**
* Decode raw scope_jsonb value from the database.
*
@ -102,6 +142,11 @@ private function decodeScopeJsonb(mixed $value): ?array
return null;
}
private function normalizedScopeFrom(mixed $value): BaselineScope
{
return BaselineScope::fromJsonb($this->decodeScopeJsonb($value));
}
public function workspace(): BelongsTo
{
return $this->belongsTo(Workspace::class);

View File

@ -17,6 +17,7 @@
use App\Support\Baselines\BaselineScope;
use App\Support\Baselines\BaselineSupportCapabilityGuard;
use App\Support\OperationRunType;
use InvalidArgumentException;
final class BaselineCaptureService
{
@ -40,9 +41,26 @@ public function startCapture(
return ['ok' => false, 'reason_code' => $precondition];
}
$effectiveScope = BaselineScope::fromJsonb(
is_array($profile->scope_jsonb) ? $profile->scope_jsonb : null,
);
try {
$effectiveScope = BaselineScope::fromJsonb(
is_array($profile->scope_jsonb) ? $profile->scope_jsonb : null,
);
} catch (InvalidArgumentException) {
return ['ok' => false, 'reason_code' => BaselineReasonCodes::CAPTURE_INVALID_SCOPE];
}
if ($effectiveScope->allTypes() === []) {
return ['ok' => false, 'reason_code' => BaselineReasonCodes::CAPTURE_INVALID_SCOPE];
}
$eligibility = $effectiveScope->operationEligibility('capture', $this->capabilityGuard);
if (! $eligibility['ok']) {
return [
'ok' => false,
'reason_code' => BaselineReasonCodes::CAPTURE_UNSUPPORTED_SCOPE,
];
}
$captureMode = $profile->capture_mode instanceof BaselineCaptureMode
? $profile->capture_mode

View File

@ -19,8 +19,12 @@
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;
final class BaselineCompareService
{
@ -30,6 +34,7 @@ public function __construct(
private readonly BaselineSnapshotTruthResolver $snapshotTruthResolver,
private readonly BaselineSupportCapabilityGuard $capabilityGuard,
private readonly CapabilityResolver $capabilityResolver,
private readonly CompareStrategyRegistry $compareStrategyRegistry,
) {}
/**
@ -107,14 +112,28 @@ public function startCompareForProfile(
$snapshot = $snapshotResolution['snapshot'];
$snapshotId = (int) $snapshot->getKey();
$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)
: null;
try {
$profileScope = $profile->normalizedScope();
$overrideScope = $assignment->override_scope_jsonb !== null
? BaselineScope::fromJsonb(
is_array($assignment->override_scope_jsonb) ? $assignment->override_scope_jsonb : null,
allowEmptyLegacyAsNoOverride: true,
)
: null;
$effectiveScope = BaselineScope::effective($profileScope, $overrideScope);
} catch (InvalidArgumentException) {
return $this->failedStart(BaselineReasonCodes::COMPARE_INVALID_SCOPE);
}
$effectiveScope = BaselineScope::effective($profileScope, $overrideScope);
if ($effectiveScope->allTypes() === []) {
return $this->failedStart(BaselineReasonCodes::COMPARE_INVALID_SCOPE);
}
$selection = $this->compareStrategyRegistry->select($effectiveScope);
if (! $selection->isSupported()) {
return $this->failedStart($this->selectionFailureReasonCode($selection));
}
$captureMode = $profile->capture_mode instanceof BaselineCaptureMode
? $profile->capture_mode
@ -129,6 +148,9 @@ public function startCompareForProfile(
'baseline_snapshot_id' => $snapshotId,
'effective_scope' => $effectiveScope->toEffectiveScopeContext($this->capabilityGuard, 'compare'),
'capture_mode' => $captureMode->value,
'baseline_compare' => [
'strategy' => $this->strategyContext($selection),
],
];
$run = $this->runs->ensureRunWithIdentity(
@ -258,6 +280,36 @@ private function validatePreconditions(BaselineProfile $profile): ?string
return null;
}
private function selectionFailureReasonCode(CompareStrategySelection $selection): string
{
return match ($selection->selectionState) {
StrategySelectionState::Mixed => BaselineReasonCodes::COMPARE_MIXED_SCOPE,
default => BaselineReasonCodes::COMPARE_UNSUPPORTED_SCOPE,
};
}
/**
* @return array{
* key: ?string,
* selection_state: string,
* matched_scope_entries: list<array<string, mixed>>,
* rejected_scope_entries: list<array<string, mixed>>,
* operator_reason: string,
* diagnostics: array<string, mixed>
* }
*/
private function strategyContext(CompareStrategySelection $selection): array
{
return [
'key' => $selection->strategyKey?->value,
'selection_state' => $selection->selectionState->value,
'matched_scope_entries' => $selection->matchedScopeEntries,
'rejected_scope_entries' => $selection->rejectedScopeEntries,
'operator_reason' => $selection->operatorReason,
'diagnostics' => $selection->diagnostics,
];
}
/**
* @return array{ok: false, reason_code: string, reason_translation?: array<string, mixed>}
*/

View File

@ -62,6 +62,7 @@ enum AuditActionId: string
case BaselineProfileCreated = 'baseline_profile.created';
case BaselineProfileUpdated = 'baseline_profile.updated';
case BaselineProfileArchived = 'baseline_profile.archived';
case BaselineProfileScopeBackfilled = 'baseline_profile.scope_backfilled';
case BaselineCaptureStarted = 'baseline_capture.started';
case BaselineCaptureCompleted = 'baseline_capture.completed';
case BaselineCaptureFailed = 'baseline_capture.failed';
@ -197,6 +198,7 @@ private static function labels(): array
self::BaselineProfileCreated->value => 'Baseline profile created',
self::BaselineProfileUpdated->value => 'Baseline profile updated',
self::BaselineProfileArchived->value => 'Baseline profile archived',
self::BaselineProfileScopeBackfilled->value => 'Baseline profile scope backfilled',
self::BaselineCaptureStarted->value => 'Baseline capture started',
self::BaselineCaptureCompleted->value => 'Baseline capture completed',
self::BaselineCaptureFailed->value => 'Baseline capture failed',
@ -284,6 +286,7 @@ private static function summaries(): array
self::BaselineProfileCreated->value => 'Baseline profile created',
self::BaselineProfileUpdated->value => 'Baseline profile updated',
self::BaselineProfileArchived->value => 'Baseline profile archived',
self::BaselineProfileScopeBackfilled->value => 'Baseline profile scope backfilled',
self::AlertDestinationCreated->value => 'Alert destination created',
self::AlertDestinationUpdated->value => 'Alert destination updated',
self::AlertDestinationDeleted->value => 'Alert destination deleted',

View File

@ -151,6 +151,7 @@ public static function diagnosticsPayload(array $baselineCompare): array
'subjects_total' => self::intOrNull($baselineCompare['subjects_total'] ?? null),
'resume_token' => self::stringOrNull($baselineCompare['resume_token'] ?? null),
'fidelity' => self::stringOrNull($baselineCompare['fidelity'] ?? null),
'strategy' => is_array($baselineCompare['strategy'] ?? null) ? $baselineCompare['strategy'] : null,
'coverage' => is_array($baselineCompare['coverage'] ?? null) ? $baselineCompare['coverage'] : null,
'evidence_capture' => is_array($baselineCompare['evidence_capture'] ?? null) ? $baselineCompare['evidence_capture'] : null,
'evidence_gaps' => is_array($baselineCompare['evidence_gaps'] ?? null) ? $baselineCompare['evidence_gaps'] : null,

View File

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

View File

@ -121,13 +121,21 @@ public static function forTenant(?Tenant $tenant): self
$snapshotReasonCode = is_string($truthResolution['reason_code'] ?? null) ? (string) $truthResolution['reason_code'] : null;
$snapshotReasonMessage = self::missingSnapshotMessage($snapshotReasonCode);
$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)
: null;
$effectiveScope = BaselineScope::effective($profileScope, $overrideScope);
try {
$profileScope = $profile->normalizedScope();
$overrideScope = $assignment->override_scope_jsonb !== null
? BaselineScope::fromJsonb(
is_array($assignment->override_scope_jsonb) ? $assignment->override_scope_jsonb : null,
allowEmptyLegacyAsNoOverride: true,
)
: null;
$effectiveScope = BaselineScope::effective($profileScope, $overrideScope);
} catch (InvalidArgumentException) {
return self::empty(
'invalid_scope',
'The assigned baseline scope is invalid or no longer supported. A workspace manager must review the baseline definition.',
);
}
$duplicateNameStats = self::duplicateNameStats($tenant, $effectiveScope);
$duplicateNamePoliciesCount = $duplicateNameStats['policy_count'];

View File

@ -18,6 +18,10 @@ final class BaselineReasonCodes
public const string CAPTURE_ROLLOUT_DISABLED = 'baseline.capture.rollout_disabled';
public const string CAPTURE_INVALID_SCOPE = 'baseline.capture.invalid_scope';
public const string CAPTURE_UNSUPPORTED_SCOPE = 'baseline.capture.unsupported_scope';
public const string SNAPSHOT_BUILDING = 'baseline.snapshot.building';
public const string SNAPSHOT_INCOMPLETE = 'baseline.snapshot.incomplete';
@ -46,6 +50,12 @@ final class BaselineReasonCodes
public const string COMPARE_ROLLOUT_DISABLED = 'baseline.compare.rollout_disabled';
public const string COMPARE_INVALID_SCOPE = 'baseline.compare.invalid_scope';
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';
@ -61,6 +71,8 @@ public static function all(): array
self::CAPTURE_MISSING_SOURCE_TENANT,
self::CAPTURE_PROFILE_NOT_ACTIVE,
self::CAPTURE_ROLLOUT_DISABLED,
self::CAPTURE_INVALID_SCOPE,
self::CAPTURE_UNSUPPORTED_SCOPE,
self::SNAPSHOT_BUILDING,
self::SNAPSHOT_INCOMPLETE,
self::SNAPSHOT_SUPERSEDED,
@ -75,6 +87,9 @@ public static function all(): array
self::COMPARE_NO_ELIGIBLE_TARGET,
self::COMPARE_INVALID_SNAPSHOT,
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,
@ -107,8 +122,13 @@ public static function trustImpact(?string $reasonCode): ?string
self::COMPARE_SNAPSHOT_BUILDING,
self::COMPARE_SNAPSHOT_INCOMPLETE,
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 => 'unusable',
self::CAPTURE_PROFILE_NOT_ACTIVE,
self::CAPTURE_INVALID_SCOPE,
self::CAPTURE_UNSUPPORTED_SCOPE => 'unusable',
default => null,
};
}
@ -132,10 +152,15 @@ public static function absencePattern(?string $reasonCode): ?string
self::COMPARE_PROFILE_NOT_ACTIVE,
self::COMPARE_NO_ELIGIBLE_TARGET,
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',
self::SNAPSHOT_CAPTURE_FAILED => 'unavailable',
self::CAPTURE_INVALID_SCOPE,
self::CAPTURE_UNSUPPORTED_SCOPE => 'unavailable',
default => null,
};
}

View File

@ -4,26 +4,36 @@
namespace App\Support\Baselines;
use App\Support\Governance\GovernanceDomainKey;
use App\Support\Governance\GovernanceSubjectClass;
use App\Support\Governance\GovernanceSubjectTaxonomyRegistry;
use App\Support\Inventory\InventoryPolicyTypeMeta;
use InvalidArgumentException;
/**
* Value object for baseline scope resolution.
*
* A scope defines which policy types are included in a baseline profile.
*
* Spec 116 semantics:
* - Empty policy_types means "all supported policy types" (excluding foundations).
* - Empty foundation_types means "none".
* Canonical storage uses versioned Governance Scope V2 entries.
* Presentation compatibility for the current Intune-first UI still projects
* back to legacy policy and foundation buckets.
*/
final class BaselineScope
{
/**
* @param array<string> $policyTypes
* @param array<string> $foundationTypes
* @param list<string> $policyTypes
* @param list<string> $foundationTypes
* @param list<array{domain_key: string, subject_class: string, subject_type_keys: list<string>, filters: array<string, mixed>}> $entries
* @param list<string> $legacyKeysPresent
*/
public function __construct(
public readonly array $policyTypes = [],
public readonly array $foundationTypes = [],
public readonly array $entries = [],
public readonly int $version = 2,
public readonly string $sourceShape = 'canonical_v2',
public readonly bool $normalizedOnRead = false,
public readonly array $legacyKeysPresent = [],
public readonly bool $saveForwardRequired = false,
) {}
/**
@ -31,21 +41,63 @@ public function __construct(
*
* @param array<string, mixed>|null $scopeJsonb
*/
public static function fromJsonb(?array $scopeJsonb): self
public static function fromJsonb(?array $scopeJsonb, bool $allowEmptyLegacyAsNoOverride = false): self
{
if ($scopeJsonb === null) {
return new self;
return self::fromLegacyPayload(
policyTypes: [],
foundationTypes: [],
legacyKeysPresent: [],
normalizedOnRead: true,
saveForwardRequired: true,
);
}
$policyTypes = $scopeJsonb['policy_types'] ?? [];
$foundationTypes = $scopeJsonb['foundation_types'] ?? [];
if (isset($scopeJsonb['canonical_scope']) && is_array($scopeJsonb['canonical_scope'])) {
return self::fromJsonb($scopeJsonb['canonical_scope'], $allowEmptyLegacyAsNoOverride);
}
$policyTypes = is_array($policyTypes) ? array_values(array_filter($policyTypes, 'is_string')) : [];
$foundationTypes = is_array($foundationTypes) ? array_values(array_filter($foundationTypes, 'is_string')) : [];
$hasLegacyKeys = array_key_exists('policy_types', $scopeJsonb) || array_key_exists('foundation_types', $scopeJsonb);
$hasCanonicalKeys = array_key_exists('version', $scopeJsonb) || array_key_exists('entries', $scopeJsonb);
return new self(
policyTypes: $policyTypes === [] ? [] : self::normalizePolicyTypes($policyTypes),
foundationTypes: self::normalizeFoundationTypes($foundationTypes),
if ($hasLegacyKeys && $hasCanonicalKeys) {
throw new InvalidArgumentException('Baseline scope payload must not mix legacy buckets with canonical V2 keys.');
}
if ($hasCanonicalKeys) {
return self::fromCanonicalPayload($scopeJsonb);
}
if (! $hasLegacyKeys) {
throw new InvalidArgumentException('Baseline scope payload must contain either legacy buckets or canonical V2 keys.');
}
$legacyKeysPresent = array_values(array_filter([
array_key_exists('policy_types', $scopeJsonb) ? 'policy_types' : null,
array_key_exists('foundation_types', $scopeJsonb) ? 'foundation_types' : null,
]));
$policyTypes = self::stringList($scopeJsonb['policy_types'] ?? []);
$foundationTypes = self::stringList($scopeJsonb['foundation_types'] ?? []);
if ($allowEmptyLegacyAsNoOverride && $policyTypes === [] && $foundationTypes === []) {
return new self(
policyTypes: [],
foundationTypes: [],
entries: [],
sourceShape: 'legacy',
normalizedOnRead: false,
legacyKeysPresent: $legacyKeysPresent,
saveForwardRequired: false,
);
}
return self::fromLegacyPayload(
policyTypes: $policyTypes,
foundationTypes: $foundationTypes,
legacyKeysPresent: $legacyKeysPresent,
normalizedOnRead: true,
saveForwardRequired: true,
);
}
@ -65,19 +117,41 @@ public static function effective(self $profileScope, ?self $overrideScope): self
$overridePolicyTypes = self::normalizePolicyTypes($overrideScope->policyTypes);
$overrideFoundationTypes = self::normalizeFoundationTypes($overrideScope->foundationTypes);
$entries = [];
$effectivePolicyTypes = $overridePolicyTypes !== []
? array_values(array_intersect($profileScope->policyTypes, $overridePolicyTypes))
: $profileScope->policyTypes;
foreach ($profileScope->entries as $entry) {
$subjectTypeKeys = $entry['subject_type_keys'];
$effectiveFoundationTypes = $overrideFoundationTypes !== []
? array_values(array_intersect($profileScope->foundationTypes, $overrideFoundationTypes))
: $profileScope->foundationTypes;
if ($entry['domain_key'] === GovernanceDomainKey::Intune->value
&& $entry['subject_class'] === GovernanceSubjectClass::Policy->value
&& $overridePolicyTypes !== []) {
$subjectTypeKeys = array_values(array_intersect($subjectTypeKeys, $overridePolicyTypes));
}
return new self(
policyTypes: self::uniqueSorted($effectivePolicyTypes),
foundationTypes: self::uniqueSorted($effectiveFoundationTypes),
);
if ($entry['domain_key'] === GovernanceDomainKey::PlatformFoundation->value
&& $entry['subject_class'] === GovernanceSubjectClass::ConfigurationResource->value
&& $overrideFoundationTypes !== []) {
$subjectTypeKeys = array_values(array_intersect($subjectTypeKeys, $overrideFoundationTypes));
}
$subjectTypeKeys = self::uniqueSorted($subjectTypeKeys);
if ($subjectTypeKeys === []) {
continue;
}
$entries[] = [
'domain_key' => $entry['domain_key'],
'subject_class' => $entry['subject_class'],
'subject_type_keys' => $subjectTypeKeys,
'filters' => $entry['filters'],
];
}
return self::fromCanonicalPayload([
'version' => 2,
'entries' => $entries,
]);
}
/**
@ -85,7 +159,7 @@ public static function effective(self $profileScope, ?self $overrideScope): self
*/
public function isEmpty(): bool
{
return $this->policyTypes === [] && $this->foundationTypes === [];
return $this->entries === [] && $this->policyTypes === [] && $this->foundationTypes === [];
}
/**
@ -93,15 +167,16 @@ public function isEmpty(): bool
*/
public function expandDefaults(): self
{
$policyTypes = $this->policyTypes === []
? self::supportedPolicyTypes()
: self::normalizePolicyTypes($this->policyTypes);
if ($this->entries !== []) {
return $this;
}
$foundationTypes = self::normalizeFoundationTypes($this->foundationTypes);
return new self(
policyTypes: $policyTypes,
foundationTypes: $foundationTypes,
return self::fromLegacyPayload(
policyTypes: $this->policyTypes,
foundationTypes: $this->foundationTypes,
legacyKeysPresent: $this->legacyKeysPresent,
normalizedOnRead: $this->normalizedOnRead,
saveForwardRequired: $this->saveForwardRequired,
);
}
@ -112,9 +187,19 @@ public function allTypes(): array
{
$expanded = $this->expandDefaults();
$canonicalTypeKeys = [];
foreach ($expanded->entries as $entry) {
$canonicalTypeKeys = array_merge(
$canonicalTypeKeys,
is_array($entry['subject_type_keys'] ?? null) ? $entry['subject_type_keys'] : [],
);
}
return self::uniqueSorted(array_merge(
$expanded->policyTypes,
$expanded->foundationTypes,
$canonicalTypeKeys,
));
}
@ -134,26 +219,107 @@ public function truthfulTypes(string $operation, ?BaselineSupportCapabilityGuard
*/
public function toJsonb(): array
{
$supportedPolicyTypes = self::supportedPolicyTypes();
$policyTypes = $this->policyTypes;
if ($policyTypes === $supportedPolicyTypes) {
$policyTypes = [];
}
return [
'policy_types' => $this->policyTypes,
'policy_types' => $policyTypes,
'foundation_types' => $this->foundationTypes,
];
}
/**
* @return array{version: 2, entries: list<array{domain_key: string, subject_class: string, subject_type_keys: list<string>, filters: array<string, mixed>}>}
*/
public function toStoredJsonb(): array
{
return [
'version' => 2,
'entries' => $this->entries,
];
}
/**
* @return array{source_shape: string, normalized_on_read: bool, legacy_keys_present: list<string>, save_forward_required: bool}
*/
public function normalizationLineage(): array
{
return [
'source_shape' => $this->sourceShape,
'normalized_on_read' => $this->normalizedOnRead,
'legacy_keys_present' => $this->legacyKeysPresent,
'save_forward_required' => $this->saveForwardRequired,
];
}
/**
* @return list<array{domain_key: string, subject_class: string, group_label: string, selected_subject_types: list<string>, capture_supported_count: int, compare_supported_count: int, inactive_count: int}>
*/
public function summaryGroups(?GovernanceSubjectTaxonomyRegistry $registry = null): array
{
$registry ??= app(GovernanceSubjectTaxonomyRegistry::class);
$groups = [];
foreach ($this->entries as $entry) {
$selectedSubjectTypes = [];
$captureSupportedCount = 0;
$compareSupportedCount = 0;
$inactiveCount = 0;
foreach ($entry['subject_type_keys'] as $subjectTypeKey) {
$subjectType = $registry->find($entry['domain_key'], $subjectTypeKey);
$selectedSubjectTypes[] = $subjectType?->label ?? $subjectTypeKey;
if ($subjectType?->captureSupported) {
$captureSupportedCount++;
}
if ($subjectType?->compareSupported) {
$compareSupportedCount++;
}
if ($subjectType !== null && ! $subjectType->active) {
$inactiveCount++;
}
}
sort($selectedSubjectTypes, SORT_STRING);
$groups[] = [
'domain_key' => $entry['domain_key'],
'subject_class' => $entry['subject_class'],
'group_label' => $registry->groupLabel($entry['domain_key'], $entry['subject_class']),
'selected_subject_types' => $selectedSubjectTypes,
'capture_supported_count' => $captureSupportedCount,
'compare_supported_count' => $compareSupportedCount,
'inactive_count' => $inactiveCount,
];
}
return $groups;
}
/**
* Effective scope payload for OperationRun.context.
*
* @return array{policy_types: list<string>, foundation_types: list<string>, all_types: list<string>, foundations_included: bool}
* @return array<string, mixed>
*/
public function toEffectiveScopeContext(?BaselineSupportCapabilityGuard $guard = null, ?string $operation = null): array
{
$expanded = $this->expandDefaults();
$allTypes = self::uniqueSorted(array_merge($expanded->policyTypes, $expanded->foundationTypes));
$allTypes = $expanded->allTypes();
$context = [
'canonical_scope' => $expanded->toStoredJsonb(),
'legacy_projection' => $expanded->toJsonb(),
'policy_types' => $expanded->policyTypes,
'foundation_types' => $expanded->foundationTypes,
'all_types' => $allTypes,
'selected_type_keys' => $allTypes,
'foundations_included' => $expanded->foundationTypes !== [],
];
@ -170,28 +336,34 @@ public function toEffectiveScopeContext(?BaselineSupportCapabilityGuard $guard =
'unsupported_types' => $guardResult['unsupported_types'],
'invalid_support_types' => $guardResult['invalid_support_types'],
'capabilities' => $guardResult['capabilities'],
'allowed_type_keys' => $guardResult['allowed_types'],
'limited_type_keys' => $guardResult['limited_types'],
'unsupported_type_keys' => $guardResult['unsupported_types'],
'capabilities_by_type' => $guardResult['capabilities'],
]);
}
/**
* @return array{ok: bool, unsupported_types: list<string>, invalid_support_types: list<string>}
*/
public function operationEligibility(string $operation, ?BaselineSupportCapabilityGuard $guard = null): array
{
$guard ??= app(BaselineSupportCapabilityGuard::class);
$guardResult = $guard->guardTypes($this->allTypes(), $operation);
return [
'ok' => $guardResult['unsupported_types'] === [] && $guardResult['invalid_support_types'] === [],
'unsupported_types' => $guardResult['unsupported_types'],
'invalid_support_types' => $guardResult['invalid_support_types'],
];
}
/**
* @return list<string>
*/
private static function supportedPolicyTypes(): array
{
$supported = config('tenantpilot.supported_policy_types', []);
if (! is_array($supported)) {
return [];
}
$types = collect($supported)
->filter(fn (mixed $row): bool => is_array($row) && filled($row['type'] ?? null))
->map(fn (array $row): string => (string) $row['type'])
->filter(fn (string $type): bool => $type !== '')
->values()
->all();
return self::uniqueSorted($types);
return app(GovernanceSubjectTaxonomyRegistry::class)->activeLegacyBucketKeys('policy_types');
}
/**
@ -199,14 +371,7 @@ private static function supportedPolicyTypes(): array
*/
private static function supportedFoundationTypes(): array
{
$types = collect(InventoryPolicyTypeMeta::baselineSupportedFoundations())
->filter(fn (mixed $row): bool => is_array($row) && filled($row['type'] ?? null))
->map(fn (array $row): string => (string) $row['type'])
->filter(fn (string $type): bool => $type !== '')
->values()
->all();
return self::uniqueSorted($types);
return app(GovernanceSubjectTaxonomyRegistry::class)->activeLegacyBucketKeys('foundation_types');
}
/**
@ -243,4 +408,286 @@ private static function uniqueSorted(array $types): array
return $types;
}
/**
* @param list<string> $policyTypes
* @param list<string> $foundationTypes
* @param list<string> $legacyKeysPresent
*/
private static function fromLegacyPayload(
array $policyTypes,
array $foundationTypes,
array $legacyKeysPresent,
bool $normalizedOnRead,
bool $saveForwardRequired,
): self {
$policyTypes = $policyTypes === []
? self::supportedPolicyTypes()
: self::normalizePolicyTypes($policyTypes);
$foundationTypes = self::normalizeFoundationTypes($foundationTypes);
$entries = [];
if ($policyTypes !== []) {
$entries[] = [
'domain_key' => GovernanceDomainKey::Intune->value,
'subject_class' => GovernanceSubjectClass::Policy->value,
'subject_type_keys' => $policyTypes,
'filters' => [],
];
}
if ($foundationTypes !== []) {
$entries[] = [
'domain_key' => GovernanceDomainKey::PlatformFoundation->value,
'subject_class' => GovernanceSubjectClass::ConfigurationResource->value,
'subject_type_keys' => $foundationTypes,
'filters' => [],
];
}
return new self(
policyTypes: $policyTypes,
foundationTypes: $foundationTypes,
entries: $entries,
sourceShape: 'legacy',
normalizedOnRead: $normalizedOnRead,
legacyKeysPresent: $legacyKeysPresent,
saveForwardRequired: $saveForwardRequired,
);
}
/**
* @param array<string, mixed> $scopeJsonb
*/
private static function fromCanonicalPayload(array $scopeJsonb): self
{
if (($scopeJsonb['version'] ?? null) !== 2) {
throw new InvalidArgumentException('Baseline scope version must equal 2.');
}
$entries = $scopeJsonb['entries'] ?? null;
if (! is_array($entries) || $entries === []) {
throw new InvalidArgumentException('Baseline scope V2 entries must be a non-empty array.');
}
$normalizedEntries = self::normalizeEntries($entries);
[$policyTypes, $foundationTypes] = self::legacyProjectionFromEntries($normalizedEntries);
return new self(
policyTypes: $policyTypes,
foundationTypes: $foundationTypes,
entries: $normalizedEntries,
sourceShape: 'canonical_v2',
normalizedOnRead: false,
legacyKeysPresent: [],
saveForwardRequired: false,
);
}
/**
* @param list<mixed> $entries
* @return list<array{domain_key: string, subject_class: string, subject_type_keys: list<string>, filters: array<string, mixed>}>
*/
private static function normalizeEntries(array $entries): array
{
$registry = app(GovernanceSubjectTaxonomyRegistry::class);
$normalizedEntries = [];
$subjectFilters = [];
foreach ($entries as $entry) {
if (! is_array($entry)) {
throw new InvalidArgumentException('Each canonical baseline scope entry must be an array.');
}
$domainKey = trim((string) ($entry['domain_key'] ?? ''));
$subjectClass = trim((string) ($entry['subject_class'] ?? ''));
if (! $registry->isKnownDomain($domainKey)) {
throw new InvalidArgumentException('Unknown governance domain ['.$domainKey.'].');
}
if (! $registry->allowsSubjectClass($domainKey, $subjectClass)) {
throw new InvalidArgumentException('Subject class ['.$subjectClass.'] is not valid for domain ['.$domainKey.'].');
}
$subjectTypeKeys = self::stringList($entry['subject_type_keys'] ?? null);
if ($subjectTypeKeys === []) {
throw new InvalidArgumentException('Canonical baseline scope entries must include at least one subject type key.');
}
$filters = $entry['filters'] ?? [];
if (! is_array($filters)) {
throw new InvalidArgumentException('Baseline scope entry filters must be an object-shaped array.');
}
$filters = self::normalizeFilters($filters);
if ($filters !== [] && ! $registry->supportsFilters($domainKey, $subjectClass)) {
throw new InvalidArgumentException('Filters are not supported for the current governance domain and subject class.');
}
$subjectTypeKeys = array_map(
static fn (string $subjectTypeKey): string => trim($subjectTypeKey),
self::uniqueSorted($subjectTypeKeys),
);
foreach ($subjectTypeKeys as $subjectTypeKey) {
$subjectType = $registry->find($domainKey, $subjectTypeKey);
if ($subjectType === null) {
throw new InvalidArgumentException('Unknown subject type ['.$subjectTypeKey.'] for domain ['.$domainKey.'].');
}
if ($subjectType->subjectClass->value !== $subjectClass) {
throw new InvalidArgumentException('Subject type ['.$subjectTypeKey.'] does not belong to subject class ['.$subjectClass.'].');
}
if (! $subjectType->active) {
throw new InvalidArgumentException('Inactive subject type ['.$subjectTypeKey.'] cannot be selected.');
}
}
$filtersHash = self::filtersHash($filters);
foreach ($subjectTypeKeys as $subjectTypeKey) {
$subjectKey = implode('|', [$domainKey, $subjectClass, $subjectTypeKey]);
$existingFiltersHash = $subjectFilters[$subjectKey] ?? null;
if ($existingFiltersHash !== null && $existingFiltersHash !== $filtersHash) {
throw new InvalidArgumentException('Ambiguous baseline scope filters were provided for ['.$subjectTypeKey.'].');
}
$subjectFilters[$subjectKey] = $filtersHash;
}
$entryKey = implode('|', [$domainKey, $subjectClass, $filtersHash]);
if (! array_key_exists($entryKey, $normalizedEntries)) {
$normalizedEntries[$entryKey] = [
'domain_key' => $domainKey,
'subject_class' => $subjectClass,
'subject_type_keys' => [],
'filters' => $filters,
];
}
$normalizedEntries[$entryKey]['subject_type_keys'] = self::uniqueSorted(array_merge(
$normalizedEntries[$entryKey]['subject_type_keys'],
$subjectTypeKeys,
));
}
$normalizedEntries = array_values($normalizedEntries);
usort($normalizedEntries, static function (array $left, array $right): int {
$leftKey = implode('|', [
$left['domain_key'],
$left['subject_class'],
self::filtersHash($left['filters']),
implode(',', $left['subject_type_keys']),
]);
$rightKey = implode('|', [
$right['domain_key'],
$right['subject_class'],
self::filtersHash($right['filters']),
implode(',', $right['subject_type_keys']),
]);
return $leftKey <=> $rightKey;
});
return $normalizedEntries;
}
/**
* @param list<array{domain_key: string, subject_class: string, subject_type_keys: list<string>, filters: array<string, mixed>}> $entries
* @return array{0: list<string>, 1: list<string>}
*/
private static function legacyProjectionFromEntries(array $entries): array
{
$policyTypes = [];
$foundationTypes = [];
foreach ($entries as $entry) {
if ($entry['domain_key'] === GovernanceDomainKey::Intune->value
&& $entry['subject_class'] === GovernanceSubjectClass::Policy->value) {
$policyTypes = array_merge($policyTypes, $entry['subject_type_keys']);
}
if ($entry['domain_key'] === GovernanceDomainKey::PlatformFoundation->value
&& $entry['subject_class'] === GovernanceSubjectClass::ConfigurationResource->value) {
$foundationTypes = array_merge($foundationTypes, $entry['subject_type_keys']);
}
}
return [
self::uniqueSorted($policyTypes),
self::uniqueSorted($foundationTypes),
];
}
/**
* @param mixed $values
* @return list<string>
*/
private static function stringList(mixed $values): array
{
if (! is_array($values)) {
return [];
}
return array_values(array_filter($values, 'is_string'));
}
/**
* @param array<string, mixed> $filters
* @return array<string, mixed>
*/
private static function normalizeFilters(array $filters): array
{
ksort($filters);
foreach ($filters as $key => $value) {
if (is_array($value)) {
$filters[$key] = self::normalizeFilterArray($value);
}
}
return $filters;
}
/**
* @param array<int|string, mixed> $values
* @return array<int|string, mixed>
*/
private static function normalizeFilterArray(array $values): array
{
foreach ($values as $key => $value) {
if (is_array($value)) {
$values[$key] = self::normalizeFilterArray($value);
}
}
if (array_is_list($values)) {
sort($values);
return array_values($values);
}
ksort($values);
return $values;
}
/**
* @param array<string, mixed> $filters
*/
private static function filtersHash(array $filters): string
{
return json_encode($filters, JSON_THROW_ON_ERROR);
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1,48 @@
<?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,
];
}
}

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

@ -0,0 +1,12 @@
<?php
declare(strict_types=1);
namespace App\Support\Governance;
enum GovernanceDomainKey: string
{
case Intune = 'intune';
case PlatformFoundation = 'platform_foundation';
case Entra = 'entra';
}

View File

@ -0,0 +1,13 @@
<?php
declare(strict_types=1);
namespace App\Support\Governance;
enum GovernanceSubjectClass: string
{
case Policy = 'policy';
case ConfigurationResource = 'configuration_resource';
case PostureDimension = 'posture_dimension';
case Control = 'control';
}

View File

@ -0,0 +1,211 @@
<?php
declare(strict_types=1);
namespace App\Support\Governance;
use App\Support\Inventory\InventoryPolicyTypeMeta;
final class GovernanceSubjectTaxonomyRegistry
{
/**
* @var array<string, list<string>>
*/
private const DOMAIN_CLASSES = [
GovernanceDomainKey::Intune->value => [GovernanceSubjectClass::Policy->value],
GovernanceDomainKey::PlatformFoundation->value => [GovernanceSubjectClass::ConfigurationResource->value],
GovernanceDomainKey::Entra->value => [GovernanceSubjectClass::Control->value],
];
/**
* @return list<GovernanceSubjectType>
*/
public function all(): array
{
return array_values(array_merge(
$this->policySubjectTypes(),
$this->foundationSubjectTypes(),
));
}
/**
* @return list<GovernanceSubjectType>
*/
public function active(): array
{
return array_values(array_filter(
$this->all(),
static fn (GovernanceSubjectType $subjectType): bool => $subjectType->active,
));
}
/**
* @return list<string>
*/
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 array_key_exists(trim($domainKey), self::DOMAIN_CLASSES);
}
public function allowsSubjectClass(string $domainKey, string $subjectClass): bool
{
$domainKey = trim($domainKey);
$subjectClass = trim($subjectClass);
return in_array($subjectClass, self::DOMAIN_CLASSES[$domainKey] ?? [], true);
}
public function supportsFilters(string $domainKey, string $subjectClass): bool
{
return false;
}
public function groupLabel(string $domainKey, string $subjectClass): string
{
return match ([trim($domainKey), trim($subjectClass)]) {
[GovernanceDomainKey::Intune->value, GovernanceSubjectClass::Policy->value] => 'Intune policies',
[GovernanceDomainKey::PlatformFoundation->value, GovernanceSubjectClass::ConfigurationResource->value] => 'Platform foundation configuration resources',
[GovernanceDomainKey::Entra->value, GovernanceSubjectClass::Control->value] => 'Entra controls',
default => trim($domainKey).' / '.trim($subjectClass),
};
}
/**
* @return list<GovernanceSubjectType>
*/
private function policySubjectTypes(): array
{
return array_values(array_map(
function (array $row): GovernanceSubjectType {
$type = (string) ($row['type'] ?? '');
$label = (string) ($row['label'] ?? $type);
$category = is_string($row['category'] ?? null) ? (string) $row['category'] : null;
$platform = is_string($row['platform'] ?? null) ? (string) $row['platform'] : null;
$contract = InventoryPolicyTypeMeta::baselineSupportContract($type);
return new GovernanceSubjectType(
domainKey: GovernanceDomainKey::Intune,
subjectClass: GovernanceSubjectClass::Policy,
subjectTypeKey: $type,
label: $label,
description: $this->descriptionFor($category, $platform),
captureSupported: in_array($contract['capture_capability'] ?? null, ['supported', 'limited'], true),
compareSupported: in_array($contract['compare_capability'] ?? null, ['supported', 'limited'], true),
inventorySupported: true,
active: true,
supportMode: $this->supportModeForContract($contract),
legacyBucket: 'policy_types',
);
},
array_values(array_filter(
InventoryPolicyTypeMeta::supported(),
static fn (array $row): bool => filled($row['type'] ?? null),
)),
));
}
/**
* @return list<GovernanceSubjectType>
*/
private function foundationSubjectTypes(): array
{
return array_values(array_map(
function (array $row): GovernanceSubjectType {
$type = (string) ($row['type'] ?? '');
$label = InventoryPolicyTypeMeta::baselineCompareLabel($type) ?? (string) ($row['label'] ?? $type);
$category = is_string($row['category'] ?? null) ? (string) $row['category'] : null;
$platform = is_string($row['platform'] ?? null) ? (string) $row['platform'] : null;
$contract = InventoryPolicyTypeMeta::baselineSupportContract($type);
$supported = (bool) data_get($row, 'baseline_compare.supported', false);
return new GovernanceSubjectType(
domainKey: GovernanceDomainKey::PlatformFoundation,
subjectClass: GovernanceSubjectClass::ConfigurationResource,
subjectTypeKey: $type,
label: $label,
description: $this->descriptionFor($category, $platform),
captureSupported: in_array($contract['capture_capability'] ?? null, ['supported', 'limited'], true),
compareSupported: in_array($contract['compare_capability'] ?? null, ['supported', 'limited'], true),
inventorySupported: in_array($contract['source_model_expected'] ?? null, ['inventory', 'policy'], true),
active: $supported,
supportMode: $this->supportModeForContract($contract),
legacyBucket: 'foundation_types',
);
},
array_values(array_filter(
InventoryPolicyTypeMeta::foundations(),
static fn (array $row): bool => filled($row['type'] ?? null),
)),
));
}
private function descriptionFor(?string $category, ?string $platform): ?string
{
$parts = array_values(array_filter([$category, $platform], static fn (?string $part): bool => filled($part)));
if ($parts === []) {
return null;
}
return implode(' | ', $parts);
}
/**
* @param array<string, mixed> $contract
*/
private function supportModeForContract(array $contract): string
{
$capabilities = [
(string) ($contract['capture_capability'] ?? 'unsupported'),
(string) ($contract['compare_capability'] ?? 'unsupported'),
];
if (! (bool) ($contract['runtime_valid'] ?? false) && (bool) ($contract['config_supported'] ?? false)) {
return 'invalid_support_config';
}
if (in_array('supported', $capabilities, true)) {
return 'supported';
}
if (in_array('limited', $capabilities, true)) {
return 'limited';
}
return 'excluded';
}
}

View File

@ -0,0 +1,42 @@
<?php
declare(strict_types=1);
namespace App\Support\Governance;
final class GovernanceSubjectType
{
public function __construct(
public readonly GovernanceDomainKey $domainKey,
public readonly GovernanceSubjectClass $subjectClass,
public readonly string $subjectTypeKey,
public readonly string $label,
public readonly ?string $description,
public readonly bool $captureSupported,
public readonly bool $compareSupported,
public readonly bool $inventorySupported,
public readonly bool $active,
public readonly ?string $supportMode = null,
public readonly ?string $legacyBucket = null,
) {}
/**
* @return array<string, bool|null|string>
*/
public function toArray(): array
{
return [
'domain_key' => $this->domainKey->value,
'subject_class' => $this->subjectClass->value,
'subject_type_key' => $this->subjectTypeKey,
'label' => $this->label,
'description' => $this->description,
'capture_supported' => $this->captureSupported,
'compare_supported' => $this->compareSupported,
'inventory_supported' => $this->inventorySupported,
'active' => $this->active,
'support_mode' => $this->supportMode,
'legacy_bucket' => $this->legacyBucket,
];
}
}

View File

@ -182,6 +182,12 @@ private function translateBaselineReason(string $reasonCode): ReasonResolutionEn
'prerequisite_missing',
'Refresh the page and select a valid snapshot for this baseline profile.',
],
BaselineReasonCodes::COMPARE_MIXED_SCOPE => [
'Mixed compare scope',
'The selected governed subjects span multiple compare strategy families, so TenantPilot will not start one misleading combined compare run.',
'prerequisite_missing',
'Narrow the governed subject selection so one compare strategy family owns the requested scope.',
],
default => [
'Baseline workflow blocked',
'TenantPilot recorded a baseline precondition that prevents this workflow from continuing safely.',
@ -236,6 +242,24 @@ private function translateBaselineCompareReason(string $reasonCode): ReasonResol
'prerequisite_missing',
'Resume or rerun evidence capture before relying on this compare result.',
],
BaselineCompareReasonCode::UnsupportedSubjects => [
'Unsupported subjects remained',
'The comparison finished, but one or more in-scope subjects are not currently supported by the selected compare strategy.',
'prerequisite_missing',
'Narrow scope or wait for support before treating zero visible findings as complete.',
],
BaselineCompareReasonCode::AmbiguousSubjects => [
'Subject identity stayed ambiguous',
'The comparison finished, but one or more in-scope subjects could not be matched cleanly enough to produce a trustworthy result.',
'prerequisite_missing',
'Review the ambiguous subject mapping before relying on this compare result.',
],
BaselineCompareReasonCode::StrategyFailed => [
'Strategy processing failed',
'The comparison finished without a fully usable result because strategy-owned subject processing failed for one or more in-scope subjects.',
'retryable_transient',
'Inspect the compare run diagnostics and retry once the subject-processing failure is addressed.',
],
BaselineCompareReasonCode::RolloutDisabled => [
'Compare rollout disabled',
'The comparison path was limited by rollout configuration, so the result is not decision-grade.',
@ -248,6 +272,24 @@ private function translateBaselineCompareReason(string $reasonCode): ReasonResol
'prerequisite_missing',
'Review scope selection and baseline inputs before comparing again.',
],
BaselineCompareReasonCode::OverdueFindingsRemain => [
'Overdue findings remain',
'The latest compare did not produce new drift, but overdue findings still require attention.',
'prerequisite_missing',
'Review and resolve the overdue findings before treating this posture as healthy.',
],
BaselineCompareReasonCode::GovernanceExpiring => [
'Accepted-risk governance is expiring',
'Accepted-risk coverage is still valid, but renewal is approaching and needs review.',
'prerequisite_missing',
'Review the expiring governance before it lapses.',
],
BaselineCompareReasonCode::GovernanceLapsed => [
'Accepted-risk governance lapsed',
'Accepted-risk coverage has lapsed, so the current posture still needs follow-up.',
'prerequisite_missing',
'Restore valid governance or move the affected findings back into active remediation.',
],
};
return new ReasonResolutionEnvelope(

View File

@ -0,0 +1,163 @@
<?php
declare(strict_types=1);
use App\Filament\Resources\BaselineProfileResource;
use App\Models\BaselineProfile;
use App\Models\BaselineSnapshot;
use App\Models\BaselineTenantAssignment;
use App\Models\Tenant;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\DB;
pest()->browser()->timeout(20_000);
uses(RefreshDatabase::class);
function spec202SmokeLoginUrl(User $user, Tenant $tenant, string $redirect = ''): string
{
return route('admin.local.smoke-login', array_filter([
'email' => $user->email,
'tenant' => $tenant->external_id,
'workspace' => $tenant->workspace->slug,
'redirect' => $redirect,
], static fn (?string $value): bool => filled($value)));
}
it('smokes governance subject scope create, edit, and view surfaces', function (): void {
[$user, $tenant] = createUserWithTenant(
role: 'owner',
workspaceRole: 'manager',
ensureDefaultMicrosoftProviderConnection: false,
);
$legacyProfile = BaselineProfile::factory()->active()->create([
'workspace_id' => (int) $tenant->workspace_id,
'name' => 'Spec202 Legacy Browser Baseline',
]);
DB::table('baseline_profiles')
->where('id', (int) $legacyProfile->getKey())
->update([
'scope_jsonb' => json_encode([
'policy_types' => ['deviceConfiguration'],
'foundation_types' => ['assignmentFilter'],
], JSON_THROW_ON_ERROR),
'updated_at' => now(),
]);
$captureProfile = BaselineProfile::factory()->active()->create([
'workspace_id' => (int) $tenant->workspace_id,
'name' => 'Spec202 Capture Browser Baseline',
'scope_jsonb' => ['policy_types' => ['deviceConfiguration'], 'foundation_types' => []],
]);
$compareProfile = BaselineProfile::factory()->active()->create([
'workspace_id' => (int) $tenant->workspace_id,
'name' => 'Spec202 Compare Browser Baseline',
'scope_jsonb' => ['policy_types' => ['deviceConfiguration'], 'foundation_types' => []],
]);
$snapshot = BaselineSnapshot::factory()->complete()->create([
'workspace_id' => (int) $tenant->workspace_id,
'baseline_profile_id' => (int) $compareProfile->getKey(),
]);
$compareProfile->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) $compareProfile->getKey(),
]);
visit(spec202SmokeLoginUrl($user, $tenant))
->waitForText('Dashboard')
->assertNoJavaScriptErrors()
->assertNoConsoleLogs();
visit(BaselineProfileResource::getUrl('create', panel: 'admin'))
->waitForText('Governed subject summary')
->assertNoJavaScriptErrors()
->assertNoConsoleLogs()
->assertSee('Support readiness')
->assertSee('Selection feedback');
visit(BaselineProfileResource::getUrl('edit', ['record' => $legacyProfile], panel: 'admin'))
->waitForText('Governed subject summary')
->assertNoJavaScriptErrors()
->assertNoConsoleLogs()
->assertSee('Support readiness')
->assertSee('Device Configuration')
->assertSee('Assignment Filter')
->assertSee('This Intune-first selection will be saved forward as canonical governed-subject scope V2.');
visit(BaselineProfileResource::getUrl('view', ['record' => $captureProfile], panel: 'admin'))
->waitForText('Governed subject summary')
->assertNoJavaScriptErrors()
->assertNoConsoleLogs()
->assertSee('Support readiness')
->assertSee('Capture baseline')
->click('Capture baseline')
->waitForText('Source Tenant')
->click('Cancel')
->assertSee('Capture baseline');
visit(BaselineProfileResource::getUrl('view', ['record' => $compareProfile], panel: 'admin'))
->waitForText('Governed subject summary')
->assertNoJavaScriptErrors()
->assertNoConsoleLogs()
->assertSee('Support readiness')
->assertSee('Normalization lineage')
->assertSee('Canonical governed-subject scope V2 is already stored for this baseline profile.')
->assertSee('Compare now')
->click('Compare now')
->waitForText('Target Tenant')
->click('Cancel')
->assertSee('Compare now');
});
it('smokes tolerant invalid scope rendering on the baseline detail surface', function (): void {
[$user, $tenant] = createUserWithTenant(
role: 'owner',
workspaceRole: 'manager',
ensureDefaultMicrosoftProviderConnection: false,
);
$invalidProfile = BaselineProfile::factory()->active()->create([
'workspace_id' => (int) $tenant->workspace_id,
'name' => 'Spec202 Invalid Browser Baseline',
]);
DB::table('baseline_profiles')
->where('id', (int) $invalidProfile->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),
'updated_at' => now(),
]);
visit(spec202SmokeLoginUrl($user, $tenant))
->waitForText('Dashboard')
->assertNoJavaScriptErrors()
->assertNoConsoleLogs();
visit(BaselineProfileResource::getUrl('view', ['record' => $invalidProfile], panel: 'admin'))
->waitForText('Governed subject summary')
->assertNoJavaScriptErrors()
->assertNoConsoleLogs()
->assertSee('Invalid governed subject selection.')
->assertSee('Support readiness')
->assertSee('Capture: blocked. Compare: blocked.')
->assertSee('Stored scope is invalid and must be repaired before capture or compare can continue.');
});

View File

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

View File

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

View File

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

View File

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

View File

@ -10,6 +10,7 @@
use App\Services\Auth\WorkspaceCapabilityResolver;
use App\Support\Workspaces\WorkspaceContext;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\DB;
uses(RefreshDatabase::class);
@ -123,6 +124,33 @@
->get(BaselineProfileResource::getUrl('edit', ['record' => $profile], panel: 'admin'))
->assertOk();
});
it('keeps edit-page authorization stable for legacy-scope profiles', function (): void {
[$owner, $tenant] = createUserWithTenant(role: 'owner');
[$readonly] = createUserWithTenant(tenant: $tenant, role: 'readonly');
$profile = BaselineProfile::factory()->create([
'workspace_id' => (int) $tenant->workspace_id,
]);
DB::table('baseline_profiles')
->where('id', (int) $profile->getKey())
->update([
'scope_jsonb' => json_encode([
'policy_types' => ['deviceConfiguration'],
'foundation_types' => [],
], JSON_THROW_ON_ERROR),
'updated_at' => now(),
]);
$this->actingAs($owner)
->get(BaselineProfileResource::getUrl('edit', ['record' => $profile], panel: 'admin'))
->assertOk();
$this->actingAs($readonly)
->get(BaselineProfileResource::getUrl('edit', ['record' => $profile], panel: 'admin'))
->assertForbidden();
});
});
describe('BaselineProfile static authorization methods', function () {

View File

@ -0,0 +1,178 @@
<?php
declare(strict_types=1);
use App\Models\AuditLog;
use App\Models\BaselineProfile;
use App\Models\Workspace;
use Illuminate\Support\Facades\DB;
function spec202ForceLegacyBaselineScope(BaselineProfile $profile, array $scope): void
{
DB::table('baseline_profiles')
->where('id', (int) $profile->getKey())
->update([
'scope_jsonb' => json_encode($scope, JSON_THROW_ON_ERROR),
'updated_at' => now(),
]);
}
it('previews only legacy baseline profile scope rows in mixed datasets without mutating them', function (): void {
config()->set('tenantpilot.supported_policy_types', [
['type' => 'deviceConfiguration', 'label' => 'Device Configuration'],
['type' => 'deviceCompliancePolicy', 'label' => 'Device Compliance'],
]);
config()->set('tenantpilot.foundation_types', [
['type' => 'assignmentFilter', 'label' => 'Assignment Filter', 'baseline_compare' => ['supported' => true]],
]);
$workspace = Workspace::factory()->create();
$legacyProfile = BaselineProfile::factory()->active()->create([
'workspace_id' => (int) $workspace->getKey(),
'name' => 'Legacy preview profile',
]);
$canonicalProfile = BaselineProfile::factory()->active()->create([
'workspace_id' => (int) $workspace->getKey(),
'name' => 'Canonical preview profile',
'scope_jsonb' => [
'version' => 2,
'entries' => [
[
'domain_key' => 'intune',
'subject_class' => 'policy',
'subject_type_keys' => ['deviceCompliancePolicy'],
'filters' => [],
],
],
],
]);
spec202ForceLegacyBaselineScope($legacyProfile, [
'policy_types' => ['deviceConfiguration'],
'foundation_types' => [],
]);
$this->artisan('tenantpilot:baseline-scope-v2:backfill', [
'--workspace' => (string) $workspace->getKey(),
])
->expectsOutputToContain('Mode: preview')
->expectsOutputToContain('Scope surface: baseline_profiles_only')
->expectsOutputToContain('Candidate count: 1')
->expectsOutputToContain('Rewritten count: 0')
->expectsOutputToContain('Audit logged: no')
->expectsOutputToContain('Legacy preview profile')
->assertSuccessful();
$legacyProfile->refresh();
$canonicalProfile->refresh();
expect($legacyProfile->rawScopeJsonb())->toBe([
'policy_types' => ['deviceConfiguration'],
'foundation_types' => [],
])->and($canonicalProfile->rawScopeJsonb())->toBe([
'version' => 2,
'entries' => [
[
'domain_key' => 'intune',
'subject_class' => 'policy',
'subject_type_keys' => ['deviceCompliancePolicy'],
'filters' => [],
],
],
]);
});
it('requires explicit write confirmation before mutating baseline profile scope rows', function (): void {
$workspace = Workspace::factory()->create();
$profile = BaselineProfile::factory()->active()->create([
'workspace_id' => (int) $workspace->getKey(),
'name' => 'Legacy confirm profile',
]);
spec202ForceLegacyBaselineScope($profile, [
'policy_types' => ['deviceConfiguration'],
'foundation_types' => [],
]);
$this->artisan('tenantpilot:baseline-scope-v2:backfill', [
'--workspace' => (string) $workspace->getKey(),
'--write' => true,
])
->expectsOutputToContain('Explicit write confirmation required.')
->assertFailed();
$profile->refresh();
expect($profile->rawScopeJsonb())->toBe([
'policy_types' => ['deviceConfiguration'],
'foundation_types' => [],
]);
});
it('rewrites legacy baseline profile scopes to canonical v2, logs audits, and stays idempotent on rerun', function (): void {
config()->set('tenantpilot.supported_policy_types', [
['type' => 'deviceConfiguration', 'label' => 'Device Configuration'],
['type' => 'deviceCompliancePolicy', 'label' => 'Device Compliance'],
]);
$workspace = Workspace::factory()->create();
$profile = BaselineProfile::factory()->active()->create([
'workspace_id' => (int) $workspace->getKey(),
'name' => 'Legacy commit profile',
]);
spec202ForceLegacyBaselineScope($profile, [
'policy_types' => ['deviceConfiguration'],
'foundation_types' => [],
]);
$this->artisan('tenantpilot:baseline-scope-v2:backfill', [
'--workspace' => (string) $workspace->getKey(),
'--write' => true,
'--confirm-write' => true,
])
->expectsOutputToContain('Mode: commit')
->expectsOutputToContain('Candidate count: 1')
->expectsOutputToContain('Rewritten count: 1')
->expectsOutputToContain('Audit logged: yes')
->assertSuccessful();
$profile->refresh();
expect($profile->rawScopeJsonb())->toBe([
'version' => 2,
'entries' => [
[
'domain_key' => 'intune',
'subject_class' => 'policy',
'subject_type_keys' => ['deviceConfiguration'],
'filters' => [],
],
],
])->and($profile->requiresScopeSaveForward())->toBeFalse();
$this->assertDatabaseHas('audit_logs', [
'workspace_id' => (int) $workspace->getKey(),
'action' => 'baseline_profile.scope_backfilled',
'resource_type' => 'baseline_profile',
'resource_id' => (string) $profile->getKey(),
]);
$auditLog = AuditLog::query()
->where('workspace_id', (int) $workspace->getKey())
->where('action', 'baseline_profile.scope_backfilled')
->latest('id')
->first();
expect($auditLog)->not->toBeNull();
$this->artisan('tenantpilot:baseline-scope-v2:backfill', [
'--workspace' => (string) $workspace->getKey(),
])
->expectsOutputToContain('Candidate count: 0')
->expectsOutputToContain('No baseline profile scope rows require backfill.')
->assertSuccessful();
});

View File

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

View File

@ -9,6 +9,7 @@
use App\Models\BaselineTenantAssignment;
use App\Support\Workspaces\WorkspaceContext;
use Filament\Facades\Filament;
use Illuminate\Support\Facades\DB;
use Livewire\Livewire;
it('keeps baseline capture and compare actions capability-gated on the profile detail page', function (): void {
@ -25,6 +26,13 @@
]);
$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($readonlyUser)
@ -34,8 +42,8 @@
Livewire::actingAs($ownerUser)
->test(ViewBaselineProfile::class, ['record' => $profile->getKey()])
->assertActionEnabled('capture')
->assertActionDisabled('compareNow');
->assertActionHidden('capture')
->assertActionEnabled('compareNow');
});
it('keeps tenant compare actions disabled for users without tenant.sync and enabled for owners', function (): void {
@ -70,3 +78,47 @@
->test(BaselineCompareLanding::class)
->assertActionEnabled('compareNow');
});
it('keeps legacy-scope capture and compare actions capability-gated on the profile detail page', function (): void {
[$readonlyUser, $tenant] = createUserWithTenant(role: 'readonly');
[$ownerUser] = createUserWithTenant(tenant: $tenant, role: 'owner');
$profile = BaselineProfile::factory()->active()->create([
'workspace_id' => (int) $tenant->workspace_id,
]);
$snapshot = BaselineSnapshot::factory()->complete()->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(),
]);
DB::table('baseline_profiles')
->where('id', (int) $profile->getKey())
->update([
'scope_jsonb' => json_encode([
'policy_types' => ['deviceConfiguration'],
'foundation_types' => [],
], JSON_THROW_ON_ERROR),
'updated_at' => now(),
]);
session()->put(WorkspaceContext::SESSION_KEY, (int) $tenant->workspace_id);
Livewire::actingAs($readonlyUser)
->test(ViewBaselineProfile::class, ['record' => $profile->getKey()])
->assertActionDisabled('capture')
->assertActionDisabled('compareNow');
Livewire::actingAs($ownerUser)
->test(ViewBaselineProfile::class, ['record' => $profile->getKey()])
->assertActionHidden('capture')
->assertActionEnabled('compareNow');
});

View File

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

View File

@ -182,3 +182,75 @@
->assertDontSee('Evidence gap details')
->assertSee('Baseline compare evidence');
});
it('includes strategy diagnostics in the landing evidence payload when strategy-owned compare processing fails', function (): void {
[$user, $tenant] = createUserWithTenant(role: 'owner');
$this->actingAs($user);
$tenant->makeCurrent();
Filament::setTenant($tenant, true);
$profile = BaselineProfile::factory()->active()->create([
'workspace_id' => (int) $tenant->workspace_id,
]);
$snapshot = BaselineSnapshot::factory()->create([
'workspace_id' => (int) $tenant->workspace_id,
'baseline_profile_id' => (int) $profile->getKey(),
]);
$profile->update(['active_snapshot_id' => (int) $snapshot->getKey()]);
BaselineTenantAssignment::factory()->create([
'workspace_id' => (int) $tenant->workspace_id,
'tenant_id' => (int) $tenant->getKey(),
'baseline_profile_id' => (int) $profile->getKey(),
]);
OperationRun::factory()->create([
'tenant_id' => (int) $tenant->getKey(),
'workspace_id' => (int) $tenant->workspace_id,
'type' => OperationRunType::BaselineCompare->value,
'status' => OperationRunStatus::Completed->value,
'outcome' => OperationRunOutcome::PartiallySucceeded->value,
'completed_at' => now(),
'context' => [
'baseline_profile_id' => (int) $profile->getKey(),
'baseline_snapshot_id' => (int) $snapshot->getKey(),
'baseline_compare' => [
'reason_code' => BaselineCompareReasonCode::StrategyFailed->value,
'coverage' => [
'effective_types' => ['deviceConfiguration'],
'covered_types' => ['deviceConfiguration'],
'uncovered_types' => [],
'proof' => true,
],
'fidelity' => 'meta',
'strategy' => [
'key' => 'intune_policy',
'selection_state' => 'supported',
'operator_reason' => 'Compare strategy resolved successfully.',
'execution_diagnostics' => [
'failed' => true,
'exception_class' => RuntimeException::class,
],
'state_counts' => [
'failed' => 1,
],
],
'evidence_gaps' => [
'count' => 1,
'by_reason' => [
'strategy_failed' => 1,
],
],
],
],
]);
Livewire::test(BaselineCompareLanding::class)
->assertSee('Baseline compare evidence')
->assertSee('intune_policy')
->assertSee('strategy_failed')
->assertSee('RuntimeException');
});

View File

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

View File

@ -12,6 +12,7 @@
use Filament\Actions\Action;
use Filament\Actions\ActionGroup;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Queue;
use Livewire\Features\SupportTesting\Testable;
use Livewire\Livewire;
@ -147,3 +148,60 @@ function baselineProfileCaptureHeaderActions(Testable $component): array
Queue::assertNotPushed(CaptureBaselineSnapshotJob::class);
expect(OperationRun::query()->where('type', 'baseline_capture')->count())->toBe(0);
});
it('shows readiness copy without exposing raw canonical scope json on the capture start surface', function (): void {
[$user, $tenant] = createUserWithTenant(role: 'owner');
$profile = BaselineProfile::factory()->active()->create([
'workspace_id' => (int) $tenant->workspace_id,
'scope_jsonb' => ['policy_types' => ['deviceConfiguration'], 'foundation_types' => []],
]);
session()->put(WorkspaceContext::SESSION_KEY, (int) $tenant->workspace_id);
Livewire::actingAs($user)
->test(ViewBaselineProfile::class, ['record' => $profile->getKey()])
->assertSee('Support readiness')
->assertSee('Capture: ready. Compare: ready.')
->assertDontSee('subject_type_keys')
->assertDontSee('canonical_scope');
});
it('does not start capture when the stored canonical scope is invalid', function (): void {
Queue::fake();
[$user, $tenant] = createUserWithTenant(role: 'owner');
$profile = BaselineProfile::factory()->active()->create([
'workspace_id' => (int) $tenant->workspace_id,
]);
DB::table('baseline_profiles')
->where('id', (int) $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),
'updated_at' => now(),
]);
session()->put(WorkspaceContext::SESSION_KEY, (int) $tenant->workspace_id);
Livewire::actingAs($user)
->test(ViewBaselineProfile::class, ['record' => $profile->getKey()])
->assertActionVisible('capture')
->callAction('capture', data: ['source_tenant_id' => (int) $tenant->getKey()])
->assertNotified('Cannot start capture')
->assertStatus(200);
Queue::assertNotPushed(CaptureBaselineSnapshotJob::class);
expect(OperationRun::query()->where('type', 'baseline_capture')->count())->toBe(0);
});

View File

@ -1,5 +1,7 @@
<?php
require_once dirname(__DIR__).'/Baselines/Support/FakeCompareStrategy.php';
use App\Filament\Resources\BaselineProfileResource;
use App\Filament\Resources\BaselineProfileResource\Pages\ViewBaselineProfile;
use App\Jobs\CompareBaselineToTenantJob;
@ -8,12 +10,18 @@
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;
use Illuminate\Support\Facades\DB;
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
{
@ -148,6 +156,64 @@ function baselineProfileHeaderActions(Testable $component): array
expect(OperationRun::query()->where('type', 'baseline_compare')->count())->toBe(0);
});
it('shows mixed-strategy compare rejection truth on the workspace start surface', function (): void {
Queue::fake();
[$user, $tenant] = createUserWithTenant(role: 'owner');
app()->instance(GovernanceSubjectTaxonomyRegistry::class, new FakeGovernanceSubjectTaxonomyRegistry);
app()->instance(CompareStrategyRegistry::class, new CompareStrategyRegistry([
app(IntuneCompareStrategy::class),
app(FakeCompareStrategy::class),
]));
$profile = BaselineProfile::factory()->active()->create([
'workspace_id' => (int) $tenant->workspace_id,
'scope_jsonb' => [
'version' => 2,
'entries' => [
[
'domain_key' => 'intune',
'subject_class' => 'policy',
'subject_type_keys' => ['deviceConfiguration'],
'filters' => [],
],
[
'domain_key' => 'entra',
'subject_class' => 'control',
'subject_type_keys' => ['conditionalAccessPolicy'],
'filters' => [],
],
],
],
]);
$snapshot = BaselineSnapshot::factory()->create([
'workspace_id' => (int) $tenant->workspace_id,
'baseline_profile_id' => (int) $profile->getKey(),
]);
$profile->update(['active_snapshot_id' => (int) $snapshot->getKey()]);
BaselineTenantAssignment::factory()->create([
'workspace_id' => (int) $tenant->workspace_id,
'tenant_id' => (int) $tenant->getKey(),
'baseline_profile_id' => (int) $profile->getKey(),
]);
session()->put(WorkspaceContext::SESSION_KEY, (int) $tenant->workspace_id);
Livewire::actingAs($user)
->test(ViewBaselineProfile::class, ['record' => $profile->getKey()])
->assertSee('Mixed strategy scope')
->callAction('compareNow', data: ['target_tenant_id' => (int) $tenant->getKey()])
->assertNotified('Cannot start comparison')
->assertStatus(200);
Queue::assertNotPushed(CompareBaselineToTenantJob::class);
expect(OperationRun::query()->where('type', 'baseline_compare')->count())->toBe(0);
});
it('moves compare-matrix navigation into related context while keeping compare-assigned-tenants secondary', function (): void {
[$user, $tenant] = createUserWithTenant(role: 'owner');
$this->actingAs($user);
@ -229,3 +295,86 @@ function baselineProfileHeaderActions(Testable $component): array
expect(collect(BaselineProfileResource::detailRelatedContextEntries($profile))->pluck('key')->all())
->toContain('compare_matrix');
});
it('shows readiness copy without exposing raw canonical scope json on the compare start surface', function (): void {
[$user, $tenant] = createUserWithTenant(role: 'owner');
$profile = BaselineProfile::factory()->active()->create([
'workspace_id' => (int) $tenant->workspace_id,
'scope_jsonb' => ['policy_types' => ['deviceConfiguration'], 'foundation_types' => []],
]);
$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('Support readiness')
->assertSee('Capture: ready. Compare: ready.')
->assertDontSee('subject_type_keys')
->assertDontSee('canonical_scope');
});
it('does not start baseline compare when the stored canonical scope is invalid', function (): void {
Queue::fake();
[$user, $tenant] = createUserWithTenant(role: 'owner');
$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(),
]);
DB::table('baseline_profiles')
->where('id', (int) $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),
'updated_at' => now(),
]);
session()->put(WorkspaceContext::SESSION_KEY, (int) $tenant->workspace_id);
Livewire::actingAs($user)
->test(ViewBaselineProfile::class, ['record' => $profile->getKey()])
->assertActionVisible('compareNow')
->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);
});

View File

@ -3,8 +3,10 @@
declare(strict_types=1);
use App\Filament\Resources\BaselineProfileResource\Pages\CreateBaselineProfile;
use App\Filament\Resources\BaselineProfileResource\Pages\EditBaselineProfile;
use App\Models\BaselineProfile;
use Filament\Forms\Components\Select;
use Illuminate\Validation\ValidationException;
use Livewire\Livewire;
it('shows only baseline-supported foundation types in the baseline profile scope picker', function (): void {
@ -61,3 +63,33 @@
expect(BaselineProfile::query()->where('name', 'Invalid RBAC baseline')->exists())->toBeFalse();
});
it('rejects inactive canonical foundation subject types when editing a baseline profile', function (): void {
[$user, $tenant] = createUserWithTenant(role: 'owner');
$profile = BaselineProfile::factory()->active()->create([
'workspace_id' => (int) $tenant->workspace_id,
'name' => 'Editable RBAC baseline',
]);
$component = Livewire::actingAs($user)
->test(EditBaselineProfile::class, ['record' => $profile->getKey()]);
$page = $component->instance();
$method = new \ReflectionMethod($page, 'mutateFormDataBeforeSave');
$method->setAccessible(true);
expect(fn () => $method->invoke($page, [
'scope_jsonb' => [
'version' => 2,
'entries' => [
[
'domain_key' => 'platform_foundation',
'subject_class' => 'configuration_resource',
'subject_type_keys' => ['intuneRoleAssignment'],
'filters' => [],
],
],
],
]))->toThrow(ValidationException::class, 'Inactive subject type');
});

View File

@ -0,0 +1,227 @@
<?php
declare(strict_types=1);
use App\Filament\Resources\BaselineProfileResource;
use App\Filament\Resources\BaselineProfileResource\Pages\CreateBaselineProfile;
use App\Filament\Resources\BaselineProfileResource\Pages\EditBaselineProfile;
use App\Filament\Resources\BaselineProfileResource\Pages\ViewBaselineProfile;
use App\Models\BaselineProfile;
use App\Support\Governance\GovernanceSubjectTaxonomyRegistry;
use App\Support\Workspaces\WorkspaceContext;
use Illuminate\Validation\ValidationException;
use Illuminate\Support\Facades\DB;
use Livewire\Livewire;
it('persists canonical v2 scope when creating a baseline profile through the current selectors', function (): void {
config()->set('tenantpilot.supported_policy_types', [
['type' => 'deviceConfiguration', 'label' => 'Device Configuration'],
['type' => 'deviceCompliancePolicy', 'label' => 'Device Compliance'],
]);
config()->set('tenantpilot.foundation_types', [
['type' => 'assignmentFilter', 'label' => 'Assignment Filter', 'baseline_compare' => ['supported' => true]],
['type' => 'intuneRoleAssignment', 'label' => 'Intune RBAC Role Assignment', 'baseline_compare' => ['supported' => false]],
]);
[$user, $tenant] = createUserWithTenant(role: 'owner');
session()->put(WorkspaceContext::SESSION_KEY, (int) $tenant->workspace_id);
Livewire::actingAs($user)
->test(CreateBaselineProfile::class)
->fillForm([
'name' => 'Canonical baseline profile',
'scope_jsonb.policy_types' => ['deviceConfiguration'],
'scope_jsonb.foundation_types' => ['assignmentFilter'],
])
->call('create')
->assertHasNoFormErrors()
->assertNotified();
$profile = BaselineProfile::query()
->where('workspace_id', (int) $tenant->workspace_id)
->where('name', 'Canonical baseline profile')
->sole();
expect($profile->scope_jsonb)->toBe([
'policy_types' => ['deviceConfiguration'],
'foundation_types' => ['assignmentFilter'],
]);
expect($profile->canonicalScopeJsonb())->toBe([
'version' => 2,
'entries' => [
[
'domain_key' => 'intune',
'subject_class' => 'policy',
'subject_type_keys' => ['deviceConfiguration'],
'filters' => [],
],
[
'domain_key' => 'platform_foundation',
'subject_class' => 'configuration_resource',
'subject_type_keys' => ['assignmentFilter'],
'filters' => [],
],
],
]);
});
it('normalizes legacy scope on read and saves it forward as canonical v2 on edit', function (): void {
config()->set('tenantpilot.supported_policy_types', [
['type' => 'deviceConfiguration', 'label' => 'Device Configuration'],
['type' => 'deviceCompliancePolicy', 'label' => 'Device Compliance'],
]);
config()->set('tenantpilot.foundation_types', [
['type' => 'assignmentFilter', 'label' => 'Assignment Filter', 'baseline_compare' => ['supported' => true]],
]);
[$user, $tenant] = createUserWithTenant(role: 'owner');
session()->put(WorkspaceContext::SESSION_KEY, (int) $tenant->workspace_id);
$profileId = BaselineProfile::query()->insertGetId([
'workspace_id' => (int) $tenant->workspace_id,
'name' => 'Legacy baseline profile',
'description' => null,
'version_label' => null,
'status' => 'active',
'capture_mode' => 'opportunistic',
'scope_jsonb' => json_encode([
'policy_types' => [],
'foundation_types' => ['assignmentFilter'],
], JSON_THROW_ON_ERROR),
'active_snapshot_id' => null,
'created_by_user_id' => (int) $user->getKey(),
'created_at' => now(),
'updated_at' => now(),
]);
$profile = BaselineProfile::query()->findOrFail($profileId);
expect($profile->normalizedScope()->normalizationLineage())->toMatchArray([
'source_shape' => 'legacy',
'normalized_on_read' => true,
'save_forward_required' => true,
'legacy_keys_present' => ['policy_types', 'foundation_types'],
])->and($profile->scope_jsonb)->toBe([
'policy_types' => [],
'foundation_types' => ['assignmentFilter'],
]);
Livewire::actingAs($user)
->test(EditBaselineProfile::class, ['record' => $profileId])
->fillForm([
'description' => 'Updated after normalization',
])
->call('save')
->assertHasNoFormErrors()
->assertNotified();
$profile->refresh();
$registry = app(GovernanceSubjectTaxonomyRegistry::class);
expect($profile->canonicalScopeJsonb())->toBe([
'version' => 2,
'entries' => [
[
'domain_key' => 'intune',
'subject_class' => 'policy',
'subject_type_keys' => $registry->activeLegacyBucketKeys('policy_types'),
'filters' => [],
],
[
'domain_key' => 'platform_foundation',
'subject_class' => 'configuration_resource',
'subject_type_keys' => ['assignmentFilter'],
'filters' => [],
],
],
])->and($profile->normalizedScope()->normalizationLineage())->toMatchArray([
'source_shape' => 'canonical_v2',
'normalized_on_read' => false,
'save_forward_required' => false,
]);
});
it('summarizes governed subjects, readiness, and save-forward feedback for current selector payloads', function (): void {
config()->set('tenantpilot.supported_policy_types', [
['type' => 'deviceConfiguration', 'label' => 'Device Configuration'],
['type' => 'deviceCompliancePolicy', 'label' => 'Device Compliance'],
]);
config()->set('tenantpilot.foundation_types', [
['type' => 'assignmentFilter', 'label' => 'Assignment Filter', 'baseline_compare' => ['supported' => true]],
]);
$payload = [
'policy_types' => ['deviceConfiguration'],
'foundation_types' => ['assignmentFilter'],
];
expect(BaselineProfileResource::scopeSummaryText($payload))
->toBe('Intune policies: Device Configuration; Platform foundation configuration resources: Assignment Filter')
->and(BaselineProfileResource::scopeSupportReadinessText($payload))
->toBe('Capture: ready. Compare: ready.')
->and(BaselineProfileResource::scopeSelectionFeedbackText($payload))
->toBe('This Intune-first selection will be saved forward as canonical governed-subject scope V2.');
});
it('shows normalization lineage on the baseline profile detail surface before a legacy row is saved forward', function (): void {
config()->set('tenantpilot.supported_policy_types', [
['type' => 'deviceConfiguration', 'label' => 'Device Configuration'],
['type' => 'deviceCompliancePolicy', 'label' => 'Device Compliance'],
]);
[$user, $tenant] = createUserWithTenant(role: 'owner');
session()->put(WorkspaceContext::SESSION_KEY, (int) $tenant->workspace_id);
$profileId = BaselineProfile::query()->insertGetId([
'workspace_id' => (int) $tenant->workspace_id,
'name' => 'Legacy lineage profile',
'description' => null,
'version_label' => null,
'status' => 'active',
'capture_mode' => 'opportunistic',
'scope_jsonb' => json_encode([
'policy_types' => ['deviceConfiguration'],
'foundation_types' => [],
], JSON_THROW_ON_ERROR),
'active_snapshot_id' => null,
'created_by_user_id' => (int) $user->getKey(),
'created_at' => now(),
'updated_at' => now(),
]);
Livewire::actingAs($user)
->test(ViewBaselineProfile::class, ['record' => $profileId])
->assertSee('Governed subject summary')
->assertSee('Intune policies: Device Configuration')
->assertSee('Legacy Intune buckets are being normalized and will be saved forward as canonical V2 on the next successful save.');
});
it('rejects unsupported canonical filters when creating a baseline profile', function (): void {
[$user, $tenant] = createUserWithTenant(role: 'owner');
session()->put(WorkspaceContext::SESSION_KEY, (int) $tenant->workspace_id);
$component = Livewire::actingAs($user)
->test(CreateBaselineProfile::class);
$page = $component->instance();
$method = new \ReflectionMethod($page, 'mutateFormDataBeforeCreate');
$method->setAccessible(true);
expect(fn () => $method->invoke($page, [
'name' => 'Invalid filtered baseline',
'scope_jsonb' => [
'version' => 2,
'entries' => [
[
'domain_key' => 'intune',
'subject_class' => 'policy',
'subject_type_keys' => ['deviceConfiguration'],
'filters' => ['tenant_ids' => ['tenant-a']],
],
],
],
]))->toThrow(ValidationException::class, 'Filters are not supported');
expect(BaselineProfile::query()->where('name', 'Invalid filtered baseline')->exists())->toBeFalse();
});

View File

@ -7,12 +7,16 @@
use App\Models\BaselineSnapshot;
use App\Models\BaselineTenantAssignment;
use App\Models\OperationRun;
use App\Services\Baselines\BaselineCaptureService;
use App\Services\Baselines\BaselineCompareService;
use App\Support\Baselines\BaselineCompareReasonCode;
use App\Support\Baselines\BaselineCompareStats;
use App\Support\Baselines\BaselineReasonCodes;
use App\Support\Ui\GovernanceArtifactTruth\ArtifactTruthPresenter;
use App\Support\Workspaces\WorkspaceContext;
use Filament\Facades\Filament;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Queue;
use Livewire\Features\SupportTesting\Testable;
use Livewire\Livewire;
@ -147,6 +151,73 @@ function visibleLivewireText(Testable $component): string
->and(mb_strpos($pageText, 'Decision'))->toBeLessThan(mb_strpos($pageText, 'Artifact truth details'));
});
it('shows strategy diagnostics and operator-safe failure meaning for strategy-owned compare failures', function (): void {
[$user, $tenant] = createUserWithTenant(role: 'owner');
$run = OperationRun::factory()->create([
'tenant_id' => (int) $tenant->getKey(),
'workspace_id' => (int) $tenant->workspace_id,
'type' => 'baseline_compare',
'status' => 'completed',
'outcome' => 'partially_succeeded',
'context' => [
'baseline_compare' => [
'reason_code' => BaselineCompareReasonCode::StrategyFailed->value,
'coverage' => [
'proof' => true,
'effective_types' => ['conditionalAccessPolicy'],
'covered_types' => ['conditionalAccessPolicy'],
'uncovered_types' => [],
],
'evidence_gaps' => [
'count' => 1,
'by_reason' => [
'strategy_failed' => 1,
],
],
'strategy' => [
'key' => 'intune_policy',
'selection_state' => 'supported',
'operator_reason' => 'Compare strategy resolved successfully.',
'execution_diagnostics' => [
'failed' => true,
'exception_class' => RuntimeException::class,
],
'state_counts' => [
'failed' => 2,
],
],
],
],
'summary_counts' => [
'total' => 0,
'processed' => 0,
'errors_recorded' => 1,
],
'completed_at' => now(),
]);
$truth = app(ArtifactTruthPresenter::class)->forOperationRun($run->fresh());
$explanation = $truth->operatorExplanation;
Filament::setTenant(null, true);
$this->withSession([WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id]);
session([WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id]);
Livewire::actingAs($user)
->test(TenantlessOperationRunViewer::class, ['run' => $run])
->assertSee($explanation?->headline ?? '')
->assertSee($explanation?->nextActionText ?? '')
->assertSee('Compare strategy')
->assertSee('Intune Policy')
->assertSee('Strategy selection')
->assertSee('Supported')
->assertSee('Strategy subject states')
->assertSee('Failed 2')
->assertSee('Baseline compare evidence')
->assertSee('RuntimeException');
});
it('deduplicates repeated artifact truth explanation text for follow-up runs without a usable artifact', function (): void {
[$user, $tenant] = createUserWithTenant(role: 'owner');
@ -248,3 +319,85 @@ function visibleLivewireText(Testable $component): string
->assertSee($explanation?->trustworthinessLabel() ?? '')
->assertDontSee('No confirmed drift in the latest baseline compare.');
});
it('records canonical effective scope and compatibility projection for baseline capture runs', function (): void {
Queue::fake();
[$user, $tenant] = createUserWithTenant(role: 'owner');
$profile = BaselineProfile::factory()->active()->create([
'workspace_id' => (int) $tenant->workspace_id,
'scope_jsonb' => ['policy_types' => ['deviceConfiguration'], 'foundation_types' => []],
]);
$result = app(BaselineCaptureService::class)->startCapture($profile, $tenant, $user);
expect($result['ok'])->toBeTrue();
$run = $result['run'];
$effectiveScope = is_array(data_get($run->context, 'effective_scope')) ? data_get($run->context, 'effective_scope') : [];
expect(data_get($effectiveScope, 'canonical_scope.version'))->toBe(2)
->and(data_get($effectiveScope, 'canonical_scope.entries.0.domain_key'))->toBe('intune')
->and(data_get($effectiveScope, 'canonical_scope.entries.0.subject_class'))->toBe('policy')
->and(data_get($effectiveScope, 'canonical_scope.entries.0.subject_type_keys'))->toBe(['deviceConfiguration'])
->and(data_get($effectiveScope, 'legacy_projection.policy_types'))->toBe(['deviceConfiguration'])
->and(data_get($effectiveScope, 'legacy_projection.foundation_types'))->toBe([])
->and(data_get($effectiveScope, 'selected_type_keys'))->toBe(['deviceConfiguration'])
->and(data_get($effectiveScope, 'allowed_type_keys'))->toBe(['deviceConfiguration'])
->and(data_get($effectiveScope, 'unsupported_type_keys'))->toBe([]);
});
it('normalizes legacy compare assignment overrides into canonical effective scope without rewriting the override row', function (): void {
Queue::fake();
[$user, $tenant] = createUserWithTenant(role: 'owner');
$profile = BaselineProfile::factory()->active()->create([
'workspace_id' => (int) $tenant->workspace_id,
'scope_jsonb' => ['policy_types' => ['deviceConfiguration', 'deviceCompliancePolicy'], 'foundation_types' => []],
]);
$snapshot = BaselineSnapshot::factory()->complete()->create([
'workspace_id' => (int) $tenant->workspace_id,
'baseline_profile_id' => (int) $profile->getKey(),
]);
$profile->update(['active_snapshot_id' => (int) $snapshot->getKey()]);
$assignment = BaselineTenantAssignment::factory()->create([
'workspace_id' => (int) $tenant->workspace_id,
'tenant_id' => (int) $tenant->getKey(),
'baseline_profile_id' => (int) $profile->getKey(),
'override_scope_jsonb' => null,
]);
DB::table('baseline_tenant_assignments')
->where('id', (int) $assignment->getKey())
->update([
'override_scope_jsonb' => json_encode([
'policy_types' => ['deviceConfiguration'],
'foundation_types' => [],
], JSON_THROW_ON_ERROR),
'updated_at' => now(),
]);
$result = app(BaselineCompareService::class)->startCompare($tenant, $user);
expect($result['ok'])->toBeTrue();
$run = $result['run'];
$effectiveScope = is_array(data_get($run->context, 'effective_scope')) ? data_get($run->context, 'effective_scope') : [];
$rawOverride = DB::table('baseline_tenant_assignments')
->where('id', (int) $assignment->getKey())
->value('override_scope_jsonb');
expect(data_get($effectiveScope, 'canonical_scope.version'))->toBe(2)
->and(data_get($effectiveScope, 'canonical_scope.entries.0.subject_type_keys'))->toBe(['deviceConfiguration'])
->and(data_get($effectiveScope, 'legacy_projection.policy_types'))->toBe(['deviceConfiguration'])
->and(data_get($effectiveScope, 'selected_type_keys'))->toBe(['deviceConfiguration'])
->and(json_decode((string) $rawOverride, true, flags: JSON_THROW_ON_ERROR))->toBe([
'policy_types' => ['deviceConfiguration'],
'foundation_types' => [],
]);
});

View File

@ -47,3 +47,183 @@
expect($scope->foundationTypes)->toBe(['assignmentFilter']);
expect($scope->allTypes())->toBe(['assignmentFilter', 'deviceConfiguration']);
});
it('normalizes canonical v2 entries and preserves canonical storage', function (): void {
config()->set('tenantpilot.supported_policy_types', [
['type' => 'deviceConfiguration', 'label' => 'Device Configuration'],
['type' => 'deviceCompliancePolicy', 'label' => 'Device Compliance'],
]);
config()->set('tenantpilot.foundation_types', [
['type' => 'assignmentFilter', 'label' => 'Assignment Filter', 'baseline_compare' => ['supported' => true]],
]);
$scope = BaselineScope::fromJsonb([
'version' => 2,
'entries' => [
[
'domain_key' => 'intune',
'subject_class' => 'policy',
'subject_type_keys' => ['deviceConfiguration', 'deviceCompliancePolicy', 'deviceConfiguration'],
'filters' => [],
],
[
'domain_key' => 'intune',
'subject_class' => 'policy',
'subject_type_keys' => ['deviceCompliancePolicy'],
'filters' => [],
],
[
'domain_key' => 'platform_foundation',
'subject_class' => 'configuration_resource',
'subject_type_keys' => ['assignmentFilter'],
'filters' => [],
],
],
]);
expect($scope->policyTypes)->toBe(['deviceCompliancePolicy', 'deviceConfiguration'])
->and($scope->foundationTypes)->toBe(['assignmentFilter'])
->and($scope->toStoredJsonb())->toBe([
'version' => 2,
'entries' => [
[
'domain_key' => 'intune',
'subject_class' => 'policy',
'subject_type_keys' => ['deviceCompliancePolicy', 'deviceConfiguration'],
'filters' => [],
],
[
'domain_key' => 'platform_foundation',
'subject_class' => 'configuration_resource',
'subject_type_keys' => ['assignmentFilter'],
'filters' => [],
],
],
])
->and($scope->normalizationLineage())->toMatchArray([
'source_shape' => 'canonical_v2',
'normalized_on_read' => false,
'save_forward_required' => false,
]);
});
it('treats a missing legacy bucket like its empty default when the other bucket is present', function (): void {
config()->set('tenantpilot.supported_policy_types', [
['type' => 'deviceConfiguration'],
['type' => 'deviceCompliancePolicy'],
]);
config()->set('tenantpilot.foundation_types', [
['type' => 'assignmentFilter', 'baseline_compare' => ['supported' => true]],
]);
$policyOnly = BaselineScope::fromJsonb([
'policy_types' => ['deviceConfiguration'],
]);
$foundationOnly = BaselineScope::fromJsonb([
'foundation_types' => ['assignmentFilter'],
]);
expect($policyOnly->policyTypes)->toBe(['deviceConfiguration'])
->and($policyOnly->foundationTypes)->toBe([])
->and($foundationOnly->policyTypes)->toBe(['deviceCompliancePolicy', 'deviceConfiguration'])
->and($foundationOnly->foundationTypes)->toBe(['assignmentFilter']);
});
it('rejects mixed legacy and canonical payloads', function (): void {
expect(fn () => BaselineScope::fromJsonb([
'policy_types' => ['deviceConfiguration'],
'version' => 2,
'entries' => [
[
'domain_key' => 'intune',
'subject_class' => 'policy',
'subject_type_keys' => ['deviceConfiguration'],
],
],
]))->toThrow(InvalidArgumentException::class, 'must not mix legacy buckets');
});
it('rejects unsupported filters for current domains', function (): void {
config()->set('tenantpilot.supported_policy_types', [
['type' => 'deviceConfiguration'],
]);
expect(fn () => BaselineScope::fromJsonb([
'version' => 2,
'entries' => [
[
'domain_key' => 'intune',
'subject_class' => 'policy',
'subject_type_keys' => ['deviceConfiguration'],
'filters' => ['tenant_ids' => ['tenant-a']],
],
],
]))->toThrow(InvalidArgumentException::class, 'Filters are not supported');
});
it('treats empty legacy override payloads as no override when requested', function (): void {
$scope = BaselineScope::fromJsonb([
'policy_types' => [],
'foundation_types' => [],
], allowEmptyLegacyAsNoOverride: true);
expect($scope->isEmpty())->toBeTrue();
});
it('rejects unknown governance domains', function (): void {
expect(fn () => BaselineScope::fromJsonb([
'version' => 2,
'entries' => [
[
'domain_key' => 'unknown_domain',
'subject_class' => 'policy',
'subject_type_keys' => ['deviceConfiguration'],
'filters' => [],
],
],
]))->toThrow(InvalidArgumentException::class, 'Unknown governance domain');
});
it('rejects invalid subject classes for known domains', function (): void {
expect(fn () => BaselineScope::fromJsonb([
'version' => 2,
'entries' => [
[
'domain_key' => 'intune',
'subject_class' => 'configuration_resource',
'subject_type_keys' => ['deviceConfiguration'],
'filters' => [],
],
],
]))->toThrow(InvalidArgumentException::class, 'is not valid for domain');
});
it('rejects inactive subject types in canonical scope entries', function (): void {
expect(fn () => BaselineScope::fromJsonb([
'version' => 2,
'entries' => [
[
'domain_key' => 'platform_foundation',
'subject_class' => 'configuration_resource',
'subject_type_keys' => ['intuneRoleAssignment'],
'filters' => [],
],
],
]))->toThrow(InvalidArgumentException::class, 'Inactive subject type');
});
it('rejects future-domain selections that have no active subject type mapping yet', function (): void {
expect(fn () => BaselineScope::fromJsonb([
'version' => 2,
'entries' => [
[
'domain_key' => 'entra',
'subject_class' => 'control',
'subject_type_keys' => ['conditionalAccessPolicy'],
'filters' => [],
],
],
]))->toThrow(InvalidArgumentException::class, 'Unknown subject type');
});

View File

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

View File

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

View File

@ -0,0 +1,45 @@
<?php
declare(strict_types=1);
use App\Support\Governance\GovernanceDomainKey;
use App\Support\Governance\GovernanceSubjectClass;
use App\Support\Governance\GovernanceSubjectTaxonomyRegistry;
it('composes active governance subject types from current policy and foundation metadata', function (): void {
$registry = app(GovernanceSubjectTaxonomyRegistry::class);
$subjectTypes = collect($registry->active())
->keyBy(static fn ($subjectType): string => $subjectType->subjectTypeKey)
->all();
expect($subjectTypes['deviceConfiguration']->domainKey)->toBe(GovernanceDomainKey::Intune)
->and($subjectTypes['deviceConfiguration']->subjectClass)->toBe(GovernanceSubjectClass::Policy)
->and($subjectTypes['deviceConfiguration']->captureSupported)->toBeTrue()
->and($subjectTypes['deviceConfiguration']->compareSupported)->toBeTrue()
->and($subjectTypes['deviceConfiguration']->legacyBucket)->toBe('policy_types')
->and($subjectTypes['assignmentFilter']->domainKey)->toBe(GovernanceDomainKey::PlatformFoundation)
->and($subjectTypes['assignmentFilter']->subjectClass)->toBe(GovernanceSubjectClass::ConfigurationResource)
->and($subjectTypes['assignmentFilter']->legacyBucket)->toBe('foundation_types')
->and(array_key_exists('intuneRoleAssignment', $subjectTypes))->toBeFalse();
});
it('keeps unsupported foundation mappings addressable but inactive in the complete registry', function (): void {
$registry = app(GovernanceSubjectTaxonomyRegistry::class);
$subjectType = $registry->find('platform_foundation', 'intuneRoleAssignment');
expect($subjectType)->not->toBeNull()
->and($subjectType?->active)->toBeFalse()
->and($subjectType?->captureSupported)->toBeFalse()
->and($subjectType?->compareSupported)->toBeFalse();
});
it('reserves future-domain vocabulary without exposing future domains as active operator selections', function (): void {
$registry = app(GovernanceSubjectTaxonomyRegistry::class);
expect($registry->isKnownDomain('entra'))->toBeTrue()
->and($registry->allowsSubjectClass('entra', 'control'))->toBeTrue()
->and(collect($registry->active())->contains(
static fn ($subjectType): bool => $subjectType->domainKey === GovernanceDomainKey::Entra,
))->toBeFalse();
});

View File

@ -1,6 +1,7 @@
<?php
use App\Services\Baselines\InventoryMetaContract;
use App\Support\Inventory\InventoryPolicyTypeMeta;
it('builds a deterministic v1 contract regardless of input ordering', function () {
$builder = app(InventoryMetaContract::class);
@ -57,3 +58,35 @@
expect($contract['scope_tag_ids'])->toBeNull();
expect($contract['assignment_target_count'])->toBeNull();
});
it('keeps baseline support contracts aligned with governance mapping for policies and foundations', function (): void {
$policyContract = InventoryPolicyTypeMeta::baselineSupportContract('deviceConfiguration');
$foundationContract = InventoryPolicyTypeMeta::baselineSupportContract('intuneRoleDefinition');
$unsupportedFoundationContract = InventoryPolicyTypeMeta::baselineSupportContract('intuneRoleAssignment');
expect($policyContract)->toMatchArray([
'config_supported' => true,
'runtime_valid' => true,
'subject_class' => 'policy_backed',
'resolution_path' => 'policy',
'compare_capability' => 'supported',
'capture_capability' => 'supported',
'source_model_expected' => 'policy',
])->and($foundationContract)->toMatchArray([
'config_supported' => true,
'runtime_valid' => true,
'subject_class' => 'foundation_backed',
'resolution_path' => 'foundation_policy',
'compare_capability' => 'supported',
'capture_capability' => 'supported',
'source_model_expected' => 'policy',
])->and($unsupportedFoundationContract)->toMatchArray([
'config_supported' => false,
'runtime_valid' => true,
'subject_class' => 'foundation_backed',
'resolution_path' => 'foundation_policy',
'compare_capability' => 'unsupported',
'capture_capability' => 'unsupported',
'source_model_expected' => 'policy',
]);
});

View File

@ -0,0 +1,36 @@
# Specification Quality Checklist: Governance Subject Taxonomy and Baseline Scope V2
**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
- Validation completed in one pass.
- No clarification markers remain in the specification.
- The spec keeps platform vocabulary, rollout strategy, and Intune no-regression behavior explicit while avoiding a broader plugin or model-generalization framework.

View File

@ -0,0 +1,618 @@
openapi: 3.1.0
info:
title: Governance Subject Taxonomy and Baseline Scope V2 Internal Contract
version: 0.1.0
summary: Internal logical contract for canonical baseline scope, taxonomy listing, save-forward writes, and normalized baseline operation starts
description: |
This contract is an internal planning artifact for Spec 202. The affected
surfaces still render through Filament and Livewire, and baseline capture or
compare continues to run through the existing Laravel services and jobs. The
schemas below define the canonical baseline scope document, the governance
subject taxonomy registry, legacy normalization behavior, and the effective
scope that capture and compare must consume. The path entries below are
logical boundary identifiers for existing Filament, Livewire, and service
entry points only; they do not imply new HTTP controllers or routes.
x-logical-artifact: true
x-governance-subject-taxonomy-consumers:
- surface: baseline.profile.form
sourceFiles:
- apps/platform/app/Filament/Resources/BaselineProfileResource.php
- apps/platform/app/Filament/Resources/BaselineProfileResource/Pages/CreateBaselineProfile.php
- apps/platform/app/Filament/Resources/BaselineProfileResource/Pages/EditBaselineProfile.php
mustRender:
- normalized_scope_summary
- active_subject_groups
- support_readiness
- invalid_scope_feedback
mustAccept:
- legacy_scope_input
- canonical_scope_v2
- surface: baseline.profile.detail
sourceFiles:
- apps/platform/app/Filament/Resources/BaselineProfileResource/Pages/ViewBaselineProfile.php
mustRender:
- canonical_scope_summary
- support_readiness
- normalization_lineage_on_demand
- surface: baseline.scope.backfill.command
sourceFiles:
- apps/platform/app/Console/Commands/BackfillBaselineScopeV2.php
mustAccept:
- preview_mode_by_default
- explicit_write_confirmation
mustProduce:
- candidate_rewrite_summary
- committed_rewrite_summary
- committed_write_audit_logging
- surface: baseline.capture.start
sourceFiles:
- apps/platform/app/Services/Baselines/BaselineCaptureService.php
mustConsume:
- effective_scope_v2
- capture_eligible_subject_types
- compatibility_projection_if_needed
- surface: baseline.compare.start
sourceFiles:
- apps/platform/app/Services/Baselines/BaselineCompareService.php
mustConsume:
- effective_scope_v2
- compare_eligible_subject_types
- compatibility_projection_if_needed
paths:
/internal/workspaces/{workspace}/governance-subject-taxonomy/baseline-subject-types:
get:
summary: List active baseline-selectable governance subject types for the current workspace context
operationId: listBaselineSubjectTypes
parameters:
- name: workspace
in: path
required: true
schema:
type: integer
responses:
'200':
description: Grouped taxonomy metadata for baseline scope selection and validation
content:
application/vnd.tenantpilot.governance-taxonomy+json:
schema:
$ref: '#/components/schemas/GovernanceSubjectTaxonomyRegistry'
'403':
description: Actor is in scope but lacks workspace baseline view capability
'404':
description: Workspace is outside actor scope
/internal/workspaces/{workspace}/baseline-scope/normalize:
post:
summary: Normalize legacy or canonical scope input into Baseline Scope V2
operationId: normalizeBaselineScope
parameters:
- name: workspace
in: path
required: true
schema:
type: integer
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/LegacyOrV2ScopeInput'
responses:
'200':
description: Canonical V2 scope plus summary and validation detail
content:
application/vnd.tenantpilot.baseline-scope-normalized+json:
schema:
$ref: '#/components/schemas/BaselineScopeNormalizationResult'
'422':
description: Scope input is invalid or ambiguous after normalization
content:
application/vnd.tenantpilot.baseline-scope-errors+json:
schema:
$ref: '#/components/schemas/BaselineScopeValidationErrors'
'403':
description: Actor is in scope but lacks workspace baseline manage capability
'404':
description: Workspace is outside actor scope
/internal/workspaces/{workspace}/baseline-scope/backfill:
post:
summary: Logical maintenance boundary for previewing or committing baseline profile scope backfill
operationId: backfillBaselineProfileScopeV2
description: Logical-only maintenance contract for `baseline_profiles.scope_jsonb`; compare assignment overrides remain tolerant-read only in this release.
parameters:
- name: workspace
in: path
required: true
schema:
type: integer
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/BaselineScopeBackfillRequest'
responses:
'200':
description: Preview or commit summary for the baseline profile scope backfill command
content:
application/vnd.tenantpilot.baseline-scope-backfill+json:
schema:
$ref: '#/components/schemas/BaselineScopeBackfillResult'
'403':
description: Actor is in scope but lacks workspace baseline manage capability
'404':
description: Workspace is outside actor scope
/admin/baseline-profiles:
post:
summary: Create a baseline profile using a scope request that is canonicalized to V2 before persistence
operationId: createBaselineProfileWithCanonicalScope
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/BaselineProfileWriteRequest'
responses:
'201':
description: Baseline profile created with canonical V2 scope persisted
content:
application/vnd.tenantpilot.baseline-profile+json:
schema:
$ref: '#/components/schemas/BaselineProfileScopeEnvelope'
'422':
description: Scope validation failed
'403':
description: Actor is in scope but lacks workspace baseline manage capability
'404':
description: Workspace is outside actor scope
/admin/baseline-profiles/{profile}:
patch:
summary: Update a baseline profile and save scope forward as canonical V2
operationId: updateBaselineProfileWithCanonicalScope
parameters:
- name: profile
in: path
required: true
schema:
type: integer
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/BaselineProfileWriteRequest'
responses:
'200':
description: Baseline profile updated with canonical V2 scope persisted
content:
application/vnd.tenantpilot.baseline-profile+json:
schema:
$ref: '#/components/schemas/BaselineProfileScopeEnvelope'
'422':
description: Scope validation failed
'403':
description: Actor is in scope but lacks workspace baseline manage capability
'404':
description: Workspace or baseline profile is outside actor scope
/internal/tenants/{tenant}/baseline-profiles/{profile}/capture:
post:
summary: Start baseline capture using normalized effective scope
operationId: startBaselineCaptureWithNormalizedScope
parameters:
- name: tenant
in: path
required: true
schema:
type: integer
- name: profile
in: path
required: true
schema:
type: integer
responses:
'202':
description: Baseline capture accepted with canonical effective scope recorded in operation context
content:
application/vnd.tenantpilot.baseline-operation+json:
schema:
$ref: '#/components/schemas/BaselineOperationEnvelope'
'422':
description: Requested scope is invalid or includes unsupported capture subject types
'403':
description: Actor is in scope but lacks capability to start capture
'404':
description: Tenant or profile is outside actor scope
/internal/tenants/{tenant}/baseline-profiles/{profile}/compare:
post:
summary: Start baseline compare using normalized effective scope
operationId: startBaselineCompareWithNormalizedScope
parameters:
- name: tenant
in: path
required: true
schema:
type: integer
- name: profile
in: path
required: true
schema:
type: integer
responses:
'202':
description: Baseline compare accepted with canonical effective scope recorded in operation context
content:
application/vnd.tenantpilot.baseline-operation+json:
schema:
$ref: '#/components/schemas/BaselineOperationEnvelope'
'422':
description: Requested scope is invalid or includes unsupported compare subject types
'403':
description: Actor is in scope but lacks capability to start compare
'404':
description: Tenant or profile is outside actor scope
components:
schemas:
GovernanceDomainKey:
type: string
description: Current active values are `intune` and `platform_foundation`; additional domain keys may be introduced later without changing the V2 contract shape.
examples:
- intune
- platform_foundation
- entra
GovernanceSubjectClass:
type: string
enum:
- policy
- configuration_resource
- posture_dimension
- control
GovernanceSubjectType:
type: object
additionalProperties: false
required:
- domain_key
- subject_class
- subject_type_key
- label
- description
- capture_supported
- compare_supported
- inventory_supported
- active
properties:
domain_key:
$ref: '#/components/schemas/GovernanceDomainKey'
subject_class:
$ref: '#/components/schemas/GovernanceSubjectClass'
subject_type_key:
type: string
label:
type: string
description:
type:
- string
- 'null'
capture_supported:
type: boolean
compare_supported:
type: boolean
inventory_supported:
type: boolean
active:
type: boolean
support_mode:
type:
- string
- 'null'
legacy_bucket:
type:
- string
- 'null'
GovernanceSubjectTaxonomyRegistry:
type: object
additionalProperties: false
required:
- subject_types
properties:
subject_types:
type: array
items:
$ref: '#/components/schemas/GovernanceSubjectType'
LegacyBaselineScopePayload:
type: object
additionalProperties: false
minProperties: 1
properties:
policy_types:
type: array
description: Empty or omitted means all supported Intune policy subject types when the other legacy bucket is present.
items:
type: string
foundation_types:
type: array
description: Empty or omitted means no foundation subject types when the other legacy bucket is present.
items:
type: string
BaselineScopeEntryV2:
type: object
additionalProperties: false
required:
- domain_key
- subject_class
- subject_type_keys
properties:
domain_key:
$ref: '#/components/schemas/GovernanceDomainKey'
subject_class:
$ref: '#/components/schemas/GovernanceSubjectClass'
subject_type_keys:
type: array
minItems: 1
items:
type: string
filters:
type: object
additionalProperties: true
default: {}
BaselineScopeDocumentV2:
type: object
additionalProperties: false
required:
- version
- entries
properties:
version:
type: integer
enum:
- 2
entries:
type: array
minItems: 1
items:
$ref: '#/components/schemas/BaselineScopeEntryV2'
LegacyOrV2ScopeInput:
oneOf:
- $ref: '#/components/schemas/LegacyBaselineScopePayload'
- $ref: '#/components/schemas/BaselineScopeDocumentV2'
BaselineScopeSummaryGroup:
type: object
additionalProperties: false
required:
- domain_key
- subject_class
- group_label
- selected_subject_types
- capture_supported_count
- compare_supported_count
properties:
domain_key:
$ref: '#/components/schemas/GovernanceDomainKey'
subject_class:
$ref: '#/components/schemas/GovernanceSubjectClass'
group_label:
type: string
selected_subject_types:
description: Operator-facing selected subject labels for the group, not raw subject type keys.
type: array
items:
type: string
capture_supported_count:
type: integer
compare_supported_count:
type: integer
inactive_count:
type: integer
BaselineScopeNormalizationResult:
type: object
additionalProperties: false
required:
- canonical_scope
- normalization_lineage
- summary
properties:
canonical_scope:
$ref: '#/components/schemas/BaselineScopeDocumentV2'
normalization_lineage:
$ref: '#/components/schemas/BaselineScopeNormalizationLineage'
summary:
type: array
items:
$ref: '#/components/schemas/BaselineScopeSummaryGroup'
legacy_projection:
type:
- object
- 'null'
additionalProperties:
type: array
items:
type: string
BaselineScopeValidationError:
type: object
additionalProperties: false
required:
- code
- message
properties:
code:
type: string
message:
type: string
path:
type:
- string
- 'null'
BaselineScopeValidationErrors:
type: object
additionalProperties: false
required:
- errors
properties:
errors:
type: array
items:
$ref: '#/components/schemas/BaselineScopeValidationError'
BaselineProfileWriteRequest:
type: object
additionalProperties: false
required:
- name
- status
- capture_mode
- requested_scope
properties:
name:
type: string
description:
type:
- string
- 'null'
status:
type: string
capture_mode:
type: string
version_label:
type:
- string
- 'null'
requested_scope:
$ref: '#/components/schemas/LegacyOrV2ScopeInput'
BaselineProfileScopeEnvelope:
type: object
additionalProperties: false
required:
- profile_id
- persisted_scope
- normalization_lineage
- summary
properties:
profile_id:
type: integer
persisted_scope:
$ref: '#/components/schemas/BaselineScopeDocumentV2'
normalization_lineage:
$ref: '#/components/schemas/BaselineScopeNormalizationLineage'
summary:
type: array
items:
$ref: '#/components/schemas/BaselineScopeSummaryGroup'
BaselineScopeNormalizationLineage:
type: object
additionalProperties: false
required:
- source_shape
- normalized_on_read
- legacy_keys_present
- save_forward_required
properties:
source_shape:
type: string
enum:
- legacy
- canonical_v2
normalized_on_read:
type: boolean
legacy_keys_present:
type: array
items:
type: string
save_forward_required:
type: boolean
EffectiveBaselineScope:
type: object
additionalProperties: false
required:
- canonical_scope
- selected_type_keys
- allowed_type_keys
- limited_type_keys
- unsupported_type_keys
properties:
canonical_scope:
$ref: '#/components/schemas/BaselineScopeDocumentV2'
selected_type_keys:
type: array
items:
type: string
allowed_type_keys:
type: array
items:
type: string
limited_type_keys:
type: array
items:
type: string
unsupported_type_keys:
type: array
items:
type: string
capabilities_by_type:
type: object
additionalProperties: true
legacy_projection:
type:
- object
- 'null'
additionalProperties:
type: array
items:
type: string
BaselineOperationEnvelope:
type: object
additionalProperties: false
required:
- profile_id
- tenant_id
- operation_type
- effective_scope
properties:
profile_id:
type: integer
tenant_id:
type: integer
operation_type:
type: string
enum:
- baseline_capture
- baseline_compare
effective_scope:
$ref: '#/components/schemas/EffectiveBaselineScope'
run_id:
type:
- integer
- 'null'
BaselineScopeBackfillRequest:
type: object
additionalProperties: false
required:
- dry_run
properties:
dry_run:
type: boolean
default: true
write_confirmed:
type: boolean
default: false
BaselineScopeBackfillResult:
type: object
additionalProperties: false
required:
- mode
- candidate_count
- rewritten_count
- audit_logged
- scope_surface
properties:
mode:
type: string
enum:
- preview
- commit
candidate_count:
type: integer
rewritten_count:
type: integer
audit_logged:
type: boolean
scope_surface:
type: string
enum:
- baseline_profiles_only

View File

@ -0,0 +1,239 @@
# Data Model: Governance Subject Taxonomy and Baseline Scope V2
## Overview
This feature introduces no new persisted entity. It reuses existing baseline scope storage and operation context storage, but replaces the internal canonical meaning of baseline scope with a versioned V2 document backed by a governance subject taxonomy registry.
## Existing Source Truths Reused Without Change
### Baseline profile persistence
The following existing persisted fields remain authoritative and are not moved into a new table:
- `baseline_profiles.scope_jsonb`
- `baseline_tenant_assignments.override_scope_jsonb`
- `operation_runs.context.effective_scope`
This feature changes how those payloads are normalized and interpreted, not where they live.
For rollout closure in this release, only `baseline_profiles.scope_jsonb` is eligible for optional cleanup rewrite. `baseline_tenant_assignments.override_scope_jsonb` remains tolerant-read and compare-only normalization state.
### Taxonomy contributors already present in the repo
The following current contributors remain the underlying source material for the new registry:
- `config('tenantpilot.supported_policy_types')`
- `config('tenantpilot.foundation_types')`
- `InventoryPolicyTypeMeta::baselineSupportContract()`
- `InventoryPolicyTypeMeta::baselineCompareLabel()` and related metadata helpers
### Existing operation truth reused without change
- `baseline_capture` remains the canonical capture operation type
- `baseline_compare` remains the canonical compare operation type
- existing audit, authorization, and queued-run behavior remain unchanged
## New Canonical Contracts
### GovernanceDomainKey
**Type**: code enum or equivalent value object
**Purpose**: identify the governance domain that owns a subject type
| Value | Status | Notes |
|------|--------|-------|
| `intune` | active | Current Intune policy subject families |
| `platform_foundation` | active | Current non-policy foundation subject families used by baselines |
| future values | reserved | Later domains such as Entra or Teams may be added without changing the V2 shape |
### GovernanceSubjectClass
**Type**: code enum or equivalent value object
**Purpose**: describe the platform-level shape of a governed subject
| Value | Status | Notes |
|------|--------|-------|
| `policy` | active | Current Intune policy types |
| `configuration_resource` | active | Current baseline foundation artifacts |
| `posture_dimension` | reserved | Future non-policy posture dimensions |
| `control` | reserved | Future control-oriented subject families |
This is intentionally separate from the existing baseline support `SubjectClass` enum because that older enum encodes resolution behavior rather than platform-facing taxonomy.
### GovernanceSubjectType
**Type**: derived registry record
**Source**: config contributors plus existing support metadata
| Field | Type | Notes |
|------|------|-------|
| `domain_key` | string | `GovernanceDomainKey` value |
| `subject_class` | string | `GovernanceSubjectClass` value |
| `subject_type_key` | string | Domain-owned leaf type discriminator |
| `label` | string | Operator-facing label |
| `description` | string or null | Short operator or admin explanation |
| `capture_supported` | boolean | Whether baseline capture may include this subject type |
| `compare_supported` | boolean | Whether baseline compare may include this subject type |
| `inventory_supported` | boolean | Whether inventory-backed browsing exists for this type |
| `active` | boolean | Whether the type is currently selectable |
| `support_mode` | string | Derived from existing support contract for audit and validation detail |
| `legacy_bucket` | string or null | Transitional mapping back to `policy_types` or `foundation_types` when required |
### GovernanceSubjectTaxonomyRegistry
**Type**: in-process registry contract
**Source**: composed from the existing config and support contributors
Required lookup behaviors:
- list active baseline-selectable subject types
- lookup one subject type by `domain_key + subject_type_key`
- validate whether a subject class is legal for a given domain
- resolve operation support flags for capture and compare
- provide operator-safe label and description metadata
### BaselineScopeEntryV2
**Type**: canonical scope selector record
| Field | Type | Notes |
|------|------|-------|
| `domain_key` | string | Required governance domain |
| `subject_class` | string | Required platform-level subject class |
| `subject_type_keys` | array<string> | Required non-empty set of subject type keys |
| `filters` | map<string, mixed> | Optional; empty for current Intune behavior |
Normalization rules:
- `subject_type_keys` are deduplicated and sorted
- entries with the same `domain_key`, `subject_class`, and normalized `filters` may be merged by unioning `subject_type_keys`
- overlapping subject type keys across entries with different filters are rejected as ambiguous until filter semantics are explicitly supported
### BaselineScopeDocumentV2
**Type**: canonical baseline scope document
| Field | Type | Notes |
|------|------|-------|
| `version` | integer | Must equal `2` |
| `entries` | array<BaselineScopeEntryV2> | Non-empty array of canonical selectors |
Semantics:
- the document is explicit; defaults are resolved before persistence
- no entry may rely on implicit Intune-only meaning
- the document is the only canonical persisted form for new or updated baseline profiles
### LegacyBaselineScopePayload
**Type**: ingestion-only compatibility payload
| Field | Type | Notes |
|------|------|-------|
| `policy_types` | array<string> | Empty or omitted means all supported Intune policy subject types when legacy input is otherwise present |
| `foundation_types` | array<string> | Empty or omitted means no foundations when legacy input is otherwise present |
Mapping rules:
- `policy_types` normalize to one V2 entry with `domain_key = intune` and `subject_class = policy`
- `foundation_types` normalize to one V2 entry with `domain_key = platform_foundation` and `subject_class = configuration_resource`
- a legacy payload with one missing bucket normalizes the missing bucket using the same semantics as its empty-list default
- a legacy payload with neither bucket present is invalid and must be rejected before normalization
- a mixed payload containing both legacy fields and explicit V2 fields is rejected
### EffectiveBaselineScope
**Type**: derived operation-start contract
**Source**: canonical profile scope + compare-assignment override when applicable + operation support gating
| Field | Type | Notes |
|------|------|-------|
| `canonical_scope` | `BaselineScopeDocumentV2` | The effective canonical scope after compare override narrowing when applicable |
| `selected_type_keys` | array<string> | Flattened selected subject type keys |
| `allowed_type_keys` | array<string> | Types eligible for the intended operation |
| `limited_type_keys` | array<string> | Types that run with limited support semantics |
| `unsupported_type_keys` | array<string> | Types rejected for the intended operation |
| `capabilities_by_type` | map<string, mixed> | Existing support metadata exposed for debugging and audit |
| `legacy_projection` | map<string, array<string>> or null | Transitional projection back to legacy buckets for current consumers only |
### BaselineScopeSummaryGroup
**Type**: derived operator-facing summary record
| Field | Type | Notes |
|------|------|-------|
| `domain_key` | string | Group identity |
| `subject_class` | string | Group identity |
| `group_label` | string | Operator-facing summary label |
| `selected_subject_types` | array<string> | Operator-facing selected labels in the group, not raw subject type keys |
| `capture_supported_count` | integer | Number of types capture may include |
| `compare_supported_count` | integer | Number of types compare may include |
| `inactive_count` | integer | Number of stored but inactive types, if historic data is being inspected |
### BaselineScopeNormalizationLineage
**Type**: derived diagnostic record for on-demand detail rendering
| Field | Type | Notes |
|------|------|-------|
| `source_shape` | string | One of `legacy` or `canonical_v2` |
| `normalized_on_read` | boolean | Whether tolerant-read normalization was required for the current payload |
| `legacy_keys_present` | array<string> | Which legacy keys were present at ingestion time, if any |
| `save_forward_required` | boolean | Whether the current payload still needs save-forward persistence to become canonical |
## Validation Rules
### Canonical V2 validation
1. `version` must equal `2`.
2. `entries` must be present and non-empty.
3. Each entry must contain a valid domain and a valid subject class for that domain.
4. Each entry must contain at least one subject type key.
5. Every subject type key must belong to the specified domain and subject class.
6. Unknown or inactive subject type keys fail validation.
7. Duplicate entries are merged only when semantically identical after normalization.
8. Mixed legacy and V2 payloads fail validation.
### Operation validation
1. Capture start rejects any effective scope containing subject types without capture support.
2. Compare start rejects any effective scope containing subject types without compare support.
3. Invalid support metadata is treated as unsupported.
4. Operation context stores the resolved effective scope used for the run, not the pre-normalized request payload.
## Relationships
- One `GovernanceSubjectTaxonomyRegistry` yields many `GovernanceSubjectType` records.
- One `BaselineScopeDocumentV2` contains one or more `BaselineScopeEntryV2` records.
- One `BaselineProfile` owns one persisted baseline scope document inside `scope_jsonb`.
- One `BaselineTenantAssignment` may contribute an override scope that narrows the profile scope before compare start.
- One `EffectiveBaselineScope` is derived for each capture or compare start attempt.
- One `BaselineScopeSummaryGroup` is derived from one canonical scope document for operator-facing baseline surfaces.
- One `BaselineScopeNormalizationLineage` is derived alongside normalized scope and exposed only on demand for detail-surface diagnostics.
## Transition Rules
### Legacy to canonical V2
1. Read legacy `scope_jsonb`.
2. Expand legacy defaults explicitly.
3. Map policy and foundation buckets into V2 entries.
4. Validate against the taxonomy registry.
5. Persist canonical V2 on the next successful save.
### Canonical V2 to operation context
1. Start from the canonical profile scope.
2. Apply any compare assignment override scope as a narrowing step when the operation supports it.
3. Flatten selected subject type keys.
4. Run capture or compare support gating.
5. Write canonical effective scope plus any temporary compatibility projection into `OperationRun.context`.
### Optional backfill
1. Select baseline profile rows still storing legacy scope shape in `baseline_profiles.scope_jsonb`.
2. Preview candidate rewrites by default and report which rows would change without mutating persisted data.
3. Require explicit write confirmation before persisting canonical V2 back into `scope_jsonb`.
4. Write audit entries for committed rewrites with actor and before-or-after mutation context appropriate to workspace-owned baseline profiles.
5. Leave already-canonical V2 profile rows untouched.
6. Leave `baseline_tenant_assignments.override_scope_jsonb` on tolerant-read normalization only in this release.

View File

@ -0,0 +1,225 @@
# Implementation Plan: Governance Subject Taxonomy and Baseline Scope V2
**Branch**: `202-governance-subject-taxonomy` | **Date**: 2026-04-13 | **Spec**: `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/specs/202-governance-subject-taxonomy/spec.md`
**Input**: Feature specification from `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/specs/202-governance-subject-taxonomy/spec.md`
**Note**: This plan upgrades baseline scope semantics from legacy Intune-shaped lists to a versioned governance-subject contract while preserving current Intune baseline behavior and keeping rollout risk low.
## Summary
Introduce a platform-safe governance subject taxonomy registry and a canonical Baseline Scope V2 document for existing baseline profiles. Reuse current config catalogs and support metadata as registry contributors, evolve the existing `BaselineScope` integration point into a V2-aware normalizer, persist canonical V2 on save, keep the current Intune-first baseline UI understandable, route baseline capture and compare through normalized effective scope with explicit eligibility validation and auditable operation context, and keep any optional cleanup path preview-first and auditable for baseline profile rows only. No new table, no new `OperationRun` type, and no broad `policy_type` rename are required.
## Technical Context
**Language/Version**: PHP 8.4.15
**Primary Dependencies**: Laravel 12, Filament v5, Livewire v4, Pest v4, Tailwind CSS v4, Laravel Sail, existing `BaselineScope`, `InventoryPolicyTypeMeta`, `BaselineSupportCapabilityGuard`, `BaselineCaptureService`, and `BaselineCompareService`
**Storage**: PostgreSQL via existing `baseline_profiles.scope_jsonb`, `baseline_tenant_assignments.override_scope_jsonb`, and `operation_runs.context`; no new tables planned
**Testing**: Pest unit, feature, and focused Filament Livewire tests run through Laravel Sail
**Target Platform**: Laravel monolith web application under `apps/platform`
**Project Type**: web application
**Performance Goals**: Keep taxonomy lookup and scope normalization fully in-process, avoid new remote calls or query fan-out on baseline surfaces, and keep capture or compare start overhead effectively unchanged aside from deterministic validation
**Constraints**: No eager migration, no new `OperationRun` type, no broad repo-wide `policy_type` rename, no generic plugin system, no UI overclaim of inactive domains, and no new panel/provider or asset work
**Scale/Scope**: One workspace-owned baseline resource, one model-cast integration point, two baseline start services, one config-backed taxonomy contributor set, one optional maintenance command, and focused unit/feature/Filament regression coverage
## Constitution Check
*GATE: Passed before Phase 0 research. Re-checked after Phase 1 design and still passing.*
| Principle | Pre-Research | Post-Design | Notes |
|-----------|--------------|-------------|-------|
| Inventory-first / snapshots-second | PASS | PASS | The feature changes baseline definition semantics only; inventory and snapshot truth sources remain unchanged. |
| Read/write separation | PASS | PASS | Save-forward writes stay on existing baseline profile save flows and retain current audit behavior; capture and compare still run through existing operation starts. |
| Graph contract path | N/A | N/A | No new Microsoft Graph path or contract-registry change is introduced. |
| Deterministic capabilities | PASS | PASS | The new taxonomy registry reuses existing config plus `InventoryPolicyTypeMeta::baselineSupportContract()` and remains snapshot-testable. |
| Workspace + tenant isolation | PASS | PASS | Baseline profiles remain workspace-owned; tenant compare and capture operations remain tenant-scoped and unchanged in authorization boundaries. |
| RBAC-UX authorization semantics | PASS | PASS | No new authorization plane or capability path is introduced; non-members remain `404` and in-scope capability failures remain `403`. |
| Run observability / Ops-UX | PASS | PASS | Existing `baseline_capture` and `baseline_compare` runs are reused; only the effective scope payload becomes canonical and more explicit. |
| Data minimization | PASS | PASS | No new persistence or secret-bearing payloads are introduced; scope remains derived from existing config and baseline data. |
| Proportionality / anti-bloat | PASS | PASS | The registry and V2 scope are justified by a current contract problem and two existing concrete catalogs; no universal plugin framework is added. |
| No premature abstraction | PASS | PASS | The registry consolidates existing supported policy types and foundation types into one authoritative contract instead of adding a speculative extension system. |
| Persisted truth / behavioral state | PASS | PASS | V2 stays inside existing `scope_jsonb`; no new table or independent lifecycle is added. |
| UI semantics / few layers | PASS | PASS | Operator summaries derive directly from canonical scope; no presenter or explanation framework is introduced. |
| Filament v5 / Livewire v4 compliance | PASS | PASS | The plan stays inside existing Filament resources and Livewire-backed pages. |
| 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 | `BaselineProfileResource` already disables global search and this plan does not change that. |
| Destructive action safety | PASS | PASS | No new destructive action is introduced. Existing destructive actions on baseline surfaces must keep confirmation and authorization unchanged. |
| Asset strategy | PASS | PASS | No new assets are required. Existing deployment handling of `cd apps/platform && php artisan filament:assets` remains unchanged. |
## Filament-Specific Compliance Notes
- **Livewire v4.0+ compliance**: The affected baseline surfaces remain on Filament v5 + Livewire v4 and no legacy API is introduced.
- **Provider registration location**: No new panel or provider is needed; Laravel 11+ provider registration remains in `bootstrap/providers.php`.
- **Global search**: `BaselineProfileResource` is already not globally searchable, so the hard rule about view or edit pages is unaffected.
- **Destructive actions**: This feature adds no new destructive action. Existing destructive actions on baseline surfaces must retain `->requiresConfirmation()` and server-side authorization.
- **Asset strategy**: No global or on-demand asset registration is planned. Deployment handling of `cd apps/platform && php artisan filament:assets` remains unchanged.
- **Testing plan**: Extend unit coverage for scope normalization and taxonomy lookups, extend baseline capture and compare feature coverage for normalized effective scope and support gating, extend Filament baseline profile tests for save-forward behavior and UI honesty, and add focused coverage for the optional backfill command.
## Phase 0 Research
Research outcomes are captured in `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/specs/202-governance-subject-taxonomy/research.md`.
Key decisions:
- Evolve the existing `BaselineScope` integration point into the V2-aware orchestration layer instead of adding a parallel top-level scope class.
- Build the governance taxonomy registry by composing current `tenantpilot.supported_policy_types`, `tenantpilot.foundation_types`, and existing baseline support metadata rather than introducing a second config truth source.
- Introduce platform-facing governance domain and subject-class vocabulary separate from the existing baseline support `SubjectClass` and `ResolutionPath` enums.
- Map current Intune policy types to `domain_key = intune` and `subject_class = policy`, and map current baseline foundations to `domain_key = platform_foundation` and `subject_class = configuration_resource`.
- Use tolerant read plus save-forward for rollout and keep backfill as an optional preview-first maintenance command with explicit write confirmation and audit logging, not a migration.
- Keep operator selection Intune-first for now and derive canonical V2 internally rather than expanding the UI into a fake multi-domain selector.
- Record canonical effective scope in operation context while keeping temporary compatibility projections only where existing consumers still require them.
- Merge duplicate entries deterministically when domain, subject class, and filters match, and reject ambiguous overlaps when they do not.
## Phase 1 Design
Design artifacts are created under `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/specs/202-governance-subject-taxonomy/`:
- `research.md`: decisions and rejected alternatives for taxonomy, scope, and rollout
- `data-model.md`: canonical V2 scope document, taxonomy entry model, legacy-ingestion contract, and effective-scope projection
- `contracts/governance-subject-taxonomy.logical.openapi.yaml`: internal logical contract for registry listing, scope normalization, save-forward writes, and normalized operation starts
- `quickstart.md`: implementation and verification sequence for the feature
Design decisions:
- Keep the canonical persisted shape explicit: `version = 2` plus `entries[]`.
- Make legacy `policy_types` and `foundation_types` ingestion-only and normalize them immediately.
- Keep current Filament baseline profile forms Intune-first while rendering normalized scope summaries from canonical V2.
- Reuse existing baseline support capability checks by attaching them to registry entries instead of duplicating support logic in the scope model.
- Store canonical effective scope in operation context for capture and compare so audit and debugging no longer depend on reconstructing legacy lists.
- Keep the optional cleanup path outside normal request flows and implement it as a one-time maintenance command that defaults to preview mode, requires explicit write confirmation, and writes audit entries for committed rewrites.
## Project Structure
### Documentation (this feature)
```text
specs/202-governance-subject-taxonomy/
├── plan.md
├── research.md
├── data-model.md
├── quickstart.md
├── spec.md
├── contracts/
│ └── governance-subject-taxonomy.logical.openapi.yaml
└── checklists/
└── requirements.md
```
### Source Code (repository root)
```text
apps/platform/
├── app/
│ ├── Console/Commands/
│ │ └── [optional legacy-scope backfill command]
│ ├── Filament/
│ │ └── Resources/
│ │ ├── BaselineProfileResource.php
│ │ └── BaselineProfileResource/
│ │ └── Pages/
│ │ ├── CreateBaselineProfile.php
│ │ ├── EditBaselineProfile.php
│ │ └── ViewBaselineProfile.php
│ ├── Models/
│ │ └── BaselineProfile.php
│ ├── Services/
│ │ └── Baselines/
│ │ ├── BaselineCaptureService.php
│ │ └── BaselineCompareService.php
│ └── Support/
│ ├── Baselines/
│ │ ├── BaselineScope.php
│ │ ├── BaselineSupportCapabilityGuard.php
│ │ ├── ResolutionPath.php
│ │ └── SubjectClass.php
│ ├── Governance/
│ │ └── [new taxonomy records and registry]
│ └── Inventory/
│ └── InventoryPolicyTypeMeta.php
├── config/
│ └── tenantpilot.php
└── tests/
├── Feature/
│ ├── Baselines/
│ │ ├── BaselineCaptureTest.php
│ │ ├── BaselineComparePreconditionsTest.php
│ │ ├── BaselineSupportCapabilityGuardTest.php
│ │ └── [new scope-v2 and backfill coverage]
│ └── Filament/
│ ├── BaselineProfileFoundationScopeTest.php
│ ├── BaselineProfileCaptureStartSurfaceTest.php
│ └── BaselineProfileCompareStartSurfaceTest.php
└── Unit/
└── Baselines/
├── BaselineScopeTest.php
└── [new taxonomy registry coverage]
```
**Structure Decision**: Keep the work inside the existing baseline model, baseline services, and Filament resource surfaces. Add one narrow `Support/Governance` namespace for platform-facing taxonomy records and registry logic, but do not introduce a wider plugin or extension framework.
## Complexity Tracking
| Violation | Why Needed | Simpler Alternative Rejected Because |
|-----------|------------|-------------------------------------|
| Governance taxonomy registry | The product already has two concrete baseline subject catalogs and one support-metadata contract that need a single authoritative selection view now | Leaving config arrays and support logic separate would preserve the hidden semantic split that this spec is explicitly fixing |
| Baseline Scope V2 document and entry model | Legacy dual-list scope cannot express domain, subject class, or future-safe filters and cannot support a stable compare-input contract | Extending `policy_types` and `foundation_types` with ad-hoc flags would keep the model Intune-shaped and ambiguous |
## Proportionality Review
- **Current operator problem**: Baseline scope still hides governed-subject meaning behind Intune policy lists and an unnamed foundation list, which makes validation, auditability, and compare-input semantics harder than they should be.
- **Existing structure is insufficient because**: Legacy scope cannot express domain ownership or platform-level subject shape, and the current support metadata lives separately from the selection contract.
- **Narrowest correct implementation**: Reuse existing config and support contributors, introduce a small taxonomy registry plus V2 scope document, normalize legacy payloads deterministically, and keep UI and run semantics otherwise unchanged.
- **Ownership cost created**: One new support namespace, explicit mapping maintenance for taxonomy entries, additional normalization and no-regression tests, and one optional backfill command to maintain.
- **Alternative intentionally rejected**: A local wrapper around legacy arrays or a broad rename/plugin system were rejected because the former keeps the semantic leak and the latter imports unnecessary churn and abstraction.
- **Release truth**: current-release contract correction that deliberately prepares the input boundary needed for Spec 203
## Implementation Strategy
### Phase A — Taxonomy Registry and Platform Vocabulary
- Add platform-facing governance domain and subject-class enums or records.
- Implement a taxonomy registry that composes current supported policy types, foundation types, labels, descriptions, and support flags.
- Keep existing support-resolution enums (`SubjectClass`, `ResolutionPath`) as internal capability metadata rather than reusing them as the operator-facing taxonomy.
### Phase B — Canonical Scope V2 and Legacy Normalization
- Upgrade the current `BaselineScope` entrypoint to accept legacy and V2 payloads.
- Add canonical V2 entries, deterministic duplicate handling, and strict mixed-payload rejection.
- Preserve derived compatibility helpers only where current UI or tests still require them during rollout.
### Phase C — Save-Forward Persistence and UI Integration
- Update baseline profile persistence so new and saved profiles write canonical V2 into `scope_jsonb`.
- Keep current Intune-first selectors for now, but derive canonical entries from them on save.
- Add normalized scope summaries to touched baseline surfaces without exposing raw V2 JSON.
### Phase D — Capture/Compare Integration and Auditable Scope Context
- Route baseline capture and compare through normalized effective scope.
- Apply eligibility gating before enqueue when selected subject types are unsupported for the requested operation.
- Write canonical effective scope into `OperationRun.context` and retain transitional projections only where existing consumers still need them.
### Phase E — Optional Cleanup and Verification
- Add an optional Artisan maintenance command that previews remaining legacy baseline profile scope rows by default and backfills them to canonical V2 only after explicit write confirmation, with audit logging for committed rewrites.
- Keep `baseline_tenant_assignments.override_scope_jsonb` on tolerant-read normalization only in this release.
- Extend unit, feature, and Filament test suites for normalization, save-forward behavior, operation gating, UI honesty, and no-regression baseline flows.
- Keep current audit, authorization, and run-observability behavior green throughout the rollout.
## Risk Assessment
| Risk | Impact | Likelihood | Mitigation |
|------|--------|------------|------------|
| The registry becomes a second or speculative truth source | High | Medium | Build it from existing config and support metadata contributors rather than from new hand-maintained arrays. |
| Foundation mapping chooses the wrong domain or subject-class shape | Medium | Medium | Keep the mapping explicit, minimal, and covered by registry tests so it can evolve intentionally in later specs rather than implicitly. |
| Legacy and V2 payloads silently diverge during transition | High | Medium | Reject mixed payloads, normalize deterministically, and add save-forward plus backfill coverage. |
| Capture or compare still bypasses canonical scope in one code path | High | Medium | Centralize effective-scope derivation through the upgraded scope contract and add feature tests on both start services. |
| UI starts implying future domain readiness too early | Medium | Low | Only expose active and supported subject types and keep the current selector intentionally Intune-first. |
## Test Strategy
- Extend `BaselineScopeTest` to cover legacy normalization into V2, duplicate merge or rejection rules, mixed-payload rejection, and explicit V2 serialization.
- Add focused registry coverage for domain, subject-class, subject-type, and support-flag mapping from the current config contributors.
- Extend `BaselineCaptureTest` and `BaselineComparePreconditionsTest` to assert canonical effective scope and operation-support gating before run creation.
- Add focused feature coverage for the optional backfill command, including preview mode, explicit write confirmation, audit logging, and legacy rows becoming canonical V2 on save.
- Extend Filament baseline profile tests to verify Intune-first form behavior still works, canonical V2 is persisted, and normalized scope summaries remain operator-safe.
- Keep existing baseline authorization and start-surface tests green so save-forward semantics do not weaken access control or operator-flow clarity.

View File

@ -0,0 +1,90 @@
# Quickstart: Governance Subject Taxonomy and Baseline Scope V2
## Goal
Turn baseline scope into a platform-capable governance-subject contract without breaking the current Intune baseline workflow. New and updated baseline profiles should persist canonical V2 scope, legacy profiles should still work, and capture or compare starts should consume normalized effective scope.
## Implementation Sequence
1. Add the governance taxonomy registry.
- Introduce platform-facing domain and subject-class vocabulary.
- Compose current `supported_policy_types`, `foundation_types`, and support metadata into one baseline-selection registry.
- Mark only active and currently supported subject types as operator-selectable.
2. Upgrade scope normalization to canonical V2.
- Evolve the current `BaselineScope` entrypoint to parse legacy and V2 inputs.
- Normalize legacy arrays into explicit V2 entries.
- Reject mixed or ambiguous payloads and handle duplicate entries deterministically.
3. Wire save-forward persistence into baseline profile flows.
- Keep the current Intune-first selectors in the Filament form.
- Persist canonical V2 into `scope_jsonb` on create and edit.
- Render a normalized scope summary on touched baseline surfaces without showing raw JSON.
4. Route capture and compare through normalized effective scope.
- Derive the effective scope from the profile scope and compare assignment override when present.
- Enforce capture or compare support gating before enqueuing runs.
- Write canonical effective scope into `OperationRun.context` for audit and debugging.
5. Add optional cleanup and regression coverage.
- Implement a maintenance command that previews remaining legacy baseline profile scope rows by default, requires explicit write confirmation for committed rewrites, and writes audit entries when it mutates profile scope rows.
- Keep compare assignment overrides on tolerant-read normalization only in this slice.
- Extend unit, feature, and Filament coverage for normalization, validation, save-forward behavior, start-surface behavior, operation-truth continuity, authorization continuity, and no-regression Intune operation paths.
## Suggested Test Files
- `apps/platform/tests/Unit/Baselines/BaselineScopeTest.php`
- `apps/platform/tests/Unit/Baselines/GovernanceSubjectTaxonomyRegistryTest.php`
- `apps/platform/tests/Unit/Baselines/InventoryMetaContractTest.php`
- `apps/platform/tests/Feature/Baselines/BaselineCaptureTest.php`
- `apps/platform/tests/Feature/Baselines/BaselineComparePreconditionsTest.php`
- `apps/platform/tests/Feature/Baselines/BaselineScopeBackfillCommandTest.php`
- `apps/platform/tests/Feature/Baselines/BaselineProfileAuthorizationTest.php`
- `apps/platform/tests/Feature/Filament/BaselineProfileFoundationScopeTest.php`
- `apps/platform/tests/Feature/Filament/BaselineProfileCaptureStartSurfaceTest.php`
- `apps/platform/tests/Feature/Filament/BaselineProfileCompareStartSurfaceTest.php`
- `apps/platform/tests/Feature/Filament/BaselineProfileScopeV2PersistenceTest.php`
- `apps/platform/tests/Feature/Filament/BaselineActionAuthorizationTest.php`
- `apps/platform/tests/Feature/Filament/OperationRunBaselineTruthSurfaceTest.php`
## Required Verification Commands
Run all commands through Sail from `apps/platform`.
```bash
cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Unit/Baselines/BaselineScopeTest.php
cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Unit/Baselines/GovernanceSubjectTaxonomyRegistryTest.php
cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Unit/Baselines/InventoryMetaContractTest.php
cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Baselines/BaselineCaptureTest.php
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/BaselineScopeBackfillCommandTest.php
cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Baselines/BaselineProfileAuthorizationTest.php
cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Filament/BaselineProfileFoundationScopeTest.php
cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Filament/BaselineProfileCaptureStartSurfaceTest.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/BaselineProfileScopeV2PersistenceTest.php
cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Filament/BaselineActionAuthorizationTest.php
cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Filament/OperationRunBaselineTruthSurfaceTest.php
cd apps/platform && ./vendor/bin/sail bin pint --dirty --format agent
```
## Manual Acceptance Checklist
1. Open a legacy baseline profile and confirm the scope renders understandably without manual migration.
2. Save that profile and confirm the persisted `scope_jsonb` is rewritten in canonical V2 form.
3. Create a new baseline profile using the current Intune-first selector UI and confirm the saved scope is V2.
4. Attempt to save an invalid domain, class, or inactive subject type and confirm the save is rejected clearly.
5. Start baseline capture from a valid profile and confirm the run stores canonical effective scope.
6. Start baseline compare from a valid profile and confirm the run stores canonical effective scope.
7. Attempt to start capture or compare with an unsupported subject type and confirm the action is blocked before run creation.
8. Run the optional backfill command in preview mode and confirm candidate baseline profile rewrites are reported without mutating rows.
9. Execute the backfill command with explicit write confirmation and confirm semantic equivalence plus audit logging for committed profile-scope rewrites.
10. Confirm compare assignment overrides still normalize correctly without requiring rewrite in this release.
11. Verify inactive or future-domain subject types are not presented as ready-for-use operator options.
## Deployment Notes
- No schema migration is expected.
- No new asset registration is expected.
- No new queue topology is expected because capture and compare continue to use the existing operation types and jobs.
- If the optional backfill command is shipped, it should run only after rollout confidence is established, should be treated as maintenance rather than a deploy prerequisite, and applies only to baseline profile scope rows in this release.

View File

@ -0,0 +1,102 @@
# Research: Governance Subject Taxonomy and Baseline Scope V2
## Decision: Evolve the existing `BaselineScope` entrypoint into the V2-aware scope orchestration layer
### Rationale
`BaselineProfile::scopeJsonb()` already normalizes persisted scope through `BaselineScope`, and both `BaselineCaptureService` and `BaselineCompareService` resolve their effective scope through the same entrypoint. Reusing that integration point keeps the rollout narrow and avoids a split where legacy and V2 scope models coexist as competing top-level contracts.
### Alternatives considered
- Introduce a parallel `BaselineScopeV2` class and migrate consumers later: rejected because it would duplicate the current integration boundary and make rollout ambiguity worse before it gets better.
- Leave `BaselineScope` untouched and normalize only in the model cast: rejected because capture, compare, tests, and operation context already rely on `BaselineScope` behavior outside the model cast.
## Decision: Build the taxonomy registry by composing existing config and support metadata contributors
### Rationale
The current truth for selectable baseline subjects already exists in three places: `tenantpilot.supported_policy_types`, `tenantpilot.foundation_types`, and `InventoryPolicyTypeMeta::baselineSupportContract()`. The narrowest registry is therefore a consolidation layer that reads and shapes those contributors into one authoritative contract for baseline selection.
### Alternatives considered
- Add a new config file dedicated to governance subjects: rejected because it would create a second manual source of truth for the same policy and foundation types.
- Hardcode baseline subject mappings inside a registry class: rejected because it would duplicate labels, support flags, and future updates already owned by config and existing support metadata.
## Decision: Introduce platform-facing taxonomy vocabulary separate from the current baseline support enums
### Rationale
The existing `SubjectClass` and `ResolutionPath` enums describe support resolution internals such as `policy_backed` and `foundation_inventory`. They do not describe operator-facing governed-subject shape. Reusing them as the new taxonomy would leak implementation semantics into the platform contract.
### Alternatives considered
- Reuse `SubjectClass` as the new platform taxonomy: rejected because `policy_backed`, `foundation_backed`, and `derived` encode comparison internals rather than stable platform vocabulary.
- Collapse taxonomy and support metadata into one enum family: rejected because operation support and governed-subject shape are related but distinct concerns.
## Decision: Map current Intune policy types to `intune/policy` and current foundations to `platform_foundation/configuration_resource`
### Rationale
The current baseline scope has two real buckets: standard Intune policy types and non-policy foundation artifacts. Mapping the first bucket to `domain_key = intune` and `subject_class = policy` keeps the Intune adapter explicit. Mapping the second bucket to `domain_key = platform_foundation` and `subject_class = configuration_resource` removes the unnamed special category while staying broad enough to cover the concrete current foundation artifacts such as assignment filters, scope tags, notification templates, and Intune RBAC definitions.
### Alternatives considered
- Map foundations under the `intune` domain: rejected because it would keep policy and non-policy baseline subjects collapsed inside the adapter taxonomy that this spec is trying to make explicit.
- Use `posture_dimension` for all current foundation types: rejected because the current concrete foundation artifacts are configuration resources, not derived posture dimensions.
## Decision: Use tolerant read plus save-forward as the rollout strategy
### Rationale
The current repo has broad test and feature coverage that writes legacy `scope_jsonb` shapes directly. Tolerant read plus save-forward allows those rows to remain valid while new or updated baseline profiles become canonical V2 immediately. This lowers rollout risk and avoids an all-at-once migration event.
### Alternatives considered
- Eagerly rewrite all existing rows in a migration: rejected because it increases rollout churn and couples semantic correction to data-rewrite risk.
- Leave legacy and V2 rows mixed indefinitely: rejected because it would preserve two canonical shapes instead of one.
## Decision: Keep the baseline UI Intune-first and derive V2 internally
### Rationale
The feature is a contract upgrade, not a multi-domain product launch. The current Filament baseline profile form already exposes `policy_types` and `foundation_types` separately. Keeping that operator-facing shape for now avoids false breadth while still allowing save-forward persistence into explicit V2 entries and better normalized summaries.
### Alternatives considered
- Replace the form with a multi-domain taxonomy picker now: rejected because only Intune-backed baseline subjects are currently ready for operator use.
- Keep the UI and continue persisting legacy arrays: rejected because the persistence contract is the core defect this spec is solving.
## Decision: Persist canonical effective scope in operation context and keep compatibility projections only as a transition aid
### Rationale
`BaselineCaptureService` and `BaselineCompareService` already write `effective_scope` into `OperationRun.context`. Making that payload canonical V2 improves auditability and debug clarity. Some legacy-compatible projections may still be needed by current surfaces or tests during rollout, but they should be derived from V2 instead of remaining authoritative.
### Alternatives considered
- Keep only the legacy context payload: rejected because it would leave operation auditability tied to the very ambiguity this spec is removing.
- Emit only V2 immediately with no compatibility projection: rejected because current code and tests may still read the old keys during the transition.
## Decision: Merge duplicate entries only when they are semantically identical and reject ambiguous overlaps otherwise
### Rationale
Legacy scope behaves like a union of subject types. V2 should preserve that determinism without hiding ambiguous entry definitions. If two entries share the same `domain_key`, `subject_class`, and normalized `filters`, their `subject_type_keys` can be merged safely. If overlapping subject types appear across entries with different filters, the system should reject the payload until filter semantics are explicitly supported.
### Alternatives considered
- Always merge duplicate entries regardless of filters: rejected because it would silently flatten distinct meanings once filters become real.
- Reject every repeated entry shape: rejected because the legacy shape and current operator intent behave like set union, not strict uniqueness by payload object.
## Decision: Deliver cleanup as an optional Artisan command rather than a mandatory migration step
### Rationale
The product only needs one canonical shape going forward. Rewriting historic rows is housekeeping, not a functional prerequisite. An optional command keeps that concern explicit and schedulable after rollout confidence exists. To remain aligned with the constitution's write-safety rules, the command should default to preview mode, require explicit write confirmation for committed rewrites, and emit audit entries only when a real mutation occurs.
This cleanup decision applies only to `baseline_profiles.scope_jsonb` in this release. Tenant assignment overrides stay on tolerant-read normalization so the rollout remains narrow and compare behavior stays correct without introducing a second maintenance rewrite surface.
### Alternatives considered
- Omit cleanup entirely: rejected because the repo should still have a supported path to remove legacy rows once the rollout stabilizes.
- Hide cleanup inside a request path or scheduled job: rejected because schema-shape rewrites should remain a deliberate operator or maintenance action with explicit write intent and auditable consequences.

View File

@ -0,0 +1,300 @@
# Feature Specification: Governance Subject Taxonomy and Baseline Scope V2
**Feature Branch**: `202-governance-subject-taxonomy`
**Created**: 2026-04-13
**Status**: Draft
**Input**: User description: "Spec 202 — Governance Subject Taxonomy & Baseline Scope V2"
## Spec Candidate Check *(mandatory — SPEC-GATE-001)*
- **Problem**: Baseline scope still communicates governed subjects primarily as Intune-flavored policy-type lists plus an unnamed foundation list, so the platform lacks a durable, platform-safe language for what a baseline actually governs.
- **Today's failure**: Existing Intune baseline workflows work, but the current scope model forces later compare, evidence, and future-domain work to infer meaning from legacy `policy_types` and `foundation_types` lists. That keeps platform-core semantics narrower than the product already claims.
- **User-visible improvement**: Operators keep an Intune-first baseline workflow, but the underlying scope becomes explicit, deterministic, and auditable. Baseline summaries can explain what is governed without raw JSON or implicit Intune assumptions, and unsupported selections are rejected before capture or compare starts.
- **Smallest enterprise-capable version**: Introduce one authoritative governance subject taxonomy contract, one versioned Baseline Scope V2 shape, deterministic legacy normalization, save-forward persistence for new or updated baselines, and only the minimal baseline-surface changes needed to keep the scope understandable.
- **Explicit non-goals**: No second governance domain, no broad repo-wide `policy_type` rename, no compare-engine rewrite, no plugin framework, no multi-domain baseline UI redesign, and no forced migration of Intune-owned adapter models into generic platform models.
- **Permanent complexity imported**: One platform taxonomy registry, one Baseline Scope V2 contract, one legacy normalization path, one optional maintenance backfill path, clarified platform-versus-Intune vocabulary, and focused regression coverage.
- **Why now**: Spec 203 depends on a clear input contract for governed subjects. Without this layer, compare-strategy extraction would either preserve hidden Intune leakage or introduce a vague abstraction without stable vocabulary.
- **Why not local**: A local wrapper around `policy_types` and `foundation_types` would hide the symptom on one surface but would not give the platform a durable answer to what a governed subject is or how future domains participate.
- **Approval class**: Core Enterprise
- **Red flags triggered**: New source-of-truth risk and cross-domain taxonomy risk. Defense: the taxonomy stays deliberately minimal, keeps Intune-owned internals in place, avoids a generic plugin system, and exists to solve a current contract problem rather than speculative future breadth.
- **Score**: Nutzen: 2 | Dringlichkeit: 2 | Scope: 2 | Komplexität: 1 | Produktnähe: 2 | Wiederverwendung: 2 | **Gesamt: 11/12**
- **Decision**: approve
## Spec Scope Fields *(mandatory)*
- **Scope**: workspace
- **Primary Routes**:
- `/admin/baseline-profiles`
- `/admin/baseline-profiles/create`
- `/admin/baseline-profiles/{record}`
- `/admin/baseline-profiles/{record}/edit`
- `/admin/t/{tenant}/baseline-compare`
- Existing baseline capture and baseline compare operation drilldowns reached from the baseline profile detail and tenant compare flows
- **Data Ownership**:
- Workspace-owned baseline profiles remain workspace-owned. New and updated profile scope data becomes canonical V2 while legacy scope remains tolerated only at ingestion boundaries during rollout.
- Tenant assignment override scope remains tolerated at ingestion boundaries and normalized on read for this release; this spec does not require rewrite of existing `baseline_tenant_assignments.override_scope_jsonb` rows.
- Tenant-owned baseline snapshots, compare runs, findings, and related evidence remain tenant-owned and keep their existing domain ownership and lifecycle.
- No new table or persisted domain entity is introduced. The change is a contract and normalization upgrade inside existing baseline scope storage.
- **RBAC**:
- Existing workspace baseline view and manage capabilities continue to govern baseline profile list, create, edit, view, and any baseline start actions launched from those surfaces.
- Existing tenant membership and tenant compare capabilities continue to govern `/admin/t/{tenant}/baseline-compare` and downstream tenant-owned compare details.
- This spec changes selection semantics and validation, not authorization boundaries. Non-members remain `404`, entitled members without the required capability remain `403`, and no new destructive operator action is introduced.
## 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 create and edit | Secondary Context Surface | Define which governed subject families belong in the baseline before any tenant capture or compare starts | Domain-safe scope summary, active subject groups, support readiness, and invalid-selection feedback | Detailed subject descriptions, inactive-type reasons, and future-domain support notes | Not primary because it supports later baseline operations rather than serving as the final governance-decision surface | Follows baseline definition workflow before capture or compare | Prevents operators from reconstructing hidden policy-versus-foundation semantics by hand |
| Baseline profile detail | Secondary Context Surface | Verify what the baseline governs before launching capture or compare | Canonical scope summary by domain and subject class, baseline status, and operation readiness | Full subject-type list, legacy-normalization detail, and downstream drilldowns | Not primary because it prepares operator action rather than being the compare result surface itself | Follows the baseline lifecycle from definition to capture and compare | Makes governed-scope truth visible in one place without raw JSON or adapter-only vocabulary |
## 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 create and edit | Config / Form | Workspace configuration form | Save an updated baseline definition | Form page itself | not applicable | Cancel, back navigation, and explanatory help remain secondary to the form body and scope summary | none | `/admin/baseline-profiles` | `/admin/baseline-profiles/{record}/edit` | Workspace context, normalized scope summary, supported subject count | Baseline profile / baseline scope | Which governed subject families the baseline includes and whether they are eligible for capture or compare | none |
| Baseline profile detail | Detail / Workflow hub | Workspace configuration detail | Verify scope and then start capture or compare | Explicit view page | not applicable | Related navigation to snapshots and compare matrix stays contextual; scope breakdown lives in detail sections rather than the action lane | none | `/admin/baseline-profiles` | `/admin/baseline-profiles/{record}` | Workspace context, domain and subject-class summary, assignment context | Baseline profile / baseline scope | Canonical governed-subject summary and current readiness for baseline operations | none |
## 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 create and edit | Workspace baseline manager | Define or revise what the baseline governs | Configuration form | What subject families will this baseline govern, and are they supported for the intended baseline workflow? | Canonical scope summary, selected subject families, capture and compare readiness, validation failures | Legacy-normalization explanation, inactive-type details, and future-domain notes | readiness, support eligibility | `TenantPilot only` | Save baseline profile, Cancel | none |
| Baseline profile detail | Workspace baseline manager | Verify scope before starting baseline capture or compare | Configuration detail and workflow hub | What does this baseline govern right now, and is it safe to start the next operation? | Domain and subject-class summary, baseline status, current assignment context, capture and compare readiness | Full subject-type breakdown, normalization lineage, and downstream run details | lifecycle, support eligibility, scope completeness | `TenantPilot only` for profile changes, `simulation only` or existing run scope for compare or capture actions | Existing capture baseline and compare actions, contextual navigation to compare matrix | none |
## Proportionality Review *(mandatory when structural complexity is introduced)*
- **New source of truth?**: yes
- **New persisted entity/table/artifact?**: no
- **New abstraction?**: yes
- **New enum/state/reason family?**: yes
- **New cross-domain UI framework/taxonomy?**: yes
- **Current operator problem**: Baseline scope still hides its actual meaning inside Intune-shaped lists. Operators can use the current screens, but the platform cannot explain or validate governed subjects without leaking adapter assumptions into capture, compare, and future expansion work.
- **Existing structure is insufficient because**: `policy_types` and `foundation_types` lists do not express domain ownership, subject class, operation support, or future domain participation. Any downstream consumer must already know Intune-only semantics to interpret them correctly.
- **Narrowest correct implementation**: Introduce only `domain_key`, `subject_class`, `subject_type_keys`, and optional `filters` in a versioned scope contract, backed by one authoritative taxonomy registry and a deterministic legacy-normalization path. Keep current Intune-first screens, existing baseline models, and existing compare architecture in place.
- **Ownership cost**: Ongoing review of taxonomy entries, normalization logic maintenance, careful rollout coverage for save-forward behavior, documentation of platform versus Intune boundaries, and regression tests for validation and no-regression baseline flows.
- **Alternative intentionally rejected**: A broad rename of `policy_type` usage or a generalized plugin framework was rejected because both would import churn and abstract machinery before the platform vocabulary is proven by real use.
- **Release truth**: current-release contract correction with deliberate preparation for follow-up compare extraction
## User Scenarios & Testing *(mandatory)*
### User Story 1 - Keep existing Intune baselines usable (Priority: P1)
As a workspace baseline manager, I want existing Intune-only baseline profiles to keep loading, saving, capturing, and comparing so that the new taxonomy contract does not interrupt current production workflows.
**Why this priority**: The spec only succeeds if current baseline operations remain intact. Platform vocabulary work that regresses the existing Intune path is not shippable.
**Independent Test**: Open a legacy Intune baseline profile, verify its scope renders understandably, save it, then launch baseline capture and compare and confirm the behavior remains unchanged.
**Acceptance Scenarios**:
1. **Given** a baseline profile stored in the legacy `policy_types` and `foundation_types` shape, **When** the profile detail or edit page loads, **Then** the system shows the effective governed scope through the normalized contract without requiring manual migration.
2. **Given** a legacy Intune-only baseline profile, **When** the operator saves the profile, **Then** the stored scope is written forward in canonical V2 form without changing the effective governed subject set.
3. **Given** a legacy Intune-only baseline profile, **When** the operator starts baseline capture or baseline compare, **Then** the resulting operation uses normalized scope semantics and preserves the current Intune behavior.
---
### User Story 2 - Define a baseline with explicit governed-subject semantics (Priority: P1)
As a workspace baseline manager, I want baseline scope selection to describe governed subject families explicitly so the platform can validate and summarize the baseline without relying on hidden Intune-only assumptions.
**Why this priority**: This is the core product change. Without an explicit governed-subject contract, compare and evidence work cannot generalize safely.
**Independent Test**: Create a new baseline profile through the existing baseline form and confirm the resulting stored scope is canonical V2 with explicit domain and subject-class semantics.
**Acceptance Scenarios**:
1. **Given** a new baseline profile is being created, **When** the operator selects supported Intune subject families, **Then** the system stores the baseline scope as versioned V2 entries rather than raw legacy lists.
2. **Given** the baseline detail page renders after save, **When** the operator reviews the summary, **Then** the scope is explained through explicit domain and subject-class language rather than an unnamed split between policy types and foundation types.
---
### User Story 3 - Reject unsupported combinations before work starts (Priority: P2)
As an operator starting baseline capture or compare, I want unsupported or invalid subject selections to fail early so I do not launch a run with ambiguous or impossible scope.
**Why this priority**: The value of a taxonomy contract is not just naming. It must prevent invalid or misleading operation starts.
**Independent Test**: Attempt to save or execute baseline scope selections that contain an unknown domain, invalid class, inactive type, or unsupported capture or compare combination and verify the operation is blocked clearly before any run starts.
**Acceptance Scenarios**:
1. **Given** a scope entry includes an unknown or inactive subject type, **When** the operator saves the baseline profile, **Then** validation fails with a deterministic error explaining the invalid selection.
2. **Given** a scope contains a subject type that can be listed but not compared, **When** the operator starts compare, **Then** the action is blocked before the run is created and the operator sees a clear reason.
---
### User Story 4 - Roll out progressively without forced migration churn (Priority: P3)
As a maintainer, I want legacy scope storage to remain readable during rollout and optionally backfillable later so the product can adopt the new contract without an all-at-once migration event.
**Why this priority**: The chosen rollout strategy deliberately trades immediate cleanup for lower production risk.
**Independent Test**: Keep a mixed dataset of legacy and V2 baseline profiles, verify both remain usable, then run the optional cleanup path and confirm legacy rows can be rewritten without changing meaning.
**Acceptance Scenarios**:
1. **Given** some baseline profiles still store legacy scope, **When** those profiles are read, **Then** they are normalized transparently and remain fully usable.
2. **Given** the optional cleanup path is executed after rollout confidence exists, **When** remaining legacy rows are backfilled, **Then** their effective governed subject set is preserved exactly.
3. **Given** the optional cleanup path is invoked without explicit write confirmation, **When** the maintainer runs it in preview mode, **Then** the command reports candidate rewrites without mutating persisted scope rows or silently bypassing audit expectations for committed writes.
### Edge Cases
- A stored scope mixes legacy fields and V2 fields in the same payload.
- A legacy payload omits one legacy key or omits both legacy keys entirely.
- Two V2 entries collapse to the same domain, subject class, and subject type set after normalization.
- A legacy profile contains a subject type that no longer exists or is no longer active.
- A subject type is valid for capture but not valid for compare, or vice versa.
- `filters` are present in V2 but contain values the current domain does not yet support.
- A future domain is registered but inactive and should remain hidden from operator selection while historic data stays inspectable.
- Duplicate subject type keys appear within the same entry or across repeated entries.
## Requirements *(mandatory)*
**Constitution alignment (required):** This feature does not introduce a new Microsoft Graph path, a new baseline run type, or a new write workflow beyond the existing baseline profile save and baseline capture or compare starts. It does change the subject-selection contract consumed by baseline capture and compare, so the taxonomy registry, validation rules, tenant isolation, operation-start gating, and tests must be explicit.
**Constitution alignment (PROP-001 / ABSTR-001 / PERSIST-001 / STATE-001 / BLOAT-001):** This feature intentionally introduces a new internal contract and a narrow taxonomy vocabulary because the current product already needs a stable way to describe governed subjects. A smaller wrapper around legacy arrays would preserve the semantic leak. No new table or universal plugin framework is justified, and no generic platform model replacement is part of this spec.
**Constitution alignment (OPS-UX):** Existing `baseline_capture` and `baseline_compare` runs remain the operational truth. This spec does not add a new `OperationRun` type, does not change service ownership of run state, and does not alter summary-count semantics. It does require the normalized scope used for those operations to be deterministically inspectable in logs, debug output, or test assertions so the operation input is auditable.
**Constitution alignment (RBAC-UX):** The feature spans workspace baseline surfaces under `/admin` and tenant compare surfaces under `/admin/t/{tenant}/...` but does not change authorization boundaries. Non-members remain `404`, entitled members lacking capability remain `403`, and any save or operation start continues to enforce server-side authorization. Validation failures for invalid scope selections are business-validation outcomes, not scope-leak exceptions.
**Constitution alignment (OPS-EX-AUTH-001):** Not applicable. No authentication handshake behavior changes.
**Constitution alignment (BADGE-001):** This feature does not introduce a new status or badge vocabulary. Existing centralized badge semantics remain unchanged.
**Constitution alignment (UI-FIL-001):** The touched baseline surfaces continue to use native Filament forms, sections, infolists, and existing resource actions. Raw V2 JSON remains hidden from operators. Scope summaries should be rendered through existing UI primitives and labels rather than a new local taxonomy widget system.
**Constitution alignment (UI-NAMING-001):** Operator-facing wording stays Intune-first where the currently supported subject families are truly Intune-owned, but platform-safe surfaces such as summaries, admin diagnostics, or validation messages must use domain-safe terms like governed subject, domain, and subject class where that language clarifies meaning.
**Constitution alignment (DECIDE-001):** The affected surfaces are secondary context surfaces, not primary decision queues. Their job is to make governed-scope truth visible before capture or compare starts. Domain and subject-class summaries must be default-visible, while deeper taxonomy detail stays on demand.
**Constitution alignment (UI-CONST-001 / UI-SURF-001 / ACTSURF-001 / UI-HARD-001 / UI-EX-001 / UI-REVIEW-001 / HDR-001):** Baseline profile create and edit remain form-first resource surfaces, and baseline profile detail remains the workflow hub for existing capture and compare actions. The introduction of normalized scope semantics must not create competing header actions or move current workflow actions out of their established, capability-gated locations.
**Constitution alignment (ACTSURF-001 - action hierarchy):** This feature does not add a new action hierarchy. Existing capture and compare actions remain where they are, while the new scope summary lives in the page body or detail sections as contextual truth rather than peer action chrome.
**Constitution alignment (OPSURF-001):** Default-visible baseline content must stay operator-first. Scope summaries should explain governed-subject breadth and readiness without exposing raw arrays or adapter-only terminology. Detailed normalization lineage or future-domain notes remain secondary.
**Constitution alignment (UI-SEM-001 / LAYER-001 / TEST-TRUTH-001):** The feature introduces one canonical scope contract and one taxonomy registry but does not add a second presenter stack or semantic overlay. Direct mapping from canonical scope to summary is preferred, and tests must focus on validation, normalization, and operation behavior rather than thin wrappers alone.
**Constitution alignment (Filament Action Surfaces):** The Action Surface Contract remains satisfied. Each touched baseline surface keeps one primary inspect or open model, no redundant View action is introduced, no empty action groups are added, and no new destructive action is introduced. The UI Action Matrix below records the affected surfaces.
**Constitution alignment (UX-001 — Layout & Information Architecture):** Existing baseline create and edit layouts remain sectioned forms, and baseline detail remains a structured detail page. The scope upgrade must not regress search, sort, empty-state, or form-layout expectations on the current baseline resource.
### Functional Requirements
- **FR-202-001 Governance taxonomy registry**: The platform MUST maintain one authoritative internal registry for governed subject metadata that includes, at minimum, `domain_key`, `subject_class`, `subject_type_key`, `label`, `description`, `capture_supported`, `compare_supported`, `inventory_supported`, and `active`.
- **FR-202-002 Domain and class validation**: The taxonomy registry MUST define which subject classes are valid within each governance domain and which subject type keys belong to each valid domain and class combination.
- **FR-202-003 Intune mapping**: Current Intune baseline-selectable subject families MUST be represented in the taxonomy as explicit Intune-owned subject types under the Intune domain and the policy subject class.
- **FR-202-004 Foundation mapping**: Current baseline foundation selections MUST be represented as explicit taxonomy entries under a named platform domain and subject class rather than as an unnamed second category in scope.
- **FR-202-005 Canonical Baseline Scope V2 shape**: The canonical baseline scope contract for new or normalized profiles MUST be versioned and include one or more scope entries containing `domain_key`, `subject_class`, a non-empty `subject_type_keys` list, and optional `filters`.
- **FR-202-006 Legacy normalization**: The system MUST accept legacy baseline scope shaped as `policy_types` and `foundation_types` only at ingestion boundaries, normalize an omitted legacy bucket the same as its empty-list default when the other bucket is present, reject payloads where both legacy buckets are absent, and deterministically normalize valid legacy input into canonical V2.
- **FR-202-007 Mixed-input rejection**: The system MUST reject scope payloads that mix legacy fields and V2 fields or otherwise remain ambiguous after normalization.
- **FR-202-008 Save-forward persistence**: Any newly created baseline profile and any updated baseline profile MUST persist canonical V2 scope rather than writing legacy scope lists.
- **FR-202-009 Duplicate handling**: Duplicate entries and duplicate subject type keys MUST be merged or rejected consistently, and that behavior MUST be deterministic and testable.
- **FR-202-010 Unknown or inactive selection rejection**: Unknown domains, invalid subject classes, unknown subject type keys, inactive subject type keys, and cross-domain mismatches MUST fail validation with a clear operator-facing reason.
- **FR-202-011 Operation eligibility gating**: Baseline capture and baseline compare initiation MUST reject subject types that are not supported for the intended operation before a run is created.
- **FR-202-012 Capture path normalization**: Baseline capture entrypoints touched by this spec MUST consume normalized V2 scope through the shared contract rather than reading raw legacy arrays.
- **FR-202-013 Compare path normalization**: Baseline compare entrypoints touched by this spec MUST consume normalized V2 scope through the shared contract rather than reading raw legacy arrays.
- **FR-202-014 Auditable canonical scope**: The effective normalized scope used for capture or compare MUST be recoverable through deterministic logs, debug output, or test assertions without reconstructing ambiguous legacy arrays.
- **FR-202-015 Intune no-regression**: Existing Intune-only baseline profiles MUST continue to load, display, save, capture, and compare without manual intervention or behavior regression.
- **FR-202-016 UI honesty**: Operator-facing baseline surfaces MUST not expose raw V2 JSON and MUST not imply that inactive or unsupported future domains are ready for normal use.
- **FR-202-017 Platform boundary protection**: No new platform-core API introduced by this spec may require callers to already think in raw Intune-only `policy_type` semantics.
- **FR-202-018 Optional filters contract**: `filters` MAY be present in V2 entries, but they MUST remain optional and empty by default for current Intune behavior unless a domain explicitly supports them.
- **FR-202-019 Rollout strategy**: Rollout MUST use tolerant read plus save-forward behavior for `baseline_profiles.scope_jsonb` rather than forcing an eager rewrite of all existing baseline scope rows, and compare assignment overrides may remain tolerant-read only in this release.
- **FR-202-020 Optional cleanup path**: The platform MAY provide a one-time maintenance backfill path for `baseline_profiles.scope_jsonb` that previews candidate rewrites by default, requires explicit write confirmation before mutating rows, writes audit logs for committed rewrites, and rewrites remaining legacy profile scope rows to canonical V2 without changing their effective meaning.
- **FR-202-021 Future-domain plausibility**: The resulting V2 contract MUST be broad enough to describe at least one plausible non-Intune governance domain shape without forcing it into `policy_type` language.
## 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 profiles list | `apps/platform/app/Filament/Resources/BaselineProfileResource.php`, `apps/platform/app/Filament/Resources/BaselineProfileResource/Pages/ListBaselineProfiles.php` | Existing create action remains the primary list header action | Existing record-open flow remains the inspect path | Existing safe list actions only | Existing grouped bulk actions only | Existing create CTA remains | n/a | n/a | Existing create and update audit semantics remain unchanged | No hierarchy change required; this spec changes scope semantics, not list action placement |
| Baseline profile create and edit | `apps/platform/app/Filament/Resources/BaselineProfileResource.php`, `apps/platform/app/Filament/Resources/BaselineProfileResource/Pages/CreateBaselineProfile.php`, `apps/platform/app/Filament/Resources/BaselineProfileResource/Pages/EditBaselineProfile.php` | No new header action; save and cancel remain form-local | Form page itself | none | none | n/a | n/a | Save and Cancel remain the only primary form controls | Existing baseline profile create and update audit entries remain | Scope selector and normalized summary live inside form sections; raw V2 JSON stays hidden |
| Baseline profile view | `apps/platform/app/Filament/Resources/BaselineProfileResource/Pages/ViewBaselineProfile.php` | Existing view-page actions remain, with scope summary added as contextual detail instead of action chrome | Explicit view page remains the inspect flow; related compare matrix navigation stays contextual but is not materially changed by this spec | none beyond existing safe shortcuts | none | n/a | Existing capture and compare actions remain state-sensitive and capability-gated | n/a | Existing capture and compare run and audit semantics remain | Action Surface Contract stays satisfied; no new destructive action and no exemption needed |
### Key Entities *(include if feature involves data)*
- **Governance Taxonomy Entry**: The authoritative description of one selectable governed subject type, including its domain, subject class, operator label, descriptive metadata, and support flags for capture, compare, and inventory.
- **Baseline Scope V2**: The canonical versioned baseline scope structure that describes one or more governed-scope entries for a baseline profile.
- **Scope Entry**: One Baseline Scope V2 selector containing a domain, a subject class, a non-empty set of subject type keys, and optional filters for future domain-specific narrowing.
- **Baseline Profile Scope Summary**: The operator-visible explanation of what a baseline profile governs after legacy normalization and canonical V2 validation have been applied.
## Success Criteria *(mandatory)*
### Measurable Outcomes
- **SC-001**: In automated regression coverage, 100% of legacy Intune-only baseline profiles used by the tests load, save, capture, and compare without manual data repair.
- **SC-002**: In automated persistence coverage, 100% of newly created or updated baseline profiles store canonical V2 scope and do not write legacy `policy_types` or `foundation_types` lists.
- **SC-003**: Invalid domain, subject-class, subject-type, inactive-type, and operation-support mismatches are rejected before baseline capture or compare starts in every scenario covered by the spec tests.
- **SC-004**: The default-visible baseline scope summary on the touched baseline surfaces explains governed-subject breadth through domain and subject-class language without exposing raw JSON.
- **SC-005**: Product review can model at least one plausible non-Intune governance domain with the same V2 vocabulary without introducing synthetic `policy_type` terminology or changing current Intune-owned models.
## Rollout Strategy
### Phase 1 - Introduce taxonomy and normalization
- Add the governance taxonomy registry.
- Add the Baseline Scope V2 contract and deterministic legacy normalization.
- Keep current Intune baseline flows operational.
### Phase 2 - Save forward
- New baseline profiles always write canonical V2 scope.
- Any saved existing baseline profile writes canonical V2 scope.
### Phase 3 - Optional cleanup
- Provide an optional maintenance path to preview and then backfill remaining legacy baseline profile scope rows with explicit write confirmation and audit logging.
- Keep compare assignment overrides on tolerant-read normalization only in this release.
- Run cleanup only after rollout confidence is established.
## Non-Goals
- Implementing a second governance domain
- Replacing the current compare engine architecture
- Renaming all existing `policy_type` usage across the repo
- Redesigning the baseline UI as a multi-domain experience
- Refactoring `Policy`, `PolicyVersion`, `BackupItem`, or current Intune adapter models into generic platform models
- Introducing a generic plugin or discovery framework for future governance domains
## Assumptions
- Intune remains the only operator-facing governance domain during the initial rollout of this spec.
- Existing baseline capture and compare actions, audit entries, and run-observability flows are already correct and will be preserved.
- The current baseline scope storage can carry canonical V2 JSON without requiring a new table or a broad schema redesign.
- Future-domain rollout can remain hidden until the taxonomy marks those subject types active and supported.
## Dependencies
- Existing baseline profile resource and baseline profile detail workflow
- Existing baseline capture and baseline compare services and jobs
- Existing tenant compare landing and compare matrix surfaces
- Existing baseline-related audit and operation-run flows
## Risks
- The taxonomy could grow into a speculative universal framework if more fields or behaviors are added before a second real domain exists.
- Hidden Intune assumptions could survive behind V2 if downstream capture or compare code still bypasses normalized scope.
- Operator-facing summaries could overclaim future-domain readiness if inactive taxonomy entries are shown too early.
- Cleanup pressure could trigger premature renaming of Intune-owned adapter concepts before the platform vocabulary stabilizes.
## Review Questions
- Does the resulting scope contract clearly distinguish platform vocabulary from Intune-owned vocabulary?
- Can a reviewer explain what a baseline governs without relying on `policy_types` and `foundation_types` lore?
- Do invalid or unsupported selections fail before a run starts rather than surfacing as downstream surprises?
- Does the rollout preserve current Intune baseline behavior without forcing an all-at-once migration?
- Is the taxonomy deliberately minimal, or has it already started to drift into a plugin system or generic framework?
## Definition of Done
This feature is complete when:
- the codebase has one authoritative governance subject taxonomy contract,
- Baseline Scope V2 is the canonical contract for new and updated baseline profiles,
- legacy scope storage is accepted only at ingestion boundaries and normalized deterministically,
- touched baseline capture and compare entrypoints consume normalized scope,
- existing Intune baseline workflows continue to work without manual intervention,
- baseline surfaces summarize governed scope without raw JSON or implicit Intune-only vocabulary,
- tests prove validation, save-forward behavior, optional cleanup safety, and no-regression Intune behavior,
- and platform-core scope APIs no longer require callers to think in raw Intune-only `policy_type` terms.

View File

@ -0,0 +1,246 @@
# Tasks: Governance Subject Taxonomy and Baseline Scope V2
**Input**: Design documents from `/specs/202-governance-subject-taxonomy/`
**Prerequisites**: `plan.md`, `spec.md`, `research.md`, `data-model.md`, `contracts/governance-subject-taxonomy.logical.openapi.yaml`, `quickstart.md`
**Tests**: Required. This feature changes runtime baseline scope persistence, Filament baseline surfaces, and capture or compare start behavior, so Pest unit, feature, and Filament coverage must be added or extended.
**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 taxonomy, transition, and normalization foundation is in place.
## Phase 1: Setup (Shared Infrastructure)
**Purpose**: Prepare focused test entry points for taxonomy, canonical scope persistence, and rollout maintenance.
- [X] T001 Create the governance taxonomy registry test scaffold in `apps/platform/tests/Unit/Baselines/GovernanceSubjectTaxonomyRegistryTest.php`
- [X] T002 [P] Create the canonical scope persistence test scaffold in `apps/platform/tests/Feature/Filament/BaselineProfileScopeV2PersistenceTest.php`
- [X] T003 [P] Create the rollout backfill command test scaffold in `apps/platform/tests/Feature/Baselines/BaselineScopeBackfillCommandTest.php`
**Checkpoint**: Dedicated Spec 202 test entry points exist and implementation can proceed without mixing this slice into unrelated suites.
---
## Phase 2: Foundational (Blocking Prerequisites)
**Purpose**: Codify the shared governance taxonomy, canonical scope, and transition infrastructure that every user story depends on.
**⚠️ CRITICAL**: No user story work should start before this phase is complete.
- [X] T004 [P] Add taxonomy composition, Intune and foundation mapping, and future-domain plausibility expectations in `apps/platform/tests/Unit/Baselines/GovernanceSubjectTaxonomyRegistryTest.php` and `apps/platform/tests/Unit/Baselines/InventoryMetaContractTest.php`
- [X] T005 [P] Add canonical V2 normalization, duplicate merge, mixed-payload rejection, default-empty filters coverage, and legacy empty-list plus missing-key defaults coverage in `apps/platform/tests/Unit/Baselines/BaselineScopeTest.php`
- [X] T006 [P] Add transition-safe effective-scope compatibility projection coverage in `apps/platform/tests/Feature/Baselines/BaselineCaptureTest.php`, `apps/platform/tests/Feature/Baselines/BaselineComparePreconditionsTest.php`, and `apps/platform/tests/Feature/Filament/OperationRunBaselineTruthSurfaceTest.php`
- [X] T007 Implement platform-facing governance domain and subject-class value objects in `apps/platform/app/Support/Governance/GovernanceDomainKey.php` and `apps/platform/app/Support/Governance/GovernanceSubjectClass.php`
- [X] T008 Implement the governance subject type record and composed taxonomy registry in `apps/platform/app/Support/Governance/GovernanceSubjectType.php` and `apps/platform/app/Support/Governance/GovernanceSubjectTaxonomyRegistry.php`
- [X] T009 Implement registry composition against existing metadata in `apps/platform/config/tenantpilot.php`, `apps/platform/app/Support/Inventory/InventoryPolicyTypeMeta.php`, and `apps/platform/app/Support/Governance/GovernanceSubjectTaxonomyRegistry.php`
- [X] T010 Wire canonical scope normalization and save-forward persistence into `apps/platform/app/Support/Baselines/BaselineScope.php` and `apps/platform/app/Models/BaselineProfile.php`
- [X] T011 Inventory effective-scope consumers and implement transition-safe compatibility projection plus canonical operation context in `apps/platform/app/Services/Baselines/BaselineCaptureService.php` and `apps/platform/app/Services/Baselines/BaselineCompareService.php`
**Checkpoint**: The repo can compose active governance subject metadata, prove future-safe contract shape, normalize legacy and V2 scope deterministically, persist canonical scope, and retain only the transition compatibility projection still required by current consumers.
---
## Phase 3: User Story 1 - Keep Existing Intune Baselines Usable (Priority: P1) 🎯 MVP
**Goal**: Preserve the current Intune baseline workflow while the underlying scope contract moves to canonical V2.
**Independent Test**: Open a legacy baseline profile, verify its normalized scope renders understandably, save it, then launch baseline capture and compare and confirm behavior remains unchanged.
### Tests for User Story 1
> **NOTE**: Write these tests first and confirm they fail before implementation.
- [X] T012 [P] [US1] Add legacy profile load, on-demand normalization-lineage, and save-forward coverage in `apps/platform/tests/Feature/Filament/BaselineProfileScopeV2PersistenceTest.php` and `apps/platform/tests/Feature/Filament/BaselineProfileFoundationScopeTest.php`
- [X] T013 [P] [US1] Extend legacy capture and compare no-regression coverage in `apps/platform/tests/Feature/Baselines/BaselineCaptureTest.php`, `apps/platform/tests/Feature/Baselines/BaselineComparePreconditionsTest.php`, and `apps/platform/tests/Feature/Filament/OperationRunBaselineTruthSurfaceTest.php`
- [X] T014 [P] [US1] Extend baseline authorization continuity coverage for legacy-scope save and start actions in `apps/platform/tests/Feature/Baselines/BaselineProfileAuthorizationTest.php` and `apps/platform/tests/Feature/Filament/BaselineActionAuthorizationTest.php`
### Implementation for User Story 1
- [X] T015 [US1] Keep legacy baseline detail and start-surface flows stable while reading normalized scope in `apps/platform/app/Filament/Resources/BaselineProfileResource.php` and `apps/platform/app/Filament/Resources/BaselineProfileResource/Pages/ViewBaselineProfile.php`
- [X] T016 [US1] Preserve capture and compare readiness semantics on the baseline view surface while canonical scope rolls out in `apps/platform/app/Filament/Resources/BaselineProfileResource.php` and `apps/platform/app/Filament/Resources/BaselineProfileResource/Pages/ViewBaselineProfile.php`
**Checkpoint**: Legacy Intune baseline profiles remain independently usable for load, save, capture, and compare after canonical scope normalization lands.
---
## Phase 4: User Story 2 - Define a Baseline with Explicit Governed-Subject Semantics (Priority: P1)
**Goal**: Keep the current Intune-first workflow while making the saved baseline contract explicit about domain, subject class, and subject families.
**Independent Test**: Create a new baseline profile through the existing baseline form and confirm the stored scope is canonical V2 with explicit domain and subject-class semantics and an operator-safe summary.
### Tests for User Story 2
> **NOTE**: Write these tests first and confirm they fail before implementation.
- [X] T017 [P] [US2] Add canonical V2 create and update persistence coverage in `apps/platform/tests/Feature/Filament/BaselineProfileScopeV2PersistenceTest.php`
- [X] T018 [P] [US2] Add create and edit form summary, active subject-group, support-readiness, and invalid-selection feedback coverage in `apps/platform/tests/Feature/Filament/BaselineProfileScopeV2PersistenceTest.php` and `apps/platform/tests/Feature/Filament/BaselineProfileFoundationScopeTest.php`
- [X] T019 [P] [US2] Add hidden-raw-json and readiness copy coverage in `apps/platform/tests/Feature/Filament/BaselineProfileCaptureStartSurfaceTest.php` and `apps/platform/tests/Feature/Filament/BaselineProfileCompareStartSurfaceTest.php`
### Implementation for User Story 2
- [X] T020 [US2] Update create and edit form state handling to translate Intune-first selectors into canonical V2 entries with empty-by-default filters in `apps/platform/app/Filament/Resources/BaselineProfileResource.php`, `apps/platform/app/Filament/Resources/BaselineProfileResource/Pages/CreateBaselineProfile.php`, and `apps/platform/app/Filament/Resources/BaselineProfileResource/Pages/EditBaselineProfile.php`
- [X] T021 [US2] Render normalized scope summaries, active subject groups, support readiness, and invalid-selection feedback on create and edit surfaces in `apps/platform/app/Filament/Resources/BaselineProfileResource.php`, `apps/platform/app/Filament/Resources/BaselineProfileResource/Pages/CreateBaselineProfile.php`, and `apps/platform/app/Filament/Resources/BaselineProfileResource/Pages/EditBaselineProfile.php`
- [X] T022 [US2] Add normalized governed-subject summaries with operator-safe selected labels and on-demand normalization lineage to the baseline detail surface in `apps/platform/app/Filament/Resources/BaselineProfileResource.php` and `apps/platform/app/Filament/Resources/BaselineProfileResource/Pages/ViewBaselineProfile.php`
- [X] T023 [US2] Keep operator-facing scope vocabulary platform-safe while remaining Intune-first in `apps/platform/app/Support/Governance/GovernanceSubjectTaxonomyRegistry.php` and `apps/platform/app/Filament/Resources/BaselineProfileResource.php`
**Checkpoint**: New and updated baseline profiles are independently functional with canonical V2 persistence, explicit form feedback, and clear governed-subject summaries.
---
## Phase 5: User Story 3 - Reject Unsupported Combinations Before Work Starts (Priority: P2)
**Goal**: Fail invalid or unsupported scope selections before any capture or compare run is created.
**Independent Test**: Attempt to save or execute scope selections with an unknown domain, invalid class, inactive subject type, unsupported filter payload, or unsupported capture or compare combination and verify the action is blocked clearly before any run starts.
### Tests for User Story 3
> **NOTE**: Write these tests first and confirm they fail before implementation.
- [X] T024 [P] [US3] Add invalid domain, invalid class, inactive-type, mixed-payload, and future-domain selection rejection coverage in `apps/platform/tests/Unit/Baselines/BaselineScopeTest.php` and `apps/platform/tests/Unit/Baselines/GovernanceSubjectTaxonomyRegistryTest.php`
- [X] T025 [P] [US3] Extend create and edit save validation coverage for inactive subject types and unsupported filters in `apps/platform/tests/Feature/Filament/BaselineProfileScopeV2PersistenceTest.php` and `apps/platform/tests/Feature/Filament/BaselineProfileFoundationScopeTest.php`
- [X] T026 [P] [US3] Extend capture and compare pre-run gating coverage in `apps/platform/tests/Feature/Baselines/BaselineCaptureTest.php`, `apps/platform/tests/Feature/Baselines/BaselineComparePreconditionsTest.php`, `apps/platform/tests/Feature/Filament/BaselineProfileCaptureStartSurfaceTest.php`, and `apps/platform/tests/Feature/Filament/BaselineProfileCompareStartSurfaceTest.php`
### Implementation for User Story 3
- [X] T027 [US3] Implement unknown-domain, invalid-class, inactive-type, future-domain, and filter guardrails in `apps/platform/app/Support/Baselines/BaselineScope.php` and `apps/platform/app/Support/Governance/GovernanceSubjectTaxonomyRegistry.php`
- [X] T028 [US3] Enforce capture and compare eligibility gating before run creation in `apps/platform/app/Services/Baselines/BaselineCaptureService.php` and `apps/platform/app/Services/Baselines/BaselineCompareService.php`
- [X] T029 [US3] Surface deterministic validation and readiness feedback without exposing inactive future domains in `apps/platform/app/Filament/Resources/BaselineProfileResource.php`, `apps/platform/app/Filament/Resources/BaselineProfileResource/Pages/CreateBaselineProfile.php`, `apps/platform/app/Filament/Resources/BaselineProfileResource/Pages/EditBaselineProfile.php`, and `apps/platform/app/Filament/Resources/BaselineProfileResource/Pages/ViewBaselineProfile.php`
**Checkpoint**: Invalid or unsupported scope combinations are independently blocked before save, capture, or compare work begins.
---
## Phase 6: User Story 4 - Roll Out Progressively Without Forced Migration Churn (Priority: P3)
**Goal**: Keep legacy rows readable during rollout and provide an optional cleanup path once canonical V2 behavior is trusted.
**Independent Test**: Keep a mixed dataset of legacy and V2 baseline profiles, verify both remain usable, then run the optional cleanup path and confirm legacy rows are rewritten without changing their governed-subject meaning.
### Tests for User Story 4
> **NOTE**: Write these tests first and confirm they fail before implementation.
- [X] T030 [P] [US4] Add mixed legacy profile-scope dataset coverage plus dry-run preview, explicit write confirmation, audit logging, and idempotent backfill assertions in `apps/platform/tests/Feature/Baselines/BaselineScopeBackfillCommandTest.php`
- [X] T031 [P] [US4] Extend tolerant-read, compatibility-projection, save-forward rollout coverage for untouched and rewritten profile rows, and compare assignment-override normalization coverage in `apps/platform/tests/Feature/Filament/BaselineProfileScopeV2PersistenceTest.php` and `apps/platform/tests/Feature/Filament/OperationRunBaselineTruthSurfaceTest.php`
### Implementation for User Story 4
- [X] T032 [US4] Create the optional baseline scope backfill command with preview-by-default and explicit write confirmation in `apps/platform/app/Console/Commands/BackfillBaselineScopeV2.php`
- [X] T033 [US4] Implement legacy baseline-profile row selection, canonical rewrite, idempotent reporting, and audit logging in `apps/platform/app/Console/Commands/BackfillBaselineScopeV2.php` and `apps/platform/app/Models/BaselineProfile.php`
- [X] T034 [US4] Keep assignment-override reads and mixed-dataset compare behavior tolerant in `apps/platform/app/Support/Baselines/BaselineScope.php` and `apps/platform/app/Services/Baselines/BaselineCompareService.php`
**Checkpoint**: Mixed legacy and V2 datasets remain independently usable, and optional cleanup can be run later without semantic drift.
---
## Phase 7: Polish & Cross-Cutting Concerns
**Purpose**: Lock the slice down with operation-truth, authorization, and focused verification coverage.
- [X] T035 [P] Add cross-cutting operation-truth assertions for canonical effective scope and compatibility projection in `apps/platform/tests/Feature/Filament/OperationRunBaselineTruthSurfaceTest.php`
- [X] T036 [P] Recheck baseline authorization and operator-copy regressions in `apps/platform/tests/Feature/Baselines/BaselineProfileAuthorizationTest.php`, `apps/platform/tests/Feature/Filament/BaselineActionAuthorizationTest.php`, and `apps/platform/tests/Feature/Filament/BaselineProfileFoundationScopeTest.php`
- [X] T037 [P] Run the full required Sail verification and formatting workflow from `specs/202-governance-subject-taxonomy/quickstart.md`
---
## 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 no-regression behavior.
- **User Story 3 (Phase 5)**: Depends on Foundational completion and should land after the P1 persistence and summary work stabilizes.
- **User Story 4 (Phase 6)**: Depends on Foundational completion and should land after the P1 and P2 rollout behavior is trusted.
- **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 builds most cleanly after US1 proves the no-regression save-forward path.
- **US3**: Depends on the shared taxonomy, default filter semantics, and transition infrastructure from Foundational and should be verified against the P1 surfaces.
- **US4**: Depends on the shared infrastructure and should follow the rollout behavior established by US1 through US3.
### Within Each User Story
- Write the story tests first and confirm they fail before implementation.
- Keep changes inside the existing baseline model, services, and Filament resource surfaces unless a task explicitly introduces a new governance support file or maintenance command.
- Finish each storys 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 `T011`.
- Within US1, `T012`, `T013`, and `T014` can run in parallel.
- Within US2, `T017`, `T018`, and `T019` can run in parallel.
- Within US3, `T024`, `T025`, and `T026` can run in parallel.
- Within US4, `T030` and `T031` can run in parallel.
- `T035`, `T036`, and `T037` can run in parallel once implementation is complete.
---
## Parallel Example: User Story 1
```bash
# Parallel test pass for US1
T012 Add legacy profile load, on-demand normalization-lineage, and save-forward coverage in apps/platform/tests/Feature/Filament/BaselineProfileScopeV2PersistenceTest.php and apps/platform/tests/Feature/Filament/BaselineProfileFoundationScopeTest.php
T013 Extend legacy capture and compare no-regression coverage in apps/platform/tests/Feature/Baselines/BaselineCaptureTest.php, apps/platform/tests/Feature/Baselines/BaselineComparePreconditionsTest.php, and apps/platform/tests/Feature/Filament/OperationRunBaselineTruthSurfaceTest.php
T014 Extend baseline authorization continuity coverage in apps/platform/tests/Feature/Baselines/BaselineProfileAuthorizationTest.php and apps/platform/tests/Feature/Filament/BaselineActionAuthorizationTest.php
```
## Parallel Example: User Story 2
```bash
# Parallel test pass for US2
T017 Add canonical V2 create and update persistence coverage in apps/platform/tests/Feature/Filament/BaselineProfileScopeV2PersistenceTest.php
T018 Add create and edit form summary, active subject-group, support-readiness, and invalid-selection feedback coverage in apps/platform/tests/Feature/Filament/BaselineProfileScopeV2PersistenceTest.php and apps/platform/tests/Feature/Filament/BaselineProfileFoundationScopeTest.php
T019 Add hidden-raw-json and readiness copy coverage in apps/platform/tests/Feature/Filament/BaselineProfileCaptureStartSurfaceTest.php and apps/platform/tests/Feature/Filament/BaselineProfileCompareStartSurfaceTest.php
```
## Parallel Example: User Story 3
```bash
# Parallel test pass for US3
T024 Add invalid domain, invalid class, inactive-type, mixed-payload, and future-domain selection rejection coverage in apps/platform/tests/Unit/Baselines/BaselineScopeTest.php and apps/platform/tests/Unit/Baselines/GovernanceSubjectTaxonomyRegistryTest.php
T025 Extend create and edit save validation coverage for inactive subject types and unsupported filters in apps/platform/tests/Feature/Filament/BaselineProfileScopeV2PersistenceTest.php and apps/platform/tests/Feature/Filament/BaselineProfileFoundationScopeTest.php
T026 Extend capture and compare pre-run gating coverage in apps/platform/tests/Feature/Baselines/BaselineCaptureTest.php, apps/platform/tests/Feature/Baselines/BaselineComparePreconditionsTest.php, and apps/platform/tests/Feature/Filament/BaselineProfileCaptureStartSurfaceTest.php and apps/platform/tests/Feature/Filament/BaselineProfileCompareStartSurfaceTest.php
```
## Parallel Example: User Story 4
```bash
# Parallel test pass for US4
T030 Add mixed legacy and V2 dataset coverage plus dry-run preview, explicit write confirmation, audit logging, and idempotent backfill assertions in apps/platform/tests/Feature/Baselines/BaselineScopeBackfillCommandTest.php
T031 Extend tolerant-read, compatibility-projection, and save-forward rollout coverage for untouched and rewritten rows in apps/platform/tests/Feature/Filament/BaselineProfileScopeV2PersistenceTest.php and apps/platform/tests/Feature/Filament/OperationRunBaselineTruthSurfaceTest.php
```
---
## Implementation Strategy
### MVP First (User Story 1 Only)
1. Complete Phase 1: Setup.
2. Complete Phase 2: Foundational taxonomy, transition, and canonical scope work.
3. Complete Phase 3: User Story 1.
4. Validate legacy load, save, capture, and compare behavior with the focused US1 tests.
5. Stop and review the no-regression baseline workflow before widening the slice.
### Incremental Delivery
1. Ship US1 to prove canonical scope can land without breaking current Intune baselines.
2. Add US2 to make new and updated baseline profiles explicit about governed-subject semantics and create or edit feedback.
3. Add US3 to block invalid or unsupported scope combinations before work starts.
4. Add US4 to provide an optional cleanup path after rollout confidence exists.
5. Finish with operation-truth, authorization, and focused verification work 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 the US2 test pass and follows once the no-regression path is stable.
- Contributor C prepares the US3 validation and gating tests against the canonical scope foundation.
- Contributor D prepares the US4 cleanup command tests.
3. Merge back for Phase 7 verification and formatting.

View File

@ -0,0 +1,35 @@
# 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`.

View File

@ -0,0 +1,442 @@
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

View File

@ -0,0 +1,224 @@
# 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.

View File

@ -0,0 +1,225 @@
# 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.

View File

@ -0,0 +1,116 @@
# 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.

View File

@ -0,0 +1,89 @@
# 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.

View File

@ -0,0 +1,304 @@
# 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.

View File

@ -0,0 +1,246 @@
# 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.