Compare commits
2 Commits
196-hard-f
...
dev
| Author | SHA1 | Date | |
|---|---|---|---|
| 7541b1eb41 | |||
| a2a42d4e5f |
8
.github/agents/copilot-instructions.md
vendored
8
.github/agents/copilot-instructions.md
vendored
@ -176,6 +176,10 @@ ## Active Technologies
|
||||
- PHP 8.4.15 + Laravel 12, Filament v5, Livewire v4, Pest v4, Tailwind CSS v4, existing `UiEnforcement`, existing audit loggers (`AuditLogger`, `WorkspaceAuditLogger`, `SystemConsoleAuditLogger`), existing mutation services (`FindingExceptionService`, `FindingWorkflowService`, `TenantReviewLifecycleService`, `EvidenceSnapshotService`, `OperationRunTriageService`) (194-governance-friction-hardening)
|
||||
- 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)
|
||||
- PostgreSQL through existing workspace-owned, tenant-owned, and system-visible models; no schema change planned (195-action-surface-closure)
|
||||
- PHP 8.4.15 + Laravel 12, Filament v5, Livewire v4, Pest v4, Tailwind CSS v4, existing `DependencyQueryService`, `DependencyTargetResolver`, `TenantRequiredPermissionsViewModelBuilder`, `ArtifactTruthPresenter`, `WorkspaceContext`, Filament `InteractsWithTable`, Filament `TableComponent`, and existing badge and action-surface helpers (196-hard-filament-nativity-cleanup)
|
||||
- PostgreSQL through existing tenant-owned and workspace-context models (`InventoryItem`, `InventoryLink`, `TenantPermission`, `EvidenceSnapshot`, `TenantReview`); no schema change planned (196-hard-filament-nativity-cleanup)
|
||||
- PHP 8.4.15 + Laravel 12, Filament v5, Livewire v4, Pest v4, Tailwind CSS v4, Laravel Sail, existing `BaselineScope`, `InventoryPolicyTypeMeta`, `BaselineSupportCapabilityGuard`, `BaselineCaptureService`, and `BaselineCompareService` (202-governance-subject-taxonomy)
|
||||
- PostgreSQL via existing `baseline_profiles.scope_jsonb`, `baseline_tenant_assignments.override_scope_jsonb`, and `operation_runs.context`; no new tables planned (202-governance-subject-taxonomy)
|
||||
|
||||
- PHP 8.4.15 (feat/005-bulk-operations)
|
||||
|
||||
@ -210,8 +214,8 @@ ## Code Style
|
||||
PHP 8.4.15: Follow standard conventions
|
||||
|
||||
## 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
|
||||
- 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
|
||||
- 194-governance-friction-hardening: Added PHP 8.4.15 + Laravel 12, Filament v5, Livewire v4, Pest v4, Tailwind CSS v4, existing `UiEnforcement`, existing audit loggers (`AuditLogger`, `WorkspaceAuditLogger`, `SystemConsoleAuditLogger`), existing mutation services (`FindingExceptionService`, `FindingWorkflowService`, `TenantReviewLifecycleService`, `EvidenceSnapshotService`, `OperationRunTriageService`)
|
||||
<!-- MANUAL ADDITIONS START -->
|
||||
<!-- MANUAL ADDITIONS END -->
|
||||
|
||||
204
apps/platform/app/Console/Commands/BackfillBaselineScopeV2.php
Normal file
204
apps/platform/app/Console/Commands/BackfillBaselineScopeV2.php
Normal 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,
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -20,6 +20,7 @@
|
||||
use App\Support\Badges\BadgeCatalog;
|
||||
use App\Support\Badges\BadgeDomain;
|
||||
use App\Support\Badges\BadgeRenderer;
|
||||
use App\Support\Baselines\BaselineScope;
|
||||
use App\Support\Baselines\BaselineCaptureMode;
|
||||
use App\Support\Baselines\BaselineFullContentRolloutGate;
|
||||
use App\Support\Baselines\BaselineProfileStatus;
|
||||
@ -51,9 +52,11 @@
|
||||
use Filament\Notifications\Notification;
|
||||
use Filament\Resources\Resource;
|
||||
use Filament\Schemas\Components\Section;
|
||||
use Filament\Schemas\Components\Utilities\Get;
|
||||
use Filament\Schemas\Schema;
|
||||
use Filament\Tables\Columns\TextColumn;
|
||||
use Filament\Tables\Table;
|
||||
use InvalidArgumentException;
|
||||
use Illuminate\Auth\Access\AuthorizationException;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
@ -216,12 +219,14 @@ public static function form(Schema $schema): Schema
|
||||
->label('Policy types')
|
||||
->multiple()
|
||||
->options(self::policyTypeOptions())
|
||||
->live()
|
||||
->helperText('Leave empty to include all supported policy types (excluding foundations).')
|
||||
->native(false),
|
||||
Select::make('scope_jsonb.foundation_types')
|
||||
->label('Foundations')
|
||||
->multiple()
|
||||
->options(self::foundationTypeOptions())
|
||||
->live()
|
||||
->helperText('Leave empty to exclude foundations. Select foundations to include them.')
|
||||
->native(false),
|
||||
Placeholder::make('metadata')
|
||||
@ -232,6 +237,26 @@ public static function form(Schema $schema): Schema
|
||||
->visible(fn (?BaselineProfile $record): bool => $record !== null),
|
||||
])
|
||||
->columns(2),
|
||||
Section::make('Governed subject summary')
|
||||
->schema([
|
||||
Placeholder::make('scope_summary_display')
|
||||
->label('Scope summary')
|
||||
->content(function (Get $get): string {
|
||||
return self::scopeSummaryText(self::formScopePayload($get));
|
||||
})
|
||||
->columnSpanFull(),
|
||||
Placeholder::make('scope_support_readiness_display')
|
||||
->label('Support readiness')
|
||||
->content(function (Get $get): string {
|
||||
return self::scopeSupportReadinessText(self::formScopePayload($get));
|
||||
}),
|
||||
Placeholder::make('scope_selection_feedback_display')
|
||||
->label('Selection feedback')
|
||||
->content(function (Get $get): string {
|
||||
return self::scopeSelectionFeedbackText(self::formScopePayload($get)) ?? 'Selections are valid for the current Intune-first workflow.';
|
||||
}),
|
||||
])
|
||||
->columns(2),
|
||||
]);
|
||||
}
|
||||
|
||||
@ -282,6 +307,17 @@ public static function infolist(Schema $schema): Schema
|
||||
->columnSpanFull(),
|
||||
Section::make('Scope')
|
||||
->schema([
|
||||
TextEntry::make('governed_subject_summary')
|
||||
->label('Governed subject summary')
|
||||
->state(fn (BaselineProfile $record): string => self::scopeSummaryText(self::scopePayload($record)))
|
||||
->columnSpanFull(),
|
||||
TextEntry::make('scope_support_readiness')
|
||||
->label('Support readiness')
|
||||
->state(fn (BaselineProfile $record): string => self::scopeSupportReadinessText(self::scopePayload($record))),
|
||||
TextEntry::make('scope_normalization_lineage')
|
||||
->label('Normalization lineage')
|
||||
->state(fn (BaselineProfile $record): string => self::scopeNormalizationLineageText($record))
|
||||
->columnSpanFull(),
|
||||
TextEntry::make('scope_jsonb.policy_types')
|
||||
->label('Policy types')
|
||||
->badge()
|
||||
@ -502,6 +538,82 @@ public static function compareMatrixUrl(BaselineProfile|int $profile): string
|
||||
return static::getUrl('compare-matrix', ['record' => $profile], panel: 'admin');
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed>|null $scopePayload
|
||||
*/
|
||||
public static function scopeSummaryText(?array $scopePayload): string
|
||||
{
|
||||
try {
|
||||
$scope = BaselineScope::fromJsonb($scopePayload);
|
||||
} catch (InvalidArgumentException) {
|
||||
return 'Invalid governed subject selection.';
|
||||
}
|
||||
|
||||
$groups = collect($scope->summaryGroups());
|
||||
|
||||
if ($groups->isEmpty()) {
|
||||
return 'No governed subjects selected.';
|
||||
}
|
||||
|
||||
return $groups
|
||||
->map(function (array $group) use ($scopePayload): string {
|
||||
$selectedSubjectTypes = $group['selected_subject_types'];
|
||||
|
||||
if ($group['domain_key'] === 'intune'
|
||||
&& $group['subject_class'] === 'policy'
|
||||
&& is_array($scopePayload)
|
||||
&& (($scopePayload['policy_types'] ?? []) === [])) {
|
||||
return $group['group_label'].': all supported Intune policy types';
|
||||
}
|
||||
|
||||
if ($selectedSubjectTypes === []) {
|
||||
return $group['group_label'].': none';
|
||||
}
|
||||
|
||||
return $group['group_label'].': '.implode(', ', $selectedSubjectTypes);
|
||||
})
|
||||
->implode('; ');
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed>|null $scopePayload
|
||||
*/
|
||||
public static function scopeSupportReadinessText(?array $scopePayload): string
|
||||
{
|
||||
try {
|
||||
$scope = BaselineScope::fromJsonb($scopePayload);
|
||||
} catch (InvalidArgumentException) {
|
||||
return 'Capture: blocked. Compare: blocked.';
|
||||
}
|
||||
|
||||
$capture = $scope->operationEligibility('capture');
|
||||
$compare = $scope->operationEligibility('compare');
|
||||
|
||||
return sprintf(
|
||||
'Capture: %s. Compare: %s.',
|
||||
$capture['ok'] ? 'ready' : 'blocked',
|
||||
$compare['ok'] ? 'ready' : 'blocked',
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed>|null $scopePayload
|
||||
*/
|
||||
public static function scopeSelectionFeedbackText(?array $scopePayload): ?string
|
||||
{
|
||||
try {
|
||||
$scope = BaselineScope::fromJsonb($scopePayload);
|
||||
} catch (InvalidArgumentException $exception) {
|
||||
return $exception->getMessage();
|
||||
}
|
||||
|
||||
if ($scope->normalizationLineage()['save_forward_required']) {
|
||||
return 'This Intune-first selection will be saved forward as canonical governed-subject scope V2.';
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, string>
|
||||
*/
|
||||
@ -693,7 +805,11 @@ private static function latestAttemptedSnapshotDescription(BaselineProfile $prof
|
||||
|
||||
private static function compareReadinessLabel(BaselineProfile $profile): string
|
||||
{
|
||||
return self::compareAvailabilityEnvelope($profile)?->operatorLabel ?? 'Ready';
|
||||
return match (self::compareAvailabilityReason($profile)) {
|
||||
BaselineReasonCodes::COMPARE_INVALID_SCOPE => 'Invalid scope',
|
||||
BaselineReasonCodes::COMPARE_UNSUPPORTED_SCOPE => 'Unsupported governed subjects',
|
||||
default => self::compareAvailabilityEnvelope($profile)?->operatorLabel ?? 'Ready',
|
||||
};
|
||||
}
|
||||
|
||||
private static function compareReadinessColor(BaselineProfile $profile): string
|
||||
@ -701,6 +817,8 @@ private static function compareReadinessColor(BaselineProfile $profile): string
|
||||
return match (self::compareAvailabilityReason($profile)) {
|
||||
null => 'success',
|
||||
BaselineReasonCodes::COMPARE_PROFILE_NOT_ACTIVE => 'gray',
|
||||
BaselineReasonCodes::COMPARE_INVALID_SCOPE,
|
||||
BaselineReasonCodes::COMPARE_UNSUPPORTED_SCOPE => 'danger',
|
||||
default => 'warning',
|
||||
};
|
||||
}
|
||||
@ -710,13 +828,19 @@ private static function compareReadinessIcon(BaselineProfile $profile): ?string
|
||||
return match (self::compareAvailabilityReason($profile)) {
|
||||
null => 'heroicon-m-check-badge',
|
||||
BaselineReasonCodes::COMPARE_PROFILE_NOT_ACTIVE => 'heroicon-m-pause-circle',
|
||||
BaselineReasonCodes::COMPARE_INVALID_SCOPE,
|
||||
BaselineReasonCodes::COMPARE_UNSUPPORTED_SCOPE => 'heroicon-m-no-symbol',
|
||||
default => 'heroicon-m-exclamation-triangle',
|
||||
};
|
||||
}
|
||||
|
||||
private static function profileNextStep(BaselineProfile $profile): string
|
||||
{
|
||||
return self::compareAvailabilityEnvelope($profile)?->guidanceText() ?? 'No action needed.';
|
||||
return match (self::compareAvailabilityReason($profile)) {
|
||||
BaselineReasonCodes::COMPARE_INVALID_SCOPE,
|
||||
BaselineReasonCodes::COMPARE_UNSUPPORTED_SCOPE => 'Review the governed subject selection before starting compare.',
|
||||
default => self::compareAvailabilityEnvelope($profile)?->guidanceText() ?? 'No action needed.',
|
||||
};
|
||||
}
|
||||
|
||||
private static function effectiveSnapshot(BaselineProfile $profile): ?BaselineSnapshot
|
||||
@ -739,6 +863,20 @@ private static function compareAvailabilityReason(BaselineProfile $profile): ?st
|
||||
return BaselineReasonCodes::COMPARE_PROFILE_NOT_ACTIVE;
|
||||
}
|
||||
|
||||
try {
|
||||
$scope = BaselineScope::fromJsonb(self::scopePayload($profile));
|
||||
} catch (InvalidArgumentException) {
|
||||
return BaselineReasonCodes::COMPARE_INVALID_SCOPE;
|
||||
}
|
||||
|
||||
if ($scope->allTypes() === []) {
|
||||
return BaselineReasonCodes::COMPARE_INVALID_SCOPE;
|
||||
}
|
||||
|
||||
if (! $scope->operationEligibility('compare')['ok']) {
|
||||
return BaselineReasonCodes::COMPARE_UNSUPPORTED_SCOPE;
|
||||
}
|
||||
|
||||
$resolution = app(BaselineSnapshotTruthResolver::class)->resolveCompareSnapshot($profile);
|
||||
$reasonCode = $resolution['reason_code'] ?? null;
|
||||
|
||||
@ -797,4 +935,37 @@ private static function hasEligibleCompareTarget(BaselineProfile $profile): bool
|
||||
->get(['id'])
|
||||
->contains(fn (Tenant $tenant): bool => $resolver->can($user, $tenant, Capabilities::TENANT_SYNC));
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed>|null $scopePayload
|
||||
*/
|
||||
private static function formScopePayload(Get $get): ?array
|
||||
{
|
||||
$scopePayload = $get('scope_jsonb');
|
||||
|
||||
return is_array($scopePayload) ? $scopePayload : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>|null
|
||||
*/
|
||||
private static function scopePayload(BaselineProfile $profile): ?array
|
||||
{
|
||||
return $profile->rawScopeJsonb();
|
||||
}
|
||||
|
||||
private static function scopeNormalizationLineageText(BaselineProfile $profile): string
|
||||
{
|
||||
try {
|
||||
$lineage = $profile->normalizedScope()->normalizationLineage();
|
||||
} catch (InvalidArgumentException) {
|
||||
return 'Stored scope is invalid and must be repaired before capture or compare can continue.';
|
||||
}
|
||||
|
||||
if ($lineage['source_shape'] === 'legacy') {
|
||||
return 'Legacy Intune buckets are being normalized and will be saved forward as canonical V2 on the next successful save.';
|
||||
}
|
||||
|
||||
return 'Canonical governed-subject scope V2 is already stored for this baseline profile.';
|
||||
}
|
||||
}
|
||||
|
||||
@ -13,6 +13,8 @@
|
||||
use App\Support\Workspaces\WorkspaceContext;
|
||||
use Filament\Notifications\Notification;
|
||||
use Filament\Resources\Pages\CreateRecord;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
use InvalidArgumentException;
|
||||
|
||||
class CreateBaselineProfile extends CreateRecord
|
||||
{
|
||||
@ -31,7 +33,13 @@ protected function mutateFormDataBeforeCreate(array $data): array
|
||||
$data['created_by_user_id'] = $user instanceof User ? $user->getKey() : null;
|
||||
|
||||
if (isset($data['scope_jsonb'])) {
|
||||
$data['scope_jsonb'] = BaselineScope::fromJsonb(is_array($data['scope_jsonb']) ? $data['scope_jsonb'] : null)->toJsonb();
|
||||
try {
|
||||
$data['scope_jsonb'] = BaselineScope::fromJsonb(is_array($data['scope_jsonb']) ? $data['scope_jsonb'] : null)->toJsonb();
|
||||
} catch (InvalidArgumentException $exception) {
|
||||
throw ValidationException::withMessages([
|
||||
'scope_jsonb.policy_types' => $exception->getMessage(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
return $data;
|
||||
|
||||
@ -11,6 +11,8 @@
|
||||
use App\Support\Baselines\BaselineScope;
|
||||
use Filament\Notifications\Notification;
|
||||
use Filament\Resources\Pages\EditRecord;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
use InvalidArgumentException;
|
||||
|
||||
class EditBaselineProfile extends EditRecord
|
||||
{
|
||||
@ -52,7 +54,13 @@ protected function mutateFormDataBeforeSave(array $data): array
|
||||
}
|
||||
|
||||
if (isset($data['scope_jsonb'])) {
|
||||
$data['scope_jsonb'] = BaselineScope::fromJsonb(is_array($data['scope_jsonb']) ? $data['scope_jsonb'] : null)->toJsonb();
|
||||
try {
|
||||
$data['scope_jsonb'] = BaselineScope::fromJsonb(is_array($data['scope_jsonb']) ? $data['scope_jsonb'] : null)->toJsonb();
|
||||
} catch (InvalidArgumentException $exception) {
|
||||
throw ValidationException::withMessages([
|
||||
'scope_jsonb.policy_types' => $exception->getMessage(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
return $data;
|
||||
|
||||
@ -110,6 +110,8 @@ private function captureAction(): Action
|
||||
BaselineReasonCodes::CAPTURE_ROLLOUT_DISABLED => 'Full-content baseline capture is currently disabled for controlled rollout.',
|
||||
BaselineReasonCodes::CAPTURE_PROFILE_NOT_ACTIVE => 'This baseline profile is not active.',
|
||||
BaselineReasonCodes::CAPTURE_MISSING_SOURCE_TENANT => 'The selected tenant is not available for this baseline profile.',
|
||||
BaselineReasonCodes::CAPTURE_INVALID_SCOPE => 'This baseline profile has an invalid governed-subject scope. Review the baseline definition before capturing.',
|
||||
BaselineReasonCodes::CAPTURE_UNSUPPORTED_SCOPE => 'This baseline profile includes governed subjects that are not currently supported for capture.',
|
||||
default => 'Reason: '.str_replace('.', ' ', $reasonCode),
|
||||
};
|
||||
|
||||
@ -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_INCOMPLETE => 'The latest baseline capture is incomplete. Capture a new baseline before comparing.',
|
||||
BaselineReasonCodes::COMPARE_SNAPSHOT_SUPERSEDED => 'A newer complete snapshot is current. Compare uses the latest complete baseline only.',
|
||||
BaselineReasonCodes::COMPARE_INVALID_SCOPE => 'This baseline profile has an invalid governed-subject scope. Review the baseline definition before comparing.',
|
||||
BaselineReasonCodes::COMPARE_UNSUPPORTED_SCOPE => 'This baseline profile includes governed subjects that are not currently supported for compare.',
|
||||
default => 'Reason: '.str_replace('.', ' ', $reasonCode),
|
||||
};
|
||||
|
||||
|
||||
@ -13,6 +13,7 @@
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
use InvalidArgumentException;
|
||||
use JsonException;
|
||||
|
||||
class BaselineProfile extends Model
|
||||
@ -62,22 +63,61 @@ protected function scopeJsonb(): Attribute
|
||||
{
|
||||
return Attribute::make(
|
||||
get: function (mixed $value): array {
|
||||
return BaselineScope::fromJsonb(
|
||||
$this->decodeScopeJsonb($value)
|
||||
)->toJsonb();
|
||||
try {
|
||||
return $this->normalizedScopeFrom($value)->toJsonb();
|
||||
} catch (InvalidArgumentException) {
|
||||
return $this->decodeScopeJsonb($value) ?? ['policy_types' => [], 'foundation_types' => []];
|
||||
}
|
||||
},
|
||||
set: function (mixed $value): string {
|
||||
$scope = BaselineScope::fromJsonb(is_array($value) ? $value : null)->toJsonb();
|
||||
$scope = BaselineScope::fromJsonb(is_array($value) ? $value : null)->toStoredJsonb();
|
||||
|
||||
try {
|
||||
return json_encode($scope, JSON_THROW_ON_ERROR);
|
||||
} catch (JsonException) {
|
||||
return '{"policy_types":[],"foundation_types":[]}';
|
||||
return '{"version":2,"entries":[]}';
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
public function normalizedScope(): BaselineScope
|
||||
{
|
||||
return $this->normalizedScopeFrom($this->getRawOriginal('scope_jsonb'));
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>|null
|
||||
*/
|
||||
public function rawScopeJsonb(): ?array
|
||||
{
|
||||
return $this->decodeScopeJsonb($this->getRawOriginal('scope_jsonb'));
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{version: 2, entries: list<array{domain_key: string, subject_class: string, subject_type_keys: list<string>, filters: array<string, mixed>}>}
|
||||
*/
|
||||
public function canonicalScopeJsonb(): array
|
||||
{
|
||||
return $this->normalizedScope()->toStoredJsonb();
|
||||
}
|
||||
|
||||
public function requiresScopeSaveForward(): bool
|
||||
{
|
||||
return (bool) $this->normalizedScope()->normalizationLineage()['save_forward_required'];
|
||||
}
|
||||
|
||||
public function rewriteScopeToCanonicalV2(): bool
|
||||
{
|
||||
$this->scope_jsonb = $this->canonicalScopeJsonb();
|
||||
|
||||
if (! $this->isDirty('scope_jsonb')) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return $this->save();
|
||||
}
|
||||
|
||||
/**
|
||||
* Decode raw scope_jsonb value from the database.
|
||||
*
|
||||
@ -102,6 +142,11 @@ private function decodeScopeJsonb(mixed $value): ?array
|
||||
return null;
|
||||
}
|
||||
|
||||
private function normalizedScopeFrom(mixed $value): BaselineScope
|
||||
{
|
||||
return BaselineScope::fromJsonb($this->decodeScopeJsonb($value));
|
||||
}
|
||||
|
||||
public function workspace(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Workspace::class);
|
||||
|
||||
@ -17,6 +17,7 @@
|
||||
use App\Support\Baselines\BaselineScope;
|
||||
use App\Support\Baselines\BaselineSupportCapabilityGuard;
|
||||
use App\Support\OperationRunType;
|
||||
use InvalidArgumentException;
|
||||
|
||||
final class BaselineCaptureService
|
||||
{
|
||||
@ -40,9 +41,26 @@ public function startCapture(
|
||||
return ['ok' => false, 'reason_code' => $precondition];
|
||||
}
|
||||
|
||||
$effectiveScope = BaselineScope::fromJsonb(
|
||||
is_array($profile->scope_jsonb) ? $profile->scope_jsonb : null,
|
||||
);
|
||||
try {
|
||||
$effectiveScope = BaselineScope::fromJsonb(
|
||||
is_array($profile->scope_jsonb) ? $profile->scope_jsonb : null,
|
||||
);
|
||||
} catch (InvalidArgumentException) {
|
||||
return ['ok' => false, 'reason_code' => BaselineReasonCodes::CAPTURE_INVALID_SCOPE];
|
||||
}
|
||||
|
||||
if ($effectiveScope->allTypes() === []) {
|
||||
return ['ok' => false, 'reason_code' => BaselineReasonCodes::CAPTURE_INVALID_SCOPE];
|
||||
}
|
||||
|
||||
$eligibility = $effectiveScope->operationEligibility('capture', $this->capabilityGuard);
|
||||
|
||||
if (! $eligibility['ok']) {
|
||||
return [
|
||||
'ok' => false,
|
||||
'reason_code' => BaselineReasonCodes::CAPTURE_UNSUPPORTED_SCOPE,
|
||||
];
|
||||
}
|
||||
|
||||
$captureMode = $profile->capture_mode instanceof BaselineCaptureMode
|
||||
? $profile->capture_mode
|
||||
|
||||
@ -21,6 +21,7 @@
|
||||
use App\Support\Baselines\BaselineSupportCapabilityGuard;
|
||||
use App\Support\OperationRunType;
|
||||
use App\Support\ReasonTranslation\ReasonPresenter;
|
||||
use InvalidArgumentException;
|
||||
|
||||
final class BaselineCompareService
|
||||
{
|
||||
@ -107,14 +108,30 @@ public function startCompareForProfile(
|
||||
$snapshot = $snapshotResolution['snapshot'];
|
||||
$snapshotId = (int) $snapshot->getKey();
|
||||
|
||||
$profileScope = BaselineScope::fromJsonb(
|
||||
is_array($profile->scope_jsonb) ? $profile->scope_jsonb : null,
|
||||
);
|
||||
$overrideScope = $assignment->override_scope_jsonb !== null
|
||||
? BaselineScope::fromJsonb(is_array($assignment->override_scope_jsonb) ? $assignment->override_scope_jsonb : null)
|
||||
: null;
|
||||
try {
|
||||
$profileScope = 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,
|
||||
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
|
||||
? $profile->capture_mode
|
||||
|
||||
@ -62,6 +62,7 @@ enum AuditActionId: string
|
||||
case BaselineProfileCreated = 'baseline_profile.created';
|
||||
case BaselineProfileUpdated = 'baseline_profile.updated';
|
||||
case BaselineProfileArchived = 'baseline_profile.archived';
|
||||
case BaselineProfileScopeBackfilled = 'baseline_profile.scope_backfilled';
|
||||
case BaselineCaptureStarted = 'baseline_capture.started';
|
||||
case BaselineCaptureCompleted = 'baseline_capture.completed';
|
||||
case BaselineCaptureFailed = 'baseline_capture.failed';
|
||||
@ -197,6 +198,7 @@ private static function labels(): array
|
||||
self::BaselineProfileCreated->value => 'Baseline profile created',
|
||||
self::BaselineProfileUpdated->value => 'Baseline profile updated',
|
||||
self::BaselineProfileArchived->value => 'Baseline profile archived',
|
||||
self::BaselineProfileScopeBackfilled->value => 'Baseline profile scope backfilled',
|
||||
self::BaselineCaptureStarted->value => 'Baseline capture started',
|
||||
self::BaselineCaptureCompleted->value => 'Baseline capture completed',
|
||||
self::BaselineCaptureFailed->value => 'Baseline capture failed',
|
||||
@ -284,6 +286,7 @@ private static function summaries(): array
|
||||
self::BaselineProfileCreated->value => 'Baseline profile created',
|
||||
self::BaselineProfileUpdated->value => 'Baseline profile updated',
|
||||
self::BaselineProfileArchived->value => 'Baseline profile archived',
|
||||
self::BaselineProfileScopeBackfilled->value => 'Baseline profile scope backfilled',
|
||||
self::AlertDestinationCreated->value => 'Alert destination created',
|
||||
self::AlertDestinationUpdated->value => 'Alert destination updated',
|
||||
self::AlertDestinationDeleted->value => 'Alert destination deleted',
|
||||
|
||||
@ -121,13 +121,23 @@ public static function forTenant(?Tenant $tenant): self
|
||||
$snapshotReasonCode = is_string($truthResolution['reason_code'] ?? null) ? (string) $truthResolution['reason_code'] : null;
|
||||
$snapshotReasonMessage = self::missingSnapshotMessage($snapshotReasonCode);
|
||||
|
||||
$profileScope = BaselineScope::fromJsonb(
|
||||
is_array($profile->scope_jsonb) ? $profile->scope_jsonb : null,
|
||||
);
|
||||
$overrideScope = $assignment->override_scope_jsonb !== null
|
||||
? BaselineScope::fromJsonb(is_array($assignment->override_scope_jsonb) ? $assignment->override_scope_jsonb : null)
|
||||
: null;
|
||||
$effectiveScope = BaselineScope::effective($profileScope, $overrideScope);
|
||||
try {
|
||||
$profileScope = 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,
|
||||
allowEmptyLegacyAsNoOverride: true,
|
||||
)
|
||||
: null;
|
||||
$effectiveScope = BaselineScope::effective($profileScope, $overrideScope);
|
||||
} catch (InvalidArgumentException) {
|
||||
return self::empty(
|
||||
'invalid_scope',
|
||||
'The assigned baseline scope is invalid or no longer supported. A workspace manager must review the baseline definition.',
|
||||
);
|
||||
}
|
||||
|
||||
$duplicateNameStats = self::duplicateNameStats($tenant, $effectiveScope);
|
||||
$duplicateNamePoliciesCount = $duplicateNameStats['policy_count'];
|
||||
|
||||
@ -18,6 +18,10 @@ final class BaselineReasonCodes
|
||||
|
||||
public const string CAPTURE_ROLLOUT_DISABLED = 'baseline.capture.rollout_disabled';
|
||||
|
||||
public const string CAPTURE_INVALID_SCOPE = 'baseline.capture.invalid_scope';
|
||||
|
||||
public const string CAPTURE_UNSUPPORTED_SCOPE = 'baseline.capture.unsupported_scope';
|
||||
|
||||
public const string SNAPSHOT_BUILDING = 'baseline.snapshot.building';
|
||||
|
||||
public const string SNAPSHOT_INCOMPLETE = 'baseline.snapshot.incomplete';
|
||||
@ -46,6 +50,10 @@ final class BaselineReasonCodes
|
||||
|
||||
public const string COMPARE_ROLLOUT_DISABLED = 'baseline.compare.rollout_disabled';
|
||||
|
||||
public const string COMPARE_INVALID_SCOPE = 'baseline.compare.invalid_scope';
|
||||
|
||||
public const string COMPARE_UNSUPPORTED_SCOPE = 'baseline.compare.unsupported_scope';
|
||||
|
||||
public const string COMPARE_SNAPSHOT_BUILDING = 'baseline.compare.snapshot_building';
|
||||
|
||||
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_PROFILE_NOT_ACTIVE,
|
||||
self::CAPTURE_ROLLOUT_DISABLED,
|
||||
self::CAPTURE_INVALID_SCOPE,
|
||||
self::CAPTURE_UNSUPPORTED_SCOPE,
|
||||
self::SNAPSHOT_BUILDING,
|
||||
self::SNAPSHOT_INCOMPLETE,
|
||||
self::SNAPSHOT_SUPERSEDED,
|
||||
@ -75,6 +85,8 @@ public static function all(): array
|
||||
self::COMPARE_NO_ELIGIBLE_TARGET,
|
||||
self::COMPARE_INVALID_SNAPSHOT,
|
||||
self::COMPARE_ROLLOUT_DISABLED,
|
||||
self::COMPARE_INVALID_SCOPE,
|
||||
self::COMPARE_UNSUPPORTED_SCOPE,
|
||||
self::COMPARE_SNAPSHOT_BUILDING,
|
||||
self::COMPARE_SNAPSHOT_INCOMPLETE,
|
||||
self::COMPARE_SNAPSHOT_SUPERSEDED,
|
||||
@ -107,8 +119,12 @@ public static function trustImpact(?string $reasonCode): ?string
|
||||
self::COMPARE_SNAPSHOT_BUILDING,
|
||||
self::COMPARE_SNAPSHOT_INCOMPLETE,
|
||||
self::COMPARE_SNAPSHOT_SUPERSEDED,
|
||||
self::COMPARE_INVALID_SCOPE,
|
||||
self::COMPARE_UNSUPPORTED_SCOPE,
|
||||
self::CAPTURE_MISSING_SOURCE_TENANT,
|
||||
self::CAPTURE_PROFILE_NOT_ACTIVE => 'unusable',
|
||||
self::CAPTURE_PROFILE_NOT_ACTIVE,
|
||||
self::CAPTURE_INVALID_SCOPE,
|
||||
self::CAPTURE_UNSUPPORTED_SCOPE => 'unusable',
|
||||
default => null,
|
||||
};
|
||||
}
|
||||
@ -132,10 +148,14 @@ public static function absencePattern(?string $reasonCode): ?string
|
||||
self::COMPARE_PROFILE_NOT_ACTIVE,
|
||||
self::COMPARE_NO_ELIGIBLE_TARGET,
|
||||
self::COMPARE_INVALID_SNAPSHOT,
|
||||
self::COMPARE_INVALID_SCOPE,
|
||||
self::COMPARE_UNSUPPORTED_SCOPE,
|
||||
self::COMPARE_ROLLOUT_DISABLED,
|
||||
self::SNAPSHOT_SUPERSEDED,
|
||||
self::COMPARE_SNAPSHOT_SUPERSEDED => 'blocked_prerequisite',
|
||||
self::SNAPSHOT_CAPTURE_FAILED => 'unavailable',
|
||||
self::CAPTURE_INVALID_SCOPE,
|
||||
self::CAPTURE_UNSUPPORTED_SCOPE => 'unavailable',
|
||||
default => null,
|
||||
};
|
||||
}
|
||||
|
||||
@ -4,26 +4,36 @@
|
||||
|
||||
namespace App\Support\Baselines;
|
||||
|
||||
use App\Support\Governance\GovernanceDomainKey;
|
||||
use App\Support\Governance\GovernanceSubjectClass;
|
||||
use App\Support\Governance\GovernanceSubjectTaxonomyRegistry;
|
||||
use App\Support\Inventory\InventoryPolicyTypeMeta;
|
||||
use InvalidArgumentException;
|
||||
|
||||
/**
|
||||
* Value object for baseline scope resolution.
|
||||
*
|
||||
* A scope defines which policy types are included in a baseline profile.
|
||||
*
|
||||
* Spec 116 semantics:
|
||||
* - Empty policy_types means "all supported policy types" (excluding foundations).
|
||||
* - Empty foundation_types means "none".
|
||||
* Canonical storage uses versioned Governance Scope V2 entries.
|
||||
* Presentation compatibility for the current Intune-first UI still projects
|
||||
* back to legacy policy and foundation buckets.
|
||||
*/
|
||||
final class BaselineScope
|
||||
{
|
||||
/**
|
||||
* @param array<string> $policyTypes
|
||||
* @param array<string> $foundationTypes
|
||||
* @param list<string> $policyTypes
|
||||
* @param list<string> $foundationTypes
|
||||
* @param list<array{domain_key: string, subject_class: string, subject_type_keys: list<string>, filters: array<string, mixed>}> $entries
|
||||
* @param list<string> $legacyKeysPresent
|
||||
*/
|
||||
public function __construct(
|
||||
public readonly array $policyTypes = [],
|
||||
public readonly array $foundationTypes = [],
|
||||
public readonly array $entries = [],
|
||||
public readonly int $version = 2,
|
||||
public readonly string $sourceShape = 'canonical_v2',
|
||||
public readonly bool $normalizedOnRead = false,
|
||||
public readonly array $legacyKeysPresent = [],
|
||||
public readonly bool $saveForwardRequired = false,
|
||||
) {}
|
||||
|
||||
/**
|
||||
@ -31,21 +41,63 @@ public function __construct(
|
||||
*
|
||||
* @param array<string, mixed>|null $scopeJsonb
|
||||
*/
|
||||
public static function fromJsonb(?array $scopeJsonb): self
|
||||
public static function fromJsonb(?array $scopeJsonb, bool $allowEmptyLegacyAsNoOverride = false): self
|
||||
{
|
||||
if ($scopeJsonb === null) {
|
||||
return new self;
|
||||
return self::fromLegacyPayload(
|
||||
policyTypes: [],
|
||||
foundationTypes: [],
|
||||
legacyKeysPresent: [],
|
||||
normalizedOnRead: true,
|
||||
saveForwardRequired: true,
|
||||
);
|
||||
}
|
||||
|
||||
$policyTypes = $scopeJsonb['policy_types'] ?? [];
|
||||
$foundationTypes = $scopeJsonb['foundation_types'] ?? [];
|
||||
if (isset($scopeJsonb['canonical_scope']) && is_array($scopeJsonb['canonical_scope'])) {
|
||||
return self::fromJsonb($scopeJsonb['canonical_scope'], $allowEmptyLegacyAsNoOverride);
|
||||
}
|
||||
|
||||
$policyTypes = is_array($policyTypes) ? array_values(array_filter($policyTypes, 'is_string')) : [];
|
||||
$foundationTypes = is_array($foundationTypes) ? array_values(array_filter($foundationTypes, 'is_string')) : [];
|
||||
$hasLegacyKeys = array_key_exists('policy_types', $scopeJsonb) || array_key_exists('foundation_types', $scopeJsonb);
|
||||
$hasCanonicalKeys = array_key_exists('version', $scopeJsonb) || array_key_exists('entries', $scopeJsonb);
|
||||
|
||||
return new self(
|
||||
policyTypes: $policyTypes === [] ? [] : self::normalizePolicyTypes($policyTypes),
|
||||
foundationTypes: self::normalizeFoundationTypes($foundationTypes),
|
||||
if ($hasLegacyKeys && $hasCanonicalKeys) {
|
||||
throw new InvalidArgumentException('Baseline scope payload must not mix legacy buckets with canonical V2 keys.');
|
||||
}
|
||||
|
||||
if ($hasCanonicalKeys) {
|
||||
return self::fromCanonicalPayload($scopeJsonb);
|
||||
}
|
||||
|
||||
if (! $hasLegacyKeys) {
|
||||
throw new InvalidArgumentException('Baseline scope payload must contain either legacy buckets or canonical V2 keys.');
|
||||
}
|
||||
|
||||
$legacyKeysPresent = array_values(array_filter([
|
||||
array_key_exists('policy_types', $scopeJsonb) ? 'policy_types' : null,
|
||||
array_key_exists('foundation_types', $scopeJsonb) ? 'foundation_types' : null,
|
||||
]));
|
||||
|
||||
$policyTypes = self::stringList($scopeJsonb['policy_types'] ?? []);
|
||||
$foundationTypes = self::stringList($scopeJsonb['foundation_types'] ?? []);
|
||||
|
||||
if ($allowEmptyLegacyAsNoOverride && $policyTypes === [] && $foundationTypes === []) {
|
||||
return new self(
|
||||
policyTypes: [],
|
||||
foundationTypes: [],
|
||||
entries: [],
|
||||
sourceShape: 'legacy',
|
||||
normalizedOnRead: false,
|
||||
legacyKeysPresent: $legacyKeysPresent,
|
||||
saveForwardRequired: false,
|
||||
);
|
||||
}
|
||||
|
||||
return self::fromLegacyPayload(
|
||||
policyTypes: $policyTypes,
|
||||
foundationTypes: $foundationTypes,
|
||||
legacyKeysPresent: $legacyKeysPresent,
|
||||
normalizedOnRead: true,
|
||||
saveForwardRequired: true,
|
||||
);
|
||||
}
|
||||
|
||||
@ -65,19 +117,41 @@ public static function effective(self $profileScope, ?self $overrideScope): self
|
||||
|
||||
$overridePolicyTypes = self::normalizePolicyTypes($overrideScope->policyTypes);
|
||||
$overrideFoundationTypes = self::normalizeFoundationTypes($overrideScope->foundationTypes);
|
||||
$entries = [];
|
||||
|
||||
$effectivePolicyTypes = $overridePolicyTypes !== []
|
||||
? array_values(array_intersect($profileScope->policyTypes, $overridePolicyTypes))
|
||||
: $profileScope->policyTypes;
|
||||
foreach ($profileScope->entries as $entry) {
|
||||
$subjectTypeKeys = $entry['subject_type_keys'];
|
||||
|
||||
$effectiveFoundationTypes = $overrideFoundationTypes !== []
|
||||
? array_values(array_intersect($profileScope->foundationTypes, $overrideFoundationTypes))
|
||||
: $profileScope->foundationTypes;
|
||||
if ($entry['domain_key'] === GovernanceDomainKey::Intune->value
|
||||
&& $entry['subject_class'] === GovernanceSubjectClass::Policy->value
|
||||
&& $overridePolicyTypes !== []) {
|
||||
$subjectTypeKeys = array_values(array_intersect($subjectTypeKeys, $overridePolicyTypes));
|
||||
}
|
||||
|
||||
return new self(
|
||||
policyTypes: self::uniqueSorted($effectivePolicyTypes),
|
||||
foundationTypes: self::uniqueSorted($effectiveFoundationTypes),
|
||||
);
|
||||
if ($entry['domain_key'] === GovernanceDomainKey::PlatformFoundation->value
|
||||
&& $entry['subject_class'] === GovernanceSubjectClass::ConfigurationResource->value
|
||||
&& $overrideFoundationTypes !== []) {
|
||||
$subjectTypeKeys = array_values(array_intersect($subjectTypeKeys, $overrideFoundationTypes));
|
||||
}
|
||||
|
||||
$subjectTypeKeys = self::uniqueSorted($subjectTypeKeys);
|
||||
|
||||
if ($subjectTypeKeys === []) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$entries[] = [
|
||||
'domain_key' => $entry['domain_key'],
|
||||
'subject_class' => $entry['subject_class'],
|
||||
'subject_type_keys' => $subjectTypeKeys,
|
||||
'filters' => $entry['filters'],
|
||||
];
|
||||
}
|
||||
|
||||
return self::fromCanonicalPayload([
|
||||
'version' => 2,
|
||||
'entries' => $entries,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
@ -85,7 +159,7 @@ public static function effective(self $profileScope, ?self $overrideScope): self
|
||||
*/
|
||||
public function isEmpty(): bool
|
||||
{
|
||||
return $this->policyTypes === [] && $this->foundationTypes === [];
|
||||
return $this->entries === [] && $this->policyTypes === [] && $this->foundationTypes === [];
|
||||
}
|
||||
|
||||
/**
|
||||
@ -93,15 +167,16 @@ public function isEmpty(): bool
|
||||
*/
|
||||
public function expandDefaults(): self
|
||||
{
|
||||
$policyTypes = $this->policyTypes === []
|
||||
? self::supportedPolicyTypes()
|
||||
: self::normalizePolicyTypes($this->policyTypes);
|
||||
if ($this->entries !== []) {
|
||||
return $this;
|
||||
}
|
||||
|
||||
$foundationTypes = self::normalizeFoundationTypes($this->foundationTypes);
|
||||
|
||||
return new self(
|
||||
policyTypes: $policyTypes,
|
||||
foundationTypes: $foundationTypes,
|
||||
return self::fromLegacyPayload(
|
||||
policyTypes: $this->policyTypes,
|
||||
foundationTypes: $this->foundationTypes,
|
||||
legacyKeysPresent: $this->legacyKeysPresent,
|
||||
normalizedOnRead: $this->normalizedOnRead,
|
||||
saveForwardRequired: $this->saveForwardRequired,
|
||||
);
|
||||
}
|
||||
|
||||
@ -134,16 +209,94 @@ public function truthfulTypes(string $operation, ?BaselineSupportCapabilityGuard
|
||||
*/
|
||||
public function toJsonb(): array
|
||||
{
|
||||
$supportedPolicyTypes = self::supportedPolicyTypes();
|
||||
$policyTypes = $this->policyTypes;
|
||||
|
||||
if ($policyTypes === $supportedPolicyTypes) {
|
||||
$policyTypes = [];
|
||||
}
|
||||
|
||||
return [
|
||||
'policy_types' => $this->policyTypes,
|
||||
'policy_types' => $policyTypes,
|
||||
'foundation_types' => $this->foundationTypes,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{version: 2, entries: list<array{domain_key: string, subject_class: string, subject_type_keys: list<string>, filters: array<string, mixed>}>}
|
||||
*/
|
||||
public function toStoredJsonb(): array
|
||||
{
|
||||
return [
|
||||
'version' => 2,
|
||||
'entries' => $this->entries,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{source_shape: string, normalized_on_read: bool, legacy_keys_present: list<string>, save_forward_required: bool}
|
||||
*/
|
||||
public function normalizationLineage(): array
|
||||
{
|
||||
return [
|
||||
'source_shape' => $this->sourceShape,
|
||||
'normalized_on_read' => $this->normalizedOnRead,
|
||||
'legacy_keys_present' => $this->legacyKeysPresent,
|
||||
'save_forward_required' => $this->saveForwardRequired,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<array{domain_key: string, subject_class: string, group_label: string, selected_subject_types: list<string>, capture_supported_count: int, compare_supported_count: int, inactive_count: int}>
|
||||
*/
|
||||
public function summaryGroups(?GovernanceSubjectTaxonomyRegistry $registry = null): array
|
||||
{
|
||||
$registry ??= app(GovernanceSubjectTaxonomyRegistry::class);
|
||||
$groups = [];
|
||||
|
||||
foreach ($this->entries as $entry) {
|
||||
$selectedSubjectTypes = [];
|
||||
$captureSupportedCount = 0;
|
||||
$compareSupportedCount = 0;
|
||||
$inactiveCount = 0;
|
||||
|
||||
foreach ($entry['subject_type_keys'] as $subjectTypeKey) {
|
||||
$subjectType = $registry->find($entry['domain_key'], $subjectTypeKey);
|
||||
$selectedSubjectTypes[] = $subjectType?->label ?? $subjectTypeKey;
|
||||
|
||||
if ($subjectType?->captureSupported) {
|
||||
$captureSupportedCount++;
|
||||
}
|
||||
|
||||
if ($subjectType?->compareSupported) {
|
||||
$compareSupportedCount++;
|
||||
}
|
||||
|
||||
if ($subjectType !== null && ! $subjectType->active) {
|
||||
$inactiveCount++;
|
||||
}
|
||||
}
|
||||
|
||||
sort($selectedSubjectTypes, SORT_STRING);
|
||||
|
||||
$groups[] = [
|
||||
'domain_key' => $entry['domain_key'],
|
||||
'subject_class' => $entry['subject_class'],
|
||||
'group_label' => $registry->groupLabel($entry['domain_key'], $entry['subject_class']),
|
||||
'selected_subject_types' => $selectedSubjectTypes,
|
||||
'capture_supported_count' => $captureSupportedCount,
|
||||
'compare_supported_count' => $compareSupportedCount,
|
||||
'inactive_count' => $inactiveCount,
|
||||
];
|
||||
}
|
||||
|
||||
return $groups;
|
||||
}
|
||||
|
||||
/**
|
||||
* Effective scope payload for OperationRun.context.
|
||||
*
|
||||
* @return array{policy_types: list<string>, foundation_types: list<string>, all_types: list<string>, foundations_included: bool}
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function toEffectiveScopeContext(?BaselineSupportCapabilityGuard $guard = null, ?string $operation = null): array
|
||||
{
|
||||
@ -151,9 +304,12 @@ public function toEffectiveScopeContext(?BaselineSupportCapabilityGuard $guard =
|
||||
$allTypes = self::uniqueSorted(array_merge($expanded->policyTypes, $expanded->foundationTypes));
|
||||
|
||||
$context = [
|
||||
'canonical_scope' => $expanded->toStoredJsonb(),
|
||||
'legacy_projection' => $expanded->toJsonb(),
|
||||
'policy_types' => $expanded->policyTypes,
|
||||
'foundation_types' => $expanded->foundationTypes,
|
||||
'all_types' => $allTypes,
|
||||
'selected_type_keys' => $allTypes,
|
||||
'foundations_included' => $expanded->foundationTypes !== [],
|
||||
];
|
||||
|
||||
@ -170,28 +326,34 @@ public function toEffectiveScopeContext(?BaselineSupportCapabilityGuard $guard =
|
||||
'unsupported_types' => $guardResult['unsupported_types'],
|
||||
'invalid_support_types' => $guardResult['invalid_support_types'],
|
||||
'capabilities' => $guardResult['capabilities'],
|
||||
'allowed_type_keys' => $guardResult['allowed_types'],
|
||||
'limited_type_keys' => $guardResult['limited_types'],
|
||||
'unsupported_type_keys' => $guardResult['unsupported_types'],
|
||||
'capabilities_by_type' => $guardResult['capabilities'],
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{ok: bool, unsupported_types: list<string>, invalid_support_types: list<string>}
|
||||
*/
|
||||
public function operationEligibility(string $operation, ?BaselineSupportCapabilityGuard $guard = null): array
|
||||
{
|
||||
$guard ??= app(BaselineSupportCapabilityGuard::class);
|
||||
$guardResult = $guard->guardTypes($this->allTypes(), $operation);
|
||||
|
||||
return [
|
||||
'ok' => $guardResult['unsupported_types'] === [] && $guardResult['invalid_support_types'] === [],
|
||||
'unsupported_types' => $guardResult['unsupported_types'],
|
||||
'invalid_support_types' => $guardResult['invalid_support_types'],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<string>
|
||||
*/
|
||||
private static function supportedPolicyTypes(): array
|
||||
{
|
||||
$supported = config('tenantpilot.supported_policy_types', []);
|
||||
|
||||
if (! is_array($supported)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$types = collect($supported)
|
||||
->filter(fn (mixed $row): bool => is_array($row) && filled($row['type'] ?? null))
|
||||
->map(fn (array $row): string => (string) $row['type'])
|
||||
->filter(fn (string $type): bool => $type !== '')
|
||||
->values()
|
||||
->all();
|
||||
|
||||
return self::uniqueSorted($types);
|
||||
return app(GovernanceSubjectTaxonomyRegistry::class)->activeLegacyBucketKeys('policy_types');
|
||||
}
|
||||
|
||||
/**
|
||||
@ -199,14 +361,7 @@ private static function supportedPolicyTypes(): array
|
||||
*/
|
||||
private static function supportedFoundationTypes(): array
|
||||
{
|
||||
$types = collect(InventoryPolicyTypeMeta::baselineSupportedFoundations())
|
||||
->filter(fn (mixed $row): bool => is_array($row) && filled($row['type'] ?? null))
|
||||
->map(fn (array $row): string => (string) $row['type'])
|
||||
->filter(fn (string $type): bool => $type !== '')
|
||||
->values()
|
||||
->all();
|
||||
|
||||
return self::uniqueSorted($types);
|
||||
return app(GovernanceSubjectTaxonomyRegistry::class)->activeLegacyBucketKeys('foundation_types');
|
||||
}
|
||||
|
||||
/**
|
||||
@ -243,4 +398,286 @@ private static function uniqueSorted(array $types): array
|
||||
|
||||
return $types;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<string> $policyTypes
|
||||
* @param list<string> $foundationTypes
|
||||
* @param list<string> $legacyKeysPresent
|
||||
*/
|
||||
private static function fromLegacyPayload(
|
||||
array $policyTypes,
|
||||
array $foundationTypes,
|
||||
array $legacyKeysPresent,
|
||||
bool $normalizedOnRead,
|
||||
bool $saveForwardRequired,
|
||||
): self {
|
||||
$policyTypes = $policyTypes === []
|
||||
? self::supportedPolicyTypes()
|
||||
: self::normalizePolicyTypes($policyTypes);
|
||||
|
||||
$foundationTypes = self::normalizeFoundationTypes($foundationTypes);
|
||||
$entries = [];
|
||||
|
||||
if ($policyTypes !== []) {
|
||||
$entries[] = [
|
||||
'domain_key' => GovernanceDomainKey::Intune->value,
|
||||
'subject_class' => GovernanceSubjectClass::Policy->value,
|
||||
'subject_type_keys' => $policyTypes,
|
||||
'filters' => [],
|
||||
];
|
||||
}
|
||||
|
||||
if ($foundationTypes !== []) {
|
||||
$entries[] = [
|
||||
'domain_key' => GovernanceDomainKey::PlatformFoundation->value,
|
||||
'subject_class' => GovernanceSubjectClass::ConfigurationResource->value,
|
||||
'subject_type_keys' => $foundationTypes,
|
||||
'filters' => [],
|
||||
];
|
||||
}
|
||||
|
||||
return new self(
|
||||
policyTypes: $policyTypes,
|
||||
foundationTypes: $foundationTypes,
|
||||
entries: $entries,
|
||||
sourceShape: 'legacy',
|
||||
normalizedOnRead: $normalizedOnRead,
|
||||
legacyKeysPresent: $legacyKeysPresent,
|
||||
saveForwardRequired: $saveForwardRequired,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $scopeJsonb
|
||||
*/
|
||||
private static function fromCanonicalPayload(array $scopeJsonb): self
|
||||
{
|
||||
if (($scopeJsonb['version'] ?? null) !== 2) {
|
||||
throw new InvalidArgumentException('Baseline scope version must equal 2.');
|
||||
}
|
||||
|
||||
$entries = $scopeJsonb['entries'] ?? null;
|
||||
|
||||
if (! is_array($entries) || $entries === []) {
|
||||
throw new InvalidArgumentException('Baseline scope V2 entries must be a non-empty array.');
|
||||
}
|
||||
|
||||
$normalizedEntries = self::normalizeEntries($entries);
|
||||
[$policyTypes, $foundationTypes] = self::legacyProjectionFromEntries($normalizedEntries);
|
||||
|
||||
return new self(
|
||||
policyTypes: $policyTypes,
|
||||
foundationTypes: $foundationTypes,
|
||||
entries: $normalizedEntries,
|
||||
sourceShape: 'canonical_v2',
|
||||
normalizedOnRead: false,
|
||||
legacyKeysPresent: [],
|
||||
saveForwardRequired: false,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<mixed> $entries
|
||||
* @return list<array{domain_key: string, subject_class: string, subject_type_keys: list<string>, filters: array<string, mixed>}>
|
||||
*/
|
||||
private static function normalizeEntries(array $entries): array
|
||||
{
|
||||
$registry = app(GovernanceSubjectTaxonomyRegistry::class);
|
||||
$normalizedEntries = [];
|
||||
$subjectFilters = [];
|
||||
|
||||
foreach ($entries as $entry) {
|
||||
if (! is_array($entry)) {
|
||||
throw new InvalidArgumentException('Each canonical baseline scope entry must be an array.');
|
||||
}
|
||||
|
||||
$domainKey = trim((string) ($entry['domain_key'] ?? ''));
|
||||
$subjectClass = trim((string) ($entry['subject_class'] ?? ''));
|
||||
|
||||
if (! $registry->isKnownDomain($domainKey)) {
|
||||
throw new InvalidArgumentException('Unknown governance domain ['.$domainKey.'].');
|
||||
}
|
||||
|
||||
if (! $registry->allowsSubjectClass($domainKey, $subjectClass)) {
|
||||
throw new InvalidArgumentException('Subject class ['.$subjectClass.'] is not valid for domain ['.$domainKey.'].');
|
||||
}
|
||||
|
||||
$subjectTypeKeys = self::stringList($entry['subject_type_keys'] ?? null);
|
||||
|
||||
if ($subjectTypeKeys === []) {
|
||||
throw new InvalidArgumentException('Canonical baseline scope entries must include at least one subject type key.');
|
||||
}
|
||||
|
||||
$filters = $entry['filters'] ?? [];
|
||||
|
||||
if (! is_array($filters)) {
|
||||
throw new InvalidArgumentException('Baseline scope entry filters must be an object-shaped array.');
|
||||
}
|
||||
|
||||
$filters = self::normalizeFilters($filters);
|
||||
|
||||
if ($filters !== [] && ! $registry->supportsFilters($domainKey, $subjectClass)) {
|
||||
throw new InvalidArgumentException('Filters are not supported for the current governance domain and subject class.');
|
||||
}
|
||||
|
||||
$subjectTypeKeys = array_map(
|
||||
static fn (string $subjectTypeKey): string => trim($subjectTypeKey),
|
||||
self::uniqueSorted($subjectTypeKeys),
|
||||
);
|
||||
|
||||
foreach ($subjectTypeKeys as $subjectTypeKey) {
|
||||
$subjectType = $registry->find($domainKey, $subjectTypeKey);
|
||||
|
||||
if ($subjectType === null) {
|
||||
throw new InvalidArgumentException('Unknown subject type ['.$subjectTypeKey.'] for domain ['.$domainKey.'].');
|
||||
}
|
||||
|
||||
if ($subjectType->subjectClass->value !== $subjectClass) {
|
||||
throw new InvalidArgumentException('Subject type ['.$subjectTypeKey.'] does not belong to subject class ['.$subjectClass.'].');
|
||||
}
|
||||
|
||||
if (! $subjectType->active) {
|
||||
throw new InvalidArgumentException('Inactive subject type ['.$subjectTypeKey.'] cannot be selected.');
|
||||
}
|
||||
}
|
||||
|
||||
$filtersHash = self::filtersHash($filters);
|
||||
|
||||
foreach ($subjectTypeKeys as $subjectTypeKey) {
|
||||
$subjectKey = implode('|', [$domainKey, $subjectClass, $subjectTypeKey]);
|
||||
$existingFiltersHash = $subjectFilters[$subjectKey] ?? null;
|
||||
|
||||
if ($existingFiltersHash !== null && $existingFiltersHash !== $filtersHash) {
|
||||
throw new InvalidArgumentException('Ambiguous baseline scope filters were provided for ['.$subjectTypeKey.'].');
|
||||
}
|
||||
|
||||
$subjectFilters[$subjectKey] = $filtersHash;
|
||||
}
|
||||
|
||||
$entryKey = implode('|', [$domainKey, $subjectClass, $filtersHash]);
|
||||
|
||||
if (! array_key_exists($entryKey, $normalizedEntries)) {
|
||||
$normalizedEntries[$entryKey] = [
|
||||
'domain_key' => $domainKey,
|
||||
'subject_class' => $subjectClass,
|
||||
'subject_type_keys' => [],
|
||||
'filters' => $filters,
|
||||
];
|
||||
}
|
||||
|
||||
$normalizedEntries[$entryKey]['subject_type_keys'] = self::uniqueSorted(array_merge(
|
||||
$normalizedEntries[$entryKey]['subject_type_keys'],
|
||||
$subjectTypeKeys,
|
||||
));
|
||||
}
|
||||
|
||||
$normalizedEntries = array_values($normalizedEntries);
|
||||
|
||||
usort($normalizedEntries, static function (array $left, array $right): int {
|
||||
$leftKey = implode('|', [
|
||||
$left['domain_key'],
|
||||
$left['subject_class'],
|
||||
self::filtersHash($left['filters']),
|
||||
implode(',', $left['subject_type_keys']),
|
||||
]);
|
||||
$rightKey = implode('|', [
|
||||
$right['domain_key'],
|
||||
$right['subject_class'],
|
||||
self::filtersHash($right['filters']),
|
||||
implode(',', $right['subject_type_keys']),
|
||||
]);
|
||||
|
||||
return $leftKey <=> $rightKey;
|
||||
});
|
||||
|
||||
return $normalizedEntries;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<array{domain_key: string, subject_class: string, subject_type_keys: list<string>, filters: array<string, mixed>}> $entries
|
||||
* @return array{0: list<string>, 1: list<string>}
|
||||
*/
|
||||
private static function legacyProjectionFromEntries(array $entries): array
|
||||
{
|
||||
$policyTypes = [];
|
||||
$foundationTypes = [];
|
||||
|
||||
foreach ($entries as $entry) {
|
||||
if ($entry['domain_key'] === GovernanceDomainKey::Intune->value
|
||||
&& $entry['subject_class'] === GovernanceSubjectClass::Policy->value) {
|
||||
$policyTypes = array_merge($policyTypes, $entry['subject_type_keys']);
|
||||
}
|
||||
|
||||
if ($entry['domain_key'] === GovernanceDomainKey::PlatformFoundation->value
|
||||
&& $entry['subject_class'] === GovernanceSubjectClass::ConfigurationResource->value) {
|
||||
$foundationTypes = array_merge($foundationTypes, $entry['subject_type_keys']);
|
||||
}
|
||||
}
|
||||
|
||||
return [
|
||||
self::uniqueSorted($policyTypes),
|
||||
self::uniqueSorted($foundationTypes),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param mixed $values
|
||||
* @return list<string>
|
||||
*/
|
||||
private static function stringList(mixed $values): array
|
||||
{
|
||||
if (! is_array($values)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return array_values(array_filter($values, 'is_string'));
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $filters
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private static function normalizeFilters(array $filters): array
|
||||
{
|
||||
ksort($filters);
|
||||
|
||||
foreach ($filters as $key => $value) {
|
||||
if (is_array($value)) {
|
||||
$filters[$key] = self::normalizeFilterArray($value);
|
||||
}
|
||||
}
|
||||
|
||||
return $filters;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int|string, mixed> $values
|
||||
* @return array<int|string, mixed>
|
||||
*/
|
||||
private static function normalizeFilterArray(array $values): array
|
||||
{
|
||||
foreach ($values as $key => $value) {
|
||||
if (is_array($value)) {
|
||||
$values[$key] = self::normalizeFilterArray($value);
|
||||
}
|
||||
}
|
||||
|
||||
if (array_is_list($values)) {
|
||||
sort($values);
|
||||
|
||||
return array_values($values);
|
||||
}
|
||||
|
||||
ksort($values);
|
||||
|
||||
return $values;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $filters
|
||||
*/
|
||||
private static function filtersHash(array $filters): string
|
||||
{
|
||||
return json_encode($filters, JSON_THROW_ON_ERROR);
|
||||
}
|
||||
}
|
||||
|
||||
12
apps/platform/app/Support/Governance/GovernanceDomainKey.php
Normal file
12
apps/platform/app/Support/Governance/GovernanceDomainKey.php
Normal 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';
|
||||
}
|
||||
@ -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';
|
||||
}
|
||||
@ -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';
|
||||
}
|
||||
}
|
||||
@ -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,
|
||||
];
|
||||
}
|
||||
}
|
||||
@ -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.');
|
||||
});
|
||||
@ -10,6 +10,7 @@
|
||||
use App\Services\Auth\WorkspaceCapabilityResolver;
|
||||
use App\Support\Workspaces\WorkspaceContext;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
@ -123,6 +124,33 @@
|
||||
->get(BaselineProfileResource::getUrl('edit', ['record' => $profile], panel: 'admin'))
|
||||
->assertOk();
|
||||
});
|
||||
|
||||
it('keeps edit-page authorization stable for legacy-scope profiles', function (): void {
|
||||
[$owner, $tenant] = createUserWithTenant(role: 'owner');
|
||||
[$readonly] = createUserWithTenant(tenant: $tenant, role: 'readonly');
|
||||
|
||||
$profile = BaselineProfile::factory()->create([
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
]);
|
||||
|
||||
DB::table('baseline_profiles')
|
||||
->where('id', (int) $profile->getKey())
|
||||
->update([
|
||||
'scope_jsonb' => json_encode([
|
||||
'policy_types' => ['deviceConfiguration'],
|
||||
'foundation_types' => [],
|
||||
], JSON_THROW_ON_ERROR),
|
||||
'updated_at' => now(),
|
||||
]);
|
||||
|
||||
$this->actingAs($owner)
|
||||
->get(BaselineProfileResource::getUrl('edit', ['record' => $profile], panel: 'admin'))
|
||||
->assertOk();
|
||||
|
||||
$this->actingAs($readonly)
|
||||
->get(BaselineProfileResource::getUrl('edit', ['record' => $profile], panel: 'admin'))
|
||||
->assertForbidden();
|
||||
});
|
||||
});
|
||||
|
||||
describe('BaselineProfile static authorization methods', function () {
|
||||
|
||||
@ -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();
|
||||
});
|
||||
@ -9,6 +9,7 @@
|
||||
use App\Models\BaselineTenantAssignment;
|
||||
use App\Support\Workspaces\WorkspaceContext;
|
||||
use Filament\Facades\Filament;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Livewire\Livewire;
|
||||
|
||||
it('keeps baseline capture and compare actions capability-gated on the profile detail page', function (): void {
|
||||
@ -25,6 +26,13 @@
|
||||
]);
|
||||
|
||||
$profile->update(['active_snapshot_id' => (int) $snapshot->getKey()]);
|
||||
|
||||
BaselineTenantAssignment::factory()->create([
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'baseline_profile_id' => (int) $profile->getKey(),
|
||||
]);
|
||||
|
||||
session()->put(WorkspaceContext::SESSION_KEY, (int) $tenant->workspace_id);
|
||||
|
||||
Livewire::actingAs($readonlyUser)
|
||||
@ -34,8 +42,8 @@
|
||||
|
||||
Livewire::actingAs($ownerUser)
|
||||
->test(ViewBaselineProfile::class, ['record' => $profile->getKey()])
|
||||
->assertActionEnabled('capture')
|
||||
->assertActionDisabled('compareNow');
|
||||
->assertActionHidden('capture')
|
||||
->assertActionEnabled('compareNow');
|
||||
});
|
||||
|
||||
it('keeps tenant compare actions disabled for users without tenant.sync and enabled for owners', function (): void {
|
||||
@ -70,3 +78,47 @@
|
||||
->test(BaselineCompareLanding::class)
|
||||
->assertActionEnabled('compareNow');
|
||||
});
|
||||
|
||||
it('keeps legacy-scope capture and compare actions capability-gated on the profile detail page', function (): void {
|
||||
[$readonlyUser, $tenant] = createUserWithTenant(role: 'readonly');
|
||||
[$ownerUser] = createUserWithTenant(tenant: $tenant, role: 'owner');
|
||||
|
||||
$profile = BaselineProfile::factory()->active()->create([
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
]);
|
||||
|
||||
$snapshot = BaselineSnapshot::factory()->complete()->create([
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'baseline_profile_id' => (int) $profile->getKey(),
|
||||
]);
|
||||
|
||||
$profile->update(['active_snapshot_id' => (int) $snapshot->getKey()]);
|
||||
|
||||
BaselineTenantAssignment::factory()->create([
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'baseline_profile_id' => (int) $profile->getKey(),
|
||||
]);
|
||||
|
||||
DB::table('baseline_profiles')
|
||||
->where('id', (int) $profile->getKey())
|
||||
->update([
|
||||
'scope_jsonb' => json_encode([
|
||||
'policy_types' => ['deviceConfiguration'],
|
||||
'foundation_types' => [],
|
||||
], JSON_THROW_ON_ERROR),
|
||||
'updated_at' => now(),
|
||||
]);
|
||||
|
||||
session()->put(WorkspaceContext::SESSION_KEY, (int) $tenant->workspace_id);
|
||||
|
||||
Livewire::actingAs($readonlyUser)
|
||||
->test(ViewBaselineProfile::class, ['record' => $profile->getKey()])
|
||||
->assertActionDisabled('capture')
|
||||
->assertActionDisabled('compareNow');
|
||||
|
||||
Livewire::actingAs($ownerUser)
|
||||
->test(ViewBaselineProfile::class, ['record' => $profile->getKey()])
|
||||
->assertActionHidden('capture')
|
||||
->assertActionEnabled('compareNow');
|
||||
});
|
||||
|
||||
@ -12,6 +12,7 @@
|
||||
use Filament\Actions\Action;
|
||||
use Filament\Actions\ActionGroup;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Queue;
|
||||
use Livewire\Features\SupportTesting\Testable;
|
||||
use Livewire\Livewire;
|
||||
@ -147,3 +148,60 @@ function baselineProfileCaptureHeaderActions(Testable $component): array
|
||||
Queue::assertNotPushed(CaptureBaselineSnapshotJob::class);
|
||||
expect(OperationRun::query()->where('type', 'baseline_capture')->count())->toBe(0);
|
||||
});
|
||||
|
||||
it('shows readiness copy without exposing raw canonical scope json on the capture start surface', function (): void {
|
||||
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||
|
||||
$profile = BaselineProfile::factory()->active()->create([
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'scope_jsonb' => ['policy_types' => ['deviceConfiguration'], 'foundation_types' => []],
|
||||
]);
|
||||
|
||||
session()->put(WorkspaceContext::SESSION_KEY, (int) $tenant->workspace_id);
|
||||
|
||||
Livewire::actingAs($user)
|
||||
->test(ViewBaselineProfile::class, ['record' => $profile->getKey()])
|
||||
->assertSee('Support readiness')
|
||||
->assertSee('Capture: ready. Compare: ready.')
|
||||
->assertDontSee('subject_type_keys')
|
||||
->assertDontSee('canonical_scope');
|
||||
});
|
||||
|
||||
it('does not start capture when the stored canonical scope is invalid', function (): void {
|
||||
Queue::fake();
|
||||
|
||||
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||
|
||||
$profile = BaselineProfile::factory()->active()->create([
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
]);
|
||||
|
||||
DB::table('baseline_profiles')
|
||||
->where('id', (int) $profile->getKey())
|
||||
->update([
|
||||
'scope_jsonb' => json_encode([
|
||||
'version' => 2,
|
||||
'entries' => [
|
||||
[
|
||||
'domain_key' => 'platform_foundation',
|
||||
'subject_class' => 'configuration_resource',
|
||||
'subject_type_keys' => ['intuneRoleAssignment'],
|
||||
'filters' => [],
|
||||
],
|
||||
],
|
||||
], JSON_THROW_ON_ERROR),
|
||||
'updated_at' => now(),
|
||||
]);
|
||||
|
||||
session()->put(WorkspaceContext::SESSION_KEY, (int) $tenant->workspace_id);
|
||||
|
||||
Livewire::actingAs($user)
|
||||
->test(ViewBaselineProfile::class, ['record' => $profile->getKey()])
|
||||
->assertActionVisible('capture')
|
||||
->callAction('capture', data: ['source_tenant_id' => (int) $tenant->getKey()])
|
||||
->assertNotified('Cannot start capture')
|
||||
->assertStatus(200);
|
||||
|
||||
Queue::assertNotPushed(CaptureBaselineSnapshotJob::class);
|
||||
expect(OperationRun::query()->where('type', 'baseline_capture')->count())->toBe(0);
|
||||
});
|
||||
|
||||
@ -11,6 +11,7 @@
|
||||
use App\Support\Workspaces\WorkspaceContext;
|
||||
use Filament\Actions\Action;
|
||||
use Filament\Actions\ActionGroup;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Queue;
|
||||
use Livewire\Features\SupportTesting\Testable;
|
||||
use Livewire\Livewire;
|
||||
@ -229,3 +230,86 @@ function baselineProfileHeaderActions(Testable $component): array
|
||||
expect(collect(BaselineProfileResource::detailRelatedContextEntries($profile))->pluck('key')->all())
|
||||
->toContain('compare_matrix');
|
||||
});
|
||||
|
||||
it('shows readiness copy without exposing raw canonical scope json on the compare start surface', function (): void {
|
||||
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||
|
||||
$profile = BaselineProfile::factory()->active()->create([
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'scope_jsonb' => ['policy_types' => ['deviceConfiguration'], 'foundation_types' => []],
|
||||
]);
|
||||
|
||||
$snapshot = BaselineSnapshot::factory()->create([
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'baseline_profile_id' => (int) $profile->getKey(),
|
||||
]);
|
||||
|
||||
$profile->update(['active_snapshot_id' => (int) $snapshot->getKey()]);
|
||||
|
||||
BaselineTenantAssignment::factory()->create([
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'baseline_profile_id' => (int) $profile->getKey(),
|
||||
]);
|
||||
|
||||
session()->put(WorkspaceContext::SESSION_KEY, (int) $tenant->workspace_id);
|
||||
|
||||
Livewire::actingAs($user)
|
||||
->test(ViewBaselineProfile::class, ['record' => $profile->getKey()])
|
||||
->assertSee('Support readiness')
|
||||
->assertSee('Capture: ready. Compare: ready.')
|
||||
->assertDontSee('subject_type_keys')
|
||||
->assertDontSee('canonical_scope');
|
||||
});
|
||||
|
||||
it('does not start baseline compare when the stored canonical scope is invalid', function (): void {
|
||||
Queue::fake();
|
||||
|
||||
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||
|
||||
$profile = BaselineProfile::factory()->active()->create([
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
]);
|
||||
|
||||
$snapshot = BaselineSnapshot::factory()->create([
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'baseline_profile_id' => (int) $profile->getKey(),
|
||||
]);
|
||||
|
||||
$profile->update(['active_snapshot_id' => (int) $snapshot->getKey()]);
|
||||
|
||||
BaselineTenantAssignment::factory()->create([
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'baseline_profile_id' => (int) $profile->getKey(),
|
||||
]);
|
||||
|
||||
DB::table('baseline_profiles')
|
||||
->where('id', (int) $profile->getKey())
|
||||
->update([
|
||||
'scope_jsonb' => json_encode([
|
||||
'version' => 2,
|
||||
'entries' => [
|
||||
[
|
||||
'domain_key' => 'platform_foundation',
|
||||
'subject_class' => 'configuration_resource',
|
||||
'subject_type_keys' => ['intuneRoleAssignment'],
|
||||
'filters' => [],
|
||||
],
|
||||
],
|
||||
], JSON_THROW_ON_ERROR),
|
||||
'updated_at' => now(),
|
||||
]);
|
||||
|
||||
session()->put(WorkspaceContext::SESSION_KEY, (int) $tenant->workspace_id);
|
||||
|
||||
Livewire::actingAs($user)
|
||||
->test(ViewBaselineProfile::class, ['record' => $profile->getKey()])
|
||||
->assertActionVisible('compareNow')
|
||||
->callAction('compareNow', data: ['target_tenant_id' => (int) $tenant->getKey()])
|
||||
->assertNotified('Cannot start comparison')
|
||||
->assertStatus(200);
|
||||
|
||||
Queue::assertNotPushed(CompareBaselineToTenantJob::class);
|
||||
expect(OperationRun::query()->where('type', 'baseline_compare')->count())->toBe(0);
|
||||
});
|
||||
|
||||
@ -3,8 +3,10 @@
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Filament\Resources\BaselineProfileResource\Pages\CreateBaselineProfile;
|
||||
use App\Filament\Resources\BaselineProfileResource\Pages\EditBaselineProfile;
|
||||
use App\Models\BaselineProfile;
|
||||
use Filament\Forms\Components\Select;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
use Livewire\Livewire;
|
||||
|
||||
it('shows only baseline-supported foundation types in the baseline profile scope picker', function (): void {
|
||||
@ -61,3 +63,33 @@
|
||||
|
||||
expect(BaselineProfile::query()->where('name', 'Invalid RBAC baseline')->exists())->toBeFalse();
|
||||
});
|
||||
|
||||
it('rejects inactive canonical foundation subject types when editing a baseline profile', function (): void {
|
||||
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||
|
||||
$profile = BaselineProfile::factory()->active()->create([
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'name' => 'Editable RBAC baseline',
|
||||
]);
|
||||
|
||||
$component = Livewire::actingAs($user)
|
||||
->test(EditBaselineProfile::class, ['record' => $profile->getKey()]);
|
||||
|
||||
$page = $component->instance();
|
||||
$method = new \ReflectionMethod($page, 'mutateFormDataBeforeSave');
|
||||
$method->setAccessible(true);
|
||||
|
||||
expect(fn () => $method->invoke($page, [
|
||||
'scope_jsonb' => [
|
||||
'version' => 2,
|
||||
'entries' => [
|
||||
[
|
||||
'domain_key' => 'platform_foundation',
|
||||
'subject_class' => 'configuration_resource',
|
||||
'subject_type_keys' => ['intuneRoleAssignment'],
|
||||
'filters' => [],
|
||||
],
|
||||
],
|
||||
],
|
||||
]))->toThrow(ValidationException::class, 'Inactive subject type');
|
||||
});
|
||||
|
||||
@ -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();
|
||||
});
|
||||
@ -7,12 +7,16 @@
|
||||
use App\Models\BaselineSnapshot;
|
||||
use App\Models\BaselineTenantAssignment;
|
||||
use App\Models\OperationRun;
|
||||
use App\Services\Baselines\BaselineCaptureService;
|
||||
use App\Services\Baselines\BaselineCompareService;
|
||||
use App\Support\Baselines\BaselineCompareReasonCode;
|
||||
use App\Support\Baselines\BaselineCompareStats;
|
||||
use App\Support\Baselines\BaselineReasonCodes;
|
||||
use App\Support\Ui\GovernanceArtifactTruth\ArtifactTruthPresenter;
|
||||
use App\Support\Workspaces\WorkspaceContext;
|
||||
use Filament\Facades\Filament;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Queue;
|
||||
use Livewire\Features\SupportTesting\Testable;
|
||||
use Livewire\Livewire;
|
||||
|
||||
@ -248,3 +252,85 @@ function visibleLivewireText(Testable $component): string
|
||||
->assertSee($explanation?->trustworthinessLabel() ?? '')
|
||||
->assertDontSee('No confirmed drift in the latest baseline compare.');
|
||||
});
|
||||
|
||||
it('records canonical effective scope and compatibility projection for baseline capture runs', function (): void {
|
||||
Queue::fake();
|
||||
|
||||
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||
|
||||
$profile = BaselineProfile::factory()->active()->create([
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'scope_jsonb' => ['policy_types' => ['deviceConfiguration'], 'foundation_types' => []],
|
||||
]);
|
||||
|
||||
$result = app(BaselineCaptureService::class)->startCapture($profile, $tenant, $user);
|
||||
|
||||
expect($result['ok'])->toBeTrue();
|
||||
|
||||
$run = $result['run'];
|
||||
$effectiveScope = is_array(data_get($run->context, 'effective_scope')) ? data_get($run->context, 'effective_scope') : [];
|
||||
|
||||
expect(data_get($effectiveScope, 'canonical_scope.version'))->toBe(2)
|
||||
->and(data_get($effectiveScope, 'canonical_scope.entries.0.domain_key'))->toBe('intune')
|
||||
->and(data_get($effectiveScope, 'canonical_scope.entries.0.subject_class'))->toBe('policy')
|
||||
->and(data_get($effectiveScope, 'canonical_scope.entries.0.subject_type_keys'))->toBe(['deviceConfiguration'])
|
||||
->and(data_get($effectiveScope, 'legacy_projection.policy_types'))->toBe(['deviceConfiguration'])
|
||||
->and(data_get($effectiveScope, 'legacy_projection.foundation_types'))->toBe([])
|
||||
->and(data_get($effectiveScope, 'selected_type_keys'))->toBe(['deviceConfiguration'])
|
||||
->and(data_get($effectiveScope, 'allowed_type_keys'))->toBe(['deviceConfiguration'])
|
||||
->and(data_get($effectiveScope, 'unsupported_type_keys'))->toBe([]);
|
||||
});
|
||||
|
||||
it('normalizes legacy compare assignment overrides into canonical effective scope without rewriting the override row', function (): void {
|
||||
Queue::fake();
|
||||
|
||||
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||
|
||||
$profile = BaselineProfile::factory()->active()->create([
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'scope_jsonb' => ['policy_types' => ['deviceConfiguration', 'deviceCompliancePolicy'], 'foundation_types' => []],
|
||||
]);
|
||||
|
||||
$snapshot = BaselineSnapshot::factory()->complete()->create([
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'baseline_profile_id' => (int) $profile->getKey(),
|
||||
]);
|
||||
|
||||
$profile->update(['active_snapshot_id' => (int) $snapshot->getKey()]);
|
||||
|
||||
$assignment = BaselineTenantAssignment::factory()->create([
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'baseline_profile_id' => (int) $profile->getKey(),
|
||||
'override_scope_jsonb' => null,
|
||||
]);
|
||||
|
||||
DB::table('baseline_tenant_assignments')
|
||||
->where('id', (int) $assignment->getKey())
|
||||
->update([
|
||||
'override_scope_jsonb' => json_encode([
|
||||
'policy_types' => ['deviceConfiguration'],
|
||||
'foundation_types' => [],
|
||||
], JSON_THROW_ON_ERROR),
|
||||
'updated_at' => now(),
|
||||
]);
|
||||
|
||||
$result = app(BaselineCompareService::class)->startCompare($tenant, $user);
|
||||
|
||||
expect($result['ok'])->toBeTrue();
|
||||
|
||||
$run = $result['run'];
|
||||
$effectiveScope = is_array(data_get($run->context, 'effective_scope')) ? data_get($run->context, 'effective_scope') : [];
|
||||
$rawOverride = DB::table('baseline_tenant_assignments')
|
||||
->where('id', (int) $assignment->getKey())
|
||||
->value('override_scope_jsonb');
|
||||
|
||||
expect(data_get($effectiveScope, 'canonical_scope.version'))->toBe(2)
|
||||
->and(data_get($effectiveScope, 'canonical_scope.entries.0.subject_type_keys'))->toBe(['deviceConfiguration'])
|
||||
->and(data_get($effectiveScope, 'legacy_projection.policy_types'))->toBe(['deviceConfiguration'])
|
||||
->and(data_get($effectiveScope, 'selected_type_keys'))->toBe(['deviceConfiguration'])
|
||||
->and(json_decode((string) $rawOverride, true, flags: JSON_THROW_ON_ERROR))->toBe([
|
||||
'policy_types' => ['deviceConfiguration'],
|
||||
'foundation_types' => [],
|
||||
]);
|
||||
});
|
||||
|
||||
@ -47,3 +47,183 @@
|
||||
expect($scope->foundationTypes)->toBe(['assignmentFilter']);
|
||||
expect($scope->allTypes())->toBe(['assignmentFilter', 'deviceConfiguration']);
|
||||
});
|
||||
|
||||
it('normalizes canonical v2 entries and preserves canonical storage', function (): void {
|
||||
config()->set('tenantpilot.supported_policy_types', [
|
||||
['type' => 'deviceConfiguration', 'label' => 'Device Configuration'],
|
||||
['type' => 'deviceCompliancePolicy', 'label' => 'Device Compliance'],
|
||||
]);
|
||||
|
||||
config()->set('tenantpilot.foundation_types', [
|
||||
['type' => 'assignmentFilter', 'label' => 'Assignment Filter', 'baseline_compare' => ['supported' => true]],
|
||||
]);
|
||||
|
||||
$scope = BaselineScope::fromJsonb([
|
||||
'version' => 2,
|
||||
'entries' => [
|
||||
[
|
||||
'domain_key' => 'intune',
|
||||
'subject_class' => 'policy',
|
||||
'subject_type_keys' => ['deviceConfiguration', 'deviceCompliancePolicy', 'deviceConfiguration'],
|
||||
'filters' => [],
|
||||
],
|
||||
[
|
||||
'domain_key' => 'intune',
|
||||
'subject_class' => 'policy',
|
||||
'subject_type_keys' => ['deviceCompliancePolicy'],
|
||||
'filters' => [],
|
||||
],
|
||||
[
|
||||
'domain_key' => 'platform_foundation',
|
||||
'subject_class' => 'configuration_resource',
|
||||
'subject_type_keys' => ['assignmentFilter'],
|
||||
'filters' => [],
|
||||
],
|
||||
],
|
||||
]);
|
||||
|
||||
expect($scope->policyTypes)->toBe(['deviceCompliancePolicy', 'deviceConfiguration'])
|
||||
->and($scope->foundationTypes)->toBe(['assignmentFilter'])
|
||||
->and($scope->toStoredJsonb())->toBe([
|
||||
'version' => 2,
|
||||
'entries' => [
|
||||
[
|
||||
'domain_key' => 'intune',
|
||||
'subject_class' => 'policy',
|
||||
'subject_type_keys' => ['deviceCompliancePolicy', 'deviceConfiguration'],
|
||||
'filters' => [],
|
||||
],
|
||||
[
|
||||
'domain_key' => 'platform_foundation',
|
||||
'subject_class' => 'configuration_resource',
|
||||
'subject_type_keys' => ['assignmentFilter'],
|
||||
'filters' => [],
|
||||
],
|
||||
],
|
||||
])
|
||||
->and($scope->normalizationLineage())->toMatchArray([
|
||||
'source_shape' => 'canonical_v2',
|
||||
'normalized_on_read' => false,
|
||||
'save_forward_required' => false,
|
||||
]);
|
||||
});
|
||||
|
||||
it('treats a missing legacy bucket like its empty default when the other bucket is present', function (): void {
|
||||
config()->set('tenantpilot.supported_policy_types', [
|
||||
['type' => 'deviceConfiguration'],
|
||||
['type' => 'deviceCompliancePolicy'],
|
||||
]);
|
||||
|
||||
config()->set('tenantpilot.foundation_types', [
|
||||
['type' => 'assignmentFilter', 'baseline_compare' => ['supported' => true]],
|
||||
]);
|
||||
|
||||
$policyOnly = BaselineScope::fromJsonb([
|
||||
'policy_types' => ['deviceConfiguration'],
|
||||
]);
|
||||
$foundationOnly = BaselineScope::fromJsonb([
|
||||
'foundation_types' => ['assignmentFilter'],
|
||||
]);
|
||||
|
||||
expect($policyOnly->policyTypes)->toBe(['deviceConfiguration'])
|
||||
->and($policyOnly->foundationTypes)->toBe([])
|
||||
->and($foundationOnly->policyTypes)->toBe(['deviceCompliancePolicy', 'deviceConfiguration'])
|
||||
->and($foundationOnly->foundationTypes)->toBe(['assignmentFilter']);
|
||||
});
|
||||
|
||||
it('rejects mixed legacy and canonical payloads', function (): void {
|
||||
expect(fn () => BaselineScope::fromJsonb([
|
||||
'policy_types' => ['deviceConfiguration'],
|
||||
'version' => 2,
|
||||
'entries' => [
|
||||
[
|
||||
'domain_key' => 'intune',
|
||||
'subject_class' => 'policy',
|
||||
'subject_type_keys' => ['deviceConfiguration'],
|
||||
],
|
||||
],
|
||||
]))->toThrow(InvalidArgumentException::class, 'must not mix legacy buckets');
|
||||
});
|
||||
|
||||
it('rejects unsupported filters for current domains', function (): void {
|
||||
config()->set('tenantpilot.supported_policy_types', [
|
||||
['type' => 'deviceConfiguration'],
|
||||
]);
|
||||
|
||||
expect(fn () => BaselineScope::fromJsonb([
|
||||
'version' => 2,
|
||||
'entries' => [
|
||||
[
|
||||
'domain_key' => 'intune',
|
||||
'subject_class' => 'policy',
|
||||
'subject_type_keys' => ['deviceConfiguration'],
|
||||
'filters' => ['tenant_ids' => ['tenant-a']],
|
||||
],
|
||||
],
|
||||
]))->toThrow(InvalidArgumentException::class, 'Filters are not supported');
|
||||
});
|
||||
|
||||
it('treats empty legacy override payloads as no override when requested', function (): void {
|
||||
$scope = BaselineScope::fromJsonb([
|
||||
'policy_types' => [],
|
||||
'foundation_types' => [],
|
||||
], allowEmptyLegacyAsNoOverride: true);
|
||||
|
||||
expect($scope->isEmpty())->toBeTrue();
|
||||
});
|
||||
|
||||
it('rejects unknown governance domains', function (): void {
|
||||
expect(fn () => BaselineScope::fromJsonb([
|
||||
'version' => 2,
|
||||
'entries' => [
|
||||
[
|
||||
'domain_key' => 'unknown_domain',
|
||||
'subject_class' => 'policy',
|
||||
'subject_type_keys' => ['deviceConfiguration'],
|
||||
'filters' => [],
|
||||
],
|
||||
],
|
||||
]))->toThrow(InvalidArgumentException::class, 'Unknown governance domain');
|
||||
});
|
||||
|
||||
it('rejects invalid subject classes for known domains', function (): void {
|
||||
expect(fn () => BaselineScope::fromJsonb([
|
||||
'version' => 2,
|
||||
'entries' => [
|
||||
[
|
||||
'domain_key' => 'intune',
|
||||
'subject_class' => 'configuration_resource',
|
||||
'subject_type_keys' => ['deviceConfiguration'],
|
||||
'filters' => [],
|
||||
],
|
||||
],
|
||||
]))->toThrow(InvalidArgumentException::class, 'is not valid for domain');
|
||||
});
|
||||
|
||||
it('rejects inactive subject types in canonical scope entries', function (): void {
|
||||
expect(fn () => BaselineScope::fromJsonb([
|
||||
'version' => 2,
|
||||
'entries' => [
|
||||
[
|
||||
'domain_key' => 'platform_foundation',
|
||||
'subject_class' => 'configuration_resource',
|
||||
'subject_type_keys' => ['intuneRoleAssignment'],
|
||||
'filters' => [],
|
||||
],
|
||||
],
|
||||
]))->toThrow(InvalidArgumentException::class, 'Inactive subject type');
|
||||
});
|
||||
|
||||
it('rejects future-domain selections that have no active subject type mapping yet', function (): void {
|
||||
expect(fn () => BaselineScope::fromJsonb([
|
||||
'version' => 2,
|
||||
'entries' => [
|
||||
[
|
||||
'domain_key' => 'entra',
|
||||
'subject_class' => 'control',
|
||||
'subject_type_keys' => ['conditionalAccessPolicy'],
|
||||
'filters' => [],
|
||||
],
|
||||
],
|
||||
]))->toThrow(InvalidArgumentException::class, 'Unknown subject type');
|
||||
});
|
||||
|
||||
@ -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();
|
||||
});
|
||||
@ -1,6 +1,7 @@
|
||||
<?php
|
||||
|
||||
use App\Services\Baselines\InventoryMetaContract;
|
||||
use App\Support\Inventory\InventoryPolicyTypeMeta;
|
||||
|
||||
it('builds a deterministic v1 contract regardless of input ordering', function () {
|
||||
$builder = app(InventoryMetaContract::class);
|
||||
@ -57,3 +58,35 @@
|
||||
expect($contract['scope_tag_ids'])->toBeNull();
|
||||
expect($contract['assignment_target_count'])->toBeNull();
|
||||
});
|
||||
|
||||
it('keeps baseline support contracts aligned with governance mapping for policies and foundations', function (): void {
|
||||
$policyContract = InventoryPolicyTypeMeta::baselineSupportContract('deviceConfiguration');
|
||||
$foundationContract = InventoryPolicyTypeMeta::baselineSupportContract('intuneRoleDefinition');
|
||||
$unsupportedFoundationContract = InventoryPolicyTypeMeta::baselineSupportContract('intuneRoleAssignment');
|
||||
|
||||
expect($policyContract)->toMatchArray([
|
||||
'config_supported' => true,
|
||||
'runtime_valid' => true,
|
||||
'subject_class' => 'policy_backed',
|
||||
'resolution_path' => 'policy',
|
||||
'compare_capability' => 'supported',
|
||||
'capture_capability' => 'supported',
|
||||
'source_model_expected' => 'policy',
|
||||
])->and($foundationContract)->toMatchArray([
|
||||
'config_supported' => true,
|
||||
'runtime_valid' => true,
|
||||
'subject_class' => 'foundation_backed',
|
||||
'resolution_path' => 'foundation_policy',
|
||||
'compare_capability' => 'supported',
|
||||
'capture_capability' => 'supported',
|
||||
'source_model_expected' => 'policy',
|
||||
])->and($unsupportedFoundationContract)->toMatchArray([
|
||||
'config_supported' => false,
|
||||
'runtime_valid' => true,
|
||||
'subject_class' => 'foundation_backed',
|
||||
'resolution_path' => 'foundation_policy',
|
||||
'compare_capability' => 'unsupported',
|
||||
'capture_capability' => 'unsupported',
|
||||
'source_model_expected' => 'policy',
|
||||
]);
|
||||
});
|
||||
|
||||
@ -0,0 +1,36 @@
|
||||
# Specification Quality Checklist: Hard Filament Nativity Cleanup
|
||||
|
||||
**Purpose**: Validate specification completeness and quality before proceeding to planning
|
||||
**Created**: 2026-04-13
|
||||
**Feature**: [spec.md](../spec.md)
|
||||
|
||||
## Content Quality
|
||||
|
||||
- [x] No implementation details (languages, frameworks, APIs)
|
||||
- [x] Focused on user value and business needs
|
||||
- [x] Written for non-technical stakeholders
|
||||
- [x] All mandatory sections completed
|
||||
|
||||
## Requirement Completeness
|
||||
|
||||
- [x] No [NEEDS CLARIFICATION] markers remain
|
||||
- [x] Requirements are testable and unambiguous
|
||||
- [x] Success criteria are measurable
|
||||
- [x] Success criteria are technology-agnostic (no implementation details)
|
||||
- [x] All acceptance scenarios are defined
|
||||
- [x] Edge cases are identified
|
||||
- [x] Scope is clearly bounded
|
||||
- [x] Dependencies and assumptions identified
|
||||
|
||||
## Feature Readiness
|
||||
|
||||
- [x] All functional requirements have clear acceptance criteria
|
||||
- [x] User scenarios cover primary flows
|
||||
- [x] Feature meets measurable outcomes defined in Success Criteria
|
||||
- [x] No implementation details leak into specification
|
||||
|
||||
## Notes
|
||||
|
||||
- Validated after initial draft on 2026-04-13.
|
||||
- Framework-specific language appears only where the feature itself and constitution require naming the native admin contract; the spec does not prescribe code-level implementation choices, new abstractions, or dependency changes.
|
||||
- No clarification questions were required from the user because scope, non-goals, and acceptance expectations were already explicit.
|
||||
@ -0,0 +1,395 @@
|
||||
openapi: 3.1.0
|
||||
info:
|
||||
title: Filament Nativity Cleanup Logical Contract
|
||||
version: 0.1.0
|
||||
description: >-
|
||||
Logical planning contract for Spec 196. This artifact defines the expected
|
||||
state ownership, filter semantics, scope guarantees, and row projections for
|
||||
the three cleaned UI surfaces. It is not a runtime API definition.
|
||||
servers:
|
||||
- url: https://logical-spec.local
|
||||
description: Non-runtime planning contract
|
||||
paths:
|
||||
/internal/ui/inventory-items/{inventoryItemId}/dependencies:
|
||||
get:
|
||||
summary: Read dependency section state for one inventory item detail surface
|
||||
operationId: getInventoryItemDependenciesView
|
||||
parameters:
|
||||
- name: inventoryItemId
|
||||
in: path
|
||||
required: true
|
||||
schema:
|
||||
type: integer
|
||||
responses:
|
||||
'200':
|
||||
description: Dependency detail-surface state and rows
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
required:
|
||||
- data
|
||||
properties:
|
||||
data:
|
||||
$ref: '#/components/schemas/DependencyEdgesView'
|
||||
'404':
|
||||
description: Returned when the actor is not entitled to the tenant or inventory-item scope.
|
||||
/internal/ui/tenants/{tenantExternalId}/required-permissions:
|
||||
get:
|
||||
summary: Read required-permissions page state for one route-scoped tenant
|
||||
operationId: getTenantRequiredPermissionsView
|
||||
parameters:
|
||||
- name: tenantExternalId
|
||||
in: path
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
- name: status
|
||||
in: query
|
||||
required: false
|
||||
schema:
|
||||
$ref: '#/components/schemas/RequiredPermissionsStatus'
|
||||
- name: type
|
||||
in: query
|
||||
required: false
|
||||
schema:
|
||||
$ref: '#/components/schemas/PermissionTypeFilter'
|
||||
- name: features
|
||||
in: query
|
||||
required: false
|
||||
schema:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
- name: search
|
||||
in: query
|
||||
required: false
|
||||
schema:
|
||||
type: string
|
||||
responses:
|
||||
'200':
|
||||
description: Required-permissions page state, summary, and rows
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
required:
|
||||
- data
|
||||
properties:
|
||||
data:
|
||||
$ref: '#/components/schemas/RequiredPermissionsView'
|
||||
'404':
|
||||
description: Returned when workspace or tenant membership is absent for the route-scoped tenant.
|
||||
/internal/ui/evidence-overview:
|
||||
get:
|
||||
summary: Read workspace evidence overview table state and rows
|
||||
operationId: getEvidenceOverviewView
|
||||
parameters:
|
||||
- name: tenantId
|
||||
in: query
|
||||
required: false
|
||||
description: Optional entitled tenant prefilter; unauthorized tenant identifiers must not reveal row existence.
|
||||
schema:
|
||||
anyOf:
|
||||
- type: integer
|
||||
- type: 'null'
|
||||
- name: search
|
||||
in: query
|
||||
required: false
|
||||
schema:
|
||||
type: string
|
||||
responses:
|
||||
'200':
|
||||
description: Workspace evidence overview state and rows
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
required:
|
||||
- data
|
||||
properties:
|
||||
data:
|
||||
$ref: '#/components/schemas/EvidenceOverviewView'
|
||||
'404':
|
||||
description: Returned when workspace membership is absent for the evidence overview surface.
|
||||
components:
|
||||
schemas:
|
||||
DependencyDirection:
|
||||
type: string
|
||||
enum:
|
||||
- all
|
||||
- inbound
|
||||
- outbound
|
||||
RelationshipTypeKey:
|
||||
type: string
|
||||
description: Recognized relationship type key from the existing dependency domain.
|
||||
RequiredPermissionsStatus:
|
||||
type: string
|
||||
enum:
|
||||
- missing
|
||||
- present
|
||||
- error
|
||||
- all
|
||||
PermissionTypeFilter:
|
||||
type: string
|
||||
enum:
|
||||
- all
|
||||
- application
|
||||
- delegated
|
||||
DependencyEdgesState:
|
||||
type: object
|
||||
required:
|
||||
- inventoryItemId
|
||||
- tenantId
|
||||
- direction
|
||||
properties:
|
||||
inventoryItemId:
|
||||
type: integer
|
||||
tenantId:
|
||||
type: integer
|
||||
direction:
|
||||
$ref: '#/components/schemas/DependencyDirection'
|
||||
relationshipType:
|
||||
anyOf:
|
||||
- $ref: '#/components/schemas/RelationshipTypeKey'
|
||||
- type: 'null'
|
||||
DependencyEdgeRow:
|
||||
type: object
|
||||
required:
|
||||
- relationshipType
|
||||
- targetType
|
||||
- renderedTarget
|
||||
- isMissing
|
||||
- missingTitle
|
||||
properties:
|
||||
relationshipType:
|
||||
type: string
|
||||
targetType:
|
||||
type: string
|
||||
targetId:
|
||||
anyOf:
|
||||
- type: string
|
||||
- type: 'null'
|
||||
renderedTarget:
|
||||
type: object
|
||||
additionalProperties: true
|
||||
isMissing:
|
||||
type: boolean
|
||||
missingTitle:
|
||||
type: string
|
||||
DependencyEdgesView:
|
||||
type: object
|
||||
required:
|
||||
- state
|
||||
- rows
|
||||
properties:
|
||||
state:
|
||||
$ref: '#/components/schemas/DependencyEdgesState'
|
||||
rows:
|
||||
type: array
|
||||
items:
|
||||
$ref: '#/components/schemas/DependencyEdgeRow'
|
||||
RequiredPermissionsState:
|
||||
type: object
|
||||
required:
|
||||
- routeTenantExternalId
|
||||
- status
|
||||
- type
|
||||
- features
|
||||
- search
|
||||
- routeTenantAuthoritative
|
||||
- seededFromQuery
|
||||
properties:
|
||||
routeTenantExternalId:
|
||||
type: string
|
||||
status:
|
||||
$ref: '#/components/schemas/RequiredPermissionsStatus'
|
||||
type:
|
||||
$ref: '#/components/schemas/PermissionTypeFilter'
|
||||
features:
|
||||
type: array
|
||||
uniqueItems: true
|
||||
description: Normalized unique list of known feature keys.
|
||||
items:
|
||||
type: string
|
||||
search:
|
||||
type: string
|
||||
routeTenantAuthoritative:
|
||||
type: boolean
|
||||
const: true
|
||||
seededFromQuery:
|
||||
type: boolean
|
||||
RequiredPermissionsSummary:
|
||||
type: object
|
||||
required:
|
||||
- counts
|
||||
- freshness
|
||||
- featureImpacts
|
||||
- copyPayloads
|
||||
- issues
|
||||
properties:
|
||||
counts:
|
||||
type: object
|
||||
additionalProperties:
|
||||
type: integer
|
||||
overall:
|
||||
anyOf:
|
||||
- type: string
|
||||
- type: 'null'
|
||||
freshness:
|
||||
type: object
|
||||
additionalProperties: true
|
||||
featureImpacts:
|
||||
type: array
|
||||
items:
|
||||
type: object
|
||||
additionalProperties: true
|
||||
copyPayloads:
|
||||
type: object
|
||||
additionalProperties:
|
||||
type: string
|
||||
issues:
|
||||
type: array
|
||||
items:
|
||||
type: object
|
||||
additionalProperties: true
|
||||
PermissionReviewRow:
|
||||
type: object
|
||||
required:
|
||||
- permissionKey
|
||||
- type
|
||||
- status
|
||||
properties:
|
||||
permissionKey:
|
||||
type: string
|
||||
type:
|
||||
type: string
|
||||
status:
|
||||
type: string
|
||||
description:
|
||||
type: string
|
||||
features:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
details:
|
||||
type: object
|
||||
additionalProperties: true
|
||||
RequiredPermissionsView:
|
||||
type: object
|
||||
required:
|
||||
- state
|
||||
- summary
|
||||
- rows
|
||||
properties:
|
||||
state:
|
||||
$ref: '#/components/schemas/RequiredPermissionsState'
|
||||
summary:
|
||||
$ref: '#/components/schemas/RequiredPermissionsSummary'
|
||||
rows:
|
||||
type: array
|
||||
items:
|
||||
$ref: '#/components/schemas/PermissionReviewRow'
|
||||
EvidenceOverviewState:
|
||||
type: object
|
||||
required:
|
||||
- workspaceId
|
||||
- authorizedTenantIds
|
||||
- tenantFilter
|
||||
- search
|
||||
- seededFromQuery
|
||||
properties:
|
||||
workspaceId:
|
||||
type: integer
|
||||
authorizedTenantIds:
|
||||
type: array
|
||||
items:
|
||||
type: integer
|
||||
tenantFilter:
|
||||
anyOf:
|
||||
- type: integer
|
||||
- type: 'null'
|
||||
search:
|
||||
type: string
|
||||
seededFromQuery:
|
||||
type: boolean
|
||||
EvidenceOverviewRow:
|
||||
type: object
|
||||
required:
|
||||
- tenantId
|
||||
- tenantName
|
||||
- snapshotId
|
||||
- artifactTruth
|
||||
- freshness
|
||||
- missingDimensions
|
||||
- staleDimensions
|
||||
- nextStep
|
||||
- viewUrl
|
||||
properties:
|
||||
tenantId:
|
||||
type: integer
|
||||
tenantName:
|
||||
type: string
|
||||
snapshotId:
|
||||
type: integer
|
||||
artifactTruth:
|
||||
type: object
|
||||
additionalProperties: true
|
||||
freshness:
|
||||
type: object
|
||||
additionalProperties: true
|
||||
generatedAt:
|
||||
anyOf:
|
||||
- type: string
|
||||
- type: 'null'
|
||||
missingDimensions:
|
||||
type: integer
|
||||
staleDimensions:
|
||||
type: integer
|
||||
nextStep:
|
||||
type: string
|
||||
viewUrl:
|
||||
type: string
|
||||
EvidenceOverviewView:
|
||||
type: object
|
||||
required:
|
||||
- state
|
||||
- rows
|
||||
properties:
|
||||
state:
|
||||
$ref: '#/components/schemas/EvidenceOverviewState'
|
||||
rows:
|
||||
type: array
|
||||
items:
|
||||
$ref: '#/components/schemas/EvidenceOverviewRow'
|
||||
x-spec-196-notes:
|
||||
consumerScope: illustrative core consumers only; Blade views and focused verification files are tracked in plan.md, quickstart.md, and tasks.md
|
||||
consumers:
|
||||
- apps/platform/app/Filament/Resources/InventoryItemResource.php
|
||||
- apps/platform/app/Livewire/InventoryItemDependencyEdgesTable.php
|
||||
- apps/platform/app/Filament/Pages/TenantRequiredPermissions.php
|
||||
- apps/platform/app/Filament/Pages/Monitoring/EvidenceOverview.php
|
||||
- apps/platform/app/Services/Intune/TenantRequiredPermissionsViewModelBuilder.php
|
||||
- apps/platform/tests/Feature/InventoryItemDependenciesTest.php
|
||||
- apps/platform/tests/Feature/Rbac/TenantRequiredPermissionsTrustedStateTest.php
|
||||
- apps/platform/tests/Feature/Evidence/EvidenceOverviewPageTest.php
|
||||
invariants:
|
||||
- route tenant stays authoritative on required-permissions
|
||||
- evidence overview only exposes entitled tenant rows
|
||||
- dependency rendering remains tenant-isolated and DB-only
|
||||
- query values may seed initial state but not stay the primary contract
|
||||
nonGoals:
|
||||
- runtime API exposure
|
||||
- new persistence
|
||||
- new provider or route families
|
||||
- global context shell redesign
|
||||
- monitoring page-state architecture rewrite
|
||||
- audit log selected-record or inspect duality cleanup
|
||||
- finding exceptions queue dual-inspect cleanup
|
||||
- baseline compare matrix or other special-visualization work
|
||||
- verification report viewer families or onboarding verification report variants
|
||||
- normalized diff or settings viewer families
|
||||
- restore preview, restore results, or enterprise-detail layout rework
|
||||
- raw anchor-to-component link consistency sweeps
|
||||
- badge-only, banner-only, or style-only polish work
|
||||
- new CI guardrail, review-enforcement, or constitution frameworks
|
||||
212
specs/196-hard-filament-nativity-cleanup/data-model.md
Normal file
212
specs/196-hard-filament-nativity-cleanup/data-model.md
Normal file
@ -0,0 +1,212 @@
|
||||
# Data Model: Hard Filament Nativity Cleanup
|
||||
|
||||
## Overview
|
||||
|
||||
This feature introduces no new persisted entity, table, enum, or product-domain source of truth. It refactors three existing UI surfaces by replacing pseudo-native interaction contracts with native page-owned or component-owned state.
|
||||
|
||||
The data model for planning is therefore a set of derived UI-state and row-projection models that answer four questions:
|
||||
|
||||
1. What state is authoritative for each cleaned surface?
|
||||
2. Which source truths continue to produce the rows and summaries?
|
||||
3. Which values may be seeded from deeplinks, and which values must remain route- or entitlement-authoritative?
|
||||
4. Which invariants must remain true after the cleanup?
|
||||
|
||||
## Existing Source Truths Reused Without Change
|
||||
|
||||
The following truths remain authoritative and are not redefined by this feature:
|
||||
|
||||
- `InventoryItem`, `InventoryLink`, `DependencyQueryService`, and `DependencyTargetResolver` for dependency edges and rendered targets
|
||||
- the current tenant-context inventory route and inventory-record scope rules
|
||||
- `TenantRequiredPermissionsViewModelBuilder`, `TenantPermission`, permission configuration, and provider guidance links for required-permissions truth
|
||||
- the route-scoped tenant on `/admin/tenants/{tenant:external_id}/required-permissions`
|
||||
- `EvidenceSnapshot`, `TenantReview`, `ArtifactTruthPresenter`, and the current workspace-context entitlement rules for evidence overview rows
|
||||
- existing capability registries, `WorkspaceContext`, tenant membership checks, and current deny-as-not-found boundaries
|
||||
|
||||
This feature changes how these truths are controlled and rendered, not what they mean.
|
||||
|
||||
## New Derived Planning Models
|
||||
|
||||
### DependencyEdgesTableState
|
||||
|
||||
**Type**: embedded detail-surface state
|
||||
**Source**: Livewire component state on inventory item detail
|
||||
|
||||
| Field | Type | Notes |
|
||||
|------|------|-------|
|
||||
| `inventoryItemId` | int | Required current detail record key |
|
||||
| `tenantId` | int | Required tenant-context key derived from the current panel or record scope |
|
||||
| `direction` | string | Allowed values: `all`, `inbound`, `outbound`; default `all` |
|
||||
| `relationshipType` | string or null | Null means all relationship types; otherwise one allowed relationship type key |
|
||||
|
||||
**Validation rules**
|
||||
|
||||
- `inventoryItemId` must resolve to the current authorized record.
|
||||
- `tenantId` must match the current tenant-context scope.
|
||||
- `direction` must stay inside the three allowed values.
|
||||
- `relationshipType` must be null or a recognized relationship type value.
|
||||
|
||||
### DependencyEdgeRow
|
||||
|
||||
**Type**: derived row projection
|
||||
**Source**: `DependencyQueryService` plus `DependencyTargetResolver`
|
||||
|
||||
| Field | Type | Notes |
|
||||
|------|------|-------|
|
||||
| `relationshipType` | string | Canonical relationship family for grouping or filter matching |
|
||||
| `targetType` | string | Current target kind, including `missing` when unresolved |
|
||||
| `targetId` | string or null | External or internal target identifier |
|
||||
| `renderedTarget` | array | Existing rendered badge and link payload |
|
||||
| `isMissing` | boolean | Derived from `targetType === missing` |
|
||||
| `missingTitle` | string | Existing descriptive fallback text for unresolved targets |
|
||||
|
||||
**Invariants**
|
||||
|
||||
- Row membership must stay tenant-isolated.
|
||||
- Missing-target rendering must preserve current operator hints.
|
||||
- Render-time behavior must remain DB-only with no Graph access.
|
||||
|
||||
### RequiredPermissionsTableState
|
||||
|
||||
**Type**: page-owned derived table state
|
||||
**Source**: native Filament table filters and search on `TenantRequiredPermissions`
|
||||
|
||||
| Field | Type | Notes |
|
||||
|------|------|-------|
|
||||
| `routeTenantExternalId` | string | Authoritative tenant scope from the route |
|
||||
| `status` | string | Allowed values: `missing`, `present`, `error`, `all` |
|
||||
| `type` | string | Allowed values: `all`, `application`, `delegated` |
|
||||
| `features` | list<string> | Zero or more selected feature keys |
|
||||
| `search` | string | Native table search text |
|
||||
| `seededFromQuery` | boolean | True only during initial mount when deeplink values were present |
|
||||
|
||||
**Validation rules**
|
||||
|
||||
- The route tenant always wins over tenant-like query values.
|
||||
- Query values may seed `status`, `type`, `features`, and `search` only at initial mount.
|
||||
- `features` must be a normalized unique list of known feature keys.
|
||||
|
||||
### RequiredPermissionsSummaryProjection
|
||||
|
||||
**Type**: derived page summary model
|
||||
**Source**: `TenantRequiredPermissionsViewModelBuilder` evaluated against the currently active normalized filter state
|
||||
|
||||
| Field | Type | Notes |
|
||||
|------|------|-------|
|
||||
| `counts` | object | Existing counts for missing application, missing delegated, present, and error rows |
|
||||
| `overall` | string or null | Existing overall readiness state |
|
||||
| `freshness` | object | Existing freshness payload including stale or not stale |
|
||||
| `featureImpacts` | list<object> | Existing per-feature impact summary |
|
||||
| `copyPayloads` | object | Existing application and delegated copy payloads |
|
||||
| `issues` | list<object> | Existing derived guidance and next-step content |
|
||||
|
||||
**Invariants**
|
||||
|
||||
- Summary and table rows must be derived from the same active filter state.
|
||||
- Copy payload semantics must remain consistent with current expectations.
|
||||
- Tenant scope must not be mutable through filter state.
|
||||
|
||||
### PermissionReviewRow
|
||||
|
||||
**Type**: derived table row
|
||||
**Source**: `TenantRequiredPermissionsViewModelBuilder`
|
||||
|
||||
| Field | Type | Notes |
|
||||
|------|------|-------|
|
||||
| `permissionKey` | string | Stable permission identifier |
|
||||
| `type` | string | `application` or `delegated` |
|
||||
| `status` | string | Current permission review status |
|
||||
| `description` | string | Human-readable permission description |
|
||||
| `features` | list<string> | Feature tags associated with the permission |
|
||||
| `details` | object | Existing supporting metadata used for inline review only |
|
||||
|
||||
### EvidenceOverviewTableState
|
||||
|
||||
**Type**: workspace-context table state
|
||||
**Source**: native Filament table search and optional query-seeded entitled tenant prefilter
|
||||
|
||||
| Field | Type | Notes |
|
||||
|------|------|-------|
|
||||
| `workspaceId` | int | Required current workspace context |
|
||||
| `authorizedTenantIds` | list<int> | Entitled tenant ids available to the actor |
|
||||
| `tenantFilter` | int or null | Current entitled tenant prefilter, nullable when not active |
|
||||
| `search` | string | Native table search across tenant-facing row labels |
|
||||
| `seededFromQuery` | boolean | True only when the initial request carried a prefilter |
|
||||
|
||||
**Validation rules**
|
||||
|
||||
- `tenantFilter` must be null or one of the actor's entitled tenant ids.
|
||||
- Missing workspace membership continues to produce `404`.
|
||||
- Non-entitled tenant ids must not leak through filter state, row counts, or drilldowns.
|
||||
|
||||
### EvidenceOverviewRow
|
||||
|
||||
**Type**: derived workspace report row
|
||||
**Source**: current snapshot query plus `ArtifactTruthPresenter`
|
||||
|
||||
| Field | Type | Notes |
|
||||
|------|------|-------|
|
||||
| `tenantId` | int | Entitled tenant identifier |
|
||||
| `tenantName` | string | Current display label |
|
||||
| `snapshotId` | int | Current active snapshot id for drilldown |
|
||||
| `artifactTruth` | object | Existing truth badge and explanation payload |
|
||||
| `freshness` | object | Existing freshness badge payload |
|
||||
| `generatedAt` | string or null | Timestamp label |
|
||||
| `missingDimensions` | int | Existing burden metric |
|
||||
| `staleDimensions` | int | Existing burden metric |
|
||||
| `nextStep` | string | Existing next-step text |
|
||||
| `viewUrl` | string | Current tenant evidence drilldown URL |
|
||||
|
||||
**Invariants**
|
||||
|
||||
- Row drilldowns must stay workspace-safe and tenant-entitlement-safe.
|
||||
- Derived-state memoization must remain effective.
|
||||
- Render-time behavior must remain DB-only.
|
||||
|
||||
### CleanupAdmissionCandidate
|
||||
|
||||
**Type**: planning-only admission check
|
||||
**Source**: implementation audit only when a possible extra hit is discovered
|
||||
|
||||
| Field | Type | Notes |
|
||||
|------|------|-------|
|
||||
| `surfaceKey` | string | Stable human-readable identifier |
|
||||
| `path` | string | File or route path for the potential extra surface |
|
||||
| `matchesProblemClass` | boolean | Must be true to qualify |
|
||||
| `opensArchitectureQuestion` | boolean | Must be false to qualify |
|
||||
| `decision` | string | `include` or `defer` |
|
||||
| `reason` | string | Explicit justification for the decision |
|
||||
|
||||
## State Transition Rules
|
||||
|
||||
### Rule 1 - Deeplink seed to native active state
|
||||
|
||||
- Initial request query values may seed filter state on `TenantRequiredPermissions` and `EvidenceOverview`.
|
||||
- After initial mount, active state belongs to the native page table or component, not to `request()`.
|
||||
|
||||
### Rule 2 - Route scope remains authoritative
|
||||
|
||||
- `TenantRequiredPermissions` may never replace its route tenant from query values.
|
||||
- Inventory dependency state may never replace the current detail record or tenant context.
|
||||
- Evidence overview may never reveal non-entitled tenant rows through a prefilter.
|
||||
|
||||
### Rule 3 - No new persistence or mirrored helper truth
|
||||
|
||||
- Filter state stays session-backed or Livewire-backed only where Filament already provides that behavior.
|
||||
- No new database table, JSON helper artifact, or persisted UI-state mirror is introduced.
|
||||
|
||||
## Safety Rules
|
||||
|
||||
- No cleaned surface may introduce a second wrapper contract that simply restyles the current non-native behavior.
|
||||
- No cleaned surface may widen current workspace or tenant scope behavior.
|
||||
- No cleaned surface may lose current empty-state meaning, next-step clarity, or inspect destination correctness.
|
||||
- No page or component may call Graph or other remote APIs during render as part of this cleanup.
|
||||
|
||||
## Planned Test Mapping
|
||||
|
||||
| Model / Rule | Existing Coverage | Planned Additions |
|
||||
|---|---|---|
|
||||
| `DependencyEdgesTableState` | `tests/Feature/InventoryItemDependenciesTest.php`, dependency tenant-isolation and query-service tests | native component test for direction and relationship interaction |
|
||||
| `RequiredPermissionsTableState` | `tests/Feature/Rbac/TenantRequiredPermissionsTrustedStateTest.php`, unit filter normalization tests | page-level native table test |
|
||||
| `RequiredPermissionsSummaryProjection` | current unit tests for freshness, overall state, feature impacts, and copy payloads | page-level summary consistency assertions |
|
||||
| `EvidenceOverviewTableState` | `tests/Feature/Evidence/EvidenceOverviewPageTest.php` | native table assertions and any new table-standard guard alignment |
|
||||
| `EvidenceOverviewRow` DB-only invariant | `tests/Feature/Filament/EvidenceOverviewDerivedStateMemoizationTest.php` | update assertions to reflect native table rendering without losing memoization guarantees |
|
||||
296
specs/196-hard-filament-nativity-cleanup/plan.md
Normal file
296
specs/196-hard-filament-nativity-cleanup/plan.md
Normal file
@ -0,0 +1,296 @@
|
||||
# Implementation Plan: Hard Filament Nativity Cleanup
|
||||
|
||||
**Branch**: `196-hard-filament-nativity-cleanup` | **Date**: 2026-04-13 | **Spec**: `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/specs/196-hard-filament-nativity-cleanup/spec.md`
|
||||
**Input**: Feature specification from `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/specs/196-hard-filament-nativity-cleanup/spec.md`
|
||||
|
||||
**Note**: This plan keeps the work inside the existing Filament v5 / Livewire v4 page layer, the current derived view-model services, the existing dependency query and target-resolution services, and the current focused RBAC and reporting tests. It explicitly avoids adding a new runtime UI framework, new persistence, or a broader shell or monitoring-state architecture.
|
||||
|
||||
## Summary
|
||||
|
||||
Remove the three hard nativity bypasses called out by Spec 196 by reusing repo-proven native Filament patterns. Convert `EvidenceOverview` and `TenantRequiredPermissions` into page-owned native table surfaces with native filter state and unchanged scope semantics. Replace the GET-form dependency micro-UI on inventory item detail with an embedded Livewire table component that owns direction and relationship state inside the current detail surface. Preserve existing domain truth, authorization, empty states, and drilldowns, and prove the cleanup through focused feature, Livewire, RBAC, and Filament guard coverage.
|
||||
|
||||
## Technical Context
|
||||
|
||||
**Language/Version**: PHP 8.4.15
|
||||
**Primary Dependencies**: 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
|
||||
**Storage**: PostgreSQL through existing tenant-owned and workspace-context models (`InventoryItem`, `InventoryLink`, `TenantPermission`, `EvidenceSnapshot`, `TenantReview`); no schema change planned
|
||||
**Testing**: Pest feature, Livewire, unit, and existing guard tests run through Laravel Sail; browser smoke only if an implementation detail proves impossible to cover with existing feature or Livewire layers
|
||||
**Target Platform**: Laravel monolith web application under `apps/platform`, spanning tenant-context admin routes under `/admin/t/{tenant}/...`, tenant-specific admin routes under `/admin/tenants/{tenant:external_id}/...`, and workspace-context canonical admin routes under `/admin/...`
|
||||
**Project Type**: web application
|
||||
**Performance Goals**: Preserve DB-only render behavior, keep dependency and evidence rendering free of Graph calls, avoid request-reload control flows, preserve current row-count and summary derivation cost, and avoid introducing extra persistence or polling
|
||||
**Constraints**: No new persistence, no new enum or status family, no new wrapper microframework, no global shell or monitoring-state refactor, no provider or panel registration changes, no weakening of current 404 or 403 semantics, no destructive-action expansion, and no new asset pipeline work
|
||||
**Scale/Scope**: 3 core surfaces, 1 embedded tenant detail micro-surface, 1 tenant workflow page, 1 workspace report page, and a focused verification pack touching roughly 12 existing or new test files; optional extra hits are allowed only if no new architecture question opens
|
||||
|
||||
## 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 | Inventory dependencies and evidence overview remain read-only views over existing inventory and evidence truth. |
|
||||
| Read/write separation | PASS | PASS | The cleanup changes interaction contracts only. Existing follow-up writes remain on their current confirmed destinations. |
|
||||
| Graph contract path | N/A | N/A | No new Graph calls or contract-registry changes are introduced. |
|
||||
| Deterministic capabilities | PASS | PASS | Existing capability registries, tenant access checks, and page authorization remain authoritative. |
|
||||
| Workspace + tenant isolation | PASS | PASS | Tenant required permissions keeps the route tenant authoritative; evidence overview keeps workspace-context entitlement filtering; inventory detail remains tenant-context scoped. |
|
||||
| RBAC-UX authorization semantics | PASS | PASS | Non-members remain `404`, in-scope capability denial remains unchanged, and no new mutation path bypasses server-side authorization. |
|
||||
| Run observability / Ops-UX | PASS | PASS | No new `OperationRun` flow is introduced. Existing run-linked destinations remain unchanged. |
|
||||
| Data minimization | PASS | PASS | No new persisted UI-state mirror or helper artifact is added, and DB-only rendering remains required. |
|
||||
| Proportionality / anti-bloat | PASS | PASS | The design reuses existing Filament patterns and adds no new persistence or generic UI layer. |
|
||||
| UI semantics / few layers | PASS | PASS | The plan maps directly from current domain truth to native UI primitives without a new presenter framework. |
|
||||
| Filament-native UI | PASS | PASS | All three target surfaces move toward native Filament tables, filters, or shared primitives and away from pseudo-native contracts. |
|
||||
| Surface taxonomy / decision-first roles | PASS | PASS | Inventory dependencies remains a secondary context sub-surface; tenant required permissions and evidence overview remain primary decision surfaces. |
|
||||
| Filament v5 / Livewire v4 compliance | PASS | PASS | All touched surfaces remain inside the current Filament v5 + Livewire v4 stack. |
|
||||
| Provider registration location | PASS | PASS | No provider changes are required; Laravel 11+ provider registration remains in `apps/platform/bootstrap/providers.php`. |
|
||||
| Global search hard rule | PASS | PASS | No searchable resource is added or modified. `TenantRequiredPermissions` and `EvidenceOverview` are pages, and inventory resource search behavior is unchanged. |
|
||||
| Destructive action safety | PASS | PASS | No new destructive action is introduced. Existing destructive follow-up actions remain on their current confirmed surfaces. |
|
||||
| Asset strategy | PASS | PASS | No new global or on-demand 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 implementation remains entirely inside Filament v5 + Livewire v4 and does not introduce legacy Filament or Livewire APIs.
|
||||
- **Provider registration location**: No provider changes are required; panel providers remain registered in `apps/platform/bootstrap/providers.php`.
|
||||
- **Global search**: No resource search behavior changes. `InventoryItemResource` already has a view page, but this spec does not change its global-search status. `TenantRequiredPermissions` and `EvidenceOverview` remain pages, not searchable resources.
|
||||
- **Destructive actions**: No new destructive actions are added. Existing linked destinations retain their current confirmation and authorization behavior.
|
||||
- **Asset strategy**: No new assets are planned. Existing deployment handling of `cd apps/platform && php artisan filament:assets` remains sufficient and unchanged.
|
||||
- **Testing plan**: Cover the cleanup through `InventoryItemDependenciesTest`, a new Livewire or table-component dependency test, `TenantRequiredPermissionsTrustedStateTest`, a new required-permissions page-table test, `EvidenceOverviewPageTest`, `EvidenceOverviewDerivedStateMemoizationTest`, and guard coverage such as `FilamentTableStandardsGuardTest` where native table adoption becomes guardable.
|
||||
|
||||
## Phase 0 Research
|
||||
|
||||
Research outcomes are captured in `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/specs/196-hard-filament-nativity-cleanup/research.md`.
|
||||
|
||||
Key decisions:
|
||||
|
||||
- Reuse the repo's existing native page-table pattern from `ReviewRegister` and `InventoryCoverage` for `TenantRequiredPermissions` and `EvidenceOverview`.
|
||||
- Keep `TenantRequiredPermissions` and `EvidenceOverview` on derived data and current services instead of adding new projections, tables, or materialized helper models.
|
||||
- Replace inventory dependency GET-form controls with an embedded Livewire `TableComponent` because the surface is detail-context and not a true relation manager or a standalone page.
|
||||
- Treat query parameters as one-time seed or deeplink inputs only; after mount, native page or component state owns filter interaction.
|
||||
- No additional low-risk same-class hit is confirmed in planning; default implementation scope stays at the three named core surfaces unless implementation audit finds one trivial match that does not widen scope.
|
||||
- Extend existing focused tests and the current Filament table guard where possible instead of introducing a new browser-only verification layer.
|
||||
|
||||
## Phase 1 Design
|
||||
|
||||
Design artifacts are created under `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/specs/196-hard-filament-nativity-cleanup/`:
|
||||
|
||||
- `research.md`: implementation-shape decisions and rejected alternatives for each surface
|
||||
- `data-model.md`: derived UI-state and row-projection models for dependency scope, required-permissions filtering, and evidence overview rows
|
||||
- `contracts/filament-nativity-cleanup.logical.openapi.yaml`: internal logical contract for page state, derived rows, scope rules, and deeplink semantics
|
||||
- `quickstart.md`: implementation and verification sequence for the feature
|
||||
|
||||
Design highlights:
|
||||
|
||||
- `EvidenceOverview` adopts `InteractsWithTable` + `HasTable` and keeps derived rows via a records callback similar to `InventoryCoverage`.
|
||||
- `TenantRequiredPermissions` adopts a native table and native table-owned filter state while keeping summary, copy, and guidance sections above the table body.
|
||||
- Inventory dependencies stays embedded on inventory detail but moves its interactive controls into a dedicated Livewire table component rather than a request-driven Blade fragment.
|
||||
- Existing domain services stay authoritative: dependency rows still come from `DependencyQueryService` and `DependencyTargetResolver`; permission truth still comes from `TenantRequiredPermissionsViewModelBuilder` when an adapter is needed; evidence truth still comes from `ArtifactTruthPresenter` and current snapshot queries.
|
||||
- No new schema, enum, or shared microframework is introduced.
|
||||
|
||||
## Project Structure
|
||||
|
||||
### Documentation (this feature)
|
||||
|
||||
```text
|
||||
specs/196-hard-filament-nativity-cleanup/
|
||||
├── plan.md
|
||||
├── research.md
|
||||
├── data-model.md
|
||||
├── quickstart.md
|
||||
├── spec.md
|
||||
├── contracts/
|
||||
│ └── filament-nativity-cleanup.logical.openapi.yaml
|
||||
└── checklists/
|
||||
└── requirements.md
|
||||
```
|
||||
|
||||
### Source Code (repository root)
|
||||
|
||||
```text
|
||||
apps/platform/
|
||||
├── app/
|
||||
│ ├── Filament/
|
||||
│ │ ├── Pages/
|
||||
│ │ │ ├── TenantRequiredPermissions.php # MODIFY
|
||||
│ │ │ └── Monitoring/
|
||||
│ │ │ └── EvidenceOverview.php # MODIFY
|
||||
│ │ └── Resources/
|
||||
│ │ └── InventoryItemResource.php # MODIFY
|
||||
│ ├── Livewire/
|
||||
│ │ └── InventoryItemDependencyEdgesTable.php # NEW
|
||||
│ └── Services/
|
||||
│ └── Intune/
|
||||
│ └── TenantRequiredPermissionsViewModelBuilder.php # MODIFY or REVIEW FOR ADAPTERS
|
||||
├── resources/
|
||||
│ └── views/
|
||||
│ └── filament/
|
||||
│ ├── components/
|
||||
│ │ └── dependency-edges.blade.php # MODIFY
|
||||
│ └── pages/
|
||||
│ ├── tenant-required-permissions.blade.php # MODIFY
|
||||
│ └── monitoring/
|
||||
│ └── evidence-overview.blade.php # MODIFY
|
||||
└── tests/
|
||||
├── Feature/
|
||||
│ ├── InventoryItemDependenciesTest.php # MODIFY
|
||||
│ ├── Evidence/
|
||||
│ │ └── EvidenceOverviewPageTest.php # MODIFY
|
||||
│ ├── Filament/
|
||||
│ │ ├── EvidenceOverviewDerivedStateMemoizationTest.php # MODIFY
|
||||
│ │ ├── InventoryItemDependencyEdgesTableTest.php # NEW
|
||||
│ │ └── TenantRequiredPermissionsPageTest.php # NEW
|
||||
│ ├── Guards/
|
||||
│ │ └── FilamentTableStandardsGuardTest.php # MODIFY
|
||||
│ └── Rbac/
|
||||
│ └── TenantRequiredPermissionsTrustedStateTest.php # MODIFY
|
||||
└── Unit/
|
||||
├── TenantRequiredPermissionsFilteringTest.php # REUSE
|
||||
├── TenantRequiredPermissionsCopyPayloadTest.php # REUSE
|
||||
├── TenantRequiredPermissionsOverallStatusTest.php # REUSE
|
||||
├── TenantRequiredPermissionsFeatureImpactTest.php # REUSE
|
||||
└── TenantRequiredPermissionsFreshnessTest.php # REUSE
|
||||
```
|
||||
|
||||
**Structure Decision**: Keep the work entirely inside the existing Laravel/Filament monolith under `apps/platform`. Add at most one new Livewire table component for the dependency sub-surface, then modify the three target page or resource files and focused tests. Do not add a new service layer, persistence shape, or cross-surface UI abstraction.
|
||||
|
||||
## Complexity Tracking
|
||||
|
||||
No constitution violation or BLOAT-triggered structural expansion is planned. The feature deliberately avoids new persistence, new enums, new UI taxonomies, or new cross-page infrastructure.
|
||||
|
||||
## Proportionality Review
|
||||
|
||||
Not triggered beyond the spec-level review already completed. The implementation plan adds no new enum, presenter framework, persisted entity, or registry. The narrowest correct implementation is to reuse native Filament tables and one embedded `TableComponent`.
|
||||
|
||||
## Implementation Strategy
|
||||
|
||||
Execution sequence for this plan is test-first at two levels: complete the shared test and guard scaffolding before story work starts, then land each story's focused tests before its implementation changes.
|
||||
|
||||
### Phase 0.5 - Establish shared test and guard scaffolding
|
||||
|
||||
Goal: create the blocking Spec 196 test entry points and shared guard coverage before surface refactors begin.
|
||||
|
||||
Changes:
|
||||
|
||||
- Create the new focused test entry points for the dependency table component and required-permissions page table.
|
||||
- Extend shared guard coverage for new native page-table expectations and faux-control regressions.
|
||||
- Add shared regression coverage for mount-only query seeding versus authoritative scope on required permissions and evidence overview.
|
||||
|
||||
Tests:
|
||||
|
||||
- This phase establishes the focused test harness and is itself the blocking prerequisite for later story delivery.
|
||||
|
||||
### Phase A - Replace the inventory dependency GET form with an embedded Livewire table component
|
||||
|
||||
Goal: keep the dependencies surface on inventory item detail, but move direction and relationship controls into native component state instead of a request-driven Blade fragment.
|
||||
|
||||
Changes:
|
||||
|
||||
- Introduce `App\Livewire\InventoryItemDependencyEdgesTable` as a Filament `TableComponent` that owns direction and relationship filter state.
|
||||
- Keep the surface embedded in the current `InventoryItemResource` detail section rather than moving it to a standalone route or relation manager.
|
||||
- Move the current request-query dependency fetch into the component so the Blade fragment no longer parses `request()` or submits a GET form.
|
||||
- Preserve existing target rendering, missing-target labels, and tenant-isolated dependency resolution through `DependencyQueryService` and `DependencyTargetResolver`.
|
||||
- Keep render-time behavior DB-only and preserve the no-Graph-call guard.
|
||||
|
||||
Tests:
|
||||
|
||||
- Extend the listed story tests before landing implementation changes.
|
||||
- Modify `tests/Feature/InventoryItemDependenciesTest.php` to assert the preserved result logic while removing dependence on manual query-string filter submission.
|
||||
- Add `tests/Feature/Filament/InventoryItemDependencyEdgesTableTest.php` to cover direction changes, relationship narrowing, missing-target rendering, and tenant isolation through the native component.
|
||||
- Reuse existing unit and feature tests around `DependencyQueryService`, `DependencyTargetResolver`, and tenant isolation as domain and safety regression coverage.
|
||||
|
||||
### Phase B - Convert `TenantRequiredPermissions` into a native page-owned table and filter contract
|
||||
|
||||
Goal: remove pseudo-native filter controls while preserving the page's summary, guidance, copy payloads, and tenant-authoritative routing semantics.
|
||||
|
||||
Changes:
|
||||
|
||||
- Add `HasTable` and `InteractsWithTable` to `App\Filament\Pages\TenantRequiredPermissions`.
|
||||
- Replace the manual public filter properties and `updated*()` handlers with native table filters and native table search, using a derived-records callback because permission rows are view-model based rather than Eloquent-backed.
|
||||
- Keep the route tenant authoritative and allow query parameters only to seed initial filter state when the page first mounts.
|
||||
- Keep the summary, copy, and guidance blocks, but derive their values from the same normalized filter state that drives the native table rows.
|
||||
- Preserve the current behavior where copy payloads remain driven by the intended filter dimensions and do not silently widen tenant scope.
|
||||
|
||||
Tests:
|
||||
|
||||
- Extend the listed story tests before landing implementation changes.
|
||||
- Modify `tests/Feature/Rbac/TenantRequiredPermissionsTrustedStateTest.php` to keep route-tenant authority and safe deeplink behavior after native filter adoption.
|
||||
- Add `tests/Feature/Filament/TenantRequiredPermissionsPageTest.php` to cover native filter behavior, summary consistency, and no-results states.
|
||||
- Reuse current unit tests for filtering, freshness, feature impacts, overall status, and copy payload derivation as unchanged domain-truth guards.
|
||||
- Extend `tests/Feature/Guards/FilamentTableStandardsGuardTest.php` if the page becomes subject to shared page-table standards.
|
||||
|
||||
### Phase C - Convert `EvidenceOverview` into a native workspace table
|
||||
|
||||
Goal: remove the hand-built report table and make filtering, empty state, and row inspection native without changing workspace-safe scope behavior.
|
||||
|
||||
Changes:
|
||||
|
||||
- Add `HasTable` and `InteractsWithTable` to `App\Filament\Pages\Monitoring\EvidenceOverview`.
|
||||
- Move row generation out of the Blade table contract and into a native table records callback, following the derived-row pattern already used by `InventoryCoverage`.
|
||||
- Convert the current `tenantFilter` query handling into native filter state seeded from an entitled tenant prefilter only.
|
||||
- Add native table search across tenant-facing row labels.
|
||||
- Keep the existing row inspect destination to tenant evidence detail through a single native inspect model.
|
||||
- Replace the Blade table markup with a page wrapper that renders the native table and keeps any lightweight surrounding layout only if still needed.
|
||||
|
||||
Tests:
|
||||
|
||||
- Extend the listed story tests before landing implementation changes.
|
||||
- Modify `tests/Feature/Evidence/EvidenceOverviewPageTest.php` to assert native table output, native search behavior, workspace safety, entitled-tenant filtering, and current drilldowns.
|
||||
- Modify `tests/Feature/Filament/EvidenceOverviewDerivedStateMemoizationTest.php` to keep DB-only derived-state guarantees after table conversion.
|
||||
- Extend `tests/Feature/Guards/FilamentTableStandardsGuardTest.php` if the new page-owned table should now satisfy shared table standards.
|
||||
|
||||
### Phase D - Verification, guard alignment, and explicit scope stop
|
||||
|
||||
Goal: confirm the cleanup remains bounded to the three core surfaces and that the repo's existing guard layer reflects newly native table surfaces where appropriate.
|
||||
|
||||
Changes:
|
||||
|
||||
- Extend guard coverage only where native table adoption now makes a page eligible for existing table standards.
|
||||
- Run focused Sail verification for the modified feature, RBAC, and guard tests.
|
||||
- Record the release close-out in `specs/196-hard-filament-nativity-cleanup/quickstart.md`, including cleaned surfaces, deferred themes, optional extra hits, and touched follow-up specs.
|
||||
- Document any optional additional same-class hit only if it was truly included; otherwise record that no extra candidate was confirmed.
|
||||
- Stop immediately if implementation reaches shared micro-UI family, monitoring-state, or shell-context architecture.
|
||||
|
||||
Tests:
|
||||
|
||||
- Focused feature and Livewire test pack for the three surfaces.
|
||||
- Existing RBAC and derived-state regression tests retained.
|
||||
- Pint run after touched files are complete.
|
||||
|
||||
## Risk Assessment
|
||||
|
||||
### Risk 1 - Scope creep into shared monitoring or detail-micro-UI architecture
|
||||
|
||||
Mitigation:
|
||||
|
||||
- Keep `EvidenceOverview` limited to native table conversion, not broader monitoring-shell cleanup.
|
||||
- Keep inventory dependencies embedded on the existing detail page and do not generalize a new micro-UI framework.
|
||||
- Reject any additional surface that opens shared-family or shell questions.
|
||||
|
||||
### Risk 2 - Deeplink or initial-state regressions on required permissions and evidence overview
|
||||
|
||||
Mitigation:
|
||||
|
||||
- Treat query values strictly as initial seed state.
|
||||
- Keep route tenant and entitled tenant scope authoritative.
|
||||
- Preserve and extend current trusted-state tests.
|
||||
|
||||
### Risk 3 - Derived-data performance or DB-only regressions after native table adoption
|
||||
|
||||
Mitigation:
|
||||
|
||||
- Reuse the repo's existing derived-records page pattern from `InventoryCoverage`.
|
||||
- Preserve current eager-loading and memoization behavior.
|
||||
- Keep the current no-Graph and DB-only tests in the verification pack.
|
||||
|
||||
### Risk 4 - Over-correcting custom read-only rendering into an unnecessary generic surface
|
||||
|
||||
Mitigation:
|
||||
|
||||
- Keep only the controls and state contract native.
|
||||
- Allow custom read-only cell or row presentation to remain where it carries real domain value.
|
||||
- Avoid relation-manager or standalone-page moves for the dependency section.
|
||||
|
||||
## Implementation Order Recommendation
|
||||
|
||||
1. Establish the shared test and guard scaffolding first so story work starts from the same blocking regression baseline captured in the task plan.
|
||||
2. Replace inventory dependencies second, with the focused story tests landing before the implementation changes.
|
||||
3. Convert `TenantRequiredPermissions` third, again extending the story tests before code changes.
|
||||
4. Convert `EvidenceOverview` fourth, with its focused page and derived-state tests updated before the refactor lands.
|
||||
5. Run the final focused verification pack, formatting, and release close-out last, and only then consider whether any optional same-class extra hit truly qualifies.
|
||||
165
specs/196-hard-filament-nativity-cleanup/quickstart.md
Normal file
165
specs/196-hard-filament-nativity-cleanup/quickstart.md
Normal file
@ -0,0 +1,165 @@
|
||||
# Quickstart: Hard Filament Nativity Cleanup
|
||||
|
||||
## Goal
|
||||
|
||||
Implement Spec 196 by replacing three pseudo-native UI contracts with native Filament or Livewire interaction models while preserving current scope, summaries, empty states, and drilldown behavior.
|
||||
|
||||
## Implementation Sequence
|
||||
|
||||
### 1. Prepare shared test and guard scaffolding
|
||||
|
||||
Touch:
|
||||
|
||||
- `apps/platform/tests/Feature/Filament/InventoryItemDependencyEdgesTableTest.php`
|
||||
- `apps/platform/tests/Feature/Filament/TenantRequiredPermissionsPageTest.php`
|
||||
- `apps/platform/tests/Feature/Guards/FilamentTableStandardsGuardTest.php`
|
||||
- `apps/platform/tests/Feature/Rbac/TenantRequiredPermissionsTrustedStateTest.php`
|
||||
- `apps/platform/tests/Feature/Evidence/EvidenceOverviewPageTest.php`
|
||||
|
||||
Do:
|
||||
|
||||
- create the new focused surface-test entry points before story implementation starts
|
||||
- add the shared guard expectations for new native page-table and faux-control regressions
|
||||
- add the shared mount-only query-seeding regression coverage that later story work depends on
|
||||
|
||||
### 2. Replace the inventory dependency GET form with an embedded `TableComponent`
|
||||
|
||||
Touch:
|
||||
|
||||
- `apps/platform/app/Filament/Resources/InventoryItemResource.php`
|
||||
- `apps/platform/app/Livewire/InventoryItemDependencyEdgesTable.php`
|
||||
- `apps/platform/resources/views/filament/components/dependency-edges.blade.php`
|
||||
|
||||
Do:
|
||||
|
||||
- extend the focused dependency tests before landing implementation changes
|
||||
- embed a native Filament `TableComponent` inside the existing inventory detail section
|
||||
- move direction and relationship state into the component
|
||||
- fetch dependency rows through current dependency services
|
||||
- keep missing-target rendering and target-link behavior intact
|
||||
|
||||
Do not:
|
||||
|
||||
- create a new standalone route for dependencies
|
||||
- convert the surface into a RelationManager
|
||||
- keep `request()` as the primary interaction-state source
|
||||
|
||||
### 3. Convert `TenantRequiredPermissions` to a native page-owned filter and table contract
|
||||
|
||||
Touch:
|
||||
|
||||
- `apps/platform/app/Filament/Pages/TenantRequiredPermissions.php`
|
||||
- `apps/platform/resources/views/filament/pages/tenant-required-permissions.blade.php`
|
||||
- `apps/platform/app/Services/Intune/TenantRequiredPermissionsViewModelBuilder.php` only if a small adapter is needed
|
||||
|
||||
Do:
|
||||
|
||||
- extend the focused required-permissions tests before landing implementation changes
|
||||
- add `HasTable` and `InteractsWithTable`
|
||||
- replace pseudo-native filter controls with native filters and native search
|
||||
- derive the summary, guidance, and copy payload blocks from the same normalized filter state that drives the table rows
|
||||
- keep the route tenant authoritative and allow query values only as initial seed state
|
||||
|
||||
Do not:
|
||||
|
||||
- let query values redefine tenant scope
|
||||
- split the page into a new resource or standalone workflow
|
||||
- introduce a wrapper abstraction that merely hides the old filter bar
|
||||
|
||||
### 4. Convert `EvidenceOverview` to a native page-owned table
|
||||
|
||||
Touch:
|
||||
|
||||
- `apps/platform/app/Filament/Pages/Monitoring/EvidenceOverview.php`
|
||||
- `apps/platform/resources/views/filament/pages/monitoring/evidence-overview.blade.php`
|
||||
|
||||
Do:
|
||||
|
||||
- extend the focused evidence overview tests before landing implementation changes
|
||||
- add `HasTable` and `InteractsWithTable`
|
||||
- move current row construction into a native table records callback
|
||||
- convert the current tenant query prefilter into a native filter seeded from entitled query input only
|
||||
- add native search across tenant-facing row labels
|
||||
- keep row inspect behavior pointed at the existing tenant evidence drilldown
|
||||
- keep empty-state behavior explicit and native
|
||||
|
||||
Do not:
|
||||
|
||||
- introduce a new read model or persistence layer
|
||||
- widen the workspace-context route into a tenant-context route
|
||||
- make remote calls during render
|
||||
|
||||
### 5. Run the final focused verification pack and formatting
|
||||
|
||||
Touch:
|
||||
|
||||
- `apps/platform/tests/Feature/InventoryItemDependenciesTest.php`
|
||||
- `apps/platform/tests/Feature/Rbac/TenantRequiredPermissionsTrustedStateTest.php`
|
||||
- `apps/platform/tests/Feature/Evidence/EvidenceOverviewPageTest.php`
|
||||
- `apps/platform/tests/Feature/Filament/EvidenceOverviewDerivedStateMemoizationTest.php`
|
||||
- `apps/platform/tests/Feature/Filament/InventoryItemDependencyEdgesTableTest.php`
|
||||
- `apps/platform/tests/Feature/Filament/TenantRequiredPermissionsPageTest.php`
|
||||
- `apps/platform/tests/Feature/Guards/FilamentTableStandardsGuardTest.php` if newly applicable
|
||||
|
||||
Do:
|
||||
|
||||
- preserve current scope and authorization assertions
|
||||
- replace GET-form assumptions with native Livewire or table-state assertions
|
||||
- keep DB-only and no-Graph render guarantees
|
||||
- keep unit tests for permission filtering and copy payload logic as domain-truth guards
|
||||
- run the full focused Sail pack and `pint` only after the three story slices are complete
|
||||
|
||||
### 6. Stop on scope boundaries
|
||||
|
||||
If implementation touches any of the following, stop and defer instead of half-solving them here:
|
||||
|
||||
- shared detail micro-UI contract work
|
||||
- monitoring page-state architecture
|
||||
- global context shell behavior
|
||||
- verification report viewer families
|
||||
- diff, settings, restore preview, or enterprise-detail layout families
|
||||
|
||||
### 7. Record the release close-out in this quickstart
|
||||
|
||||
When implementation is complete, update this file with a short close-out note that records:
|
||||
|
||||
- which surfaces were actually cleaned
|
||||
- whether any optional same-class extra hit was included or explicitly rejected
|
||||
- which related themes stayed out of scope and were deferred
|
||||
- which follow-up specs or artifacts were touched
|
||||
|
||||
## Suggested Test Pack
|
||||
|
||||
Run the minimum targeted verification pack through Sail.
|
||||
|
||||
```bash
|
||||
export PATH="/bin:/usr/bin:/usr/local/bin:$PATH"
|
||||
cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/InventoryItemDependenciesTest.php
|
||||
cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Filament/InventoryItemDependencyEdgesTableTest.php
|
||||
cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Rbac/TenantRequiredPermissionsTrustedStateTest.php
|
||||
cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Filament/TenantRequiredPermissionsPageTest.php
|
||||
cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Evidence/EvidenceOverviewPageTest.php
|
||||
cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Filament/EvidenceOverviewDerivedStateMemoizationTest.php
|
||||
cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Guards/FilamentTableStandardsGuardTest.php
|
||||
cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Unit/TenantRequiredPermissionsFilteringTest.php
|
||||
cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Unit/TenantRequiredPermissionsCopyPayloadTest.php
|
||||
cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Unit/TenantRequiredPermissionsOverallStatusTest.php
|
||||
cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Unit/TenantRequiredPermissionsFeatureImpactTest.php
|
||||
cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Unit/TenantRequiredPermissionsFreshnessTest.php
|
||||
cd apps/platform && ./vendor/bin/sail bin pint --dirty --format agent
|
||||
```
|
||||
|
||||
## Manual Smoke Checklist
|
||||
|
||||
1. Open an inventory item detail page and confirm dependency direction and relationship changes happen without a foreign apply-and-reload workflow.
|
||||
2. Open tenant required permissions and confirm the filter surface feels native, while summary counts, guidance, and copy flows remain correct.
|
||||
3. Open evidence overview and confirm the table behaves like a native Filament report with clear empty state and row inspect behavior.
|
||||
4. Confirm no cleaned surface leaks scope through query manipulation.
|
||||
5. Confirm no implementation expanded into monitoring-state, shell, or shared micro-UI redesign work.
|
||||
|
||||
## Deployment Notes
|
||||
|
||||
- No migration is expected.
|
||||
- No provider registration change is expected.
|
||||
- No new assets are expected.
|
||||
- Existing `cd apps/platform && php artisan filament:assets` deployment handling remains sufficient and unchanged.
|
||||
90
specs/196-hard-filament-nativity-cleanup/research.md
Normal file
90
specs/196-hard-filament-nativity-cleanup/research.md
Normal file
@ -0,0 +1,90 @@
|
||||
# Research: Hard Filament Nativity Cleanup
|
||||
|
||||
## Decision: Reuse the repo's existing native page-table pattern for `EvidenceOverview` and `TenantRequiredPermissions`
|
||||
|
||||
### Rationale
|
||||
|
||||
The codebase already has two strong native examples for page-owned tables outside normal resource index pages: `ReviewRegister` and `InventoryCoverage`. Both use `InteractsWithTable`, `HasTable`, native Filament filters, Filament-managed filter state, native empty states, and one consistent inspect model. That makes them the narrowest repo-consistent replacement for the two current page-level bypasses.
|
||||
|
||||
`EvidenceOverview` is currently a hand-built Blade report table, and `TenantRequiredPermissions` is currently a custom page with pseudo-native filter controls. Both are better modeled as page-owned native tables than as bespoke Blade contracts.
|
||||
|
||||
### Alternatives considered
|
||||
|
||||
- Keep the current Blade table and filter bars, but restyle them more convincingly: rejected because that preserves the separate contract instead of removing it.
|
||||
- Move either surface into a Resource or RelationManager: rejected because both already have correct route and page ownership; only their internal interaction model is wrong.
|
||||
|
||||
## Decision: Keep both page-level surfaces on derived data instead of adding new projections or schema
|
||||
|
||||
### Rationale
|
||||
|
||||
`EvidenceOverview` rows are already derived from `EvidenceSnapshot`, `TenantReview`, and `ArtifactTruthPresenter`, while `TenantRequiredPermissions` rows and summaries are already derived through `TenantRequiredPermissionsViewModelBuilder`. The current product truth is sufficient. The problem is not missing data infrastructure, but the non-native way the data is exposed.
|
||||
|
||||
Using derived table records keeps the implementation proportional and avoids importing persistence or a second source of truth for UI state.
|
||||
|
||||
### Alternatives considered
|
||||
|
||||
- Add dedicated read models or materialized projections for overview rows: rejected because the spec is cleanup, not reporting-architecture expansion.
|
||||
- Convert the permission or evidence pages into query-first Eloquent resources: rejected because the current derived summaries and guidance would still need a second layer and would not simplify the domain.
|
||||
|
||||
## Decision: Replace inventory dependency GET controls with an embedded Livewire `TableComponent`
|
||||
|
||||
### Rationale
|
||||
|
||||
The dependency surface is not a standalone page and not a true Eloquent relationship that should become a RelationManager. It is a detail-context sub-surface inside inventory item view. The narrowest native replacement is therefore an embedded Livewire `TableComponent` that owns direction and relationship state, renders native filters, and stays inside the current inventory detail section.
|
||||
|
||||
The repo already uses Filament `TableComponent` in `BackupSetPolicyPickerTable`, which proves the pattern is acceptable and reusable here.
|
||||
|
||||
### Alternatives considered
|
||||
|
||||
- Convert the dependency section into a RelationManager: rejected because dependency edges are query-driven, not a direct relationship manager surface.
|
||||
- Move dependencies to a new standalone page: rejected because it would break the current inspect-one-record workflow and widen scope.
|
||||
- Keep a custom Blade fragment with `wire:model` on raw inputs: rejected because that still leaves a pseudo-native control surface instead of a real native table contract.
|
||||
|
||||
## Decision: Query parameters may seed initial state, but they do not remain the authoritative interaction contract
|
||||
|
||||
### Rationale
|
||||
|
||||
Both `TenantRequiredPermissions` and `EvidenceOverview` have valid deeplink or workflow-continuity reasons to accept initial query values. The spec explicitly allows that. What needs to change is ongoing ownership of page-body state. After first mount, filter state must live in native page or component state rather than continuing to be reconstructed from `request()` on every interaction.
|
||||
|
||||
This preserves existing deeplink behavior without letting query values become a shadow state system.
|
||||
|
||||
### Alternatives considered
|
||||
|
||||
- Remove all query seeding entirely: rejected because the current product does rely on deeplink and continuity behavior.
|
||||
- Keep query parameters as the main contract forever: rejected because that is the bypass pattern the spec exists to remove.
|
||||
|
||||
## Decision: Preserve custom read-only presentation where it carries domain value, but make control state native
|
||||
|
||||
### Rationale
|
||||
|
||||
The spec is not a repo-wide custom Blade purge. Some read-only rendering still carries useful domain formatting, especially for dependency target badges, missing-target hints, permission guidance blocks, and evidence explanation text. The actual harm sits in fake controls, manual GET submission, and hand-built primary table contracts.
|
||||
|
||||
The narrowest implementation therefore replaces the primary control and table contracts while allowing domain-specific read-only cells or layout blocks to remain when they do not create a second state system.
|
||||
|
||||
### Alternatives considered
|
||||
|
||||
- Force every touched surface into generic Filament markup only: rejected because it risks over-correction and would expand scope into broader micro-UI standardization.
|
||||
- Leave custom presentation and custom control markup mixed together: rejected because it would keep the core nativity problem alive.
|
||||
|
||||
## Decision: No additional same-class low-risk hit is confirmed during planning
|
||||
|
||||
### Rationale
|
||||
|
||||
The planning audit found the three target surfaces clearly. It did not identify a fourth candidate that is both obviously the same problem class and clearly small enough to include without opening shared-family or shell questions. That means the safe default is to keep the implementation scope locked to the three named surfaces and only admit an extra hit if implementation discovers a truly trivial match.
|
||||
|
||||
### Alternatives considered
|
||||
|
||||
- Expand planning scope now to include visually similar custom Blade surfaces elsewhere in monitoring or verification: rejected because those families carry broader architecture and product-semantics questions already marked out of scope.
|
||||
|
||||
## Decision: Extend existing focused tests and guardrails rather than introducing a new browser-centric verification layer
|
||||
|
||||
### Rationale
|
||||
|
||||
The repo already has meaningful coverage for all three areas: dependency rendering and tenant isolation, required-permissions trusted-state behavior and view-model derivation, and evidence overview authorization and DB-only rendering. The cleanup should lean on that existing coverage, then add only the missing surface-level native-table or component assertions.
|
||||
|
||||
This keeps the feature aligned with `TEST-TRUTH-001` and avoids creating a heavier verification framework than the change requires.
|
||||
|
||||
### Alternatives considered
|
||||
|
||||
- Add a new browser suite for all three surfaces as the primary proof: rejected because most required outcomes are already testable with feature and Livewire tests.
|
||||
- Rely only on manual smoke checks: rejected because the repo rules require automated coverage for changed behavior.
|
||||
250
specs/196-hard-filament-nativity-cleanup/spec.md
Normal file
250
specs/196-hard-filament-nativity-cleanup/spec.md
Normal file
@ -0,0 +1,250 @@
|
||||
# Feature Specification: Hard Filament Nativity Cleanup
|
||||
|
||||
**Feature Branch**: `[196-hard-filament-nativity-cleanup]`
|
||||
**Created**: 2026-04-13
|
||||
**Status**: Proposed
|
||||
**Input**: User description: "Spec 196 - Hard Filament Nativity Cleanup"
|
||||
|
||||
## Spec Candidate Check *(mandatory - SPEC-GATE-001)*
|
||||
|
||||
- **Problem**: Three active admin surfaces signal native Filament behavior but still run on separate UI contracts: a GET-form dependency filter inside inventory detail, a plain-HTML filter bar on required permissions, and a hand-built evidence report table.
|
||||
- **Today's failure**: Operators hit inconsistent filter behavior, apply-and-reload interaction, request-driven body state, and bespoke empty-state or navigation semantics inside surfaces that otherwise live in Filament and Livewire.
|
||||
- **User-visible improvement**: Dependency review, permission follow-up, and evidence review feel like the rest of the admin product, with fewer foreign workflows and less hidden state drift.
|
||||
- **Smallest enterprise-capable version**: Clean only the three confirmed bypass surfaces and only the parts that create the non-native contract; keep larger shell, monitoring-state, verification-report, and shared micro-UI families out of scope.
|
||||
- **Explicit non-goals**: No global context-shell redesign, no monitoring page-state architecture rewrite, no repo-wide custom Blade purge, no special visualization rework, no badge-only polish sweep, and no new CI guardrail, review-enforcement, or constitution framework in this spec.
|
||||
- **Permanent complexity imported**: Focused surface refactors, targeted regression coverage, and one close-out note. No new models, tables, enums, abstractions, or cross-surface UI framework are introduced.
|
||||
- **Why now**: These are already active operator surfaces with real maintenance and consistency cost, and they are the clearest low-dispute cleanup targets before later specs touch larger UI families.
|
||||
- **Why not local**: The harm comes from the same problem class repeating across multiple live surfaces. One-off cosmetic edits would leave the same parallel contracts and drift pattern intact.
|
||||
- **Approval class**: Cleanup
|
||||
- **Red flags triggered**: One mild red flag: multiple surfaces are touched in one spec. This is justified because all included surfaces share the same unnecessary nativity bypass and remain bounded to three concrete entry points plus optional same-class low-risk extras.
|
||||
- **Score**: Nutzen: 2 | Dringlichkeit: 2 | Scope: 2 | Komplexitat: 1 | Produktnahe: 2 | Wiederverwendung: 2 | **Gesamt: 11/12**
|
||||
- **Decision**: approve
|
||||
|
||||
## Spec Scope Fields *(mandatory)*
|
||||
|
||||
- **Scope**: tenant + workspace canonical-view cleanup
|
||||
- **Primary Routes**:
|
||||
- `/admin/t/{tenant}/inventory/inventory-items/{record}`
|
||||
- `/admin/tenants/{tenant:external_id}/required-permissions`
|
||||
- `/admin/evidence/overview`
|
||||
- **Data Ownership**: Inventory dependencies continue to read tenant-owned inventory items and dependency edges in tenant context. Tenant required permissions continues to read tenant-owned permission verification truth and provider guidance for a single tenant. Evidence overview continues to read tenant-owned evidence snapshots inside a workspace-context route. This spec adds no new persistence and does not move ownership boundaries.
|
||||
- **RBAC**: Inventory dependencies stays under tenant-context inventory detail and keeps existing tenant membership plus tenant entitlement requirements. Tenant required permissions keeps workspace and tenant entitlement, preserves route-tenant authority, and remains deny-as-not-found for non-members. Evidence overview remains workspace-context, still requires workspace membership, and must only reveal entitled tenant rows and drilldowns.
|
||||
|
||||
For canonical-view specs, the spec MUST define:
|
||||
|
||||
- **Default filter behavior when tenant-context is active**: Evidence overview may honor an entitled tenant prefilter for deeplink or workflow continuity, but it remains a workspace-context page and must not silently redefine scope from unrelated tenant-like query values.
|
||||
- **Explicit entitlement checks preventing cross-tenant leakage**: Evidence overview rows, filters, and row drilldowns must resolve only within the current workspace and the viewer's entitled tenant set. Unauthorized tenant ids must not reveal rows, row counts, or drilldown targets.
|
||||
|
||||
## 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 |
|
||||
|---|---|---|---|---|---|---|---|
|
||||
| Inventory item dependencies section | Secondary Context Surface | While inspecting one inventory item, decide whether a related object explains impact or follow-up | Current inventory item context, dependency direction scope, relationship family, matching edges, missing-target markers | Linked target pages, raw references, last-known-name hints | Not primary because the operator's main decision remains about the current inventory item detail | Follows inspect-one-record workflow instead of creating a side workflow | Removes apply-and-reload detours inside detail view |
|
||||
| Tenant required permissions | Primary Decision Surface | Decide whether tenant consent or verification follow-up is required and what action to take next | Overall state, freshness, missing application vs delegated counts, active filters, matching permission rows | Copy payloads, consent guidance, provider-connection destination | Primary because the page itself answers what permission action is next for this tenant | Follows tenant permission follow-up workflow instead of request-parameter reconstruction | Keeps filter state and guidance in one page-owned contract |
|
||||
| Evidence overview | Primary Decision Surface | Decide which tenant's evidence needs refresh or review next | Tenant, artifact truth, freshness, burden, next step, inspect affordance | Tenant evidence detail, deeper snapshot explanation, row-specific follow-up context | Primary because it is the workspace evidence review list where operators choose the next follow-up target | Follows workspace evidence-review workflow instead of bespoke report markup | Native table behavior reduces bespoke scanning, empty-state, and drilldown rules |
|
||||
|
||||
## 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 |
|
||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
||||
| Inventory item dependencies section | Detail / Inspect | Detail-first resource sub-surface | Open a dependency target or change dependency scope | Current inventory item detail with linked dependency targets | forbidden | Inline non-destructive section controls only | none | `/admin/t/{tenant}/inventory/inventory-items/{record}` | Same route plus linked target destinations | Active tenant, current inventory item, dependency direction, relationship scope | Inventory item dependencies / dependency edge | Current record context, relationship family, missing-target state | Embedded detail micro-surface remains custom for domain-specific read-only edge rendering, but not for primary controls |
|
||||
| Tenant required permissions | List / Guidance / Diagnostic | List-only read-first workflow page | Grant consent, rerun verification, or narrow the current filter state | Inline page itself | forbidden | Safe guidance and copy actions remain secondary in page sections or header | none | `/admin/tenants/{tenant:external_id}/required-permissions` | Same page | Current workspace, current tenant, freshness, active filters | Required permissions / permission row | Overall readiness, freshness, missing counts, active filter state | Permission rows remain an inline review matrix rather than a separate inspect route |
|
||||
| Evidence overview | List / Table / Report | Read-only registry report | Open tenant evidence for the row that needs attention | Full-row inspect into tenant evidence detail | required | Header clear-filter action only; any safe secondary action stays clearly secondary | none | `/admin/evidence/overview` | Tenant evidence snapshot view for the selected row | Current workspace, entitled-tenant filter, artifact truth, freshness | Evidence overview / evidence snapshot | Artifact truth, freshness, burden, next step | 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 |
|
||||
|---|---|---|---|---|---|---|---|---|---|---|
|
||||
| Inventory item dependencies section | Tenant operator reviewing one inventory item | Decide whether related objects explain the current item and which target to inspect next | Detail micro-surface | Which dependencies matter for this item right now? | Current direction and relationship scope, grouped edges, missing-target markers, target badges | Raw references, last-known names, deeper target detail | relationship direction, relationship type, target availability | none; read-only inspect flow | Change direction scope, change relationship scope, open target | none |
|
||||
| Tenant required permissions | Tenant operator or tenant manager | Decide whether consent, delegated follow-up, or verification rerun is needed | Read-first workflow page | What permission gap blocks this tenant right now and what should happen next? | Overall state, freshness, counts, active filters, matching permission rows | Copy payload detail, consent guidance, provider-connection management destination | overall readiness, freshness, permission status, permission type | read-only page with outbound follow-up links; no new mutation starts on this page | Adjust filters, open consent guidance, rerun verification, manage provider connection | none introduced by this spec |
|
||||
| Evidence overview | Workspace operator | Decide which tenant evidence snapshot needs review or refresh next | Workspace report table | Which tenant needs evidence follow-up right now? | Tenant row, artifact truth, freshness, burden, next step, inspect affordance | Deeper snapshot explanation inside tenant evidence detail | artifact truth, freshness, evidence burden | none; read-only drilldown | Change filters, open tenant evidence row | none |
|
||||
|
||||
## Proportionality Review *(mandatory when structural complexity is introduced)*
|
||||
|
||||
- **New source of truth?**: no
|
||||
- **New persisted entity/table/artifact?**: no
|
||||
- **New abstraction?**: no
|
||||
- **New enum/state/reason family?**: no
|
||||
- **New cross-domain UI framework/taxonomy?**: no
|
||||
- **Current operator problem**: Active admin surfaces inside existing Filament and Livewire context still bypass native primitives, forcing operators and maintainers to juggle extra contracts for simple filters and tables.
|
||||
- **Existing structure is insufficient because**: The current harm comes from the mismatch itself. These surfaces already live in Filament and Livewire, so keeping plain HTML control contracts, request-driven state, or hand-built report tables preserves avoidable drift rather than solving a domain gap.
|
||||
- **Narrowest correct implementation**: Convert only the three clear bypasses and only the parts that create the non-native contract. Keep legitimate custom read-only presentation and larger shell, monitoring-state, and shared-family questions out of scope.
|
||||
- **Ownership cost**: Bounded surface refactors, focused tests, and one close-out note. No new domain model, state family, or UI framework is introduced.
|
||||
- **Alternative intentionally rejected**: A repo-wide Filament purity sweep, a global shell or state redesign, or wrapper abstractions that merely hide the same non-native contract.
|
||||
- **Release truth**: current-release cleanup that removes existing drift before later specs tackle larger UI families
|
||||
|
||||
## User Scenarios & Testing *(mandatory)*
|
||||
|
||||
### User Story 1 - Review Dependencies Without A Foreign Workflow (Priority: P1)
|
||||
|
||||
While inspecting an inventory item, an operator can change dependency scope and understand the resulting edges without submitting a separate GET form or feeling like the detail page has switched into a different mini app.
|
||||
|
||||
**Why this priority**: The inventory detail page already owns the current record context. A foreign interaction model inside that detail page directly harms comprehension and confidence.
|
||||
|
||||
**Independent Test**: Can be tested by opening an inventory item detail page, changing dependency direction and relationship scope, and verifying that the same matching edges, missing-target markers, and empty states appear without a manual apply-and-reload contract.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** an inventory item with inbound and outbound edges, **When** the operator changes dependency direction, **Then** the visible edge set updates within the current detail surface without a separate GET apply workflow.
|
||||
2. **Given** an inventory item with multiple relationship families, **When** the operator narrows relationship scope, **Then** only matching edges remain and the current record context stays intact.
|
||||
3. **Given** an inventory item with no edges for the selected scope, **When** the operator applies that scope, **Then** the surface shows the same no-results meaning as today without losing tenant or record context.
|
||||
|
||||
---
|
||||
|
||||
### User Story 2 - Filter Required Permissions In One Native Page Contract (Priority: P1)
|
||||
|
||||
On the tenant required-permissions page, an operator can adjust status, type, feature, and search state through one native interaction contract while preserving the current tenant, guidance, copy flows, and verification follow-up paths.
|
||||
|
||||
**Why this priority**: This page is already a live operator decision surface. If its primary controls remain pseudo-native, the page keeps teaching a separate contract for a core admin workflow.
|
||||
|
||||
**Independent Test**: Can be tested by loading the required-permissions page with and without deeplink query values, adjusting filters live, and verifying that the route tenant stays authoritative while results, counts, and copy payloads remain correct.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** a tenant required-permissions page with stored verification data, **When** the operator changes status, type, feature, or search state, **Then** the matching permission rows, counts, and related guidance update without a separate plain-HTML filter bar contract.
|
||||
2. **Given** deeplink query values for status, type, or search, **When** the page first loads, **Then** the page may seed initial state from the deeplink while keeping the route tenant authoritative.
|
||||
3. **Given** tenant-like query values that point at a different tenant, **When** the page loads for the current tenant route, **Then** the current route tenant remains the only authoritative tenant scope.
|
||||
|
||||
---
|
||||
|
||||
### User Story 3 - Review Evidence Through A Native Workspace Table (Priority: P2)
|
||||
|
||||
On the evidence overview, a workspace operator can scan, filter, and open the next tenant evidence item through a native table surface with consistent empty-state and row-inspect behavior.
|
||||
|
||||
**Why this priority**: The page is clearly a tabular workspace review surface. Keeping it as a hand-built report table preserves bespoke behavior where native table semantics are a better fit.
|
||||
|
||||
**Independent Test**: Can be tested by loading the workspace evidence overview with multiple entitled tenants, applying an entitled tenant prefilter, and verifying that rows, empty state, and drilldown behavior remain workspace-safe while the page behaves like a native table surface.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** multiple entitled tenant evidence rows, **When** the operator opens the overview, **Then** the page renders them through one native table contract with the expected columns, inspect model, and empty-state rules.
|
||||
2. **Given** an entitled tenant prefilter, **When** the operator applies or clears it, **Then** only the authorized rows remain in scope and row drilldown stays workspace-safe.
|
||||
3. **Given** a user without workspace membership, **When** that user requests the evidence overview, **Then** the route remains deny-as-not-found.
|
||||
|
||||
### Edge Cases
|
||||
|
||||
- Dependency edges may resolve to missing targets; fallback labels, missing-target markers, and helpful hints must remain intact after the control contract changes.
|
||||
- Tenant required permissions may open from a deeplink with initial filter state; the deeplink may seed state, but it must not redefine authoritative tenant scope or remain the page's ongoing state source.
|
||||
- Evidence overview may receive an unauthorized tenant prefilter; the page must not leak that tenant's existence through rows, counts, or drilldown affordances.
|
||||
- Evidence overview may have no rows in the current scope; the replacement table surface must preserve a clear empty state and a single safe recovery action.
|
||||
- If an apparently similar surface expands into shared detail micro-UI, monitoring-state, context-shell, diff viewer, or verification-report architecture, that work must stop and be deferred instead of being half-cleaned here.
|
||||
|
||||
## Requirements *(mandatory)*
|
||||
|
||||
**Constitution alignment (required):** This feature changes three existing operator-facing admin surfaces only. It introduces no new Microsoft Graph endpoint family, no new write workflow, and no new queued or scheduled run. Existing audit, preview, confirmation, and run-observability rules remain authoritative for the destinations these pages may already link to.
|
||||
|
||||
**Constitution alignment (PROP-001 / ABSTR-001 / PERSIST-001 / STATE-001 / BLOAT-001):** No new persistence, abstraction, or state family is introduced. The bias is replacement before layering: remove pseudo-native contracts and use native existing primitives rather than adding wrapper infrastructure.
|
||||
|
||||
**Constitution alignment (OPS-UX):** Not applicable. This cleanup does not create or repurpose an `OperationRun`.
|
||||
|
||||
**Constitution alignment (RBAC-UX):** The feature spans tenant-context admin routes under `/admin/t/{tenant}/...`, a tenant-specific admin route under `/admin/tenants/{tenant:external_id}/required-permissions`, and the workspace-context canonical route `/admin/evidence/overview`. Non-members remain `404`. In-scope members keep current capability and entitlement rules. Tenant required permissions must keep the route tenant authoritative. Evidence overview must continue to suppress unauthorized tenant rows and remain deny-as-not-found when workspace membership is absent. No new destructive action is introduced.
|
||||
|
||||
**Constitution alignment (OPS-EX-AUTH-001):** Not applicable. Authentication handshake behavior is unchanged.
|
||||
|
||||
**Constitution alignment (BADGE-001):** Existing badge semantics remain centralized. The cleanup must not introduce page-local status languages or bespoke badge mappings for dependency state, permission state, or evidence state.
|
||||
|
||||
**Constitution alignment (UI-FIL-001):** Native Filament forms, filters, tables, actions, and existing shared UI primitives must replace pseudo-native primary controls and table contracts where they are an appropriate fit. Local markup may remain only for domain-specific read-only content cells and must not recreate fake controls or a second state contract.
|
||||
|
||||
**Constitution alignment (UI-NAMING-001):** Operator-facing vocabulary remains consistent across labels, empty states, actions, and follow-up copy: `Dependencies`, `Direction`, `Relationship`, `Required permissions`, `Status`, `Type`, `Search`, `Evidence overview`, `Artifact truth`, `Freshness`, and `Next step` stay stable and are not replaced by implementation-first terms.
|
||||
|
||||
**Constitution alignment (DECIDE-001):** Inventory dependencies remains a secondary context surface attached to inventory detail. Tenant required permissions and evidence overview remain primary decision surfaces. Each must keep the first decision visible without cross-page reconstruction and avoid making the default experience larger or noisier than it is today.
|
||||
|
||||
**Constitution alignment (UI-CONST-001 / UI-SURF-001 / ACTSURF-001 / UI-HARD-001 / UI-EX-001 / UI-REVIEW-001 / HDR-001):** The affected surfaces remain one embedded detail micro-surface, one read-first workflow page, and one read-only workspace report. Each keeps one primary inspect model, keeps safe secondary actions clearly secondary, and does not open a hidden shell or cross-page-state refactor in this spec.
|
||||
|
||||
**Constitution alignment (ACTSURF-001 - action hierarchy):** No destructive actions are added. Evidence overview keeps clear filters separate from inspect. Tenant required permissions keeps filter controls separate from copy and external-guidance actions. Inventory dependencies keeps scope controls separate from target inspection.
|
||||
|
||||
**Constitution alignment (OPSURF-001):** Default-visible content remains operator-first: dependency scope and edges on inventory detail, permission counts and matching rows on required permissions, and truth, freshness, burden, and next step on evidence overview. Diagnostics remain explicitly secondary.
|
||||
|
||||
**Constitution alignment (UI-SEM-001 / LAYER-001 / TEST-TRUTH-001):** Direct mapping from existing domain truth to UI remains sufficient. The cleanup must not introduce a presenter framework, wrapper layer, or second semantics system just to hide raw HTML controls or a custom table contract. Tests focus on user-visible behavior, scope safety, and contract removal.
|
||||
|
||||
**Constitution alignment (Filament Action Surfaces):** The Action Surface Contract remains satisfied. A UI Action Matrix is included below. Each affected surface keeps one primary inspect or open model, redundant `View` actions are absent, empty placeholder action groups are absent, and destructive action placement rules remain satisfied because no destructive actions are introduced. UI-FIL-001 is satisfied, with limited exceptions only for custom read-only content rendering inside inventory dependencies and the required-permissions matrix.
|
||||
|
||||
**Constitution alignment (UX-001 - Layout & Information Architecture):** Tenant required permissions and inventory detail remain section-based, view-first surfaces; their cleanup must remove naked pseudo-controls without forcing a broader page redesign. Evidence overview must provide native table search, filters, row inspection, and a clear empty state. No wider layout re-architecture is in scope.
|
||||
|
||||
### Functional Requirements
|
||||
|
||||
- **FR-196-001**: The inventory dependencies surface on inventory item detail MUST NOT use a GET form with raw HTML select and button elements as its primary interaction surface.
|
||||
- **FR-196-002**: Inventory dependency direction and relationship scope MUST live in a native page-owned or component-owned state model within the current detail page and MUST update the result set without a separate apply-and-reload workflow.
|
||||
- **FR-196-003**: The inventory dependency fragment MUST NOT derive its primary interaction state from `request()` or manual query parsing inside the Blade fragment.
|
||||
- **FR-196-004**: Inventory dependency cleanup MUST preserve the current functional outcome: direction options, relationship narrowing, edge resolution, missing-target handling, empty-state meaning, and current-record context stay equivalent.
|
||||
- **FR-196-005**: Inventory dependency cleanup MUST preserve tenant scoping, record scoping, linked-target safety, and existing authorization behavior.
|
||||
- **FR-196-006**: The tenant required-permissions page MUST NOT use plain HTML controls styled as fake native inputs for its primary status, type, feature, or search controls.
|
||||
- **FR-196-007**: Tenant required-permissions filter state MUST be expressed through one native page-owned form or filter contract that matches the surrounding admin experience.
|
||||
- **FR-196-008**: Query parameters on tenant required permissions MAY seed deeplink or initial state, but they MUST NOT redefine the authoritative route tenant or remain the page's primary body-state contract after initial load.
|
||||
- **FR-196-009**: Tenant required permissions MUST preserve current functional depth, including overview counts, freshness messaging, feature narrowing, copy payload support, guidance links, and permission-row filtering.
|
||||
- **FR-196-010**: Tenant required-permissions cleanup MUST NOT introduce a replacement wrapper pattern that merely restyles raw controls or recreates a second mini contract outside native page state.
|
||||
- **FR-196-011**: Evidence overview MUST replace the hand-built primary report table with a native table surface that expresses columns, filters, empty state, and row inspection using native table semantics.
|
||||
- **FR-196-012**: Evidence overview MUST provide one consistent inspect or open model for authorized rows and MUST preserve the current workspace-safe drilldown into tenant evidence.
|
||||
- **FR-196-013**: Evidence overview MUST remove manual page-body query and Blade wiring that exists only because the report table is hand-built, while preserving entitled tenant prefilter behavior.
|
||||
- **FR-196-014**: Evidence overview MUST preserve workspace boundary enforcement, entitled-tenant filtering, and deny-as-not-found behavior for users outside the workspace boundary.
|
||||
- **FR-196-015**: Any additional cleanup hit included under this spec MUST share the same unnecessary nativity bypass, remain low to medium complexity, add no new product semantics, and avoid shared-family, shell, monitoring-state, and special-visualization work.
|
||||
- **FR-196-016**: Any discovered related surface that crosses into shared detail micro-UI, monitoring state, context shell, verification report, diff or settings viewer, restore preview or result layouts, or other declared non-goal families MUST be documented and deferred instead of partially refactored here.
|
||||
- **FR-196-017**: This cleanup MUST NOT introduce a new wrapper microframework, presenter layer, or cross-page UI abstraction whose main purpose is to hide the same non-native contract.
|
||||
- **FR-196-018**: Each cleaned surface MUST remain operatorically at least as clear as before, with no loss of empty-state meaning, next-step clarity, scope signals, or inspect navigation.
|
||||
- **FR-196-019**: Release close-out MUST list which surfaces were actually cleaned, which optional same-class low-risk hits were included, which related themes remained out of scope, and which follow-up specs were touched.
|
||||
|
||||
## 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 |
|
||||
|---|---|---|---|---|---|---|---|---|---|---|
|
||||
| Inventory item dependencies section | `/admin/t/{tenant}/inventory/inventory-items/{record}` | none added by this spec | Linked dependency target inside the section; no separate row menu | Open dependency target | none | none | Existing inventory item view header actions remain unchanged | n/a | no new audit event | Embedded detail sub-surface. Action Surface Contract remains satisfied because one inspect model exists for linked targets and no destructive actions are added. Native controls replace the GET/apply contract. |
|
||||
| Tenant required permissions | `/admin/tenants/{tenant:external_id}/required-permissions` | none required; one safe native reset action is allowed if needed | Inline review matrix; no per-row inspect destination | none | none | State-specific reset or re-run verification CTA as appropriate | n/a | n/a | no new audit event | Inline workflow exemption remains legitimate. Copy payload and guidance actions stay secondary and non-destructive. Native filter contract replaces pseudo controls. |
|
||||
| Evidence overview | `/admin/evidence/overview` | `Clear filters` when a prefilter is active | Full-row inspect into tenant evidence detail | none | none | `Clear filters` | n/a | n/a | no new audit event | Action Surface Contract remains satisfied with one primary inspect model, no redundant `View` row action, and no destructive action. Native table semantics replace the bespoke report table. |
|
||||
|
||||
### Key Entities *(include if feature involves data)*
|
||||
|
||||
- **Dependency edge filter state**: The current direction and relationship scope bound to one inventory item detail context.
|
||||
- **Required permissions filter state**: The current status, type, selected features, and search state for one tenant's required-permissions workflow page.
|
||||
- **Evidence overview row projection**: The workspace-scoped summary row for one entitled tenant, including artifact truth, freshness, burden, next step, and inspect destination.
|
||||
- **Cleanup admission candidate**: A discovered extra surface that may only be included when it matches the same low-risk nativity-bypass problem class.
|
||||
|
||||
## Success Criteria *(mandatory)*
|
||||
|
||||
### Measurable Outcomes
|
||||
|
||||
- **SC-196-001**: Focused release validation and regression coverage pass for all three core surfaces with preserved scope safety, empty-state meaning, and result logic.
|
||||
- **SC-196-002**: On all three core surfaces, operators can change the primary in-scope controls or inspect targets without relying on a separate GET apply workflow or a request-driven page-body contract.
|
||||
- **SC-196-003**: Evidence overview presents 100% of authorized rows through one native table inspect model, and zero hand-built primary report tables remain within the boundaries of this spec.
|
||||
- **SC-196-004**: Release validation finds zero primary plain HTML control surfaces on the three core pages whose only purpose is to imitate native admin controls.
|
||||
- **SC-196-005**: Deeplink and prefilter behaviors continue to work for the targeted routes without allowing unauthorized tenant scope changes or cross-tenant row leakage.
|
||||
- **SC-196-006**: Final close-out documentation explicitly records completed surfaces, deferred related themes, and any optional extra hits that were admitted under the shared rule.
|
||||
|
||||
## Assumptions
|
||||
|
||||
- Current domain semantics for dependency direction, relationship type, permission status, freshness, artifact truth, and evidence drilldown remain authoritative; this spec changes interaction contracts, not domain meaning.
|
||||
- Inventory dependencies may keep domain-specific read-only edge rendering as long as primary controls and state ownership become native.
|
||||
- Tenant required permissions may keep inline diagnostic content and guidance blocks as long as the primary filter contract becomes native.
|
||||
- Evidence overview can adopt native table semantics without reopening broader monitoring information architecture questions.
|
||||
- Optional extra hits are not required for success and may be omitted entirely if no low-risk candidate qualifies.
|
||||
|
||||
## Non-Goals
|
||||
|
||||
- Global context bar or workspace or tenant shell redesign
|
||||
- Monitoring operations tab or page-state contract redesign
|
||||
- Audit log selected-record or inspect duality cleanup
|
||||
- Finding exceptions queue dual-inspect cleanup
|
||||
- Baseline compare matrix or other special visualization surfaces
|
||||
- Verification report viewer family or onboarding verification report variants
|
||||
- Normalized diff, normalized settings, or other large detail micro-UI families
|
||||
- Restore preview, restore results, or enterprise-detail read-only layout rework
|
||||
- Raw anchor-to-component link consistency sweeps
|
||||
- Badge-only, banner-only, or style-only polish work
|
||||
- New constitution rules, new CI guardrail frameworks, or broad review-enforcement programs
|
||||
|
||||
## Dependencies
|
||||
|
||||
- Existing inventory dependency resolution and rendered-target services remain the authoritative source for dependency result logic.
|
||||
- Existing tenant required-permissions view-model building remains the authoritative source for counts, row filtering, copy payloads, and guidance content.
|
||||
- Existing evidence snapshot truth and row drilldown destinations remain the authoritative domain truth for evidence overview rows.
|
||||
- Existing workspace-selection, tenant entitlement, and route-boundary rules remain authoritative and must be preserved by the cleanup.
|
||||
- Follow-up specs for shared detail micro-UI, monitoring page-state, global context shell, UI constitution extension, and enforcement guardrails remain separate work and are not absorbed here.
|
||||
|
||||
## Definition of Done
|
||||
|
||||
- Inventory dependencies, tenant required permissions, and evidence overview are cleaned within the scope defined above.
|
||||
- None of the three core surfaces relies primarily on fake native controls or a request-driven page-body contract.
|
||||
- Evidence overview is no longer a hand-built primary report table.
|
||||
- Tests covering the targeted functional and authorization behavior pass.
|
||||
- Manual smoke checks confirm that dependency review, permission follow-up, and evidence review still feel clear and correct.
|
||||
- No out-of-scope shell, monitoring-state, shared-family, or special-visualization topic is half-solved under this spec.
|
||||
- Close-out documentation records completed work, deliberate deferrals, and any admitted same-class extra hits.
|
||||
166
specs/196-hard-filament-nativity-cleanup/tasks.md
Normal file
166
specs/196-hard-filament-nativity-cleanup/tasks.md
Normal file
@ -0,0 +1,166 @@
|
||||
---
|
||||
description: "Task list for Spec 196 hard Filament nativity cleanup implementation"
|
||||
---
|
||||
|
||||
# Tasks: Hard Filament Nativity Cleanup
|
||||
|
||||
**Input**: Design documents from `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/specs/196-hard-filament-nativity-cleanup/`
|
||||
**Prerequisites**: `plan.md`, `spec.md`, `research.md`, `data-model.md`, `quickstart.md`, `contracts/filament-nativity-cleanup.logical.openapi.yaml`
|
||||
|
||||
**Tests**: Runtime behavior changes on existing Filament v5 / Livewire v4 operator surfaces require Pest feature, Livewire, RBAC, unit, and guard coverage. This task list adds or extends only the focused tests needed for the three in-scope surfaces.
|
||||
**Operations**: This cleanup does not introduce new queued work or `OperationRun` flows. Existing linked follow-up paths remain unchanged.
|
||||
**RBAC**: Tenant-context, route-tenant, workspace-membership, and entitled-tenant boundaries remain authoritative. Non-members stay `404`, and no new destructive action is added.
|
||||
**UI Naming**: Keep existing operator terms stable: `Dependencies`, `Direction`, `Relationship`, `Required permissions`, `Status`, `Type`, `Search`, `Evidence overview`, `Artifact truth`, `Freshness`, and `Next step`.
|
||||
**Filament UI Action Surfaces**: The feature replaces pseudo-native controls and a hand-built report table with native Filament or Livewire contracts without changing the current inspect destinations or adding new actions.
|
||||
**Proportionality / Anti-Bloat**: Stay inside the three named surfaces plus one embedded `TableComponent`. Do not add new persistence, enums, presenters, or shared UI frameworks.
|
||||
|
||||
## Phase 1: Setup (Shared Review Inputs)
|
||||
|
||||
**Purpose**: Confirm the exact implementation entry points, native reference patterns, and focused regression baselines before editing the three in-scope surfaces.
|
||||
|
||||
- [ ] T001 Audit the current nativity-bypass entry points and native reference implementations in `apps/platform/app/Filament/Resources/InventoryItemResource.php`, `apps/platform/resources/views/filament/components/dependency-edges.blade.php`, `apps/platform/app/Filament/Pages/TenantRequiredPermissions.php`, `apps/platform/resources/views/filament/pages/tenant-required-permissions.blade.php`, `apps/platform/app/Filament/Pages/Monitoring/EvidenceOverview.php`, `apps/platform/resources/views/filament/pages/monitoring/evidence-overview.blade.php`, `apps/platform/app/Filament/Pages/Reviews/ReviewRegister.php`, `apps/platform/app/Filament/Pages/InventoryCoverage.php`, and `apps/platform/app/Livewire/BackupSetPolicyPickerTable.php`
|
||||
- [ ] T002 [P] Audit the focused regression baselines in `apps/platform/tests/Feature/InventoryItemDependenciesTest.php`, `apps/platform/tests/Feature/Rbac/TenantRequiredPermissionsTrustedStateTest.php`, `apps/platform/tests/Feature/Evidence/EvidenceOverviewPageTest.php`, `apps/platform/tests/Feature/Filament/EvidenceOverviewDerivedStateMemoizationTest.php`, `apps/platform/tests/Unit/TenantRequiredPermissionsFilteringTest.php`, `apps/platform/tests/Unit/TenantRequiredPermissionsOverallStatusTest.php`, `apps/platform/tests/Unit/TenantRequiredPermissionsFeatureImpactTest.php`, `apps/platform/tests/Unit/TenantRequiredPermissionsFreshnessTest.php`, `apps/platform/tests/Unit/TenantRequiredPermissionsCopyPayloadTest.php`, and `apps/platform/tests/Feature/Guards/FilamentTableStandardsGuardTest.php`
|
||||
|
||||
---
|
||||
|
||||
## Phase 2: Foundational (Blocking Prerequisites)
|
||||
|
||||
**Purpose**: Create the shared Spec 196 test and guard scaffolding that all three surface refactors depend on.
|
||||
|
||||
**CRITICAL**: No user story work should begin until this phase is complete.
|
||||
|
||||
- [ ] T003 [P] Create the new Spec 196 surface-test entry points in `apps/platform/tests/Feature/Filament/InventoryItemDependencyEdgesTableTest.php` and `apps/platform/tests/Feature/Filament/TenantRequiredPermissionsPageTest.php`
|
||||
- [ ] T004 [P] Review and, if newly applicable, extend shared native-table guard coverage for Spec 196 page-owned tables and faux-control regressions in `apps/platform/tests/Feature/Guards/FilamentTableStandardsGuardTest.php`
|
||||
- [ ] T005 [P] Add shared regression coverage for mount-only query seeding versus authoritative scope in `apps/platform/tests/Feature/Rbac/TenantRequiredPermissionsTrustedStateTest.php` and `apps/platform/tests/Feature/Evidence/EvidenceOverviewPageTest.php`
|
||||
|
||||
**Checkpoint**: The shared Spec 196 test harness is in place, and later surface work can prove native state ownership without reopening scope or guard assumptions.
|
||||
|
||||
---
|
||||
|
||||
## Phase 3: User Story 1 - Review Dependencies Without A Foreign Workflow (Priority: P1) MVP
|
||||
|
||||
**Goal**: Keep inventory dependencies embedded on inventory item detail while replacing the GET apply contract with native component-owned state.
|
||||
|
||||
**Independent Test**: Open an inventory item detail page, change dependency direction and relationship scope, and verify that the same matching edges, missing-target markers, and empty states appear without a manual GET apply workflow.
|
||||
|
||||
### Tests for User Story 1
|
||||
|
||||
> **NOTE**: Write these tests first and confirm they fail before implementation.
|
||||
|
||||
- [ ] T006 [P] [US1] Extend `apps/platform/tests/Feature/InventoryItemDependenciesTest.php` with native component-state expectations for direction changes, relationship narrowing, empty states, and preserved target safety
|
||||
- [ ] T007 [P] [US1] Add Livewire table-component coverage in `apps/platform/tests/Feature/Filament/InventoryItemDependencyEdgesTableTest.php` for mount state, filter updates, missing-target rendering, and tenant isolation
|
||||
|
||||
### Implementation for User Story 1
|
||||
|
||||
- [ ] T008 [US1] Create `apps/platform/app/Livewire/InventoryItemDependencyEdgesTable.php` as an embedded Filament `TableComponent` that owns direction and relationship state and queries rows through the current dependency services
|
||||
- [ ] T009 [US1] Update `apps/platform/app/Filament/Resources/InventoryItemResource.php` and `apps/platform/resources/views/filament/components/dependency-edges.blade.php` to mount the embedded table component and remove the GET-form / `request()`-driven control contract while preserving target links, badges, and missing-target hints
|
||||
|
||||
**Checkpoint**: User Story 1 is complete when inventory detail keeps the same dependency meaning and target safety without switching operators into a foreign apply-and-reload workflow.
|
||||
|
||||
---
|
||||
|
||||
## Phase 4: User Story 2 - Filter Required Permissions In One Native Page Contract (Priority: P1)
|
||||
|
||||
**Goal**: Make tenant required permissions use one native page-owned filter and table contract while preserving route-tenant authority, summaries, guidance, and copy payloads.
|
||||
|
||||
**Independent Test**: Load the required-permissions page with and without deeplink query values, adjust filters live, and verify that the route tenant stays authoritative while rows, counts, guidance, and copy payloads remain correct.
|
||||
|
||||
### Tests for User Story 2
|
||||
|
||||
> **NOTE**: Write these tests first and confirm they fail before implementation.
|
||||
|
||||
- [ ] T010 [P] [US2] Extend `apps/platform/tests/Feature/Rbac/TenantRequiredPermissionsTrustedStateTest.php` for route-tenant authority, query-seeded status/type/search/features state, and ignored foreign-tenant query values
|
||||
- [ ] T011 [P] [US2] Add native page-table coverage in `apps/platform/tests/Feature/Filament/TenantRequiredPermissionsPageTest.php` for filter updates, search, summary consistency, guidance visibility, copy payload continuity, and no-results states
|
||||
- [ ] T012 [P] [US2] Keep filter-normalization, overall-status, feature-impact, freshness, and copy-payload invariants aligned in `apps/platform/tests/Unit/TenantRequiredPermissionsFilteringTest.php`, `apps/platform/tests/Unit/TenantRequiredPermissionsOverallStatusTest.php`, `apps/platform/tests/Unit/TenantRequiredPermissionsFeatureImpactTest.php`, `apps/platform/tests/Unit/TenantRequiredPermissionsFreshnessTest.php`, and `apps/platform/tests/Unit/TenantRequiredPermissionsCopyPayloadTest.php`
|
||||
|
||||
### Implementation for User Story 2
|
||||
|
||||
- [ ] T013 [US2] Convert `apps/platform/app/Filament/Pages/TenantRequiredPermissions.php` to `HasTable` / `InteractsWithTable` with native filters, native search, and mount-only query seeding
|
||||
- [ ] T014 [US2] Align `apps/platform/resources/views/filament/pages/tenant-required-permissions.blade.php` and, if needed, `apps/platform/app/Services/Intune/TenantRequiredPermissionsViewModelBuilder.php` so summary counts, freshness, feature impacts, guidance, and copy payloads are derived from the same normalized native table state
|
||||
|
||||
**Checkpoint**: User Story 2 is complete when required permissions behaves like one native Filament page without losing tenant authority, summary clarity, or follow-up guidance.
|
||||
|
||||
---
|
||||
|
||||
## Phase 5: User Story 3 - Review Evidence Through A Native Workspace Table (Priority: P2)
|
||||
|
||||
**Goal**: Replace the hand-built evidence report table with a native workspace table that preserves entitled-tenant filtering, clear empty states, and one inspect model.
|
||||
|
||||
**Independent Test**: Load the workspace evidence overview with multiple entitled tenants, apply and clear an entitled tenant prefilter, and verify that rows, empty state, and drilldown behavior remain workspace-safe while the page behaves like a native table surface.
|
||||
|
||||
### Tests for User Story 3
|
||||
|
||||
> **NOTE**: Write these tests first and confirm they fail before implementation.
|
||||
|
||||
- [ ] T015 [P] [US3] Extend `apps/platform/tests/Feature/Evidence/EvidenceOverviewPageTest.php` for native table rendering, native search behavior, entitled-tenant seed and clear behavior, workspace-safe row drilldown, empty states, and deny-as-not-found enforcement
|
||||
- [ ] T016 [P] [US3] Extend `apps/platform/tests/Feature/Filament/EvidenceOverviewDerivedStateMemoizationTest.php` and, if newly applicable, `apps/platform/tests/Feature/Guards/FilamentTableStandardsGuardTest.php` for DB-only derived-row rendering and the new page-owned native table contract
|
||||
|
||||
### Implementation for User Story 3
|
||||
|
||||
- [ ] T017 [US3] Convert `apps/platform/app/Filament/Pages/Monitoring/EvidenceOverview.php` to `HasTable` / `InteractsWithTable` with derived row callbacks, native filter and search state, entitled-tenant query seeding, and one inspect model
|
||||
- [ ] T018 [US3] Replace the hand-built report table in `apps/platform/resources/views/filament/pages/monitoring/evidence-overview.blade.php` with a native table wrapper that preserves the clear-filter affordance and current drilldown copy
|
||||
|
||||
**Checkpoint**: User Story 3 is complete when evidence overview reads like one native workspace review table without leaking unauthorized tenant scope or losing the current drilldown path.
|
||||
|
||||
---
|
||||
|
||||
## Phase 6: Polish & Cross-Cutting Verification
|
||||
|
||||
**Purpose**: Run the focused verification pack, format the touched files, and record the final bounded scope outcome for Spec 196.
|
||||
|
||||
- [ ] T019 Run the focused Spec 196 Sail verification pack from `specs/196-hard-filament-nativity-cleanup/quickstart.md` against `apps/platform/tests/Feature/InventoryItemDependenciesTest.php`, `apps/platform/tests/Feature/Filament/InventoryItemDependencyEdgesTableTest.php`, `apps/platform/tests/Feature/Rbac/TenantRequiredPermissionsTrustedStateTest.php`, `apps/platform/tests/Feature/Filament/TenantRequiredPermissionsPageTest.php`, `apps/platform/tests/Feature/Evidence/EvidenceOverviewPageTest.php`, `apps/platform/tests/Feature/Filament/EvidenceOverviewDerivedStateMemoizationTest.php`, `apps/platform/tests/Feature/Guards/FilamentTableStandardsGuardTest.php`, `apps/platform/tests/Unit/TenantRequiredPermissionsFilteringTest.php`, `apps/platform/tests/Unit/TenantRequiredPermissionsOverallStatusTest.php`, `apps/platform/tests/Unit/TenantRequiredPermissionsFeatureImpactTest.php`, `apps/platform/tests/Unit/TenantRequiredPermissionsFreshnessTest.php`, and `apps/platform/tests/Unit/TenantRequiredPermissionsCopyPayloadTest.php`
|
||||
- [ ] T020 Run `cd apps/platform && ./vendor/bin/sail bin pint --dirty --format agent` and resolve formatting issues in the changed files under `apps/platform/app/`, `apps/platform/resources/views/filament/`, and `apps/platform/tests/`
|
||||
- [ ] T021 Execute the manual smoke checklist in `specs/196-hard-filament-nativity-cleanup/quickstart.md` across the three cleaned surfaces and capture any sign-off notes needed for release close-out
|
||||
- [ ] T022 Record the Spec 196 release close-out in `specs/196-hard-filament-nativity-cleanup/quickstart.md` with the final cleaned surfaces, any optional same-class extra hit decision, deferred themes, and touched follow-up specs
|
||||
- [ ] T023 Verify the final close-out note in `specs/196-hard-filament-nativity-cleanup/quickstart.md` and the contract-modeled consumers, invariants, and non-goals in `specs/196-hard-filament-nativity-cleanup/contracts/filament-nativity-cleanup.logical.openapi.yaml` remain aligned with the implemented scope
|
||||
|
||||
---
|
||||
|
||||
## Dependencies
|
||||
|
||||
- Setup tasks T001-T002 precede all implementation work.
|
||||
- Foundational tasks T003-T005 block all user stories.
|
||||
- User Story 1 depends on Phase 2 and is the recommended MVP cut.
|
||||
- User Story 2 depends on Phase 2 and can proceed after User Story 1 or in parallel once the shared guard and seed-state scaffolding are stable.
|
||||
- User Story 3 depends on Phase 2 and should land after the shared guard scaffolding is stable so the new page-owned table contract is enforced consistently.
|
||||
- Polish tasks T019-T023 depend on all selected user stories being complete.
|
||||
|
||||
## Parallel Execution Examples
|
||||
|
||||
- After T001, run T002 in parallel with any remaining setup review.
|
||||
- In Phase 2, T003, T004, and T005 can run in parallel.
|
||||
- In User Story 1, T006 and T007 can run in parallel.
|
||||
- In User Story 2, T010, T011, and T012 can run in parallel.
|
||||
- In User Story 3, T015 and T016 can run in parallel.
|
||||
|
||||
## Parallel Example: User Story 1
|
||||
|
||||
```bash
|
||||
# Parallel test pass for US1
|
||||
T006 Extend inventory dependency regression coverage
|
||||
T007 Add Livewire table-component coverage
|
||||
```
|
||||
|
||||
## Parallel Example: User Story 2
|
||||
|
||||
```bash
|
||||
# Parallel test pass for US2
|
||||
T010 Extend trusted-state authority coverage
|
||||
T011 Add native required-permissions page-table coverage
|
||||
T012 Keep required-permissions unit invariants aligned
|
||||
```
|
||||
|
||||
## Parallel Example: User Story 3
|
||||
|
||||
```bash
|
||||
# Parallel test pass for US3
|
||||
T015 Extend evidence overview page coverage
|
||||
T016 Extend memoization and guard coverage
|
||||
```
|
||||
|
||||
## Implementation Strategy
|
||||
|
||||
- Start with Phase 1 and Phase 2 so the native-table guard and new surface-test entry points are ready before any refactor lands.
|
||||
- Deliver User Story 1 first as the MVP because it removes the most obvious foreign workflow inside an existing detail page with the least scope spill.
|
||||
- Deliver User Story 2 next to normalize the second P1 surface and prove route-tenant authority still wins over deeplink state.
|
||||
- Finish with User Story 3 once the shared table guard is stable, then run the focused Sail pack and Pint formatting from Phase 6.
|
||||
@ -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.
|
||||
@ -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
|
||||
239
specs/202-governance-subject-taxonomy/data-model.md
Normal file
239
specs/202-governance-subject-taxonomy/data-model.md
Normal 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.
|
||||
225
specs/202-governance-subject-taxonomy/plan.md
Normal file
225
specs/202-governance-subject-taxonomy/plan.md
Normal 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.
|
||||
90
specs/202-governance-subject-taxonomy/quickstart.md
Normal file
90
specs/202-governance-subject-taxonomy/quickstart.md
Normal 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.
|
||||
102
specs/202-governance-subject-taxonomy/research.md
Normal file
102
specs/202-governance-subject-taxonomy/research.md
Normal 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.
|
||||
300
specs/202-governance-subject-taxonomy/spec.md
Normal file
300
specs/202-governance-subject-taxonomy/spec.md
Normal 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.
|
||||
246
specs/202-governance-subject-taxonomy/tasks.md
Normal file
246
specs/202-governance-subject-taxonomy/tasks.md
Normal 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 story’s focused verification before moving to the next priority.
|
||||
|
||||
### Parallel Opportunities
|
||||
|
||||
- `T002` and `T003` can run in parallel after `T001`.
|
||||
- `T004`, `T005`, and `T006` can run in parallel before `T007` through `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.
|
||||
Loading…
Reference in New Issue
Block a user