Spec 202: implement governance subject taxonomy and baseline scope V2 #232

Merged
ahmido merged 2 commits from 202-governance-subject-taxonomy into dev 2026-04-13 15:33:34 +00:00
38 changed files with 4560 additions and 89 deletions
Showing only changes of commit b5b1b465ea - Show all commits

View File

@ -178,6 +178,8 @@ ## Active Technologies
- PostgreSQL through existing workspace-owned, tenant-owned, and system-visible models; no schema change planned (195-action-surface-closure) - 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) - 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) - 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 (feat/005-bulk-operations) - PHP 8.4.15 (feat/005-bulk-operations)
@ -212,8 +214,8 @@ ## Code Style
PHP 8.4.15: Follow standard conventions PHP 8.4.15: Follow standard conventions
## Recent Changes ## Recent Changes
- 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 - 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 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 START -->
<!-- MANUAL ADDITIONS END --> <!-- MANUAL ADDITIONS END -->

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

@ -20,6 +20,7 @@
use App\Support\Badges\BadgeCatalog; use App\Support\Badges\BadgeCatalog;
use App\Support\Badges\BadgeDomain; use App\Support\Badges\BadgeDomain;
use App\Support\Badges\BadgeRenderer; use App\Support\Badges\BadgeRenderer;
use App\Support\Baselines\BaselineScope;
use App\Support\Baselines\BaselineCaptureMode; use App\Support\Baselines\BaselineCaptureMode;
use App\Support\Baselines\BaselineFullContentRolloutGate; use App\Support\Baselines\BaselineFullContentRolloutGate;
use App\Support\Baselines\BaselineProfileStatus; use App\Support\Baselines\BaselineProfileStatus;
@ -51,9 +52,11 @@
use Filament\Notifications\Notification; use Filament\Notifications\Notification;
use Filament\Resources\Resource; use Filament\Resources\Resource;
use Filament\Schemas\Components\Section; use Filament\Schemas\Components\Section;
use Filament\Schemas\Components\Utilities\Get;
use Filament\Schemas\Schema; use Filament\Schemas\Schema;
use Filament\Tables\Columns\TextColumn; use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Table; use Filament\Tables\Table;
use InvalidArgumentException;
use Illuminate\Auth\Access\AuthorizationException; use Illuminate\Auth\Access\AuthorizationException;
use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
@ -216,12 +219,14 @@ public static function form(Schema $schema): Schema
->label('Policy types') ->label('Policy types')
->multiple() ->multiple()
->options(self::policyTypeOptions()) ->options(self::policyTypeOptions())
->live()
->helperText('Leave empty to include all supported policy types (excluding foundations).') ->helperText('Leave empty to include all supported policy types (excluding foundations).')
->native(false), ->native(false),
Select::make('scope_jsonb.foundation_types') Select::make('scope_jsonb.foundation_types')
->label('Foundations') ->label('Foundations')
->multiple() ->multiple()
->options(self::foundationTypeOptions()) ->options(self::foundationTypeOptions())
->live()
->helperText('Leave empty to exclude foundations. Select foundations to include them.') ->helperText('Leave empty to exclude foundations. Select foundations to include them.')
->native(false), ->native(false),
Placeholder::make('metadata') Placeholder::make('metadata')
@ -232,6 +237,26 @@ public static function form(Schema $schema): Schema
->visible(fn (?BaselineProfile $record): bool => $record !== null), ->visible(fn (?BaselineProfile $record): bool => $record !== null),
]) ])
->columns(2), ->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 +307,17 @@ public static function infolist(Schema $schema): Schema
->columnSpanFull(), ->columnSpanFull(),
Section::make('Scope') Section::make('Scope')
->schema([ ->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') TextEntry::make('scope_jsonb.policy_types')
->label('Policy types') ->label('Policy types')
->badge() ->badge()
@ -502,6 +538,82 @@ public static function compareMatrixUrl(BaselineProfile|int $profile): string
return static::getUrl('compare-matrix', ['record' => $profile], panel: 'admin'); 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> * @return array<string, string>
*/ */
@ -693,7 +805,11 @@ private static function latestAttemptedSnapshotDescription(BaselineProfile $prof
private static function compareReadinessLabel(BaselineProfile $profile): string 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_UNSUPPORTED_SCOPE => 'Unsupported governed subjects',
default => self::compareAvailabilityEnvelope($profile)?->operatorLabel ?? 'Ready',
};
} }
private static function compareReadinessColor(BaselineProfile $profile): string private static function compareReadinessColor(BaselineProfile $profile): string
@ -701,6 +817,8 @@ private static function compareReadinessColor(BaselineProfile $profile): string
return match (self::compareAvailabilityReason($profile)) { return match (self::compareAvailabilityReason($profile)) {
null => 'success', null => 'success',
BaselineReasonCodes::COMPARE_PROFILE_NOT_ACTIVE => 'gray', BaselineReasonCodes::COMPARE_PROFILE_NOT_ACTIVE => 'gray',
BaselineReasonCodes::COMPARE_INVALID_SCOPE,
BaselineReasonCodes::COMPARE_UNSUPPORTED_SCOPE => 'danger',
default => 'warning', default => 'warning',
}; };
} }
@ -710,13 +828,19 @@ private static function compareReadinessIcon(BaselineProfile $profile): ?string
return match (self::compareAvailabilityReason($profile)) { return match (self::compareAvailabilityReason($profile)) {
null => 'heroicon-m-check-badge', null => 'heroicon-m-check-badge',
BaselineReasonCodes::COMPARE_PROFILE_NOT_ACTIVE => 'heroicon-m-pause-circle', BaselineReasonCodes::COMPARE_PROFILE_NOT_ACTIVE => 'heroicon-m-pause-circle',
BaselineReasonCodes::COMPARE_INVALID_SCOPE,
BaselineReasonCodes::COMPARE_UNSUPPORTED_SCOPE => 'heroicon-m-no-symbol',
default => 'heroicon-m-exclamation-triangle', default => 'heroicon-m-exclamation-triangle',
}; };
} }
private static function profileNextStep(BaselineProfile $profile): string 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_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 private static function effectiveSnapshot(BaselineProfile $profile): ?BaselineSnapshot
@ -739,6 +863,20 @@ private static function compareAvailabilityReason(BaselineProfile $profile): ?st
return BaselineReasonCodes::COMPARE_PROFILE_NOT_ACTIVE; 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;
}
if (! $scope->operationEligibility('compare')['ok']) {
return BaselineReasonCodes::COMPARE_UNSUPPORTED_SCOPE;
}
$resolution = app(BaselineSnapshotTruthResolver::class)->resolveCompareSnapshot($profile); $resolution = app(BaselineSnapshotTruthResolver::class)->resolveCompareSnapshot($profile);
$reasonCode = $resolution['reason_code'] ?? null; $reasonCode = $resolution['reason_code'] ?? null;
@ -797,4 +935,37 @@ private static function hasEligibleCompareTarget(BaselineProfile $profile): bool
->get(['id']) ->get(['id'])
->contains(fn (Tenant $tenant): bool => $resolver->can($user, $tenant, Capabilities::TENANT_SYNC)); ->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 App\Support\Workspaces\WorkspaceContext;
use Filament\Notifications\Notification; use Filament\Notifications\Notification;
use Filament\Resources\Pages\CreateRecord; use Filament\Resources\Pages\CreateRecord;
use Illuminate\Validation\ValidationException;
use InvalidArgumentException;
class CreateBaselineProfile extends CreateRecord 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; $data['created_by_user_id'] = $user instanceof User ? $user->getKey() : null;
if (isset($data['scope_jsonb'])) { 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; return $data;

View File

@ -11,6 +11,8 @@
use App\Support\Baselines\BaselineScope; use App\Support\Baselines\BaselineScope;
use Filament\Notifications\Notification; use Filament\Notifications\Notification;
use Filament\Resources\Pages\EditRecord; use Filament\Resources\Pages\EditRecord;
use Illuminate\Validation\ValidationException;
use InvalidArgumentException;
class EditBaselineProfile extends EditRecord class EditBaselineProfile extends EditRecord
{ {
@ -52,7 +54,13 @@ protected function mutateFormDataBeforeSave(array $data): array
} }
if (isset($data['scope_jsonb'])) { 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; 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_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_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_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), default => 'Reason: '.str_replace('.', ' ', $reasonCode),
}; };
@ -257,6 +259,8 @@ private function compareNowAction(): Action
BaselineReasonCodes::COMPARE_SNAPSHOT_BUILDING => 'The latest baseline capture is still building. Compare will be available after it completes.', 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_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_SNAPSHOT_SUPERSEDED => 'A newer complete snapshot is current. Compare uses the latest complete baseline only.',
BaselineReasonCodes::COMPARE_INVALID_SCOPE => 'This baseline profile has an invalid governed-subject scope. Review the baseline definition before comparing.',
BaselineReasonCodes::COMPARE_UNSUPPORTED_SCOPE => 'This baseline profile includes governed subjects that are not currently supported for compare.',
default => 'Reason: '.str_replace('.', ' ', $reasonCode), default => 'Reason: '.str_replace('.', ' ', $reasonCode),
}; };

View File

@ -13,6 +13,7 @@
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Database\Eloquent\Relations\HasMany;
use InvalidArgumentException;
use JsonException; use JsonException;
class BaselineProfile extends Model class BaselineProfile extends Model
@ -62,22 +63,61 @@ protected function scopeJsonb(): Attribute
{ {
return Attribute::make( return Attribute::make(
get: function (mixed $value): array { get: function (mixed $value): array {
return BaselineScope::fromJsonb( try {
$this->decodeScopeJsonb($value) return $this->normalizedScopeFrom($value)->toJsonb();
)->toJsonb(); } catch (InvalidArgumentException) {
return $this->decodeScopeJsonb($value) ?? ['policy_types' => [], 'foundation_types' => []];
}
}, },
set: function (mixed $value): string { set: function (mixed $value): string {
$scope = BaselineScope::fromJsonb(is_array($value) ? $value : null)->toJsonb(); $scope = BaselineScope::fromJsonb(is_array($value) ? $value : null)->toStoredJsonb();
try { try {
return json_encode($scope, JSON_THROW_ON_ERROR); return json_encode($scope, JSON_THROW_ON_ERROR);
} catch (JsonException) { } 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. * Decode raw scope_jsonb value from the database.
* *
@ -102,6 +142,11 @@ private function decodeScopeJsonb(mixed $value): ?array
return null; return null;
} }
private function normalizedScopeFrom(mixed $value): BaselineScope
{
return BaselineScope::fromJsonb($this->decodeScopeJsonb($value));
}
public function workspace(): BelongsTo public function workspace(): BelongsTo
{ {
return $this->belongsTo(Workspace::class); return $this->belongsTo(Workspace::class);

View File

@ -17,6 +17,7 @@
use App\Support\Baselines\BaselineScope; use App\Support\Baselines\BaselineScope;
use App\Support\Baselines\BaselineSupportCapabilityGuard; use App\Support\Baselines\BaselineSupportCapabilityGuard;
use App\Support\OperationRunType; use App\Support\OperationRunType;
use InvalidArgumentException;
final class BaselineCaptureService final class BaselineCaptureService
{ {
@ -40,9 +41,26 @@ public function startCapture(
return ['ok' => false, 'reason_code' => $precondition]; return ['ok' => false, 'reason_code' => $precondition];
} }
$effectiveScope = BaselineScope::fromJsonb( try {
is_array($profile->scope_jsonb) ? $profile->scope_jsonb : null, $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 $captureMode = $profile->capture_mode instanceof BaselineCaptureMode
? $profile->capture_mode ? $profile->capture_mode

View File

@ -21,6 +21,7 @@
use App\Support\Baselines\BaselineSupportCapabilityGuard; use App\Support\Baselines\BaselineSupportCapabilityGuard;
use App\Support\OperationRunType; use App\Support\OperationRunType;
use App\Support\ReasonTranslation\ReasonPresenter; use App\Support\ReasonTranslation\ReasonPresenter;
use InvalidArgumentException;
final class BaselineCompareService final class BaselineCompareService
{ {
@ -107,14 +108,30 @@ public function startCompareForProfile(
$snapshot = $snapshotResolution['snapshot']; $snapshot = $snapshotResolution['snapshot'];
$snapshotId = (int) $snapshot->getKey(); $snapshotId = (int) $snapshot->getKey();
$profileScope = BaselineScope::fromJsonb( try {
is_array($profile->scope_jsonb) ? $profile->scope_jsonb : null, $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) $overrideScope = $assignment->override_scope_jsonb !== null
: 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);
}
$eligibility = $effectiveScope->operationEligibility('compare', $this->capabilityGuard);
if (! $eligibility['ok']) {
return $this->failedStart(BaselineReasonCodes::COMPARE_UNSUPPORTED_SCOPE);
}
$captureMode = $profile->capture_mode instanceof BaselineCaptureMode $captureMode = $profile->capture_mode instanceof BaselineCaptureMode
? $profile->capture_mode ? $profile->capture_mode

View File

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

View File

@ -121,13 +121,23 @@ public static function forTenant(?Tenant $tenant): self
$snapshotReasonCode = is_string($truthResolution['reason_code'] ?? null) ? (string) $truthResolution['reason_code'] : null; $snapshotReasonCode = is_string($truthResolution['reason_code'] ?? null) ? (string) $truthResolution['reason_code'] : null;
$snapshotReasonMessage = self::missingSnapshotMessage($snapshotReasonCode); $snapshotReasonMessage = self::missingSnapshotMessage($snapshotReasonCode);
$profileScope = BaselineScope::fromJsonb( try {
is_array($profile->scope_jsonb) ? $profile->scope_jsonb : null, $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) $overrideScope = $assignment->override_scope_jsonb !== null
: null; ? BaselineScope::fromJsonb(
$effectiveScope = BaselineScope::effective($profileScope, $overrideScope); 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); $duplicateNameStats = self::duplicateNameStats($tenant, $effectiveScope);
$duplicateNamePoliciesCount = $duplicateNameStats['policy_count']; $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_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_BUILDING = 'baseline.snapshot.building';
public const string SNAPSHOT_INCOMPLETE = 'baseline.snapshot.incomplete'; public const string SNAPSHOT_INCOMPLETE = 'baseline.snapshot.incomplete';
@ -46,6 +50,10 @@ final class BaselineReasonCodes
public const string COMPARE_ROLLOUT_DISABLED = 'baseline.compare.rollout_disabled'; 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_SNAPSHOT_BUILDING = 'baseline.compare.snapshot_building'; public const string COMPARE_SNAPSHOT_BUILDING = 'baseline.compare.snapshot_building';
public const string COMPARE_SNAPSHOT_INCOMPLETE = 'baseline.compare.snapshot_incomplete'; public const string COMPARE_SNAPSHOT_INCOMPLETE = 'baseline.compare.snapshot_incomplete';
@ -61,6 +69,8 @@ public static function all(): array
self::CAPTURE_MISSING_SOURCE_TENANT, self::CAPTURE_MISSING_SOURCE_TENANT,
self::CAPTURE_PROFILE_NOT_ACTIVE, self::CAPTURE_PROFILE_NOT_ACTIVE,
self::CAPTURE_ROLLOUT_DISABLED, self::CAPTURE_ROLLOUT_DISABLED,
self::CAPTURE_INVALID_SCOPE,
self::CAPTURE_UNSUPPORTED_SCOPE,
self::SNAPSHOT_BUILDING, self::SNAPSHOT_BUILDING,
self::SNAPSHOT_INCOMPLETE, self::SNAPSHOT_INCOMPLETE,
self::SNAPSHOT_SUPERSEDED, self::SNAPSHOT_SUPERSEDED,
@ -75,6 +85,8 @@ public static function all(): array
self::COMPARE_NO_ELIGIBLE_TARGET, self::COMPARE_NO_ELIGIBLE_TARGET,
self::COMPARE_INVALID_SNAPSHOT, self::COMPARE_INVALID_SNAPSHOT,
self::COMPARE_ROLLOUT_DISABLED, self::COMPARE_ROLLOUT_DISABLED,
self::COMPARE_INVALID_SCOPE,
self::COMPARE_UNSUPPORTED_SCOPE,
self::COMPARE_SNAPSHOT_BUILDING, self::COMPARE_SNAPSHOT_BUILDING,
self::COMPARE_SNAPSHOT_INCOMPLETE, self::COMPARE_SNAPSHOT_INCOMPLETE,
self::COMPARE_SNAPSHOT_SUPERSEDED, self::COMPARE_SNAPSHOT_SUPERSEDED,
@ -107,8 +119,12 @@ public static function trustImpact(?string $reasonCode): ?string
self::COMPARE_SNAPSHOT_BUILDING, self::COMPARE_SNAPSHOT_BUILDING,
self::COMPARE_SNAPSHOT_INCOMPLETE, self::COMPARE_SNAPSHOT_INCOMPLETE,
self::COMPARE_SNAPSHOT_SUPERSEDED, self::COMPARE_SNAPSHOT_SUPERSEDED,
self::COMPARE_INVALID_SCOPE,
self::COMPARE_UNSUPPORTED_SCOPE,
self::CAPTURE_MISSING_SOURCE_TENANT, 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, default => null,
}; };
} }
@ -132,10 +148,14 @@ public static function absencePattern(?string $reasonCode): ?string
self::COMPARE_PROFILE_NOT_ACTIVE, self::COMPARE_PROFILE_NOT_ACTIVE,
self::COMPARE_NO_ELIGIBLE_TARGET, self::COMPARE_NO_ELIGIBLE_TARGET,
self::COMPARE_INVALID_SNAPSHOT, self::COMPARE_INVALID_SNAPSHOT,
self::COMPARE_INVALID_SCOPE,
self::COMPARE_UNSUPPORTED_SCOPE,
self::COMPARE_ROLLOUT_DISABLED, self::COMPARE_ROLLOUT_DISABLED,
self::SNAPSHOT_SUPERSEDED, self::SNAPSHOT_SUPERSEDED,
self::COMPARE_SNAPSHOT_SUPERSEDED => 'blocked_prerequisite', self::COMPARE_SNAPSHOT_SUPERSEDED => 'blocked_prerequisite',
self::SNAPSHOT_CAPTURE_FAILED => 'unavailable', self::SNAPSHOT_CAPTURE_FAILED => 'unavailable',
self::CAPTURE_INVALID_SCOPE,
self::CAPTURE_UNSUPPORTED_SCOPE => 'unavailable',
default => null, default => null,
}; };
} }

View File

@ -4,26 +4,36 @@
namespace App\Support\Baselines; 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 App\Support\Inventory\InventoryPolicyTypeMeta;
use InvalidArgumentException;
/** /**
* Value object for baseline scope resolution. * Value object for baseline scope resolution.
* *
* A scope defines which policy types are included in a baseline profile. * Canonical storage uses versioned Governance Scope V2 entries.
* * Presentation compatibility for the current Intune-first UI still projects
* Spec 116 semantics: * back to legacy policy and foundation buckets.
* - Empty policy_types means "all supported policy types" (excluding foundations).
* - Empty foundation_types means "none".
*/ */
final class BaselineScope final class BaselineScope
{ {
/** /**
* @param array<string> $policyTypes * @param list<string> $policyTypes
* @param array<string> $foundationTypes * @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 function __construct(
public readonly array $policyTypes = [], public readonly array $policyTypes = [],
public readonly array $foundationTypes = [], 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 * @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) { if ($scopeJsonb === null) {
return new self; return self::fromLegacyPayload(
policyTypes: [],
foundationTypes: [],
legacyKeysPresent: [],
normalizedOnRead: true,
saveForwardRequired: true,
);
} }
$policyTypes = $scopeJsonb['policy_types'] ?? []; if (isset($scopeJsonb['canonical_scope']) && is_array($scopeJsonb['canonical_scope'])) {
$foundationTypes = $scopeJsonb['foundation_types'] ?? []; return self::fromJsonb($scopeJsonb['canonical_scope'], $allowEmptyLegacyAsNoOverride);
}
$policyTypes = is_array($policyTypes) ? array_values(array_filter($policyTypes, 'is_string')) : []; $hasLegacyKeys = array_key_exists('policy_types', $scopeJsonb) || array_key_exists('foundation_types', $scopeJsonb);
$foundationTypes = is_array($foundationTypes) ? array_values(array_filter($foundationTypes, 'is_string')) : []; $hasCanonicalKeys = array_key_exists('version', $scopeJsonb) || array_key_exists('entries', $scopeJsonb);
return new self( if ($hasLegacyKeys && $hasCanonicalKeys) {
policyTypes: $policyTypes === [] ? [] : self::normalizePolicyTypes($policyTypes), throw new InvalidArgumentException('Baseline scope payload must not mix legacy buckets with canonical V2 keys.');
foundationTypes: self::normalizeFoundationTypes($foundationTypes), }
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); $overridePolicyTypes = self::normalizePolicyTypes($overrideScope->policyTypes);
$overrideFoundationTypes = self::normalizeFoundationTypes($overrideScope->foundationTypes); $overrideFoundationTypes = self::normalizeFoundationTypes($overrideScope->foundationTypes);
$entries = [];
$effectivePolicyTypes = $overridePolicyTypes !== [] foreach ($profileScope->entries as $entry) {
? array_values(array_intersect($profileScope->policyTypes, $overridePolicyTypes)) $subjectTypeKeys = $entry['subject_type_keys'];
: $profileScope->policyTypes;
$effectiveFoundationTypes = $overrideFoundationTypes !== [] if ($entry['domain_key'] === GovernanceDomainKey::Intune->value
? array_values(array_intersect($profileScope->foundationTypes, $overrideFoundationTypes)) && $entry['subject_class'] === GovernanceSubjectClass::Policy->value
: $profileScope->foundationTypes; && $overridePolicyTypes !== []) {
$subjectTypeKeys = array_values(array_intersect($subjectTypeKeys, $overridePolicyTypes));
}
return new self( if ($entry['domain_key'] === GovernanceDomainKey::PlatformFoundation->value
policyTypes: self::uniqueSorted($effectivePolicyTypes), && $entry['subject_class'] === GovernanceSubjectClass::ConfigurationResource->value
foundationTypes: self::uniqueSorted($effectiveFoundationTypes), && $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 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 public function expandDefaults(): self
{ {
$policyTypes = $this->policyTypes === [] if ($this->entries !== []) {
? self::supportedPolicyTypes() return $this;
: self::normalizePolicyTypes($this->policyTypes); }
$foundationTypes = self::normalizeFoundationTypes($this->foundationTypes); return self::fromLegacyPayload(
policyTypes: $this->policyTypes,
return new self( foundationTypes: $this->foundationTypes,
policyTypes: $policyTypes, legacyKeysPresent: $this->legacyKeysPresent,
foundationTypes: $foundationTypes, normalizedOnRead: $this->normalizedOnRead,
saveForwardRequired: $this->saveForwardRequired,
); );
} }
@ -134,16 +209,94 @@ public function truthfulTypes(string $operation, ?BaselineSupportCapabilityGuard
*/ */
public function toJsonb(): array public function toJsonb(): array
{ {
$supportedPolicyTypes = self::supportedPolicyTypes();
$policyTypes = $this->policyTypes;
if ($policyTypes === $supportedPolicyTypes) {
$policyTypes = [];
}
return [ return [
'policy_types' => $this->policyTypes, 'policy_types' => $policyTypes,
'foundation_types' => $this->foundationTypes, '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. * 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 public function toEffectiveScopeContext(?BaselineSupportCapabilityGuard $guard = null, ?string $operation = null): array
{ {
@ -151,9 +304,12 @@ public function toEffectiveScopeContext(?BaselineSupportCapabilityGuard $guard =
$allTypes = self::uniqueSorted(array_merge($expanded->policyTypes, $expanded->foundationTypes)); $allTypes = self::uniqueSorted(array_merge($expanded->policyTypes, $expanded->foundationTypes));
$context = [ $context = [
'canonical_scope' => $expanded->toStoredJsonb(),
'legacy_projection' => $expanded->toJsonb(),
'policy_types' => $expanded->policyTypes, 'policy_types' => $expanded->policyTypes,
'foundation_types' => $expanded->foundationTypes, 'foundation_types' => $expanded->foundationTypes,
'all_types' => $allTypes, 'all_types' => $allTypes,
'selected_type_keys' => $allTypes,
'foundations_included' => $expanded->foundationTypes !== [], 'foundations_included' => $expanded->foundationTypes !== [],
]; ];
@ -170,28 +326,34 @@ public function toEffectiveScopeContext(?BaselineSupportCapabilityGuard $guard =
'unsupported_types' => $guardResult['unsupported_types'], 'unsupported_types' => $guardResult['unsupported_types'],
'invalid_support_types' => $guardResult['invalid_support_types'], 'invalid_support_types' => $guardResult['invalid_support_types'],
'capabilities' => $guardResult['capabilities'], '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> * @return list<string>
*/ */
private static function supportedPolicyTypes(): array private static function supportedPolicyTypes(): array
{ {
$supported = config('tenantpilot.supported_policy_types', []); return app(GovernanceSubjectTaxonomyRegistry::class)->activeLegacyBucketKeys('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);
} }
/** /**
@ -199,14 +361,7 @@ private static function supportedPolicyTypes(): array
*/ */
private static function supportedFoundationTypes(): array private static function supportedFoundationTypes(): array
{ {
$types = collect(InventoryPolicyTypeMeta::baselineSupportedFoundations()) return app(GovernanceSubjectTaxonomyRegistry::class)->activeLegacyBucketKeys('foundation_types');
->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);
} }
/** /**
@ -243,4 +398,286 @@ private static function uniqueSorted(array $types): array
return $types; 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,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

@ -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

@ -10,6 +10,7 @@
use App\Services\Auth\WorkspaceCapabilityResolver; use App\Services\Auth\WorkspaceCapabilityResolver;
use App\Support\Workspaces\WorkspaceContext; use App\Support\Workspaces\WorkspaceContext;
use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\DB;
uses(RefreshDatabase::class); uses(RefreshDatabase::class);
@ -123,6 +124,33 @@
->get(BaselineProfileResource::getUrl('edit', ['record' => $profile], panel: 'admin')) ->get(BaselineProfileResource::getUrl('edit', ['record' => $profile], panel: 'admin'))
->assertOk(); ->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 () { 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

@ -9,6 +9,7 @@
use App\Models\BaselineTenantAssignment; use App\Models\BaselineTenantAssignment;
use App\Support\Workspaces\WorkspaceContext; use App\Support\Workspaces\WorkspaceContext;
use Filament\Facades\Filament; use Filament\Facades\Filament;
use Illuminate\Support\Facades\DB;
use Livewire\Livewire; use Livewire\Livewire;
it('keeps baseline capture and compare actions capability-gated on the profile detail page', function (): void { 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()]); $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); session()->put(WorkspaceContext::SESSION_KEY, (int) $tenant->workspace_id);
Livewire::actingAs($readonlyUser) Livewire::actingAs($readonlyUser)
@ -34,8 +42,8 @@
Livewire::actingAs($ownerUser) Livewire::actingAs($ownerUser)
->test(ViewBaselineProfile::class, ['record' => $profile->getKey()]) ->test(ViewBaselineProfile::class, ['record' => $profile->getKey()])
->assertActionEnabled('capture') ->assertActionHidden('capture')
->assertActionDisabled('compareNow'); ->assertActionEnabled('compareNow');
}); });
it('keeps tenant compare actions disabled for users without tenant.sync and enabled for owners', function (): void { it('keeps tenant compare actions disabled for users without tenant.sync and enabled for owners', function (): void {
@ -70,3 +78,47 @@
->test(BaselineCompareLanding::class) ->test(BaselineCompareLanding::class)
->assertActionEnabled('compareNow'); ->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

@ -12,6 +12,7 @@
use Filament\Actions\Action; use Filament\Actions\Action;
use Filament\Actions\ActionGroup; use Filament\Actions\ActionGroup;
use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Queue; use Illuminate\Support\Facades\Queue;
use Livewire\Features\SupportTesting\Testable; use Livewire\Features\SupportTesting\Testable;
use Livewire\Livewire; use Livewire\Livewire;
@ -147,3 +148,60 @@ function baselineProfileCaptureHeaderActions(Testable $component): array
Queue::assertNotPushed(CaptureBaselineSnapshotJob::class); Queue::assertNotPushed(CaptureBaselineSnapshotJob::class);
expect(OperationRun::query()->where('type', 'baseline_capture')->count())->toBe(0); 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

@ -11,6 +11,7 @@
use App\Support\Workspaces\WorkspaceContext; use App\Support\Workspaces\WorkspaceContext;
use Filament\Actions\Action; use Filament\Actions\Action;
use Filament\Actions\ActionGroup; use Filament\Actions\ActionGroup;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Queue; use Illuminate\Support\Facades\Queue;
use Livewire\Features\SupportTesting\Testable; use Livewire\Features\SupportTesting\Testable;
use Livewire\Livewire; use Livewire\Livewire;
@ -229,3 +230,86 @@ function baselineProfileHeaderActions(Testable $component): array
expect(collect(BaselineProfileResource::detailRelatedContextEntries($profile))->pluck('key')->all()) expect(collect(BaselineProfileResource::detailRelatedContextEntries($profile))->pluck('key')->all())
->toContain('compare_matrix'); ->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); declare(strict_types=1);
use App\Filament\Resources\BaselineProfileResource\Pages\CreateBaselineProfile; use App\Filament\Resources\BaselineProfileResource\Pages\CreateBaselineProfile;
use App\Filament\Resources\BaselineProfileResource\Pages\EditBaselineProfile;
use App\Models\BaselineProfile; use App\Models\BaselineProfile;
use Filament\Forms\Components\Select; use Filament\Forms\Components\Select;
use Illuminate\Validation\ValidationException;
use Livewire\Livewire; use Livewire\Livewire;
it('shows only baseline-supported foundation types in the baseline profile scope picker', function (): void { 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(); 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\BaselineSnapshot;
use App\Models\BaselineTenantAssignment; use App\Models\BaselineTenantAssignment;
use App\Models\OperationRun; use App\Models\OperationRun;
use App\Services\Baselines\BaselineCaptureService;
use App\Services\Baselines\BaselineCompareService;
use App\Support\Baselines\BaselineCompareReasonCode; use App\Support\Baselines\BaselineCompareReasonCode;
use App\Support\Baselines\BaselineCompareStats; use App\Support\Baselines\BaselineCompareStats;
use App\Support\Baselines\BaselineReasonCodes; use App\Support\Baselines\BaselineReasonCodes;
use App\Support\Ui\GovernanceArtifactTruth\ArtifactTruthPresenter; use App\Support\Ui\GovernanceArtifactTruth\ArtifactTruthPresenter;
use App\Support\Workspaces\WorkspaceContext; use App\Support\Workspaces\WorkspaceContext;
use Filament\Facades\Filament; use Filament\Facades\Filament;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Queue;
use Livewire\Features\SupportTesting\Testable; use Livewire\Features\SupportTesting\Testable;
use Livewire\Livewire; use Livewire\Livewire;
@ -248,3 +252,85 @@ function visibleLivewireText(Testable $component): string
->assertSee($explanation?->trustworthinessLabel() ?? '') ->assertSee($explanation?->trustworthinessLabel() ?? '')
->assertDontSee('No confirmed drift in the latest baseline compare.'); ->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->foundationTypes)->toBe(['assignmentFilter']);
expect($scope->allTypes())->toBe(['assignmentFilter', 'deviceConfiguration']); 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,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 <?php
use App\Services\Baselines\InventoryMetaContract; use App\Services\Baselines\InventoryMetaContract;
use App\Support\Inventory\InventoryPolicyTypeMeta;
it('builds a deterministic v1 contract regardless of input ordering', function () { it('builds a deterministic v1 contract regardless of input ordering', function () {
$builder = app(InventoryMetaContract::class); $builder = app(InventoryMetaContract::class);
@ -57,3 +58,35 @@
expect($contract['scope_tag_ids'])->toBeNull(); expect($contract['scope_tag_ids'])->toBeNull();
expect($contract['assignment_target_count'])->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,224 @@
# Implementation Plan: Governance Subject Taxonomy and Baseline Scope V2
**Branch**: `001-governance-subject-taxonomy` | **Date**: 2026-04-13 | **Spec**: `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/specs/001-governance-subject-taxonomy/spec.md`
**Input**: Feature specification from `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/specs/001-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, and route baseline capture and compare through normalized effective scope with explicit eligibility validation and auditable operation context. 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/001-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 maintenance command, 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/001-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.
## Project Structure
### Documentation (this feature)
```text
specs/001-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 to backfill remaining legacy scope rows to canonical V2.
- 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 and for 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,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.