Compare commits
4 Commits
195-action
...
dev
| Author | SHA1 | Date | |
|---|---|---|---|
| d644265d30 | |||
| 7541b1eb41 | |||
| a2a42d4e5f | |||
| 1c291fb9fe |
14
.github/agents/copilot-instructions.md
vendored
14
.github/agents/copilot-instructions.md
vendored
@ -174,6 +174,14 @@ ## Active Technologies
|
||||
- PHP 8.4.15 + Laravel 12, Filament v5, Livewire v4, Pest v4, Tailwind CSS v4, existing `OperateHubShell`, `CanonicalNavigationContext`, `CanonicalAdminTenantFilterState`, `UiEnforcement`, `ActionSurfaceValidator`, and Filament page or resource action builders (193-monitoring-action-hierarchy)
|
||||
- PostgreSQL through existing workspace-owned and tenant-owned models; no schema change planned (193-monitoring-action-hierarchy)
|
||||
- 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 + Laravel 12, Filament v5, Livewire v4, Pest v4, Laravel Sail, existing `BaselineCompareService`, `CompareBaselineToTenantJob`, `SubjectResolver`, `CurrentStateHashResolver`, `DriftHasher`, `BaselineCompareSummaryAssessor`, and finding lifecycle services (203-baseline-compare-strategy)
|
||||
- PostgreSQL via existing baseline snapshots, baseline snapshot items, `operation_runs`, findings, and baseline scope JSON; no new top-level tables planned (203-baseline-compare-strategy)
|
||||
|
||||
- PHP 8.4.15 (feat/005-bulk-operations)
|
||||
|
||||
@ -208,8 +216,8 @@ ## Code Style
|
||||
PHP 8.4.15: Follow standard conventions
|
||||
|
||||
## Recent Changes
|
||||
- 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`)
|
||||
- 193-monitoring-action-hierarchy: Added PHP 8.4.15 + Laravel 12, Filament v5, Livewire v4, Pest v4, Tailwind CSS v4, existing `OperateHubShell`, `CanonicalNavigationContext`, `CanonicalAdminTenantFilterState`, `UiEnforcement`, `ActionSurfaceValidator`, and Filament page or resource action builders
|
||||
- 192-record-header-discipline: Added PHP 8.4.15 + Laravel 12, Filament v5, Livewire v4, Pest v4, Tailwind CSS v4, existing `UiEnforcement`, `RelatedNavigationResolver`, `ActionSurfaceValidator`, and page-local Filament action builders
|
||||
- 203-baseline-compare-strategy: Added PHP 8.4.15 + Laravel 12, Filament v5, Livewire v4, Pest v4, Laravel Sail, existing `BaselineCompareService`, `CompareBaselineToTenantJob`, `SubjectResolver`, `CurrentStateHashResolver`, `DriftHasher`, `BaselineCompareSummaryAssessor`, and finding lifecycle services
|
||||
- 202-governance-subject-taxonomy: Added PHP 8.4.15 + Laravel 12, Filament v5, Livewire v4, Pest v4, Tailwind CSS v4, Laravel Sail, existing `BaselineScope`, `InventoryPolicyTypeMeta`, `BaselineSupportCapabilityGuard`, `BaselineCaptureService`, and `BaselineCompareService`
|
||||
- 196-hard-filament-nativity-cleanup: Added PHP 8.4.15 + Laravel 12, Filament v5, Livewire v4, Pest v4, Tailwind CSS v4, existing `DependencyQueryService`, `DependencyTargetResolver`, `TenantRequiredPermissionsViewModelBuilder`, `ArtifactTruthPresenter`, `WorkspaceContext`, Filament `InteractsWithTable`, Filament `TableComponent`, and existing badge and action-surface helpers
|
||||
<!-- MANUAL ADDITIONS START -->
|
||||
<!-- MANUAL ADDITIONS END -->
|
||||
|
||||
8
.github/skills/giteaflow/SKILL.md
vendored
Normal file
8
.github/skills/giteaflow/SKILL.md
vendored
Normal file
@ -0,0 +1,8 @@
|
||||
---
|
||||
name: giteaflow
|
||||
description: Describe what this skill does and when to use it. Include keywords that help agents identify relevant tasks.
|
||||
---
|
||||
|
||||
<!-- Tip: Use /create-skill in chat to generate content with agent assistance -->
|
||||
|
||||
comit all changes, push to remote, and create a pull request against dev with gitea mcp
|
||||
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,
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -381,17 +381,23 @@ private function compareNowAction(): Action
|
||||
|
||||
if (! ($result['ok'] ?? false)) {
|
||||
$reasonCode = is_string($result['reason_code'] ?? null) ? (string) $result['reason_code'] : 'unknown';
|
||||
$translation = is_array($result['reason_translation'] ?? null) ? $result['reason_translation'] : [];
|
||||
|
||||
$message = match ($reasonCode) {
|
||||
\App\Support\Baselines\BaselineReasonCodes::COMPARE_ROLLOUT_DISABLED => 'Full-content baseline compare is currently disabled for controlled rollout.',
|
||||
\App\Support\Baselines\BaselineReasonCodes::COMPARE_PROFILE_NOT_ACTIVE => 'The assigned baseline profile is not active.',
|
||||
\App\Support\Baselines\BaselineReasonCodes::COMPARE_NO_ACTIVE_SNAPSHOT,
|
||||
\App\Support\Baselines\BaselineReasonCodes::COMPARE_NO_CONSUMABLE_SNAPSHOT => 'No complete baseline snapshot is currently available for compare.',
|
||||
\App\Support\Baselines\BaselineReasonCodes::COMPARE_SNAPSHOT_BUILDING => 'The latest baseline capture is still building. Compare will be available after it completes.',
|
||||
\App\Support\Baselines\BaselineReasonCodes::COMPARE_SNAPSHOT_INCOMPLETE => 'The latest baseline capture is incomplete. Capture a new baseline before comparing.',
|
||||
\App\Support\Baselines\BaselineReasonCodes::COMPARE_SNAPSHOT_SUPERSEDED => 'A newer complete baseline snapshot is current. Compare uses the latest complete baseline only.',
|
||||
default => 'Reason: '.$reasonCode,
|
||||
};
|
||||
$message = is_string($translation['short_explanation'] ?? null) && trim((string) $translation['short_explanation']) !== ''
|
||||
? trim((string) $translation['short_explanation'])
|
||||
: match ($reasonCode) {
|
||||
\App\Support\Baselines\BaselineReasonCodes::COMPARE_ROLLOUT_DISABLED => 'Full-content baseline compare is currently disabled for controlled rollout.',
|
||||
\App\Support\Baselines\BaselineReasonCodes::COMPARE_PROFILE_NOT_ACTIVE => 'The assigned baseline profile is not active.',
|
||||
\App\Support\Baselines\BaselineReasonCodes::COMPARE_NO_ACTIVE_SNAPSHOT,
|
||||
\App\Support\Baselines\BaselineReasonCodes::COMPARE_NO_CONSUMABLE_SNAPSHOT => 'No complete baseline snapshot is currently available for compare.',
|
||||
\App\Support\Baselines\BaselineReasonCodes::COMPARE_SNAPSHOT_BUILDING => 'The latest baseline capture is still building. Compare will be available after it completes.',
|
||||
\App\Support\Baselines\BaselineReasonCodes::COMPARE_SNAPSHOT_INCOMPLETE => 'The latest baseline capture is incomplete. Capture a new baseline before comparing.',
|
||||
\App\Support\Baselines\BaselineReasonCodes::COMPARE_SNAPSHOT_SUPERSEDED => 'A newer complete baseline snapshot is current. Compare uses the latest complete baseline only.',
|
||||
\App\Support\Baselines\BaselineReasonCodes::COMPARE_INVALID_SCOPE => 'The assigned baseline scope is invalid and must be reviewed before compare can start.',
|
||||
\App\Support\Baselines\BaselineReasonCodes::COMPARE_UNSUPPORTED_SCOPE => 'The selected governed subjects are not supported by any compare strategy.',
|
||||
\App\Support\Baselines\BaselineReasonCodes::COMPARE_MIXED_SCOPE => 'The selected governed subjects span multiple compare strategy families and must be narrowed before compare can start.',
|
||||
default => 'Reason: '.$reasonCode,
|
||||
};
|
||||
|
||||
Notification::make()
|
||||
->title('Cannot start comparison')
|
||||
|
||||
@ -14,6 +14,8 @@
|
||||
use App\Services\Baselines\BaselineCompareService;
|
||||
use App\Support\Auth\Capabilities;
|
||||
use App\Support\Baselines\BaselineCompareMatrixBuilder;
|
||||
use App\Support\Baselines\BaselineReasonCodes;
|
||||
use App\Support\Baselines\Compare\CompareStrategyRegistry;
|
||||
use App\Support\Navigation\CanonicalNavigationContext;
|
||||
use App\Support\OperationRunLinks;
|
||||
use App\Support\OpsUx\OperationUxPresenter;
|
||||
@ -246,7 +248,22 @@ protected function getHeaderActions(): array
|
||||
->requireCapability(Capabilities::WORKSPACE_BASELINES_MANAGE)
|
||||
->preserveDisabled()
|
||||
->tooltip('You need workspace baseline manage access to compare the visible assigned set.')
|
||||
->apply();
|
||||
->apply()
|
||||
->tooltip(function (): ?string {
|
||||
$user = auth()->user();
|
||||
$workspace = $this->workspace();
|
||||
|
||||
if ($user instanceof User && $workspace instanceof Workspace) {
|
||||
/** @var WorkspaceCapabilityResolver $resolver */
|
||||
$resolver = app(WorkspaceCapabilityResolver::class);
|
||||
|
||||
if ($resolver->isMember($user, $workspace) && ! $resolver->can($user, $workspace, Capabilities::WORKSPACE_BASELINES_MANAGE)) {
|
||||
return 'You need workspace baseline manage access to compare the visible assigned set.';
|
||||
}
|
||||
}
|
||||
|
||||
return $this->compareAssignedTenantsDisabledReason();
|
||||
});
|
||||
|
||||
return [
|
||||
Action::make('backToBaselineProfile')
|
||||
@ -616,9 +633,41 @@ private function compareAssignedTenantsDisabledReason(): ?string
|
||||
return 'No visible assigned tenants are available for compare.';
|
||||
}
|
||||
|
||||
return $this->compareStartReasonMessage($this->compareAssignedTenantsReasonCode());
|
||||
}
|
||||
|
||||
private function compareAssignedTenantsReasonCode(): ?string
|
||||
{
|
||||
try {
|
||||
$scope = $this->getRecord()->normalizedScope();
|
||||
} catch (\Throwable) {
|
||||
return BaselineReasonCodes::COMPARE_INVALID_SCOPE;
|
||||
}
|
||||
|
||||
$selection = app(CompareStrategyRegistry::class)->select($scope);
|
||||
|
||||
if ($selection->isMixed()) {
|
||||
return BaselineReasonCodes::COMPARE_MIXED_SCOPE;
|
||||
}
|
||||
|
||||
if (! $selection->isSupported()) {
|
||||
return BaselineReasonCodes::COMPARE_UNSUPPORTED_SCOPE;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private function compareStartReasonMessage(?string $reasonCode): ?string
|
||||
{
|
||||
return match ($reasonCode) {
|
||||
BaselineReasonCodes::COMPARE_INVALID_SCOPE => 'The assigned baseline scope is invalid and must be reviewed before comparing assigned tenants.',
|
||||
BaselineReasonCodes::COMPARE_UNSUPPORTED_SCOPE => 'The selected governed subjects are not supported by any compare strategy yet.',
|
||||
BaselineReasonCodes::COMPARE_MIXED_SCOPE => 'The selected governed subjects span multiple compare strategy families and must be narrowed before comparing assigned tenants.',
|
||||
'tenant_sync_required' => 'You need tenant sync access for each visible tenant before compare can start.',
|
||||
default => null,
|
||||
};
|
||||
}
|
||||
|
||||
private function compareAssignedTenants(): void
|
||||
{
|
||||
$user = auth()->user();
|
||||
@ -639,6 +688,15 @@ private function compareAssignedTenants(): void
|
||||
(int) $result['visibleAssignedTenantCount'],
|
||||
(int) $result['visibleAssignedTenantCount'] === 1 ? '' : 's',
|
||||
);
|
||||
$blockedReasonCodes = collect($result['targets'])
|
||||
->where('launchState', 'blocked')
|
||||
->pluck('reasonCode')
|
||||
->filter(static fn (mixed $reasonCode): bool => is_string($reasonCode) && trim($reasonCode) !== '')
|
||||
->unique()
|
||||
->values();
|
||||
$blockedReasonMessage = $blockedReasonCodes->count() === 1
|
||||
? $this->compareStartReasonMessage((string) $blockedReasonCodes->first())
|
||||
: null;
|
||||
|
||||
if ((int) $result['queuedCount'] > 0 || (int) $result['alreadyQueuedCount'] > 0) {
|
||||
OpsUxBrowserEvents::dispatchRunEnqueued($this);
|
||||
@ -661,7 +719,7 @@ private function compareAssignedTenants(): void
|
||||
} else {
|
||||
Notification::make()
|
||||
->title('No baseline compares were started')
|
||||
->body($summary)
|
||||
->body($blockedReasonMessage !== null ? $blockedReasonMessage.' '.$summary : $summary)
|
||||
->warning()
|
||||
->send();
|
||||
}
|
||||
|
||||
@ -20,10 +20,12 @@
|
||||
use App\Support\Badges\BadgeCatalog;
|
||||
use App\Support\Badges\BadgeDomain;
|
||||
use App\Support\Badges\BadgeRenderer;
|
||||
use App\Support\Baselines\BaselineScope;
|
||||
use App\Support\Baselines\BaselineCaptureMode;
|
||||
use App\Support\Baselines\BaselineFullContentRolloutGate;
|
||||
use App\Support\Baselines\BaselineProfileStatus;
|
||||
use App\Support\Baselines\BaselineReasonCodes;
|
||||
use App\Support\Baselines\Compare\CompareStrategyRegistry;
|
||||
use App\Support\Filament\FilterOptionCatalog;
|
||||
use App\Support\Inventory\InventoryPolicyTypeMeta;
|
||||
use App\Support\Navigation\CrossResourceNavigationMatrix;
|
||||
@ -51,9 +53,11 @@
|
||||
use Filament\Notifications\Notification;
|
||||
use Filament\Resources\Resource;
|
||||
use Filament\Schemas\Components\Section;
|
||||
use Filament\Schemas\Components\Utilities\Get;
|
||||
use Filament\Schemas\Schema;
|
||||
use Filament\Tables\Columns\TextColumn;
|
||||
use Filament\Tables\Table;
|
||||
use InvalidArgumentException;
|
||||
use Illuminate\Auth\Access\AuthorizationException;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
@ -216,12 +220,14 @@ public static function form(Schema $schema): Schema
|
||||
->label('Policy types')
|
||||
->multiple()
|
||||
->options(self::policyTypeOptions())
|
||||
->live()
|
||||
->helperText('Leave empty to include all supported policy types (excluding foundations).')
|
||||
->native(false),
|
||||
Select::make('scope_jsonb.foundation_types')
|
||||
->label('Foundations')
|
||||
->multiple()
|
||||
->options(self::foundationTypeOptions())
|
||||
->live()
|
||||
->helperText('Leave empty to exclude foundations. Select foundations to include them.')
|
||||
->native(false),
|
||||
Placeholder::make('metadata')
|
||||
@ -232,6 +238,26 @@ public static function form(Schema $schema): Schema
|
||||
->visible(fn (?BaselineProfile $record): bool => $record !== null),
|
||||
])
|
||||
->columns(2),
|
||||
Section::make('Governed subject summary')
|
||||
->schema([
|
||||
Placeholder::make('scope_summary_display')
|
||||
->label('Scope summary')
|
||||
->content(function (Get $get): string {
|
||||
return self::scopeSummaryText(self::formScopePayload($get));
|
||||
})
|
||||
->columnSpanFull(),
|
||||
Placeholder::make('scope_support_readiness_display')
|
||||
->label('Support readiness')
|
||||
->content(function (Get $get): string {
|
||||
return self::scopeSupportReadinessText(self::formScopePayload($get));
|
||||
}),
|
||||
Placeholder::make('scope_selection_feedback_display')
|
||||
->label('Selection feedback')
|
||||
->content(function (Get $get): string {
|
||||
return self::scopeSelectionFeedbackText(self::formScopePayload($get)) ?? 'Selections are valid for the current Intune-first workflow.';
|
||||
}),
|
||||
])
|
||||
->columns(2),
|
||||
]);
|
||||
}
|
||||
|
||||
@ -282,6 +308,17 @@ public static function infolist(Schema $schema): Schema
|
||||
->columnSpanFull(),
|
||||
Section::make('Scope')
|
||||
->schema([
|
||||
TextEntry::make('governed_subject_summary')
|
||||
->label('Governed subject summary')
|
||||
->state(fn (BaselineProfile $record): string => self::scopeSummaryText(self::scopePayload($record)))
|
||||
->columnSpanFull(),
|
||||
TextEntry::make('scope_support_readiness')
|
||||
->label('Support readiness')
|
||||
->state(fn (BaselineProfile $record): string => self::scopeSupportReadinessText(self::scopePayload($record))),
|
||||
TextEntry::make('scope_normalization_lineage')
|
||||
->label('Normalization lineage')
|
||||
->state(fn (BaselineProfile $record): string => self::scopeNormalizationLineageText($record))
|
||||
->columnSpanFull(),
|
||||
TextEntry::make('scope_jsonb.policy_types')
|
||||
->label('Policy types')
|
||||
->badge()
|
||||
@ -502,6 +539,82 @@ public static function compareMatrixUrl(BaselineProfile|int $profile): string
|
||||
return static::getUrl('compare-matrix', ['record' => $profile], panel: 'admin');
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed>|null $scopePayload
|
||||
*/
|
||||
public static function scopeSummaryText(?array $scopePayload): string
|
||||
{
|
||||
try {
|
||||
$scope = BaselineScope::fromJsonb($scopePayload);
|
||||
} catch (InvalidArgumentException) {
|
||||
return 'Invalid governed subject selection.';
|
||||
}
|
||||
|
||||
$groups = collect($scope->summaryGroups());
|
||||
|
||||
if ($groups->isEmpty()) {
|
||||
return 'No governed subjects selected.';
|
||||
}
|
||||
|
||||
return $groups
|
||||
->map(function (array $group) use ($scopePayload): string {
|
||||
$selectedSubjectTypes = $group['selected_subject_types'];
|
||||
|
||||
if ($group['domain_key'] === 'intune'
|
||||
&& $group['subject_class'] === 'policy'
|
||||
&& is_array($scopePayload)
|
||||
&& (($scopePayload['policy_types'] ?? []) === [])) {
|
||||
return $group['group_label'].': all supported Intune policy types';
|
||||
}
|
||||
|
||||
if ($selectedSubjectTypes === []) {
|
||||
return $group['group_label'].': none';
|
||||
}
|
||||
|
||||
return $group['group_label'].': '.implode(', ', $selectedSubjectTypes);
|
||||
})
|
||||
->implode('; ');
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed>|null $scopePayload
|
||||
*/
|
||||
public static function scopeSupportReadinessText(?array $scopePayload): string
|
||||
{
|
||||
try {
|
||||
$scope = BaselineScope::fromJsonb($scopePayload);
|
||||
} catch (InvalidArgumentException) {
|
||||
return 'Capture: blocked. Compare: blocked.';
|
||||
}
|
||||
|
||||
$capture = $scope->operationEligibility('capture');
|
||||
$compare = $scope->operationEligibility('compare');
|
||||
|
||||
return sprintf(
|
||||
'Capture: %s. Compare: %s.',
|
||||
$capture['ok'] ? 'ready' : 'blocked',
|
||||
$compare['ok'] ? 'ready' : 'blocked',
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed>|null $scopePayload
|
||||
*/
|
||||
public static function scopeSelectionFeedbackText(?array $scopePayload): ?string
|
||||
{
|
||||
try {
|
||||
$scope = BaselineScope::fromJsonb($scopePayload);
|
||||
} catch (InvalidArgumentException $exception) {
|
||||
return $exception->getMessage();
|
||||
}
|
||||
|
||||
if ($scope->normalizationLineage()['save_forward_required']) {
|
||||
return 'This Intune-first selection will be saved forward as canonical governed-subject scope V2.';
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, string>
|
||||
*/
|
||||
@ -693,7 +806,12 @@ private static function latestAttemptedSnapshotDescription(BaselineProfile $prof
|
||||
|
||||
private static function compareReadinessLabel(BaselineProfile $profile): string
|
||||
{
|
||||
return self::compareAvailabilityEnvelope($profile)?->operatorLabel ?? 'Ready';
|
||||
return match (self::compareAvailabilityReason($profile)) {
|
||||
BaselineReasonCodes::COMPARE_INVALID_SCOPE => 'Invalid scope',
|
||||
BaselineReasonCodes::COMPARE_MIXED_SCOPE => 'Mixed strategy scope',
|
||||
BaselineReasonCodes::COMPARE_UNSUPPORTED_SCOPE => 'Unsupported governed subjects',
|
||||
default => self::compareAvailabilityEnvelope($profile)?->operatorLabel ?? 'Ready',
|
||||
};
|
||||
}
|
||||
|
||||
private static function compareReadinessColor(BaselineProfile $profile): string
|
||||
@ -701,6 +819,9 @@ private static function compareReadinessColor(BaselineProfile $profile): string
|
||||
return match (self::compareAvailabilityReason($profile)) {
|
||||
null => 'success',
|
||||
BaselineReasonCodes::COMPARE_PROFILE_NOT_ACTIVE => 'gray',
|
||||
BaselineReasonCodes::COMPARE_INVALID_SCOPE,
|
||||
BaselineReasonCodes::COMPARE_MIXED_SCOPE,
|
||||
BaselineReasonCodes::COMPARE_UNSUPPORTED_SCOPE => 'danger',
|
||||
default => 'warning',
|
||||
};
|
||||
}
|
||||
@ -710,13 +831,21 @@ private static function compareReadinessIcon(BaselineProfile $profile): ?string
|
||||
return match (self::compareAvailabilityReason($profile)) {
|
||||
null => 'heroicon-m-check-badge',
|
||||
BaselineReasonCodes::COMPARE_PROFILE_NOT_ACTIVE => 'heroicon-m-pause-circle',
|
||||
BaselineReasonCodes::COMPARE_INVALID_SCOPE,
|
||||
BaselineReasonCodes::COMPARE_MIXED_SCOPE,
|
||||
BaselineReasonCodes::COMPARE_UNSUPPORTED_SCOPE => 'heroicon-m-no-symbol',
|
||||
default => 'heroicon-m-exclamation-triangle',
|
||||
};
|
||||
}
|
||||
|
||||
private static function profileNextStep(BaselineProfile $profile): string
|
||||
{
|
||||
return self::compareAvailabilityEnvelope($profile)?->guidanceText() ?? 'No action needed.';
|
||||
return match (self::compareAvailabilityReason($profile)) {
|
||||
BaselineReasonCodes::COMPARE_INVALID_SCOPE,
|
||||
BaselineReasonCodes::COMPARE_MIXED_SCOPE,
|
||||
BaselineReasonCodes::COMPARE_UNSUPPORTED_SCOPE => 'Review the governed subject selection before starting compare.',
|
||||
default => self::compareAvailabilityEnvelope($profile)?->guidanceText() ?? 'No action needed.',
|
||||
};
|
||||
}
|
||||
|
||||
private static function effectiveSnapshot(BaselineProfile $profile): ?BaselineSnapshot
|
||||
@ -739,6 +868,26 @@ private static function compareAvailabilityReason(BaselineProfile $profile): ?st
|
||||
return BaselineReasonCodes::COMPARE_PROFILE_NOT_ACTIVE;
|
||||
}
|
||||
|
||||
try {
|
||||
$scope = BaselineScope::fromJsonb(self::scopePayload($profile));
|
||||
} catch (InvalidArgumentException) {
|
||||
return BaselineReasonCodes::COMPARE_INVALID_SCOPE;
|
||||
}
|
||||
|
||||
if ($scope->allTypes() === []) {
|
||||
return BaselineReasonCodes::COMPARE_INVALID_SCOPE;
|
||||
}
|
||||
|
||||
$selection = app(CompareStrategyRegistry::class)->select($scope);
|
||||
|
||||
if ($selection->isMixed()) {
|
||||
return BaselineReasonCodes::COMPARE_MIXED_SCOPE;
|
||||
}
|
||||
|
||||
if (! $selection->isSupported()) {
|
||||
return BaselineReasonCodes::COMPARE_UNSUPPORTED_SCOPE;
|
||||
}
|
||||
|
||||
$resolution = app(BaselineSnapshotTruthResolver::class)->resolveCompareSnapshot($profile);
|
||||
$reasonCode = $resolution['reason_code'] ?? null;
|
||||
|
||||
@ -797,4 +946,37 @@ private static function hasEligibleCompareTarget(BaselineProfile $profile): bool
|
||||
->get(['id'])
|
||||
->contains(fn (Tenant $tenant): bool => $resolver->can($user, $tenant, Capabilities::TENANT_SYNC));
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed>|null $scopePayload
|
||||
*/
|
||||
private static function formScopePayload(Get $get): ?array
|
||||
{
|
||||
$scopePayload = $get('scope_jsonb');
|
||||
|
||||
return is_array($scopePayload) ? $scopePayload : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>|null
|
||||
*/
|
||||
private static function scopePayload(BaselineProfile $profile): ?array
|
||||
{
|
||||
return $profile->rawScopeJsonb();
|
||||
}
|
||||
|
||||
private static function scopeNormalizationLineageText(BaselineProfile $profile): string
|
||||
{
|
||||
try {
|
||||
$lineage = $profile->normalizedScope()->normalizationLineage();
|
||||
} catch (InvalidArgumentException) {
|
||||
return 'Stored scope is invalid and must be repaired before capture or compare can continue.';
|
||||
}
|
||||
|
||||
if ($lineage['source_shape'] === 'legacy') {
|
||||
return 'Legacy Intune buckets are being normalized and will be saved forward as canonical V2 on the next successful save.';
|
||||
}
|
||||
|
||||
return 'Canonical governed-subject scope V2 is already stored for this baseline profile.';
|
||||
}
|
||||
}
|
||||
|
||||
@ -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),
|
||||
};
|
||||
|
||||
@ -248,17 +250,23 @@ private function compareNowAction(): Action
|
||||
|
||||
if (! ($result['ok'] ?? false)) {
|
||||
$reasonCode = is_string($result['reason_code'] ?? null) ? (string) $result['reason_code'] : 'unknown';
|
||||
$translation = is_array($result['reason_translation'] ?? null) ? $result['reason_translation'] : [];
|
||||
|
||||
$message = match ($reasonCode) {
|
||||
BaselineReasonCodes::COMPARE_ROLLOUT_DISABLED => 'Full-content baseline compare is currently disabled for controlled rollout.',
|
||||
BaselineReasonCodes::COMPARE_PROFILE_NOT_ACTIVE => 'This baseline profile is not active.',
|
||||
BaselineReasonCodes::COMPARE_NO_ACTIVE_SNAPSHOT,
|
||||
BaselineReasonCodes::COMPARE_NO_CONSUMABLE_SNAPSHOT => 'This baseline profile has no complete snapshot available for compare yet.',
|
||||
BaselineReasonCodes::COMPARE_SNAPSHOT_BUILDING => 'The latest baseline capture is still building. Compare will be available after it completes.',
|
||||
BaselineReasonCodes::COMPARE_SNAPSHOT_INCOMPLETE => 'The latest baseline capture is incomplete. Capture a new baseline before comparing.',
|
||||
BaselineReasonCodes::COMPARE_SNAPSHOT_SUPERSEDED => 'A newer complete snapshot is current. Compare uses the latest complete baseline only.',
|
||||
default => 'Reason: '.str_replace('.', ' ', $reasonCode),
|
||||
};
|
||||
$message = is_string($translation['short_explanation'] ?? null) && trim((string) $translation['short_explanation']) !== ''
|
||||
? trim((string) $translation['short_explanation'])
|
||||
: match ($reasonCode) {
|
||||
BaselineReasonCodes::COMPARE_ROLLOUT_DISABLED => 'Full-content baseline compare is currently disabled for controlled rollout.',
|
||||
BaselineReasonCodes::COMPARE_PROFILE_NOT_ACTIVE => 'This baseline profile is not active.',
|
||||
BaselineReasonCodes::COMPARE_NO_ACTIVE_SNAPSHOT,
|
||||
BaselineReasonCodes::COMPARE_NO_CONSUMABLE_SNAPSHOT => 'This baseline profile has no complete snapshot available for compare yet.',
|
||||
BaselineReasonCodes::COMPARE_SNAPSHOT_BUILDING => 'The latest baseline capture is still building. Compare will be available after it completes.',
|
||||
BaselineReasonCodes::COMPARE_SNAPSHOT_INCOMPLETE => 'The latest baseline capture is incomplete. Capture a new baseline before comparing.',
|
||||
BaselineReasonCodes::COMPARE_SNAPSHOT_SUPERSEDED => 'A newer complete snapshot is current. Compare uses the latest complete baseline only.',
|
||||
BaselineReasonCodes::COMPARE_INVALID_SCOPE => 'This baseline profile has an invalid governed-subject scope. Review the baseline definition before comparing.',
|
||||
BaselineReasonCodes::COMPARE_UNSUPPORTED_SCOPE => 'This baseline profile includes governed subjects that are not currently supported for compare.',
|
||||
BaselineReasonCodes::COMPARE_MIXED_SCOPE => 'This baseline profile mixes governed subjects that require different compare strategies. Narrow the selection before comparing.',
|
||||
default => 'Reason: '.str_replace('.', ' ', $reasonCode),
|
||||
};
|
||||
|
||||
Notification::make()
|
||||
->title('Cannot start comparison')
|
||||
|
||||
@ -957,6 +957,49 @@ private static function baselineCompareFacts(
|
||||
);
|
||||
}
|
||||
|
||||
$strategy = data_get($context, 'baseline_compare.strategy');
|
||||
if (is_array($strategy)) {
|
||||
$strategyKey = is_string($strategy['key'] ?? null) && trim((string) $strategy['key']) !== ''
|
||||
? trim((string) $strategy['key'])
|
||||
: null;
|
||||
$selectionState = is_string($strategy['selection_state'] ?? null) && trim((string) $strategy['selection_state']) !== ''
|
||||
? trim((string) $strategy['selection_state'])
|
||||
: null;
|
||||
$operatorReason = is_string($strategy['operator_reason'] ?? null) && trim((string) $strategy['operator_reason']) !== ''
|
||||
? trim((string) $strategy['operator_reason'])
|
||||
: null;
|
||||
$stateCounts = is_array($strategy['state_counts'] ?? null)
|
||||
? array_filter(
|
||||
array_map(static fn (mixed $count): int => (int) $count, $strategy['state_counts']),
|
||||
static fn (int $count): bool => $count > 0,
|
||||
)
|
||||
: [];
|
||||
|
||||
if ($strategyKey !== null) {
|
||||
$facts[] = $factory->keyFact(
|
||||
'Compare strategy',
|
||||
\Illuminate\Support\Str::of($strategyKey)->replace('_', ' ')->headline()->toString(),
|
||||
);
|
||||
}
|
||||
|
||||
if ($selectionState !== null) {
|
||||
$facts[] = $factory->keyFact(
|
||||
'Strategy selection',
|
||||
\Illuminate\Support\Str::of($selectionState)->replace('_', ' ')->headline()->toString(),
|
||||
$operatorReason,
|
||||
);
|
||||
}
|
||||
|
||||
if ($stateCounts !== []) {
|
||||
$facts[] = $factory->keyFact(
|
||||
'Strategy subject states',
|
||||
collect($stateCounts)
|
||||
->map(static fn (int $count, string $state): string => \Illuminate\Support\Str::of($state)->replace('_', ' ')->headline()->append(' ', (string) $count)->toString())
|
||||
->implode(', '),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if ((int) ($gapSummary['count'] ?? 0) > 0) {
|
||||
$facts[] = $factory->keyFact(
|
||||
'Evidence gap detail',
|
||||
@ -1009,6 +1052,9 @@ private static function baselineCompareEvidencePayload(OperationRun $record): ar
|
||||
? (int) data_get($context, 'baseline_compare.evidence_gaps.count')
|
||||
: null,
|
||||
'resume_token' => data_get($context, 'baseline_compare.resume_token'),
|
||||
'strategy' => is_array(data_get($context, 'baseline_compare.strategy'))
|
||||
? data_get($context, 'baseline_compare.strategy')
|
||||
: null,
|
||||
'evidence_capture' => is_array(data_get($context, 'baseline_compare.evidence_capture'))
|
||||
? data_get($context, 'baseline_compare.evidence_capture')
|
||||
: null,
|
||||
|
||||
@ -49,7 +49,7 @@ public function workspaceTenants(): Collection
|
||||
->where('workspace_id', (int) $this->workspace->getKey())
|
||||
->orderBy('name')
|
||||
->limit(10)
|
||||
->get(['id', 'name', 'status', 'workspace_id']);
|
||||
->get(['id', 'name', 'status', 'workspace_id', 'external_id']);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@ -42,6 +42,13 @@
|
||||
use App\Support\Baselines\BaselineReasonCodes;
|
||||
use App\Support\Baselines\BaselineScope;
|
||||
use App\Support\Baselines\BaselineSubjectKey;
|
||||
use App\Support\Baselines\Compare\CompareFindingCandidate;
|
||||
use App\Support\Baselines\Compare\CompareOrchestrationContext;
|
||||
use App\Support\Baselines\Compare\CompareState;
|
||||
use App\Support\Baselines\Compare\CompareStrategyRegistry;
|
||||
use App\Support\Baselines\Compare\CompareStrategySelection;
|
||||
use App\Support\Baselines\Compare\CompareSubjectResult;
|
||||
use App\Support\Baselines\Compare\StrategySelectionState;
|
||||
use App\Support\Baselines\PolicyVersionCapturePurpose;
|
||||
use App\Support\Baselines\SubjectResolver;
|
||||
use App\Support\Inventory\InventoryCoverage;
|
||||
@ -102,6 +109,7 @@ public function handle(
|
||||
?BaselineContentCapturePhase $contentCapturePhase = null,
|
||||
?BaselineFullContentRolloutGate $rolloutGate = null,
|
||||
?ContentEvidenceProvider $contentEvidenceProvider = null,
|
||||
?CompareStrategyRegistry $compareStrategyRegistry = null,
|
||||
): void {
|
||||
$settingsResolver ??= app(SettingsResolver::class);
|
||||
$baselineAutoCloseService ??= app(BaselineAutoCloseService::class);
|
||||
@ -111,6 +119,7 @@ public function handle(
|
||||
$contentCapturePhase ??= app(BaselineContentCapturePhase::class);
|
||||
$rolloutGate ??= app(BaselineFullContentRolloutGate::class);
|
||||
$contentEvidenceProvider ??= app(ContentEvidenceProvider::class);
|
||||
$compareStrategyRegistry ??= app(CompareStrategyRegistry::class);
|
||||
|
||||
if (! $this->operationRun instanceof OperationRun) {
|
||||
$this->fail(new RuntimeException('OperationRun context is required for CompareBaselineToTenantJob.'));
|
||||
@ -339,6 +348,44 @@ public function handle(
|
||||
$snapshot = $snapshotResolution['snapshot'];
|
||||
$snapshotId = (int) $snapshot->getKey();
|
||||
|
||||
$strategySelection = $compareStrategyRegistry->select($effectiveScope);
|
||||
$context = $this->withCompareStrategySelection($context, $strategySelection);
|
||||
$this->operationRun->update(['context' => $context]);
|
||||
$this->operationRun->refresh();
|
||||
|
||||
if (! $strategySelection->isSupported()) {
|
||||
$this->auditStarted(
|
||||
auditLogger: $auditLogger,
|
||||
tenant: $tenant,
|
||||
profile: $profile,
|
||||
initiator: $initiator,
|
||||
captureMode: $captureMode,
|
||||
subjectsTotal: 0,
|
||||
effectiveScope: $effectiveScope,
|
||||
);
|
||||
|
||||
$this->completeWithCoverageWarning(
|
||||
operationRunService: $operationRunService,
|
||||
auditLogger: $auditLogger,
|
||||
tenant: $tenant,
|
||||
profile: $profile,
|
||||
initiator: $initiator,
|
||||
inventorySyncRun: $inventorySyncRun,
|
||||
coverageProof: true,
|
||||
effectiveTypes: $effectiveTypes,
|
||||
coveredTypes: $coveredTypes,
|
||||
uncoveredTypes: $uncoveredTypes,
|
||||
errorsRecorded: max(1, count($coveredTypes !== [] ? $coveredTypes : $effectiveTypes)),
|
||||
captureMode: $captureMode,
|
||||
reasonCode: BaselineCompareReasonCode::UnsupportedSubjects,
|
||||
evidenceGapsByReason: [
|
||||
$this->strategySelectionGapReason($strategySelection) => max(1, count($coveredTypes !== [] ? $coveredTypes : $effectiveTypes)),
|
||||
],
|
||||
);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$since = $snapshot->captured_at instanceof \DateTimeInterface
|
||||
? CarbonImmutable::instance($snapshot->captured_at)
|
||||
: null;
|
||||
@ -459,32 +506,79 @@ public function handle(
|
||||
resolvedMetaEvidence: $resolvedCurrentMetaEvidence,
|
||||
);
|
||||
|
||||
$baselinePolicyVersionResolver = app(BaselinePolicyVersionResolver::class);
|
||||
$driftHasher = app(DriftHasher::class);
|
||||
$settingsNormalizer = app(SettingsNormalizer::class);
|
||||
$assignmentsNormalizer = app(AssignmentsNormalizer::class);
|
||||
$scopeTagsNormalizer = app(ScopeTagsNormalizer::class);
|
||||
|
||||
$computeResult = $this->computeDrift(
|
||||
tenant: $tenant,
|
||||
$strategy = $compareStrategyRegistry->resolve($strategySelection->strategyKey);
|
||||
$orchestrationContext = new CompareOrchestrationContext(
|
||||
workspaceId: (int) $workspace->getKey(),
|
||||
tenantId: (int) $tenant->getKey(),
|
||||
baselineProfileId: (int) $profile->getKey(),
|
||||
baselineSnapshotId: (int) $snapshot->getKey(),
|
||||
compareOperationRunId: (int) $this->operationRun->getKey(),
|
||||
inventorySyncRunId: (int) $inventorySyncRun->getKey(),
|
||||
baselineItems: $baselineItems,
|
||||
currentItems: $currentItems,
|
||||
resolvedCurrentEvidence: $resolvedEffectiveCurrentEvidence,
|
||||
severityMapping: $this->resolveSeverityMapping($workspace, $settingsResolver),
|
||||
baselinePolicyVersionResolver: $baselinePolicyVersionResolver,
|
||||
hasher: $driftHasher,
|
||||
settingsNormalizer: $settingsNormalizer,
|
||||
assignmentsNormalizer: $assignmentsNormalizer,
|
||||
scopeTagsNormalizer: $scopeTagsNormalizer,
|
||||
contentEvidenceProvider: $contentEvidenceProvider,
|
||||
operationRunId: (int) $this->operationRun->getKey(),
|
||||
normalizedScope: $effectiveScope->toStoredJsonb(),
|
||||
strategySelection: $strategySelection,
|
||||
coverageContext: [
|
||||
'inventory_sync_run_id' => (int) $inventorySyncRun->getKey(),
|
||||
'effective_types' => $effectiveTypes,
|
||||
'covered_types' => $coveredTypes,
|
||||
'uncovered_types' => $uncoveredTypes,
|
||||
],
|
||||
launchContext: is_array($context['baseline_compare'] ?? null) ? $context['baseline_compare'] : [],
|
||||
);
|
||||
$driftResults = $computeResult['drift'];
|
||||
$driftGaps = $computeResult['evidence_gaps'];
|
||||
$rbacRoleDefinitionSummary = $computeResult['rbac_role_definitions'];
|
||||
|
||||
try {
|
||||
$compareResult = $strategy->compare(
|
||||
context: $orchestrationContext,
|
||||
tenant: $tenant,
|
||||
baselineItems: $baselineItems,
|
||||
currentItems: $currentItems,
|
||||
resolvedCurrentEvidence: $resolvedEffectiveCurrentEvidence,
|
||||
severityMapping: $this->resolveSeverityMapping($workspace, $settingsResolver),
|
||||
);
|
||||
} catch (\Throwable $exception) {
|
||||
$failedContext = $this->withCompareStrategyDiagnostics(
|
||||
context: is_array($this->operationRun->context) ? $this->operationRun->context : [],
|
||||
strategySelection: $strategySelection,
|
||||
executionDiagnostics: [
|
||||
'failed' => true,
|
||||
'exception_class' => $exception::class,
|
||||
],
|
||||
);
|
||||
|
||||
$this->operationRun->update(['context' => $failedContext]);
|
||||
$this->operationRun->refresh();
|
||||
|
||||
$this->completeWithCoverageWarning(
|
||||
operationRunService: $operationRunService,
|
||||
auditLogger: $auditLogger,
|
||||
tenant: $tenant,
|
||||
profile: $profile,
|
||||
initiator: $initiator,
|
||||
inventorySyncRun: $inventorySyncRun,
|
||||
coverageProof: true,
|
||||
effectiveTypes: $effectiveTypes,
|
||||
coveredTypes: $coveredTypes,
|
||||
uncoveredTypes: $uncoveredTypes,
|
||||
errorsRecorded: max(1, count($coveredTypes !== [] ? $coveredTypes : $effectiveTypes)),
|
||||
captureMode: $captureMode,
|
||||
reasonCode: BaselineCompareReasonCode::StrategyFailed,
|
||||
evidenceGapsByReason: [
|
||||
'strategy_failed' => max(1, count($coveredTypes !== [] ? $coveredTypes : $effectiveTypes)),
|
||||
],
|
||||
);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$normalizedStrategyResults = $this->normalizeStrategySubjectResults($compareResult['subject_results'] ?? []);
|
||||
$driftResults = $normalizedStrategyResults['drift_results'];
|
||||
$driftGaps = $normalizedStrategyResults['gap_counts'];
|
||||
$rbacRoleDefinitionSummary = is_array($compareResult['diagnostics']['rbac_role_definitions'] ?? null)
|
||||
? $compareResult['diagnostics']['rbac_role_definitions']
|
||||
: $this->emptyRbacRoleDefinitionSummary();
|
||||
$strategyGapSubjects = $normalizedStrategyResults['gap_subjects'];
|
||||
$strategyStateCounts = $normalizedStrategyResults['state_counts'];
|
||||
$strategyDiagnostics = is_array($compareResult['diagnostics'] ?? null)
|
||||
? $compareResult['diagnostics']
|
||||
: [];
|
||||
|
||||
$upsertResult = $this->upsertFindings(
|
||||
$tenant,
|
||||
@ -502,7 +596,7 @@ public function handle(
|
||||
$gapSubjects = $this->collectGapSubjects(
|
||||
ambiguousKeys: $ambiguousKeys,
|
||||
phaseGapSubjects: $phaseGapSubjects ?? [],
|
||||
driftGapSubjects: $computeResult['evidence_gap_subjects'] ?? [],
|
||||
driftGapSubjects: $strategyGapSubjects,
|
||||
);
|
||||
|
||||
$summaryCounts = [
|
||||
@ -565,6 +659,9 @@ public function handle(
|
||||
} elseif (count($driftResults) === 0) {
|
||||
$reasonCode = match (true) {
|
||||
$uncoveredTypes !== [] => BaselineCompareReasonCode::CoverageUnproven,
|
||||
($strategyStateCounts[CompareState::Failed->value] ?? 0) > 0 => BaselineCompareReasonCode::StrategyFailed,
|
||||
($strategyStateCounts[CompareState::Ambiguous->value] ?? 0) > 0 => BaselineCompareReasonCode::AmbiguousSubjects,
|
||||
($strategyStateCounts[CompareState::Unsupported->value] ?? 0) > 0 => BaselineCompareReasonCode::UnsupportedSubjects,
|
||||
$resumeToken !== null || $gapsCount > 0 => BaselineCompareReasonCode::EvidenceCaptureIncomplete,
|
||||
default => BaselineCompareReasonCode::NoDriftDetected,
|
||||
};
|
||||
@ -577,6 +674,11 @@ public function handle(
|
||||
'inventory_sync_run_id' => (int) $inventorySyncRun->getKey(),
|
||||
'since' => $since?->toIso8601String(),
|
||||
'subjects_total' => $subjectsTotal,
|
||||
'strategy' => $this->strategyContext(
|
||||
strategySelection: $strategySelection,
|
||||
executionDiagnostics: $strategyDiagnostics,
|
||||
stateCounts: $strategyStateCounts,
|
||||
),
|
||||
'evidence_capture' => $phaseStats,
|
||||
'evidence_gaps' => [
|
||||
'count' => $gapsCount,
|
||||
@ -994,6 +1096,193 @@ private function withCompareReasonTranslation(array $context, ?string $reasonCod
|
||||
return $context;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $context
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function withCompareStrategySelection(array $context, CompareStrategySelection $strategySelection): array
|
||||
{
|
||||
$context['baseline_compare'] = array_merge(
|
||||
is_array($context['baseline_compare'] ?? null) ? $context['baseline_compare'] : [],
|
||||
[
|
||||
'strategy' => $this->strategyContext($strategySelection),
|
||||
],
|
||||
);
|
||||
|
||||
return $context;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $context
|
||||
* @param array<string, mixed> $executionDiagnostics
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function withCompareStrategyDiagnostics(
|
||||
array $context,
|
||||
CompareStrategySelection $strategySelection,
|
||||
array $executionDiagnostics,
|
||||
array $stateCounts = [],
|
||||
): array {
|
||||
$context['baseline_compare'] = array_merge(
|
||||
is_array($context['baseline_compare'] ?? null) ? $context['baseline_compare'] : [],
|
||||
[
|
||||
'strategy' => $this->strategyContext($strategySelection, $executionDiagnostics, $stateCounts),
|
||||
],
|
||||
);
|
||||
|
||||
return $context;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $executionDiagnostics
|
||||
* @param array<string, int> $stateCounts
|
||||
* @return array{
|
||||
* key: ?string,
|
||||
* selection_state: string,
|
||||
* matched_scope_entries: list<array<string, mixed>>,
|
||||
* rejected_scope_entries: list<array<string, mixed>>,
|
||||
* operator_reason: string,
|
||||
* diagnostics: array<string, mixed>,
|
||||
* execution_diagnostics: array<string, mixed>,
|
||||
* state_counts: array<string, int>
|
||||
* }
|
||||
*/
|
||||
private function strategyContext(
|
||||
CompareStrategySelection $strategySelection,
|
||||
array $executionDiagnostics = [],
|
||||
array $stateCounts = [],
|
||||
): array {
|
||||
return [
|
||||
'key' => $strategySelection->strategyKey?->value,
|
||||
'selection_state' => $strategySelection->selectionState->value,
|
||||
'matched_scope_entries' => $strategySelection->matchedScopeEntries,
|
||||
'rejected_scope_entries' => $strategySelection->rejectedScopeEntries,
|
||||
'operator_reason' => $strategySelection->operatorReason,
|
||||
'diagnostics' => $strategySelection->diagnostics,
|
||||
'execution_diagnostics' => $executionDiagnostics,
|
||||
'state_counts' => $stateCounts,
|
||||
];
|
||||
}
|
||||
|
||||
private function strategySelectionGapReason(CompareStrategySelection $strategySelection): string
|
||||
{
|
||||
return $strategySelection->selectionState === StrategySelectionState::Mixed
|
||||
? 'mixed_scope'
|
||||
: 'unsupported_subjects';
|
||||
}
|
||||
|
||||
/**
|
||||
* @param mixed $subjectResults
|
||||
* @return array{
|
||||
* drift_results: array<int, array{change_type: string, severity: string, evidence_fidelity: string, subject_type: string, subject_external_id: string, subject_key: string, policy_type: string, baseline_hash: string, current_hash: string, evidence: array<string, mixed>}>,
|
||||
* gap_counts: array<string, int>,
|
||||
* gap_subjects: list<array<string, mixed>>,
|
||||
* state_counts: array<string, int>
|
||||
* }
|
||||
*/
|
||||
private function normalizeStrategySubjectResults(mixed $subjectResults): array
|
||||
{
|
||||
if (! is_array($subjectResults)) {
|
||||
return [
|
||||
'drift_results' => [],
|
||||
'gap_counts' => [],
|
||||
'gap_subjects' => [],
|
||||
'state_counts' => [],
|
||||
];
|
||||
}
|
||||
|
||||
$driftResults = [];
|
||||
$gapCounts = [];
|
||||
$gapSubjects = [];
|
||||
$stateCounts = [];
|
||||
|
||||
foreach ($subjectResults as $subjectResult) {
|
||||
if (! $subjectResult instanceof CompareSubjectResult) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$state = $subjectResult->compareState->value;
|
||||
$stateCounts[$state] = ($stateCounts[$state] ?? 0) + 1;
|
||||
|
||||
if ($subjectResult->compareState === CompareState::Drift && $subjectResult->findingCandidate instanceof CompareFindingCandidate) {
|
||||
$driftResults[] = [
|
||||
'change_type' => $subjectResult->findingCandidate->changeType,
|
||||
'severity' => $subjectResult->findingCandidate->severity,
|
||||
'subject_type' => $subjectResult->projection->platformSubjectClass,
|
||||
'subject_external_id' => $subjectResult->subjectIdentity->externalSubjectId ?? '',
|
||||
'subject_key' => $subjectResult->subjectIdentity->subjectKey,
|
||||
'policy_type' => $subjectResult->subjectIdentity->subjectTypeKey,
|
||||
'evidence_fidelity' => $subjectResult->evidenceQuality,
|
||||
'baseline_hash' => is_string(data_get($subjectResult->findingCandidate->evidencePayload, 'baseline.hash'))
|
||||
? (string) data_get($subjectResult->findingCandidate->evidencePayload, 'baseline.hash')
|
||||
: '',
|
||||
'current_hash' => is_string(data_get($subjectResult->findingCandidate->evidencePayload, 'current.hash'))
|
||||
? (string) data_get($subjectResult->findingCandidate->evidencePayload, 'current.hash')
|
||||
: '',
|
||||
'evidence' => $subjectResult->findingCandidate->evidencePayload,
|
||||
];
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
if (! $subjectResult->isGapState()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$reasonCode = $subjectResult->gapReasonCode() ?? $this->defaultGapReasonForState($subjectResult->compareState);
|
||||
$gapCounts[$reasonCode] = ($gapCounts[$reasonCode] ?? 0) + 1;
|
||||
$gapSubjects[] = $subjectResult->gapRecord() ?? $this->fallbackGapRecord($subjectResult, $reasonCode);
|
||||
}
|
||||
|
||||
ksort($gapCounts);
|
||||
ksort($stateCounts);
|
||||
|
||||
return [
|
||||
'drift_results' => $driftResults,
|
||||
'gap_counts' => $gapCounts,
|
||||
'gap_subjects' => $gapSubjects,
|
||||
'state_counts' => $stateCounts,
|
||||
];
|
||||
}
|
||||
|
||||
private function defaultGapReasonForState(CompareState $state): string
|
||||
{
|
||||
return match ($state) {
|
||||
CompareState::Unsupported => 'unsupported_subject',
|
||||
CompareState::Ambiguous => 'ambiguous_match',
|
||||
CompareState::Failed => 'strategy_failed',
|
||||
default => 'missing_current',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function fallbackGapRecord(CompareSubjectResult $subjectResult, string $reasonCode): array
|
||||
{
|
||||
$descriptor = $this->subjectResolver()->describeForCompare(
|
||||
policyType: $subjectResult->subjectIdentity->subjectTypeKey,
|
||||
subjectExternalId: $subjectResult->subjectIdentity->externalSubjectId,
|
||||
subjectKey: $subjectResult->subjectIdentity->subjectKey,
|
||||
);
|
||||
|
||||
$outcome = match ($reasonCode) {
|
||||
'missing_current' => $this->subjectResolver()->missingExpectedRecord($descriptor),
|
||||
'ambiguous_match' => $this->subjectResolver()->ambiguousMatch($descriptor),
|
||||
default => $this->subjectResolver()->captureFailed($descriptor),
|
||||
};
|
||||
|
||||
return array_merge($descriptor->toArray(), $outcome->toArray(), [
|
||||
'reason_code' => $reasonCode,
|
||||
'search_text' => strtolower(implode(' ', array_filter([
|
||||
$subjectResult->subjectIdentity->subjectTypeKey,
|
||||
$subjectResult->subjectIdentity->subjectKey,
|
||||
$reasonCode,
|
||||
$subjectResult->projection->operatorLabel,
|
||||
]))),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Load current inventory items keyed by "policy_type|subject_key".
|
||||
*
|
||||
@ -1069,13 +1358,13 @@ private function loadCurrentInventory(
|
||||
'subject_external_id' => (string) $inventoryItem->external_id,
|
||||
'subject_key' => $subjectKey,
|
||||
'policy_type' => (string) $inventoryItem->policy_type,
|
||||
'meta_jsonb' => [
|
||||
'display_name' => $inventoryItem->display_name,
|
||||
'category' => $inventoryItem->category,
|
||||
'platform' => $inventoryItem->platform,
|
||||
'meta_jsonb' => array_replace($metaJsonb, [
|
||||
'display_name' => $metaJsonb['display_name'] ?? $inventoryItem->display_name,
|
||||
'category' => $metaJsonb['category'] ?? $inventoryItem->category,
|
||||
'platform' => $metaJsonb['platform'] ?? $inventoryItem->platform,
|
||||
'is_built_in' => $metaJsonb['is_built_in'] ?? null,
|
||||
'role_permission_count' => $metaJsonb['role_permission_count'] ?? null,
|
||||
],
|
||||
]),
|
||||
];
|
||||
}
|
||||
});
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -36,7 +36,7 @@ public function panel(Panel $panel): Panel
|
||||
->colors([
|
||||
'primary' => Color::Blue,
|
||||
])
|
||||
->databaseNotifications()
|
||||
->databaseNotifications(isLazy: false)
|
||||
->databaseNotificationsPolling(null)
|
||||
->renderHook(
|
||||
PanelsRenderHook::BODY_START,
|
||||
|
||||
@ -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
|
||||
|
||||
@ -19,8 +19,12 @@
|
||||
use App\Support\Baselines\BaselineReasonCodes;
|
||||
use App\Support\Baselines\BaselineScope;
|
||||
use App\Support\Baselines\BaselineSupportCapabilityGuard;
|
||||
use App\Support\Baselines\Compare\CompareStrategyRegistry;
|
||||
use App\Support\Baselines\Compare\CompareStrategySelection;
|
||||
use App\Support\Baselines\Compare\StrategySelectionState;
|
||||
use App\Support\OperationRunType;
|
||||
use App\Support\ReasonTranslation\ReasonPresenter;
|
||||
use InvalidArgumentException;
|
||||
|
||||
final class BaselineCompareService
|
||||
{
|
||||
@ -30,6 +34,7 @@ public function __construct(
|
||||
private readonly BaselineSnapshotTruthResolver $snapshotTruthResolver,
|
||||
private readonly BaselineSupportCapabilityGuard $capabilityGuard,
|
||||
private readonly CapabilityResolver $capabilityResolver,
|
||||
private readonly CompareStrategyRegistry $compareStrategyRegistry,
|
||||
) {}
|
||||
|
||||
/**
|
||||
@ -107,14 +112,28 @@ public function startCompareForProfile(
|
||||
$snapshot = $snapshotResolution['snapshot'];
|
||||
$snapshotId = (int) $snapshot->getKey();
|
||||
|
||||
$profileScope = BaselineScope::fromJsonb(
|
||||
is_array($profile->scope_jsonb) ? $profile->scope_jsonb : null,
|
||||
);
|
||||
$overrideScope = $assignment->override_scope_jsonb !== null
|
||||
? BaselineScope::fromJsonb(is_array($assignment->override_scope_jsonb) ? $assignment->override_scope_jsonb : null)
|
||||
: null;
|
||||
try {
|
||||
$profileScope = $profile->normalizedScope();
|
||||
$overrideScope = $assignment->override_scope_jsonb !== null
|
||||
? BaselineScope::fromJsonb(
|
||||
is_array($assignment->override_scope_jsonb) ? $assignment->override_scope_jsonb : null,
|
||||
allowEmptyLegacyAsNoOverride: true,
|
||||
)
|
||||
: null;
|
||||
$effectiveScope = BaselineScope::effective($profileScope, $overrideScope);
|
||||
} catch (InvalidArgumentException) {
|
||||
return $this->failedStart(BaselineReasonCodes::COMPARE_INVALID_SCOPE);
|
||||
}
|
||||
|
||||
$effectiveScope = BaselineScope::effective($profileScope, $overrideScope);
|
||||
if ($effectiveScope->allTypes() === []) {
|
||||
return $this->failedStart(BaselineReasonCodes::COMPARE_INVALID_SCOPE);
|
||||
}
|
||||
|
||||
$selection = $this->compareStrategyRegistry->select($effectiveScope);
|
||||
|
||||
if (! $selection->isSupported()) {
|
||||
return $this->failedStart($this->selectionFailureReasonCode($selection));
|
||||
}
|
||||
|
||||
$captureMode = $profile->capture_mode instanceof BaselineCaptureMode
|
||||
? $profile->capture_mode
|
||||
@ -129,6 +148,9 @@ public function startCompareForProfile(
|
||||
'baseline_snapshot_id' => $snapshotId,
|
||||
'effective_scope' => $effectiveScope->toEffectiveScopeContext($this->capabilityGuard, 'compare'),
|
||||
'capture_mode' => $captureMode->value,
|
||||
'baseline_compare' => [
|
||||
'strategy' => $this->strategyContext($selection),
|
||||
],
|
||||
];
|
||||
|
||||
$run = $this->runs->ensureRunWithIdentity(
|
||||
@ -258,6 +280,36 @@ private function validatePreconditions(BaselineProfile $profile): ?string
|
||||
return null;
|
||||
}
|
||||
|
||||
private function selectionFailureReasonCode(CompareStrategySelection $selection): string
|
||||
{
|
||||
return match ($selection->selectionState) {
|
||||
StrategySelectionState::Mixed => BaselineReasonCodes::COMPARE_MIXED_SCOPE,
|
||||
default => BaselineReasonCodes::COMPARE_UNSUPPORTED_SCOPE,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{
|
||||
* key: ?string,
|
||||
* selection_state: string,
|
||||
* matched_scope_entries: list<array<string, mixed>>,
|
||||
* rejected_scope_entries: list<array<string, mixed>>,
|
||||
* operator_reason: string,
|
||||
* diagnostics: array<string, mixed>
|
||||
* }
|
||||
*/
|
||||
private function strategyContext(CompareStrategySelection $selection): array
|
||||
{
|
||||
return [
|
||||
'key' => $selection->strategyKey?->value,
|
||||
'selection_state' => $selection->selectionState->value,
|
||||
'matched_scope_entries' => $selection->matchedScopeEntries,
|
||||
'rejected_scope_entries' => $selection->rejectedScopeEntries,
|
||||
'operator_reason' => $selection->operatorReason,
|
||||
'diagnostics' => $selection->diagnostics,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{ok: false, reason_code: string, reason_translation?: array<string, mixed>}
|
||||
*/
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -151,6 +151,7 @@ public static function diagnosticsPayload(array $baselineCompare): array
|
||||
'subjects_total' => self::intOrNull($baselineCompare['subjects_total'] ?? null),
|
||||
'resume_token' => self::stringOrNull($baselineCompare['resume_token'] ?? null),
|
||||
'fidelity' => self::stringOrNull($baselineCompare['fidelity'] ?? null),
|
||||
'strategy' => is_array($baselineCompare['strategy'] ?? null) ? $baselineCompare['strategy'] : null,
|
||||
'coverage' => is_array($baselineCompare['coverage'] ?? null) ? $baselineCompare['coverage'] : null,
|
||||
'evidence_capture' => is_array($baselineCompare['evidence_capture'] ?? null) ? $baselineCompare['evidence_capture'] : null,
|
||||
'evidence_gaps' => is_array($baselineCompare['evidence_gaps'] ?? null) ? $baselineCompare['evidence_gaps'] : null,
|
||||
|
||||
@ -12,6 +12,9 @@ enum BaselineCompareReasonCode: string
|
||||
case NoSubjectsInScope = 'no_subjects_in_scope';
|
||||
case CoverageUnproven = 'coverage_unproven';
|
||||
case EvidenceCaptureIncomplete = 'evidence_capture_incomplete';
|
||||
case UnsupportedSubjects = 'unsupported_subjects';
|
||||
case AmbiguousSubjects = 'ambiguous_subjects';
|
||||
case StrategyFailed = 'strategy_failed';
|
||||
case RolloutDisabled = 'rollout_disabled';
|
||||
case NoDriftDetected = 'no_drift_detected';
|
||||
case OverdueFindingsRemain = 'overdue_findings_remain';
|
||||
@ -24,6 +27,9 @@ public function message(): string
|
||||
self::NoSubjectsInScope => 'No subjects were in scope for this comparison.',
|
||||
self::CoverageUnproven => 'Coverage proof was missing or incomplete, so some findings were suppressed for safety.',
|
||||
self::EvidenceCaptureIncomplete => 'Evidence capture was incomplete, so some drift evaluation may have been suppressed.',
|
||||
self::UnsupportedSubjects => 'One or more in-scope subjects could not be compared by the selected strategy.',
|
||||
self::AmbiguousSubjects => 'One or more in-scope subjects could not be compared because identity matching stayed ambiguous.',
|
||||
self::StrategyFailed => 'One or more in-scope subjects failed during strategy processing, so the compare result is incomplete.',
|
||||
self::RolloutDisabled => 'Full-content baseline compare is currently disabled by rollout configuration.',
|
||||
self::NoDriftDetected => 'No drift was detected for in-scope subjects.',
|
||||
self::OverdueFindingsRemain => 'Overdue findings still need action even though the latest compare did not produce new drift.',
|
||||
@ -38,10 +44,13 @@ public function explanationFamily(): ExplanationFamily
|
||||
self::NoDriftDetected => ExplanationFamily::NoIssuesDetected,
|
||||
self::CoverageUnproven,
|
||||
self::EvidenceCaptureIncomplete,
|
||||
self::UnsupportedSubjects,
|
||||
self::AmbiguousSubjects,
|
||||
self::RolloutDisabled,
|
||||
self::OverdueFindingsRemain,
|
||||
self::GovernanceExpiring,
|
||||
self::GovernanceLapsed => ExplanationFamily::CompletedButLimited,
|
||||
self::StrategyFailed => ExplanationFamily::BlockedPrerequisite,
|
||||
self::NoSubjectsInScope => ExplanationFamily::MissingInput,
|
||||
};
|
||||
}
|
||||
@ -52,9 +61,12 @@ public function trustworthinessLevel(): TrustworthinessLevel
|
||||
self::NoDriftDetected => TrustworthinessLevel::Trustworthy,
|
||||
self::CoverageUnproven,
|
||||
self::EvidenceCaptureIncomplete,
|
||||
self::UnsupportedSubjects,
|
||||
self::AmbiguousSubjects,
|
||||
self::OverdueFindingsRemain,
|
||||
self::GovernanceExpiring,
|
||||
self::GovernanceLapsed => TrustworthinessLevel::LimitedConfidence,
|
||||
self::StrategyFailed,
|
||||
self::RolloutDisabled,
|
||||
self::NoSubjectsInScope => TrustworthinessLevel::Unusable,
|
||||
};
|
||||
@ -66,9 +78,12 @@ public function absencePattern(): ?string
|
||||
self::NoDriftDetected => 'true_no_result',
|
||||
self::CoverageUnproven,
|
||||
self::EvidenceCaptureIncomplete,
|
||||
self::UnsupportedSubjects,
|
||||
self::AmbiguousSubjects,
|
||||
self::OverdueFindingsRemain,
|
||||
self::GovernanceExpiring,
|
||||
self::GovernanceLapsed => 'suppressed_output',
|
||||
self::StrategyFailed,
|
||||
self::RolloutDisabled => 'blocked_prerequisite',
|
||||
self::NoSubjectsInScope => 'missing_input',
|
||||
};
|
||||
|
||||
@ -121,13 +121,21 @@ public static function forTenant(?Tenant $tenant): self
|
||||
$snapshotReasonCode = is_string($truthResolution['reason_code'] ?? null) ? (string) $truthResolution['reason_code'] : null;
|
||||
$snapshotReasonMessage = self::missingSnapshotMessage($snapshotReasonCode);
|
||||
|
||||
$profileScope = BaselineScope::fromJsonb(
|
||||
is_array($profile->scope_jsonb) ? $profile->scope_jsonb : null,
|
||||
);
|
||||
$overrideScope = $assignment->override_scope_jsonb !== null
|
||||
? BaselineScope::fromJsonb(is_array($assignment->override_scope_jsonb) ? $assignment->override_scope_jsonb : null)
|
||||
: null;
|
||||
$effectiveScope = BaselineScope::effective($profileScope, $overrideScope);
|
||||
try {
|
||||
$profileScope = $profile->normalizedScope();
|
||||
$overrideScope = $assignment->override_scope_jsonb !== null
|
||||
? BaselineScope::fromJsonb(
|
||||
is_array($assignment->override_scope_jsonb) ? $assignment->override_scope_jsonb : null,
|
||||
allowEmptyLegacyAsNoOverride: true,
|
||||
)
|
||||
: null;
|
||||
$effectiveScope = BaselineScope::effective($profileScope, $overrideScope);
|
||||
} catch (InvalidArgumentException) {
|
||||
return self::empty(
|
||||
'invalid_scope',
|
||||
'The assigned baseline scope is invalid or no longer supported. A workspace manager must review the baseline definition.',
|
||||
);
|
||||
}
|
||||
|
||||
$duplicateNameStats = self::duplicateNameStats($tenant, $effectiveScope);
|
||||
$duplicateNamePoliciesCount = $duplicateNameStats['policy_count'];
|
||||
|
||||
@ -18,6 +18,10 @@ final class BaselineReasonCodes
|
||||
|
||||
public const string CAPTURE_ROLLOUT_DISABLED = 'baseline.capture.rollout_disabled';
|
||||
|
||||
public const string CAPTURE_INVALID_SCOPE = 'baseline.capture.invalid_scope';
|
||||
|
||||
public const string CAPTURE_UNSUPPORTED_SCOPE = 'baseline.capture.unsupported_scope';
|
||||
|
||||
public const string SNAPSHOT_BUILDING = 'baseline.snapshot.building';
|
||||
|
||||
public const string SNAPSHOT_INCOMPLETE = 'baseline.snapshot.incomplete';
|
||||
@ -46,6 +50,12 @@ final class BaselineReasonCodes
|
||||
|
||||
public const string COMPARE_ROLLOUT_DISABLED = 'baseline.compare.rollout_disabled';
|
||||
|
||||
public const string COMPARE_INVALID_SCOPE = 'baseline.compare.invalid_scope';
|
||||
|
||||
public const string COMPARE_UNSUPPORTED_SCOPE = 'baseline.compare.unsupported_scope';
|
||||
|
||||
public const string COMPARE_MIXED_SCOPE = 'baseline.compare.mixed_scope';
|
||||
|
||||
public const string COMPARE_SNAPSHOT_BUILDING = 'baseline.compare.snapshot_building';
|
||||
|
||||
public const string COMPARE_SNAPSHOT_INCOMPLETE = 'baseline.compare.snapshot_incomplete';
|
||||
@ -61,6 +71,8 @@ public static function all(): array
|
||||
self::CAPTURE_MISSING_SOURCE_TENANT,
|
||||
self::CAPTURE_PROFILE_NOT_ACTIVE,
|
||||
self::CAPTURE_ROLLOUT_DISABLED,
|
||||
self::CAPTURE_INVALID_SCOPE,
|
||||
self::CAPTURE_UNSUPPORTED_SCOPE,
|
||||
self::SNAPSHOT_BUILDING,
|
||||
self::SNAPSHOT_INCOMPLETE,
|
||||
self::SNAPSHOT_SUPERSEDED,
|
||||
@ -75,6 +87,9 @@ public static function all(): array
|
||||
self::COMPARE_NO_ELIGIBLE_TARGET,
|
||||
self::COMPARE_INVALID_SNAPSHOT,
|
||||
self::COMPARE_ROLLOUT_DISABLED,
|
||||
self::COMPARE_INVALID_SCOPE,
|
||||
self::COMPARE_UNSUPPORTED_SCOPE,
|
||||
self::COMPARE_MIXED_SCOPE,
|
||||
self::COMPARE_SNAPSHOT_BUILDING,
|
||||
self::COMPARE_SNAPSHOT_INCOMPLETE,
|
||||
self::COMPARE_SNAPSHOT_SUPERSEDED,
|
||||
@ -107,8 +122,13 @@ public static function trustImpact(?string $reasonCode): ?string
|
||||
self::COMPARE_SNAPSHOT_BUILDING,
|
||||
self::COMPARE_SNAPSHOT_INCOMPLETE,
|
||||
self::COMPARE_SNAPSHOT_SUPERSEDED,
|
||||
self::COMPARE_INVALID_SCOPE,
|
||||
self::COMPARE_UNSUPPORTED_SCOPE,
|
||||
self::COMPARE_MIXED_SCOPE,
|
||||
self::CAPTURE_MISSING_SOURCE_TENANT,
|
||||
self::CAPTURE_PROFILE_NOT_ACTIVE => 'unusable',
|
||||
self::CAPTURE_PROFILE_NOT_ACTIVE,
|
||||
self::CAPTURE_INVALID_SCOPE,
|
||||
self::CAPTURE_UNSUPPORTED_SCOPE => 'unusable',
|
||||
default => null,
|
||||
};
|
||||
}
|
||||
@ -132,10 +152,15 @@ public static function absencePattern(?string $reasonCode): ?string
|
||||
self::COMPARE_PROFILE_NOT_ACTIVE,
|
||||
self::COMPARE_NO_ELIGIBLE_TARGET,
|
||||
self::COMPARE_INVALID_SNAPSHOT,
|
||||
self::COMPARE_INVALID_SCOPE,
|
||||
self::COMPARE_UNSUPPORTED_SCOPE,
|
||||
self::COMPARE_MIXED_SCOPE,
|
||||
self::COMPARE_ROLLOUT_DISABLED,
|
||||
self::SNAPSHOT_SUPERSEDED,
|
||||
self::COMPARE_SNAPSHOT_SUPERSEDED => 'blocked_prerequisite',
|
||||
self::SNAPSHOT_CAPTURE_FAILED => 'unavailable',
|
||||
self::CAPTURE_INVALID_SCOPE,
|
||||
self::CAPTURE_UNSUPPORTED_SCOPE => 'unavailable',
|
||||
default => null,
|
||||
};
|
||||
}
|
||||
|
||||
@ -4,26 +4,36 @@
|
||||
|
||||
namespace App\Support\Baselines;
|
||||
|
||||
use App\Support\Governance\GovernanceDomainKey;
|
||||
use App\Support\Governance\GovernanceSubjectClass;
|
||||
use App\Support\Governance\GovernanceSubjectTaxonomyRegistry;
|
||||
use App\Support\Inventory\InventoryPolicyTypeMeta;
|
||||
use InvalidArgumentException;
|
||||
|
||||
/**
|
||||
* Value object for baseline scope resolution.
|
||||
*
|
||||
* A scope defines which policy types are included in a baseline profile.
|
||||
*
|
||||
* Spec 116 semantics:
|
||||
* - Empty policy_types means "all supported policy types" (excluding foundations).
|
||||
* - Empty foundation_types means "none".
|
||||
* Canonical storage uses versioned Governance Scope V2 entries.
|
||||
* Presentation compatibility for the current Intune-first UI still projects
|
||||
* back to legacy policy and foundation buckets.
|
||||
*/
|
||||
final class BaselineScope
|
||||
{
|
||||
/**
|
||||
* @param array<string> $policyTypes
|
||||
* @param array<string> $foundationTypes
|
||||
* @param list<string> $policyTypes
|
||||
* @param list<string> $foundationTypes
|
||||
* @param list<array{domain_key: string, subject_class: string, subject_type_keys: list<string>, filters: array<string, mixed>}> $entries
|
||||
* @param list<string> $legacyKeysPresent
|
||||
*/
|
||||
public function __construct(
|
||||
public readonly array $policyTypes = [],
|
||||
public readonly array $foundationTypes = [],
|
||||
public readonly array $entries = [],
|
||||
public readonly int $version = 2,
|
||||
public readonly string $sourceShape = 'canonical_v2',
|
||||
public readonly bool $normalizedOnRead = false,
|
||||
public readonly array $legacyKeysPresent = [],
|
||||
public readonly bool $saveForwardRequired = false,
|
||||
) {}
|
||||
|
||||
/**
|
||||
@ -31,21 +41,63 @@ public function __construct(
|
||||
*
|
||||
* @param array<string, mixed>|null $scopeJsonb
|
||||
*/
|
||||
public static function fromJsonb(?array $scopeJsonb): self
|
||||
public static function fromJsonb(?array $scopeJsonb, bool $allowEmptyLegacyAsNoOverride = false): self
|
||||
{
|
||||
if ($scopeJsonb === null) {
|
||||
return new self;
|
||||
return self::fromLegacyPayload(
|
||||
policyTypes: [],
|
||||
foundationTypes: [],
|
||||
legacyKeysPresent: [],
|
||||
normalizedOnRead: true,
|
||||
saveForwardRequired: true,
|
||||
);
|
||||
}
|
||||
|
||||
$policyTypes = $scopeJsonb['policy_types'] ?? [];
|
||||
$foundationTypes = $scopeJsonb['foundation_types'] ?? [];
|
||||
if (isset($scopeJsonb['canonical_scope']) && is_array($scopeJsonb['canonical_scope'])) {
|
||||
return self::fromJsonb($scopeJsonb['canonical_scope'], $allowEmptyLegacyAsNoOverride);
|
||||
}
|
||||
|
||||
$policyTypes = is_array($policyTypes) ? array_values(array_filter($policyTypes, 'is_string')) : [];
|
||||
$foundationTypes = is_array($foundationTypes) ? array_values(array_filter($foundationTypes, 'is_string')) : [];
|
||||
$hasLegacyKeys = array_key_exists('policy_types', $scopeJsonb) || array_key_exists('foundation_types', $scopeJsonb);
|
||||
$hasCanonicalKeys = array_key_exists('version', $scopeJsonb) || array_key_exists('entries', $scopeJsonb);
|
||||
|
||||
return new self(
|
||||
policyTypes: $policyTypes === [] ? [] : self::normalizePolicyTypes($policyTypes),
|
||||
foundationTypes: self::normalizeFoundationTypes($foundationTypes),
|
||||
if ($hasLegacyKeys && $hasCanonicalKeys) {
|
||||
throw new InvalidArgumentException('Baseline scope payload must not mix legacy buckets with canonical V2 keys.');
|
||||
}
|
||||
|
||||
if ($hasCanonicalKeys) {
|
||||
return self::fromCanonicalPayload($scopeJsonb);
|
||||
}
|
||||
|
||||
if (! $hasLegacyKeys) {
|
||||
throw new InvalidArgumentException('Baseline scope payload must contain either legacy buckets or canonical V2 keys.');
|
||||
}
|
||||
|
||||
$legacyKeysPresent = array_values(array_filter([
|
||||
array_key_exists('policy_types', $scopeJsonb) ? 'policy_types' : null,
|
||||
array_key_exists('foundation_types', $scopeJsonb) ? 'foundation_types' : null,
|
||||
]));
|
||||
|
||||
$policyTypes = self::stringList($scopeJsonb['policy_types'] ?? []);
|
||||
$foundationTypes = self::stringList($scopeJsonb['foundation_types'] ?? []);
|
||||
|
||||
if ($allowEmptyLegacyAsNoOverride && $policyTypes === [] && $foundationTypes === []) {
|
||||
return new self(
|
||||
policyTypes: [],
|
||||
foundationTypes: [],
|
||||
entries: [],
|
||||
sourceShape: 'legacy',
|
||||
normalizedOnRead: false,
|
||||
legacyKeysPresent: $legacyKeysPresent,
|
||||
saveForwardRequired: false,
|
||||
);
|
||||
}
|
||||
|
||||
return self::fromLegacyPayload(
|
||||
policyTypes: $policyTypes,
|
||||
foundationTypes: $foundationTypes,
|
||||
legacyKeysPresent: $legacyKeysPresent,
|
||||
normalizedOnRead: true,
|
||||
saveForwardRequired: true,
|
||||
);
|
||||
}
|
||||
|
||||
@ -65,19 +117,41 @@ public static function effective(self $profileScope, ?self $overrideScope): self
|
||||
|
||||
$overridePolicyTypes = self::normalizePolicyTypes($overrideScope->policyTypes);
|
||||
$overrideFoundationTypes = self::normalizeFoundationTypes($overrideScope->foundationTypes);
|
||||
$entries = [];
|
||||
|
||||
$effectivePolicyTypes = $overridePolicyTypes !== []
|
||||
? array_values(array_intersect($profileScope->policyTypes, $overridePolicyTypes))
|
||||
: $profileScope->policyTypes;
|
||||
foreach ($profileScope->entries as $entry) {
|
||||
$subjectTypeKeys = $entry['subject_type_keys'];
|
||||
|
||||
$effectiveFoundationTypes = $overrideFoundationTypes !== []
|
||||
? array_values(array_intersect($profileScope->foundationTypes, $overrideFoundationTypes))
|
||||
: $profileScope->foundationTypes;
|
||||
if ($entry['domain_key'] === GovernanceDomainKey::Intune->value
|
||||
&& $entry['subject_class'] === GovernanceSubjectClass::Policy->value
|
||||
&& $overridePolicyTypes !== []) {
|
||||
$subjectTypeKeys = array_values(array_intersect($subjectTypeKeys, $overridePolicyTypes));
|
||||
}
|
||||
|
||||
return new self(
|
||||
policyTypes: self::uniqueSorted($effectivePolicyTypes),
|
||||
foundationTypes: self::uniqueSorted($effectiveFoundationTypes),
|
||||
);
|
||||
if ($entry['domain_key'] === GovernanceDomainKey::PlatformFoundation->value
|
||||
&& $entry['subject_class'] === GovernanceSubjectClass::ConfigurationResource->value
|
||||
&& $overrideFoundationTypes !== []) {
|
||||
$subjectTypeKeys = array_values(array_intersect($subjectTypeKeys, $overrideFoundationTypes));
|
||||
}
|
||||
|
||||
$subjectTypeKeys = self::uniqueSorted($subjectTypeKeys);
|
||||
|
||||
if ($subjectTypeKeys === []) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$entries[] = [
|
||||
'domain_key' => $entry['domain_key'],
|
||||
'subject_class' => $entry['subject_class'],
|
||||
'subject_type_keys' => $subjectTypeKeys,
|
||||
'filters' => $entry['filters'],
|
||||
];
|
||||
}
|
||||
|
||||
return self::fromCanonicalPayload([
|
||||
'version' => 2,
|
||||
'entries' => $entries,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
@ -85,7 +159,7 @@ public static function effective(self $profileScope, ?self $overrideScope): self
|
||||
*/
|
||||
public function isEmpty(): bool
|
||||
{
|
||||
return $this->policyTypes === [] && $this->foundationTypes === [];
|
||||
return $this->entries === [] && $this->policyTypes === [] && $this->foundationTypes === [];
|
||||
}
|
||||
|
||||
/**
|
||||
@ -93,15 +167,16 @@ public function isEmpty(): bool
|
||||
*/
|
||||
public function expandDefaults(): self
|
||||
{
|
||||
$policyTypes = $this->policyTypes === []
|
||||
? self::supportedPolicyTypes()
|
||||
: self::normalizePolicyTypes($this->policyTypes);
|
||||
if ($this->entries !== []) {
|
||||
return $this;
|
||||
}
|
||||
|
||||
$foundationTypes = self::normalizeFoundationTypes($this->foundationTypes);
|
||||
|
||||
return new self(
|
||||
policyTypes: $policyTypes,
|
||||
foundationTypes: $foundationTypes,
|
||||
return self::fromLegacyPayload(
|
||||
policyTypes: $this->policyTypes,
|
||||
foundationTypes: $this->foundationTypes,
|
||||
legacyKeysPresent: $this->legacyKeysPresent,
|
||||
normalizedOnRead: $this->normalizedOnRead,
|
||||
saveForwardRequired: $this->saveForwardRequired,
|
||||
);
|
||||
}
|
||||
|
||||
@ -112,9 +187,19 @@ public function allTypes(): array
|
||||
{
|
||||
$expanded = $this->expandDefaults();
|
||||
|
||||
$canonicalTypeKeys = [];
|
||||
|
||||
foreach ($expanded->entries as $entry) {
|
||||
$canonicalTypeKeys = array_merge(
|
||||
$canonicalTypeKeys,
|
||||
is_array($entry['subject_type_keys'] ?? null) ? $entry['subject_type_keys'] : [],
|
||||
);
|
||||
}
|
||||
|
||||
return self::uniqueSorted(array_merge(
|
||||
$expanded->policyTypes,
|
||||
$expanded->foundationTypes,
|
||||
$canonicalTypeKeys,
|
||||
));
|
||||
}
|
||||
|
||||
@ -134,26 +219,107 @@ public function truthfulTypes(string $operation, ?BaselineSupportCapabilityGuard
|
||||
*/
|
||||
public function toJsonb(): array
|
||||
{
|
||||
$supportedPolicyTypes = self::supportedPolicyTypes();
|
||||
$policyTypes = $this->policyTypes;
|
||||
|
||||
if ($policyTypes === $supportedPolicyTypes) {
|
||||
$policyTypes = [];
|
||||
}
|
||||
|
||||
return [
|
||||
'policy_types' => $this->policyTypes,
|
||||
'policy_types' => $policyTypes,
|
||||
'foundation_types' => $this->foundationTypes,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{version: 2, entries: list<array{domain_key: string, subject_class: string, subject_type_keys: list<string>, filters: array<string, mixed>}>}
|
||||
*/
|
||||
public function toStoredJsonb(): array
|
||||
{
|
||||
return [
|
||||
'version' => 2,
|
||||
'entries' => $this->entries,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{source_shape: string, normalized_on_read: bool, legacy_keys_present: list<string>, save_forward_required: bool}
|
||||
*/
|
||||
public function normalizationLineage(): array
|
||||
{
|
||||
return [
|
||||
'source_shape' => $this->sourceShape,
|
||||
'normalized_on_read' => $this->normalizedOnRead,
|
||||
'legacy_keys_present' => $this->legacyKeysPresent,
|
||||
'save_forward_required' => $this->saveForwardRequired,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<array{domain_key: string, subject_class: string, group_label: string, selected_subject_types: list<string>, capture_supported_count: int, compare_supported_count: int, inactive_count: int}>
|
||||
*/
|
||||
public function summaryGroups(?GovernanceSubjectTaxonomyRegistry $registry = null): array
|
||||
{
|
||||
$registry ??= app(GovernanceSubjectTaxonomyRegistry::class);
|
||||
$groups = [];
|
||||
|
||||
foreach ($this->entries as $entry) {
|
||||
$selectedSubjectTypes = [];
|
||||
$captureSupportedCount = 0;
|
||||
$compareSupportedCount = 0;
|
||||
$inactiveCount = 0;
|
||||
|
||||
foreach ($entry['subject_type_keys'] as $subjectTypeKey) {
|
||||
$subjectType = $registry->find($entry['domain_key'], $subjectTypeKey);
|
||||
$selectedSubjectTypes[] = $subjectType?->label ?? $subjectTypeKey;
|
||||
|
||||
if ($subjectType?->captureSupported) {
|
||||
$captureSupportedCount++;
|
||||
}
|
||||
|
||||
if ($subjectType?->compareSupported) {
|
||||
$compareSupportedCount++;
|
||||
}
|
||||
|
||||
if ($subjectType !== null && ! $subjectType->active) {
|
||||
$inactiveCount++;
|
||||
}
|
||||
}
|
||||
|
||||
sort($selectedSubjectTypes, SORT_STRING);
|
||||
|
||||
$groups[] = [
|
||||
'domain_key' => $entry['domain_key'],
|
||||
'subject_class' => $entry['subject_class'],
|
||||
'group_label' => $registry->groupLabel($entry['domain_key'], $entry['subject_class']),
|
||||
'selected_subject_types' => $selectedSubjectTypes,
|
||||
'capture_supported_count' => $captureSupportedCount,
|
||||
'compare_supported_count' => $compareSupportedCount,
|
||||
'inactive_count' => $inactiveCount,
|
||||
];
|
||||
}
|
||||
|
||||
return $groups;
|
||||
}
|
||||
|
||||
/**
|
||||
* Effective scope payload for OperationRun.context.
|
||||
*
|
||||
* @return array{policy_types: list<string>, foundation_types: list<string>, all_types: list<string>, foundations_included: bool}
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function toEffectiveScopeContext(?BaselineSupportCapabilityGuard $guard = null, ?string $operation = null): array
|
||||
{
|
||||
$expanded = $this->expandDefaults();
|
||||
$allTypes = self::uniqueSorted(array_merge($expanded->policyTypes, $expanded->foundationTypes));
|
||||
$allTypes = $expanded->allTypes();
|
||||
|
||||
$context = [
|
||||
'canonical_scope' => $expanded->toStoredJsonb(),
|
||||
'legacy_projection' => $expanded->toJsonb(),
|
||||
'policy_types' => $expanded->policyTypes,
|
||||
'foundation_types' => $expanded->foundationTypes,
|
||||
'all_types' => $allTypes,
|
||||
'selected_type_keys' => $allTypes,
|
||||
'foundations_included' => $expanded->foundationTypes !== [],
|
||||
];
|
||||
|
||||
@ -170,28 +336,34 @@ public function toEffectiveScopeContext(?BaselineSupportCapabilityGuard $guard =
|
||||
'unsupported_types' => $guardResult['unsupported_types'],
|
||||
'invalid_support_types' => $guardResult['invalid_support_types'],
|
||||
'capabilities' => $guardResult['capabilities'],
|
||||
'allowed_type_keys' => $guardResult['allowed_types'],
|
||||
'limited_type_keys' => $guardResult['limited_types'],
|
||||
'unsupported_type_keys' => $guardResult['unsupported_types'],
|
||||
'capabilities_by_type' => $guardResult['capabilities'],
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{ok: bool, unsupported_types: list<string>, invalid_support_types: list<string>}
|
||||
*/
|
||||
public function operationEligibility(string $operation, ?BaselineSupportCapabilityGuard $guard = null): array
|
||||
{
|
||||
$guard ??= app(BaselineSupportCapabilityGuard::class);
|
||||
$guardResult = $guard->guardTypes($this->allTypes(), $operation);
|
||||
|
||||
return [
|
||||
'ok' => $guardResult['unsupported_types'] === [] && $guardResult['invalid_support_types'] === [],
|
||||
'unsupported_types' => $guardResult['unsupported_types'],
|
||||
'invalid_support_types' => $guardResult['invalid_support_types'],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<string>
|
||||
*/
|
||||
private static function supportedPolicyTypes(): array
|
||||
{
|
||||
$supported = config('tenantpilot.supported_policy_types', []);
|
||||
|
||||
if (! is_array($supported)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$types = collect($supported)
|
||||
->filter(fn (mixed $row): bool => is_array($row) && filled($row['type'] ?? null))
|
||||
->map(fn (array $row): string => (string) $row['type'])
|
||||
->filter(fn (string $type): bool => $type !== '')
|
||||
->values()
|
||||
->all();
|
||||
|
||||
return self::uniqueSorted($types);
|
||||
return app(GovernanceSubjectTaxonomyRegistry::class)->activeLegacyBucketKeys('policy_types');
|
||||
}
|
||||
|
||||
/**
|
||||
@ -199,14 +371,7 @@ private static function supportedPolicyTypes(): array
|
||||
*/
|
||||
private static function supportedFoundationTypes(): array
|
||||
{
|
||||
$types = collect(InventoryPolicyTypeMeta::baselineSupportedFoundations())
|
||||
->filter(fn (mixed $row): bool => is_array($row) && filled($row['type'] ?? null))
|
||||
->map(fn (array $row): string => (string) $row['type'])
|
||||
->filter(fn (string $type): bool => $type !== '')
|
||||
->values()
|
||||
->all();
|
||||
|
||||
return self::uniqueSorted($types);
|
||||
return app(GovernanceSubjectTaxonomyRegistry::class)->activeLegacyBucketKeys('foundation_types');
|
||||
}
|
||||
|
||||
/**
|
||||
@ -243,4 +408,286 @@ private static function uniqueSorted(array $types): array
|
||||
|
||||
return $types;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<string> $policyTypes
|
||||
* @param list<string> $foundationTypes
|
||||
* @param list<string> $legacyKeysPresent
|
||||
*/
|
||||
private static function fromLegacyPayload(
|
||||
array $policyTypes,
|
||||
array $foundationTypes,
|
||||
array $legacyKeysPresent,
|
||||
bool $normalizedOnRead,
|
||||
bool $saveForwardRequired,
|
||||
): self {
|
||||
$policyTypes = $policyTypes === []
|
||||
? self::supportedPolicyTypes()
|
||||
: self::normalizePolicyTypes($policyTypes);
|
||||
|
||||
$foundationTypes = self::normalizeFoundationTypes($foundationTypes);
|
||||
$entries = [];
|
||||
|
||||
if ($policyTypes !== []) {
|
||||
$entries[] = [
|
||||
'domain_key' => GovernanceDomainKey::Intune->value,
|
||||
'subject_class' => GovernanceSubjectClass::Policy->value,
|
||||
'subject_type_keys' => $policyTypes,
|
||||
'filters' => [],
|
||||
];
|
||||
}
|
||||
|
||||
if ($foundationTypes !== []) {
|
||||
$entries[] = [
|
||||
'domain_key' => GovernanceDomainKey::PlatformFoundation->value,
|
||||
'subject_class' => GovernanceSubjectClass::ConfigurationResource->value,
|
||||
'subject_type_keys' => $foundationTypes,
|
||||
'filters' => [],
|
||||
];
|
||||
}
|
||||
|
||||
return new self(
|
||||
policyTypes: $policyTypes,
|
||||
foundationTypes: $foundationTypes,
|
||||
entries: $entries,
|
||||
sourceShape: 'legacy',
|
||||
normalizedOnRead: $normalizedOnRead,
|
||||
legacyKeysPresent: $legacyKeysPresent,
|
||||
saveForwardRequired: $saveForwardRequired,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $scopeJsonb
|
||||
*/
|
||||
private static function fromCanonicalPayload(array $scopeJsonb): self
|
||||
{
|
||||
if (($scopeJsonb['version'] ?? null) !== 2) {
|
||||
throw new InvalidArgumentException('Baseline scope version must equal 2.');
|
||||
}
|
||||
|
||||
$entries = $scopeJsonb['entries'] ?? null;
|
||||
|
||||
if (! is_array($entries) || $entries === []) {
|
||||
throw new InvalidArgumentException('Baseline scope V2 entries must be a non-empty array.');
|
||||
}
|
||||
|
||||
$normalizedEntries = self::normalizeEntries($entries);
|
||||
[$policyTypes, $foundationTypes] = self::legacyProjectionFromEntries($normalizedEntries);
|
||||
|
||||
return new self(
|
||||
policyTypes: $policyTypes,
|
||||
foundationTypes: $foundationTypes,
|
||||
entries: $normalizedEntries,
|
||||
sourceShape: 'canonical_v2',
|
||||
normalizedOnRead: false,
|
||||
legacyKeysPresent: [],
|
||||
saveForwardRequired: false,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<mixed> $entries
|
||||
* @return list<array{domain_key: string, subject_class: string, subject_type_keys: list<string>, filters: array<string, mixed>}>
|
||||
*/
|
||||
private static function normalizeEntries(array $entries): array
|
||||
{
|
||||
$registry = app(GovernanceSubjectTaxonomyRegistry::class);
|
||||
$normalizedEntries = [];
|
||||
$subjectFilters = [];
|
||||
|
||||
foreach ($entries as $entry) {
|
||||
if (! is_array($entry)) {
|
||||
throw new InvalidArgumentException('Each canonical baseline scope entry must be an array.');
|
||||
}
|
||||
|
||||
$domainKey = trim((string) ($entry['domain_key'] ?? ''));
|
||||
$subjectClass = trim((string) ($entry['subject_class'] ?? ''));
|
||||
|
||||
if (! $registry->isKnownDomain($domainKey)) {
|
||||
throw new InvalidArgumentException('Unknown governance domain ['.$domainKey.'].');
|
||||
}
|
||||
|
||||
if (! $registry->allowsSubjectClass($domainKey, $subjectClass)) {
|
||||
throw new InvalidArgumentException('Subject class ['.$subjectClass.'] is not valid for domain ['.$domainKey.'].');
|
||||
}
|
||||
|
||||
$subjectTypeKeys = self::stringList($entry['subject_type_keys'] ?? null);
|
||||
|
||||
if ($subjectTypeKeys === []) {
|
||||
throw new InvalidArgumentException('Canonical baseline scope entries must include at least one subject type key.');
|
||||
}
|
||||
|
||||
$filters = $entry['filters'] ?? [];
|
||||
|
||||
if (! is_array($filters)) {
|
||||
throw new InvalidArgumentException('Baseline scope entry filters must be an object-shaped array.');
|
||||
}
|
||||
|
||||
$filters = self::normalizeFilters($filters);
|
||||
|
||||
if ($filters !== [] && ! $registry->supportsFilters($domainKey, $subjectClass)) {
|
||||
throw new InvalidArgumentException('Filters are not supported for the current governance domain and subject class.');
|
||||
}
|
||||
|
||||
$subjectTypeKeys = array_map(
|
||||
static fn (string $subjectTypeKey): string => trim($subjectTypeKey),
|
||||
self::uniqueSorted($subjectTypeKeys),
|
||||
);
|
||||
|
||||
foreach ($subjectTypeKeys as $subjectTypeKey) {
|
||||
$subjectType = $registry->find($domainKey, $subjectTypeKey);
|
||||
|
||||
if ($subjectType === null) {
|
||||
throw new InvalidArgumentException('Unknown subject type ['.$subjectTypeKey.'] for domain ['.$domainKey.'].');
|
||||
}
|
||||
|
||||
if ($subjectType->subjectClass->value !== $subjectClass) {
|
||||
throw new InvalidArgumentException('Subject type ['.$subjectTypeKey.'] does not belong to subject class ['.$subjectClass.'].');
|
||||
}
|
||||
|
||||
if (! $subjectType->active) {
|
||||
throw new InvalidArgumentException('Inactive subject type ['.$subjectTypeKey.'] cannot be selected.');
|
||||
}
|
||||
}
|
||||
|
||||
$filtersHash = self::filtersHash($filters);
|
||||
|
||||
foreach ($subjectTypeKeys as $subjectTypeKey) {
|
||||
$subjectKey = implode('|', [$domainKey, $subjectClass, $subjectTypeKey]);
|
||||
$existingFiltersHash = $subjectFilters[$subjectKey] ?? null;
|
||||
|
||||
if ($existingFiltersHash !== null && $existingFiltersHash !== $filtersHash) {
|
||||
throw new InvalidArgumentException('Ambiguous baseline scope filters were provided for ['.$subjectTypeKey.'].');
|
||||
}
|
||||
|
||||
$subjectFilters[$subjectKey] = $filtersHash;
|
||||
}
|
||||
|
||||
$entryKey = implode('|', [$domainKey, $subjectClass, $filtersHash]);
|
||||
|
||||
if (! array_key_exists($entryKey, $normalizedEntries)) {
|
||||
$normalizedEntries[$entryKey] = [
|
||||
'domain_key' => $domainKey,
|
||||
'subject_class' => $subjectClass,
|
||||
'subject_type_keys' => [],
|
||||
'filters' => $filters,
|
||||
];
|
||||
}
|
||||
|
||||
$normalizedEntries[$entryKey]['subject_type_keys'] = self::uniqueSorted(array_merge(
|
||||
$normalizedEntries[$entryKey]['subject_type_keys'],
|
||||
$subjectTypeKeys,
|
||||
));
|
||||
}
|
||||
|
||||
$normalizedEntries = array_values($normalizedEntries);
|
||||
|
||||
usort($normalizedEntries, static function (array $left, array $right): int {
|
||||
$leftKey = implode('|', [
|
||||
$left['domain_key'],
|
||||
$left['subject_class'],
|
||||
self::filtersHash($left['filters']),
|
||||
implode(',', $left['subject_type_keys']),
|
||||
]);
|
||||
$rightKey = implode('|', [
|
||||
$right['domain_key'],
|
||||
$right['subject_class'],
|
||||
self::filtersHash($right['filters']),
|
||||
implode(',', $right['subject_type_keys']),
|
||||
]);
|
||||
|
||||
return $leftKey <=> $rightKey;
|
||||
});
|
||||
|
||||
return $normalizedEntries;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<array{domain_key: string, subject_class: string, subject_type_keys: list<string>, filters: array<string, mixed>}> $entries
|
||||
* @return array{0: list<string>, 1: list<string>}
|
||||
*/
|
||||
private static function legacyProjectionFromEntries(array $entries): array
|
||||
{
|
||||
$policyTypes = [];
|
||||
$foundationTypes = [];
|
||||
|
||||
foreach ($entries as $entry) {
|
||||
if ($entry['domain_key'] === GovernanceDomainKey::Intune->value
|
||||
&& $entry['subject_class'] === GovernanceSubjectClass::Policy->value) {
|
||||
$policyTypes = array_merge($policyTypes, $entry['subject_type_keys']);
|
||||
}
|
||||
|
||||
if ($entry['domain_key'] === GovernanceDomainKey::PlatformFoundation->value
|
||||
&& $entry['subject_class'] === GovernanceSubjectClass::ConfigurationResource->value) {
|
||||
$foundationTypes = array_merge($foundationTypes, $entry['subject_type_keys']);
|
||||
}
|
||||
}
|
||||
|
||||
return [
|
||||
self::uniqueSorted($policyTypes),
|
||||
self::uniqueSorted($foundationTypes),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param mixed $values
|
||||
* @return list<string>
|
||||
*/
|
||||
private static function stringList(mixed $values): array
|
||||
{
|
||||
if (! is_array($values)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return array_values(array_filter($values, 'is_string'));
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $filters
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private static function normalizeFilters(array $filters): array
|
||||
{
|
||||
ksort($filters);
|
||||
|
||||
foreach ($filters as $key => $value) {
|
||||
if (is_array($value)) {
|
||||
$filters[$key] = self::normalizeFilterArray($value);
|
||||
}
|
||||
}
|
||||
|
||||
return $filters;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int|string, mixed> $values
|
||||
* @return array<int|string, mixed>
|
||||
*/
|
||||
private static function normalizeFilterArray(array $values): array
|
||||
{
|
||||
foreach ($values as $key => $value) {
|
||||
if (is_array($value)) {
|
||||
$values[$key] = self::normalizeFilterArray($value);
|
||||
}
|
||||
}
|
||||
|
||||
if (array_is_list($values)) {
|
||||
sort($values);
|
||||
|
||||
return array_values($values);
|
||||
}
|
||||
|
||||
ksort($values);
|
||||
|
||||
return $values;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $filters
|
||||
*/
|
||||
private static function filtersHash(array $filters): string
|
||||
{
|
||||
return json_encode($filters, JSON_THROW_ON_ERROR);
|
||||
}
|
||||
}
|
||||
|
||||
@ -0,0 +1,46 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Support\Baselines\Compare;
|
||||
|
||||
use InvalidArgumentException;
|
||||
|
||||
final class CompareFindingCandidate
|
||||
{
|
||||
/**
|
||||
* @param array<string, mixed> $fingerprintBasis
|
||||
* @param array<string, mixed> $evidencePayload
|
||||
*/
|
||||
public function __construct(
|
||||
public readonly string $changeType,
|
||||
public readonly string $severity,
|
||||
public readonly array $fingerprintBasis,
|
||||
public readonly array $evidencePayload,
|
||||
public readonly bool $autoCloseEligible = true,
|
||||
) {
|
||||
if (trim($this->changeType) === '' || trim($this->severity) === '') {
|
||||
throw new InvalidArgumentException('Compare finding candidates require non-empty change type and severity values.');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{
|
||||
* change_type: string,
|
||||
* severity: string,
|
||||
* fingerprint_basis: array<string, mixed>,
|
||||
* evidence_payload: array<string, mixed>,
|
||||
* auto_close_eligible: bool
|
||||
* }
|
||||
*/
|
||||
public function toArray(): array
|
||||
{
|
||||
return [
|
||||
'change_type' => $this->changeType,
|
||||
'severity' => $this->severity,
|
||||
'fingerprint_basis' => $this->fingerprintBasis,
|
||||
'evidence_payload' => $this->evidencePayload,
|
||||
'auto_close_eligible' => $this->autoCloseEligible,
|
||||
];
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,82 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Support\Baselines\Compare;
|
||||
|
||||
use InvalidArgumentException;
|
||||
|
||||
final class CompareOrchestrationContext
|
||||
{
|
||||
/**
|
||||
* @param array<string, mixed> $normalizedScope
|
||||
* @param array<string, mixed> $coverageContext
|
||||
* @param array<string, mixed> $launchContext
|
||||
*/
|
||||
public function __construct(
|
||||
public readonly int $workspaceId,
|
||||
public readonly int $tenantId,
|
||||
public readonly int $baselineProfileId,
|
||||
public readonly int $baselineSnapshotId,
|
||||
public readonly int $operationRunId,
|
||||
public readonly array $normalizedScope,
|
||||
public readonly CompareStrategySelection $strategySelection,
|
||||
public readonly array $coverageContext = [],
|
||||
public readonly array $launchContext = [],
|
||||
) {
|
||||
if ($this->workspaceId <= 0 || $this->tenantId <= 0 || $this->baselineProfileId <= 0 || $this->baselineSnapshotId <= 0 || $this->operationRunId <= 0) {
|
||||
throw new InvalidArgumentException('Compare orchestration contexts require positive workspace, tenant, profile, snapshot, and operation run identifiers.');
|
||||
}
|
||||
}
|
||||
|
||||
public function strategyKey(): CompareStrategyKey
|
||||
{
|
||||
if (! $this->strategySelection->strategyKey instanceof CompareStrategyKey) {
|
||||
throw new InvalidArgumentException('Compare orchestration context requires a supported strategy selection before execution.');
|
||||
}
|
||||
|
||||
return $this->strategySelection->strategyKey;
|
||||
}
|
||||
|
||||
public function inventorySyncRunId(): ?int
|
||||
{
|
||||
$value = $this->coverageContext['inventory_sync_run_id'] ?? $this->launchContext['inventory_sync_run_id'] ?? null;
|
||||
|
||||
return is_numeric($value) ? (int) $value : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{
|
||||
* workspace_id: int,
|
||||
* tenant_id: int,
|
||||
* baseline_profile_id: int,
|
||||
* baseline_snapshot_id: int,
|
||||
* operation_run_id: int,
|
||||
* normalized_scope: array<string, mixed>,
|
||||
* strategy_selection: array{
|
||||
* selection_state: string,
|
||||
* strategy_key: ?string,
|
||||
* matched_scope_entries: list<array<string, mixed>>,
|
||||
* rejected_scope_entries: list<array<string, mixed>>,
|
||||
* operator_reason: string,
|
||||
* diagnostics: array<string, mixed>
|
||||
* },
|
||||
* coverage_context: array<string, mixed>,
|
||||
* launch_context: array<string, mixed>
|
||||
* }
|
||||
*/
|
||||
public function toArray(): array
|
||||
{
|
||||
return [
|
||||
'workspace_id' => $this->workspaceId,
|
||||
'tenant_id' => $this->tenantId,
|
||||
'baseline_profile_id' => $this->baselineProfileId,
|
||||
'baseline_snapshot_id' => $this->baselineSnapshotId,
|
||||
'operation_run_id' => $this->operationRunId,
|
||||
'normalized_scope' => $this->normalizedScope,
|
||||
'strategy_selection' => $this->strategySelection->toArray(),
|
||||
'coverage_context' => $this->coverageContext,
|
||||
'launch_context' => $this->launchContext,
|
||||
];
|
||||
}
|
||||
}
|
||||
15
apps/platform/app/Support/Baselines/Compare/CompareState.php
Normal file
15
apps/platform/app/Support/Baselines/Compare/CompareState.php
Normal file
@ -0,0 +1,15 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Support\Baselines\Compare;
|
||||
|
||||
enum CompareState: string
|
||||
{
|
||||
case NoDrift = 'no_drift';
|
||||
case Drift = 'drift';
|
||||
case Unsupported = 'unsupported';
|
||||
case Incomplete = 'incomplete';
|
||||
case Ambiguous = 'ambiguous';
|
||||
case Failed = 'failed';
|
||||
}
|
||||
@ -0,0 +1,34 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Support\Baselines\Compare;
|
||||
|
||||
use App\Models\Tenant;
|
||||
use App\Services\Baselines\Evidence\ResolvedEvidence;
|
||||
|
||||
interface CompareStrategy
|
||||
{
|
||||
public function key(): CompareStrategyKey;
|
||||
|
||||
/**
|
||||
* @return list<CompareStrategyCapability>
|
||||
*/
|
||||
public function capabilities(): array;
|
||||
|
||||
/**
|
||||
* @param array<string, array{subject_type: string, subject_external_id: string, subject_key: string, policy_type: string, baseline_hash: string, meta_jsonb: array<string, mixed>}> $baselineItems
|
||||
* @param array<string, array{subject_external_id: string, subject_key: string, policy_type: string, meta_jsonb: array<string, mixed>}> $currentItems
|
||||
* @param array<string, ResolvedEvidence|null> $resolvedCurrentEvidence
|
||||
* @param array<string, string> $severityMapping
|
||||
* @return array{subject_results: list<CompareSubjectResult>, diagnostics: array<string, mixed>}
|
||||
*/
|
||||
public function compare(
|
||||
CompareOrchestrationContext $context,
|
||||
Tenant $tenant,
|
||||
array $baselineItems,
|
||||
array $currentItems,
|
||||
array $resolvedCurrentEvidence,
|
||||
array $severityMapping,
|
||||
): array;
|
||||
}
|
||||
@ -0,0 +1,84 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Support\Baselines\Compare;
|
||||
|
||||
use InvalidArgumentException;
|
||||
|
||||
final class CompareStrategyCapability
|
||||
{
|
||||
/**
|
||||
* @param list<string> $domainKeys
|
||||
* @param list<string> $subjectClasses
|
||||
* @param list<string>|'all' $subjectTypeKeys
|
||||
*/
|
||||
public function __construct(
|
||||
public readonly CompareStrategyKey $strategyKey,
|
||||
public readonly array $domainKeys,
|
||||
public readonly array $subjectClasses,
|
||||
public readonly array|string $subjectTypeKeys = 'all',
|
||||
public readonly bool $compareSupported = true,
|
||||
public readonly bool $active = true,
|
||||
) {
|
||||
if ($this->domainKeys === [] || $this->subjectClasses === []) {
|
||||
throw new InvalidArgumentException('Compare strategy capabilities require at least one domain key and one subject class.');
|
||||
}
|
||||
|
||||
if ($this->subjectTypeKeys !== 'all' && $this->subjectTypeKeys === []) {
|
||||
throw new InvalidArgumentException('Compare strategy capabilities must either support all subject type keys or at least one explicit subject type key.');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array{domain_key?: mixed, subject_class?: mixed, subject_type_keys?: mixed} $entry
|
||||
*/
|
||||
public function supportsEntry(array $entry): bool
|
||||
{
|
||||
if (! $this->active || ! $this->compareSupported) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$domainKey = is_string($entry['domain_key'] ?? null) ? trim((string) $entry['domain_key']) : '';
|
||||
$subjectClass = is_string($entry['subject_class'] ?? null) ? trim((string) $entry['subject_class']) : '';
|
||||
$subjectTypeKeys = is_array($entry['subject_type_keys'] ?? null)
|
||||
? array_values(array_filter($entry['subject_type_keys'], 'is_string'))
|
||||
: [];
|
||||
|
||||
if ($domainKey === '' || $subjectClass === '' || $subjectTypeKeys === []) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (! in_array($domainKey, $this->domainKeys, true) || ! in_array($subjectClass, $this->subjectClasses, true)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if ($this->subjectTypeKeys === 'all') {
|
||||
return true;
|
||||
}
|
||||
|
||||
return array_diff($subjectTypeKeys, $this->subjectTypeKeys) === [];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{
|
||||
* strategy_key: string,
|
||||
* domain_keys: list<string>,
|
||||
* subject_classes: list<string>,
|
||||
* subject_type_keys: list<string>|'all',
|
||||
* compare_supported: bool,
|
||||
* active: bool
|
||||
* }
|
||||
*/
|
||||
public function toArray(): array
|
||||
{
|
||||
return [
|
||||
'strategy_key' => $this->strategyKey->value,
|
||||
'domain_keys' => $this->domainKeys,
|
||||
'subject_classes' => $this->subjectClasses,
|
||||
'subject_type_keys' => $this->subjectTypeKeys,
|
||||
'compare_supported' => $this->compareSupported,
|
||||
'active' => $this->active,
|
||||
];
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,44 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Support\Baselines\Compare;
|
||||
|
||||
use InvalidArgumentException;
|
||||
use Stringable;
|
||||
|
||||
final class CompareStrategyKey implements Stringable
|
||||
{
|
||||
public readonly string $value;
|
||||
|
||||
public function __construct(string $value)
|
||||
{
|
||||
$normalized = trim(mb_strtolower($value));
|
||||
|
||||
if ($normalized === '' || ! preg_match('/^[a-z0-9_]+$/', $normalized)) {
|
||||
throw new InvalidArgumentException('Compare strategy keys must be non-empty lowercase snake_case strings.');
|
||||
}
|
||||
|
||||
$this->value = $normalized;
|
||||
}
|
||||
|
||||
public static function from(self|string $value): self
|
||||
{
|
||||
return $value instanceof self ? $value : new self($value);
|
||||
}
|
||||
|
||||
public static function intunePolicy(): self
|
||||
{
|
||||
return new self('intune_policy');
|
||||
}
|
||||
|
||||
public function equals(self|string $other): bool
|
||||
{
|
||||
return $this->value === self::from($other)->value;
|
||||
}
|
||||
|
||||
public function __toString(): string
|
||||
{
|
||||
return $this->value;
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,202 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Support\Baselines\Compare;
|
||||
|
||||
use App\Support\Baselines\BaselineScope;
|
||||
use App\Support\Governance\GovernanceDomainKey;
|
||||
use App\Support\Governance\GovernanceSubjectClass;
|
||||
use InvalidArgumentException;
|
||||
|
||||
final class CompareStrategyRegistry
|
||||
{
|
||||
/**
|
||||
* @param list<CompareStrategy> $strategies
|
||||
*/
|
||||
public function __construct(
|
||||
private readonly array $strategies = [],
|
||||
) {}
|
||||
|
||||
/**
|
||||
* @return list<CompareStrategy>
|
||||
*/
|
||||
public function all(): array
|
||||
{
|
||||
if ($this->strategies !== []) {
|
||||
return $this->strategies;
|
||||
}
|
||||
|
||||
return [app(IntuneCompareStrategy::class)];
|
||||
}
|
||||
|
||||
public function find(CompareStrategyKey|string|null $strategyKey): ?CompareStrategy
|
||||
{
|
||||
if ($strategyKey === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$normalizedKey = CompareStrategyKey::from($strategyKey);
|
||||
|
||||
foreach ($this->all() as $strategy) {
|
||||
if ($strategy->key()->equals($normalizedKey)) {
|
||||
return $strategy;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
public function resolve(CompareStrategyKey|string $strategyKey): CompareStrategy
|
||||
{
|
||||
$strategy = $this->find($strategyKey);
|
||||
|
||||
if ($strategy instanceof CompareStrategy) {
|
||||
return $strategy;
|
||||
}
|
||||
|
||||
throw new InvalidArgumentException('Unknown compare strategy ['.CompareStrategyKey::from($strategyKey)->value.'].');
|
||||
}
|
||||
|
||||
public function select(BaselineScope $scope): CompareStrategySelection
|
||||
{
|
||||
$entries = $this->entriesForScope($scope);
|
||||
|
||||
if ($entries === []) {
|
||||
return CompareStrategySelection::unsupported(
|
||||
matchedScopeEntries: [],
|
||||
rejectedScopeEntries: [],
|
||||
diagnostics: [],
|
||||
operatorReason: 'No governed subjects were selected for compare.',
|
||||
);
|
||||
}
|
||||
|
||||
$matchedScopeEntries = [];
|
||||
$rejectedScopeEntries = [];
|
||||
$matchedStrategyKeys = [];
|
||||
$entryMatches = [];
|
||||
|
||||
foreach ($entries as $entry) {
|
||||
$matchingKeys = $this->matchingStrategyKeysForEntry($entry);
|
||||
$entryFingerprint = $this->entryFingerprint($entry);
|
||||
$entryMatches[$entryFingerprint] = $matchingKeys;
|
||||
|
||||
if ($matchingKeys === []) {
|
||||
$rejectedScopeEntries[] = $entry;
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$matchedScopeEntries[] = $entry;
|
||||
$matchedStrategyKeys = array_values(array_unique(array_merge($matchedStrategyKeys, $matchingKeys)));
|
||||
}
|
||||
|
||||
sort($matchedStrategyKeys, SORT_STRING);
|
||||
|
||||
if ($rejectedScopeEntries !== []) {
|
||||
return CompareStrategySelection::unsupported(
|
||||
matchedScopeEntries: $matchedScopeEntries,
|
||||
rejectedScopeEntries: $rejectedScopeEntries,
|
||||
diagnostics: [
|
||||
'matched_strategy_keys' => $matchedStrategyKeys,
|
||||
'entry_matches' => $entryMatches,
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
if (count($matchedStrategyKeys) !== 1) {
|
||||
return CompareStrategySelection::mixed(
|
||||
matchedScopeEntries: $matchedScopeEntries,
|
||||
diagnostics: [
|
||||
'matched_strategy_keys' => $matchedStrategyKeys,
|
||||
'entry_matches' => $entryMatches,
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
return CompareStrategySelection::supported(
|
||||
strategyKey: $matchedStrategyKeys[0],
|
||||
matchedScopeEntries: $matchedScopeEntries,
|
||||
diagnostics: [
|
||||
'matched_strategy_keys' => $matchedStrategyKeys,
|
||||
'entry_matches' => $entryMatches,
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array{domain_key?: mixed, subject_class?: mixed, subject_type_keys?: mixed, filters?: mixed} $entry
|
||||
* @return list<string>
|
||||
*/
|
||||
private function matchingStrategyKeysForEntry(array $entry): array
|
||||
{
|
||||
$matches = [];
|
||||
|
||||
foreach ($this->all() as $strategy) {
|
||||
foreach ($strategy->capabilities() as $capability) {
|
||||
if (! $capability->supportsEntry($entry)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$matches[] = $strategy->key()->value;
|
||||
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
$matches = array_values(array_unique($matches));
|
||||
sort($matches, SORT_STRING);
|
||||
|
||||
return $matches;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<array{domain_key: string, subject_class: string, subject_type_keys: list<string>, filters: array<string, mixed>}>
|
||||
*/
|
||||
private function entriesForScope(BaselineScope $scope): array
|
||||
{
|
||||
if ($scope->entries !== []) {
|
||||
return $scope->entries;
|
||||
}
|
||||
|
||||
$entries = [];
|
||||
|
||||
if ($scope->policyTypes !== []) {
|
||||
$entries[] = [
|
||||
'domain_key' => GovernanceDomainKey::Intune->value,
|
||||
'subject_class' => GovernanceSubjectClass::Policy->value,
|
||||
'subject_type_keys' => $scope->policyTypes,
|
||||
'filters' => [],
|
||||
];
|
||||
}
|
||||
|
||||
if ($scope->foundationTypes !== []) {
|
||||
$entries[] = [
|
||||
'domain_key' => GovernanceDomainKey::PlatformFoundation->value,
|
||||
'subject_class' => GovernanceSubjectClass::ConfigurationResource->value,
|
||||
'subject_type_keys' => $scope->foundationTypes,
|
||||
'filters' => [],
|
||||
];
|
||||
}
|
||||
|
||||
return $entries;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array{domain_key?: mixed, subject_class?: mixed, subject_type_keys?: mixed, filters?: mixed} $entry
|
||||
*/
|
||||
private function entryFingerprint(array $entry): string
|
||||
{
|
||||
$subjectTypeKeys = is_array($entry['subject_type_keys'] ?? null)
|
||||
? array_values(array_filter($entry['subject_type_keys'], 'is_string'))
|
||||
: [];
|
||||
|
||||
sort($subjectTypeKeys, SORT_STRING);
|
||||
|
||||
return implode('|', [
|
||||
trim((string) ($entry['domain_key'] ?? '')),
|
||||
trim((string) ($entry['subject_class'] ?? '')),
|
||||
implode(',', $subjectTypeKeys),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,129 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Support\Baselines\Compare;
|
||||
|
||||
use InvalidArgumentException;
|
||||
|
||||
final class CompareStrategySelection
|
||||
{
|
||||
/**
|
||||
* @param list<array<string, mixed>> $matchedScopeEntries
|
||||
* @param list<array<string, mixed>> $rejectedScopeEntries
|
||||
* @param array<string, mixed> $diagnostics
|
||||
*/
|
||||
public function __construct(
|
||||
public readonly StrategySelectionState $selectionState,
|
||||
public readonly ?CompareStrategyKey $strategyKey,
|
||||
public readonly array $matchedScopeEntries,
|
||||
public readonly array $rejectedScopeEntries,
|
||||
public readonly string $operatorReason,
|
||||
public readonly array $diagnostics = [],
|
||||
) {
|
||||
if ($this->selectionState === StrategySelectionState::Supported && ! $this->strategyKey instanceof CompareStrategyKey) {
|
||||
throw new InvalidArgumentException('Supported compare strategy selections require a strategy key.');
|
||||
}
|
||||
|
||||
if (trim($this->operatorReason) === '') {
|
||||
throw new InvalidArgumentException('Compare strategy selections require an operator-safe reason.');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<array<string, mixed>> $matchedScopeEntries
|
||||
* @param array<string, mixed> $diagnostics
|
||||
*/
|
||||
public static function supported(
|
||||
CompareStrategyKey|string $strategyKey,
|
||||
array $matchedScopeEntries,
|
||||
array $diagnostics = [],
|
||||
string $operatorReason = 'Compare strategy resolved successfully.',
|
||||
): self {
|
||||
return new self(
|
||||
selectionState: StrategySelectionState::Supported,
|
||||
strategyKey: CompareStrategyKey::from($strategyKey),
|
||||
matchedScopeEntries: $matchedScopeEntries,
|
||||
rejectedScopeEntries: [],
|
||||
operatorReason: $operatorReason,
|
||||
diagnostics: $diagnostics,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<array<string, mixed>> $matchedScopeEntries
|
||||
* @param list<array<string, mixed>> $rejectedScopeEntries
|
||||
* @param array<string, mixed> $diagnostics
|
||||
*/
|
||||
public static function unsupported(
|
||||
array $matchedScopeEntries,
|
||||
array $rejectedScopeEntries,
|
||||
array $diagnostics = [],
|
||||
string $operatorReason = 'No compare strategy supports the selected governed subjects.',
|
||||
): self {
|
||||
return new self(
|
||||
selectionState: StrategySelectionState::Unsupported,
|
||||
strategyKey: null,
|
||||
matchedScopeEntries: $matchedScopeEntries,
|
||||
rejectedScopeEntries: $rejectedScopeEntries,
|
||||
operatorReason: $operatorReason,
|
||||
diagnostics: $diagnostics,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<array<string, mixed>> $matchedScopeEntries
|
||||
* @param array<string, mixed> $diagnostics
|
||||
*/
|
||||
public static function mixed(
|
||||
array $matchedScopeEntries,
|
||||
array $diagnostics = [],
|
||||
string $operatorReason = 'The selected governed subjects span multiple compare strategy families.',
|
||||
): self {
|
||||
return new self(
|
||||
selectionState: StrategySelectionState::Mixed,
|
||||
strategyKey: null,
|
||||
matchedScopeEntries: $matchedScopeEntries,
|
||||
rejectedScopeEntries: [],
|
||||
operatorReason: $operatorReason,
|
||||
diagnostics: $diagnostics,
|
||||
);
|
||||
}
|
||||
|
||||
public function isSupported(): bool
|
||||
{
|
||||
return $this->selectionState === StrategySelectionState::Supported;
|
||||
}
|
||||
|
||||
public function isUnsupported(): bool
|
||||
{
|
||||
return $this->selectionState === StrategySelectionState::Unsupported;
|
||||
}
|
||||
|
||||
public function isMixed(): bool
|
||||
{
|
||||
return $this->selectionState === StrategySelectionState::Mixed;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{
|
||||
* selection_state: string,
|
||||
* strategy_key: ?string,
|
||||
* matched_scope_entries: list<array<string, mixed>>,
|
||||
* rejected_scope_entries: list<array<string, mixed>>,
|
||||
* operator_reason: string,
|
||||
* diagnostics: array<string, mixed>
|
||||
* }
|
||||
*/
|
||||
public function toArray(): array
|
||||
{
|
||||
return [
|
||||
'selection_state' => $this->selectionState->value,
|
||||
'strategy_key' => $this->strategyKey?->value,
|
||||
'matched_scope_entries' => $this->matchedScopeEntries,
|
||||
'rejected_scope_entries' => $this->rejectedScopeEntries,
|
||||
'operator_reason' => $this->operatorReason,
|
||||
'diagnostics' => $this->diagnostics,
|
||||
];
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,42 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Support\Baselines\Compare;
|
||||
|
||||
use InvalidArgumentException;
|
||||
|
||||
final class CompareSubjectIdentity
|
||||
{
|
||||
public function __construct(
|
||||
public readonly string $domainKey,
|
||||
public readonly string $subjectClass,
|
||||
public readonly string $subjectTypeKey,
|
||||
public readonly ?string $externalSubjectId,
|
||||
public readonly string $subjectKey,
|
||||
) {
|
||||
if (trim($this->domainKey) === '' || trim($this->subjectClass) === '' || trim($this->subjectTypeKey) === '' || trim($this->subjectKey) === '') {
|
||||
throw new InvalidArgumentException('Compare subject identities require non-empty domain, subject class, subject type key, and subject key values.');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{
|
||||
* domain_key: string,
|
||||
* subject_class: string,
|
||||
* subject_type_key: string,
|
||||
* external_subject_id: ?string,
|
||||
* subject_key: string
|
||||
* }
|
||||
*/
|
||||
public function toArray(): array
|
||||
{
|
||||
return [
|
||||
'domain_key' => $this->domainKey,
|
||||
'subject_class' => $this->subjectClass,
|
||||
'subject_type_key' => $this->subjectTypeKey,
|
||||
'external_subject_id' => $this->externalSubjectId,
|
||||
'subject_key' => $this->subjectKey,
|
||||
];
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,48 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Support\Baselines\Compare;
|
||||
|
||||
use InvalidArgumentException;
|
||||
|
||||
final class CompareSubjectProjection
|
||||
{
|
||||
/**
|
||||
* @param array<string, string> $additionalLabels
|
||||
*/
|
||||
public function __construct(
|
||||
public readonly string $platformSubjectClass,
|
||||
public readonly string $domainKey,
|
||||
public readonly string $subjectTypeKey,
|
||||
public readonly string $operatorLabel,
|
||||
public readonly ?string $summaryKind = null,
|
||||
public readonly array $additionalLabels = [],
|
||||
) {
|
||||
if (trim($this->platformSubjectClass) === '' || trim($this->domainKey) === '' || trim($this->subjectTypeKey) === '' || trim($this->operatorLabel) === '') {
|
||||
throw new InvalidArgumentException('Compare subject projections require non-empty platform subject class, domain key, subject type key, and operator label values.');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{
|
||||
* platform_subject_class: string,
|
||||
* domain_key: string,
|
||||
* subject_type_key: string,
|
||||
* operator_label: string,
|
||||
* summary_kind: ?string,
|
||||
* additional_labels: array<string, string>
|
||||
* }
|
||||
*/
|
||||
public function toArray(): array
|
||||
{
|
||||
return [
|
||||
'platform_subject_class' => $this->platformSubjectClass,
|
||||
'domain_key' => $this->domainKey,
|
||||
'subject_type_key' => $this->subjectTypeKey,
|
||||
'operator_label' => $this->operatorLabel,
|
||||
'summary_kind' => $this->summaryKind,
|
||||
'additional_labels' => $this->additionalLabels,
|
||||
];
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,119 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Support\Baselines\Compare;
|
||||
|
||||
use InvalidArgumentException;
|
||||
|
||||
final class CompareSubjectResult
|
||||
{
|
||||
/**
|
||||
* @param array<string, mixed> $diagnostics
|
||||
*/
|
||||
public function __construct(
|
||||
public readonly CompareSubjectIdentity $subjectIdentity,
|
||||
public readonly CompareSubjectProjection $projection,
|
||||
public readonly string $baselineAvailability,
|
||||
public readonly string $currentStateAvailability,
|
||||
public readonly CompareState $compareState,
|
||||
public readonly string $trustLevel,
|
||||
public readonly string $evidenceQuality,
|
||||
public readonly ?string $severityRecommendation = null,
|
||||
public readonly ?CompareFindingCandidate $findingCandidate = null,
|
||||
public readonly array $diagnostics = [],
|
||||
) {
|
||||
if (trim($this->baselineAvailability) === '' || trim($this->currentStateAvailability) === '' || trim($this->trustLevel) === '' || trim($this->evidenceQuality) === '') {
|
||||
throw new InvalidArgumentException('Compare subject results require non-empty availability, trust level, and evidence quality values.');
|
||||
}
|
||||
|
||||
if ($this->compareState === CompareState::Drift && ! $this->findingCandidate instanceof CompareFindingCandidate) {
|
||||
throw new InvalidArgumentException('Drift compare subject results require a finding candidate.');
|
||||
}
|
||||
|
||||
if ($this->compareState !== CompareState::Drift && $this->findingCandidate instanceof CompareFindingCandidate) {
|
||||
throw new InvalidArgumentException('Only drift compare subject results may carry a finding candidate.');
|
||||
}
|
||||
}
|
||||
|
||||
public function hasFindingCandidate(): bool
|
||||
{
|
||||
return $this->findingCandidate instanceof CompareFindingCandidate;
|
||||
}
|
||||
|
||||
public function isGapState(): bool
|
||||
{
|
||||
return in_array($this->compareState, [
|
||||
CompareState::Unsupported,
|
||||
CompareState::Incomplete,
|
||||
CompareState::Ambiguous,
|
||||
CompareState::Failed,
|
||||
], true);
|
||||
}
|
||||
|
||||
public function gapReasonCode(): ?string
|
||||
{
|
||||
$reasonCode = $this->diagnostics['reason_code'] ?? null;
|
||||
|
||||
return is_string($reasonCode) && trim($reasonCode) !== '' ? trim($reasonCode) : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>|null
|
||||
*/
|
||||
public function gapRecord(): ?array
|
||||
{
|
||||
$gapRecord = $this->diagnostics['gap_record'] ?? null;
|
||||
|
||||
return is_array($gapRecord) ? $gapRecord : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{
|
||||
* subject_identity: array{
|
||||
* domain_key: string,
|
||||
* subject_class: string,
|
||||
* subject_type_key: string,
|
||||
* external_subject_id: ?string,
|
||||
* subject_key: string
|
||||
* },
|
||||
* projection: array{
|
||||
* platform_subject_class: string,
|
||||
* domain_key: string,
|
||||
* subject_type_key: string,
|
||||
* operator_label: string,
|
||||
* summary_kind: ?string,
|
||||
* additional_labels: array<string, string>
|
||||
* },
|
||||
* baseline_availability: string,
|
||||
* current_state_availability: string,
|
||||
* compare_state: string,
|
||||
* trust_level: string,
|
||||
* evidence_quality: string,
|
||||
* severity_recommendation: ?string,
|
||||
* finding_candidate: ?array{
|
||||
* change_type: string,
|
||||
* severity: string,
|
||||
* fingerprint_basis: array<string, mixed>,
|
||||
* evidence_payload: array<string, mixed>,
|
||||
* auto_close_eligible: bool
|
||||
* },
|
||||
* diagnostics: array<string, mixed>
|
||||
* }
|
||||
*/
|
||||
public function toArray(): array
|
||||
{
|
||||
return [
|
||||
'subject_identity' => $this->subjectIdentity->toArray(),
|
||||
'projection' => $this->projection->toArray(),
|
||||
'baseline_availability' => $this->baselineAvailability,
|
||||
'current_state_availability' => $this->currentStateAvailability,
|
||||
'compare_state' => $this->compareState->value,
|
||||
'trust_level' => $this->trustLevel,
|
||||
'evidence_quality' => $this->evidenceQuality,
|
||||
'severity_recommendation' => $this->severityRecommendation,
|
||||
'finding_candidate' => $this->findingCandidate?->toArray(),
|
||||
'diagnostics' => $this->diagnostics,
|
||||
];
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@ -0,0 +1,12 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Support\Baselines\Compare;
|
||||
|
||||
enum StrategySelectionState: string
|
||||
{
|
||||
case Supported = 'supported';
|
||||
case Unsupported = 'unsupported';
|
||||
case Mixed = 'mixed';
|
||||
}
|
||||
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,
|
||||
];
|
||||
}
|
||||
}
|
||||
@ -182,6 +182,12 @@ private function translateBaselineReason(string $reasonCode): ReasonResolutionEn
|
||||
'prerequisite_missing',
|
||||
'Refresh the page and select a valid snapshot for this baseline profile.',
|
||||
],
|
||||
BaselineReasonCodes::COMPARE_MIXED_SCOPE => [
|
||||
'Mixed compare scope',
|
||||
'The selected governed subjects span multiple compare strategy families, so TenantPilot will not start one misleading combined compare run.',
|
||||
'prerequisite_missing',
|
||||
'Narrow the governed subject selection so one compare strategy family owns the requested scope.',
|
||||
],
|
||||
default => [
|
||||
'Baseline workflow blocked',
|
||||
'TenantPilot recorded a baseline precondition that prevents this workflow from continuing safely.',
|
||||
@ -236,6 +242,24 @@ private function translateBaselineCompareReason(string $reasonCode): ReasonResol
|
||||
'prerequisite_missing',
|
||||
'Resume or rerun evidence capture before relying on this compare result.',
|
||||
],
|
||||
BaselineCompareReasonCode::UnsupportedSubjects => [
|
||||
'Unsupported subjects remained',
|
||||
'The comparison finished, but one or more in-scope subjects are not currently supported by the selected compare strategy.',
|
||||
'prerequisite_missing',
|
||||
'Narrow scope or wait for support before treating zero visible findings as complete.',
|
||||
],
|
||||
BaselineCompareReasonCode::AmbiguousSubjects => [
|
||||
'Subject identity stayed ambiguous',
|
||||
'The comparison finished, but one or more in-scope subjects could not be matched cleanly enough to produce a trustworthy result.',
|
||||
'prerequisite_missing',
|
||||
'Review the ambiguous subject mapping before relying on this compare result.',
|
||||
],
|
||||
BaselineCompareReasonCode::StrategyFailed => [
|
||||
'Strategy processing failed',
|
||||
'The comparison finished without a fully usable result because strategy-owned subject processing failed for one or more in-scope subjects.',
|
||||
'retryable_transient',
|
||||
'Inspect the compare run diagnostics and retry once the subject-processing failure is addressed.',
|
||||
],
|
||||
BaselineCompareReasonCode::RolloutDisabled => [
|
||||
'Compare rollout disabled',
|
||||
'The comparison path was limited by rollout configuration, so the result is not decision-grade.',
|
||||
@ -248,6 +272,24 @@ private function translateBaselineCompareReason(string $reasonCode): ReasonResol
|
||||
'prerequisite_missing',
|
||||
'Review scope selection and baseline inputs before comparing again.',
|
||||
],
|
||||
BaselineCompareReasonCode::OverdueFindingsRemain => [
|
||||
'Overdue findings remain',
|
||||
'The latest compare did not produce new drift, but overdue findings still require attention.',
|
||||
'prerequisite_missing',
|
||||
'Review and resolve the overdue findings before treating this posture as healthy.',
|
||||
],
|
||||
BaselineCompareReasonCode::GovernanceExpiring => [
|
||||
'Accepted-risk governance is expiring',
|
||||
'Accepted-risk coverage is still valid, but renewal is approaching and needs review.',
|
||||
'prerequisite_missing',
|
||||
'Review the expiring governance before it lapses.',
|
||||
],
|
||||
BaselineCompareReasonCode::GovernanceLapsed => [
|
||||
'Accepted-risk governance lapsed',
|
||||
'Accepted-risk coverage has lapsed, so the current posture still needs follow-up.',
|
||||
'prerequisite_missing',
|
||||
'Restore valid governance or move the affected findings back into active remediation.',
|
||||
],
|
||||
};
|
||||
|
||||
return new ReasonResolutionEnvelope(
|
||||
|
||||
@ -4,6 +4,9 @@
|
||||
|
||||
namespace App\Support\Ui\ActionSurface;
|
||||
|
||||
use App\Filament\Pages\BreakGlassRecovery;
|
||||
use App\Filament\Pages\ChooseTenant;
|
||||
use App\Filament\Pages\ChooseWorkspace;
|
||||
use App\Filament\Pages\BaselineCompareLanding;
|
||||
use App\Filament\Pages\BaselineCompareMatrix;
|
||||
use App\Filament\Pages\Monitoring\Alerts;
|
||||
@ -13,7 +16,11 @@
|
||||
use App\Filament\Pages\Monitoring\Operations;
|
||||
use App\Filament\Pages\Operations\TenantlessOperationRunViewer;
|
||||
use App\Filament\Pages\Reviews\ReviewRegister;
|
||||
use App\Filament\Pages\TenantDashboard;
|
||||
use App\Filament\Pages\TenantDiagnostics;
|
||||
use App\Filament\Pages\Tenancy\RegisterTenant;
|
||||
use App\Filament\Pages\Workspaces\ManagedTenantOnboardingWizard;
|
||||
use App\Filament\Pages\Workspaces\ManagedTenantsLanding;
|
||||
use App\Filament\Resources\AlertDeliveryResource\Pages\ListAlertDeliveries;
|
||||
use App\Filament\Resources\AlertDestinationResource\Pages\ViewAlertDestination;
|
||||
use App\Filament\Resources\BackupSetResource\Pages\ViewBackupSet;
|
||||
@ -29,6 +36,12 @@
|
||||
use App\Filament\Resources\TenantResource\Pages\ViewTenant;
|
||||
use App\Filament\Resources\TenantReviewResource\Pages\ViewTenantReview;
|
||||
use App\Filament\Resources\Workspaces\Pages\ViewWorkspace;
|
||||
use App\Filament\System\Pages\Dashboard as SystemDashboard;
|
||||
use App\Filament\System\Pages\Directory\ViewTenant as SystemDirectoryViewTenant;
|
||||
use App\Filament\System\Pages\Directory\ViewWorkspace as SystemDirectoryViewWorkspace;
|
||||
use App\Filament\System\Pages\Ops\Runbooks;
|
||||
use App\Filament\System\Pages\Ops\ViewRun;
|
||||
use App\Filament\System\Pages\RepairWorkspaceOwners;
|
||||
use App\Support\WorkspaceIsolation\TenantOwnedModelFamilies;
|
||||
|
||||
final class ActionSurfaceExemptions
|
||||
@ -46,7 +59,6 @@ public static function baseline(): self
|
||||
// Baseline allowlist for legacy surfaces. Keep shrinking this list.
|
||||
// Declared system table pages are discovered directly; deferred system tooling stays out of scope by not opting in.
|
||||
'App\\Filament\\Pages\\Auth\\Login' => 'Auth entry page is out-of-scope for action-surface retrofits in spec 082.',
|
||||
'App\\Filament\\Pages\\BreakGlassRecovery' => 'Break-glass flow is governed by dedicated security specs and tests.',
|
||||
'App\\Filament\\Pages\\ChooseTenant' => 'Tenant chooser has no contract-style table action surface.',
|
||||
'App\\Filament\\Pages\\ChooseWorkspace' => 'Workspace chooser has no contract-style table action surface.',
|
||||
'App\\Filament\\Pages\\Tenancy\\RegisterTenant' => 'Tenant onboarding route is covered by onboarding/RBAC specs.',
|
||||
@ -541,4 +553,400 @@ public static function spec193MonitoringSurface(string $className): ?array
|
||||
{
|
||||
return self::spec193MonitoringSurfaceInventory()[$className] ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, array{
|
||||
* surfaceKey: string,
|
||||
* surfaceName: string,
|
||||
* pageClass: string,
|
||||
* panelPlane: string,
|
||||
* surfaceKind: string,
|
||||
* discoveryState: string,
|
||||
* closureDecision: string,
|
||||
* reasonCategory: ?string,
|
||||
* explicitReason: string,
|
||||
* evidence: array<int, array{
|
||||
* kind: string,
|
||||
* reference: string,
|
||||
* proves: string
|
||||
* }>,
|
||||
* followUpAction: string,
|
||||
* mustRemainBaselineExempt: bool,
|
||||
* mustNotRemainBaselineExempt: bool
|
||||
* }>
|
||||
*/
|
||||
public static function spec195ResidualSurfaceInventory(): array
|
||||
{
|
||||
return [
|
||||
SystemDashboard::class => [
|
||||
'surfaceKey' => 'system_dashboard',
|
||||
'surfaceName' => 'System Console Dashboard',
|
||||
'pageClass' => SystemDashboard::class,
|
||||
'panelPlane' => 'system',
|
||||
'surfaceKind' => 'dashboard_shell',
|
||||
'discoveryState' => 'outside_primary_discovery',
|
||||
'closureDecision' => 'separately_governed',
|
||||
'reasonCategory' => 'workflow_specific_governance',
|
||||
'explicitReason' => 'The system dashboard keeps its console-window and break-glass controls under dedicated system and recovery tests instead of the generic declaration-backed contract.',
|
||||
'evidence' => [
|
||||
[
|
||||
'kind' => 'feature_livewire_test',
|
||||
'reference' => 'tests/Feature/System/Spec114/ControlTowerDashboardTest.php',
|
||||
'proves' => 'The control-tower shell keeps its window action and dashboard rendering behavior under focused system coverage.',
|
||||
],
|
||||
[
|
||||
'kind' => 'feature_livewire_test',
|
||||
'reference' => 'tests/Feature/Auth/BreakGlassModeTest.php',
|
||||
'proves' => 'Break-glass entry and exit remain confirmed, audited dashboard actions rather than silent utility links.',
|
||||
],
|
||||
],
|
||||
'followUpAction' => 'add_guard_only',
|
||||
'mustRemainBaselineExempt' => false,
|
||||
'mustNotRemainBaselineExempt' => true,
|
||||
],
|
||||
ViewRun::class => [
|
||||
'surfaceKey' => 'system_ops_view_run',
|
||||
'surfaceName' => 'System Ops View Run',
|
||||
'pageClass' => ViewRun::class,
|
||||
'panelPlane' => 'system',
|
||||
'surfaceKind' => 'system_detail',
|
||||
'discoveryState' => 'outside_primary_discovery',
|
||||
'closureDecision' => 'separately_governed',
|
||||
'reasonCategory' => 'system_triage_surface',
|
||||
'explicitReason' => 'Run triage remains a dedicated decision surface with confirmed retry, cancel, and investigate behavior instead of fitting the generic declaration-backed list/detail shape.',
|
||||
'evidence' => [
|
||||
[
|
||||
'kind' => 'feature_livewire_test',
|
||||
'reference' => 'tests/Feature/System/Spec114/OpsTriageActionsTest.php',
|
||||
'proves' => 'The view-run surface keeps explicit navigation, triage actions, and capability-sensitive visibility.',
|
||||
],
|
||||
[
|
||||
'kind' => 'guard_test',
|
||||
'reference' => 'tests/Feature/Guards/Spec194GovernanceActionSemanticsGuardTest.php',
|
||||
'proves' => 'The retry, cancel, and investigate actions remain part of the governed system action semantics inventory.',
|
||||
],
|
||||
],
|
||||
'followUpAction' => 'add_guard_only',
|
||||
'mustRemainBaselineExempt' => false,
|
||||
'mustNotRemainBaselineExempt' => true,
|
||||
],
|
||||
Runbooks::class => [
|
||||
'surfaceKey' => 'system_ops_runbooks',
|
||||
'surfaceName' => 'System Ops Runbooks',
|
||||
'pageClass' => Runbooks::class,
|
||||
'panelPlane' => 'system',
|
||||
'surfaceKind' => 'system_utility',
|
||||
'discoveryState' => 'outside_primary_discovery',
|
||||
'closureDecision' => 'separately_governed',
|
||||
'reasonCategory' => 'workflow_specific_governance',
|
||||
'explicitReason' => 'Runbooks is a workflow utility hub with its own trusted-state, authorization, and confirmation semantics rather than a declaration-backed record or table surface.',
|
||||
'evidence' => [
|
||||
[
|
||||
'kind' => 'feature_livewire_test',
|
||||
'reference' => 'tests/Feature/System/OpsRunbooks/FindingsLifecycleBackfillStartTest.php',
|
||||
'proves' => 'The runbooks shell enforces preflight-first execution, typed confirmation, and capability-gated run behavior.',
|
||||
],
|
||||
[
|
||||
'kind' => 'authorization_test',
|
||||
'reference' => 'tests/Feature/System/Spec113/AuthorizationSemanticsTest.php',
|
||||
'proves' => 'The system plane still returns 403 when runbook-view capabilities are missing.',
|
||||
],
|
||||
[
|
||||
'kind' => 'guard_test',
|
||||
'reference' => 'tests/Feature/Guards/LivewireTrustedStateGuardTest.php',
|
||||
'proves' => 'Runbooks keeps its trusted-state policy under explicit guard coverage.',
|
||||
],
|
||||
],
|
||||
'followUpAction' => 'add_guard_only',
|
||||
'mustRemainBaselineExempt' => false,
|
||||
'mustNotRemainBaselineExempt' => true,
|
||||
],
|
||||
RepairWorkspaceOwners::class => [
|
||||
'surfaceKey' => 'repair_workspace_owners',
|
||||
'surfaceName' => 'Repair Workspace Owners',
|
||||
'pageClass' => RepairWorkspaceOwners::class,
|
||||
'panelPlane' => 'system',
|
||||
'surfaceKind' => 'system_utility',
|
||||
'discoveryState' => 'outside_primary_discovery',
|
||||
'closureDecision' => 'separately_governed',
|
||||
'reasonCategory' => 'break_glass_repair_utility',
|
||||
'explicitReason' => 'Emergency owner repair stays under dedicated break-glass and table guard coverage instead of the generic declaration-backed system-table contract.',
|
||||
'evidence' => [
|
||||
[
|
||||
'kind' => 'feature_livewire_test',
|
||||
'reference' => 'tests/Feature/Auth/BreakGlassWorkspaceOwnerRecoveryTest.php',
|
||||
'proves' => 'The repair utility requires break-glass context and records audited recovery behavior.',
|
||||
],
|
||||
[
|
||||
'kind' => 'guard_test',
|
||||
'reference' => 'tests/Feature/Guards/FilamentTableStandardsGuardTest.php',
|
||||
'proves' => 'The table shell keeps explicit empty-state and table-standard coverage even while remaining outside the primary declaration path.',
|
||||
],
|
||||
],
|
||||
'followUpAction' => 'add_guard_only',
|
||||
'mustRemainBaselineExempt' => false,
|
||||
'mustNotRemainBaselineExempt' => true,
|
||||
],
|
||||
SystemDirectoryViewTenant::class => [
|
||||
'surfaceKey' => 'system_directory_view_tenant',
|
||||
'surfaceName' => 'System Directory View Tenant',
|
||||
'pageClass' => SystemDirectoryViewTenant::class,
|
||||
'panelPlane' => 'system',
|
||||
'surfaceKind' => 'read_mostly_context',
|
||||
'discoveryState' => 'outside_primary_discovery',
|
||||
'closureDecision' => 'harmless_special_case',
|
||||
'reasonCategory' => 'read_mostly_context_detail',
|
||||
'explicitReason' => 'The tenant directory detail page is a read-mostly drilldown that links outward to canonical admin and run surfaces without introducing its own mutating controls.',
|
||||
'evidence' => [
|
||||
[
|
||||
'kind' => 'feature_livewire_test',
|
||||
'reference' => 'tests/Feature/System/Spec195/SystemDirectoryResidualSurfaceTest.php',
|
||||
'proves' => 'The detail page renders contextual connectivity and recent-run information while staying read-mostly and capability-gated.',
|
||||
],
|
||||
[
|
||||
'kind' => 'authorization_test',
|
||||
'reference' => 'tests/Feature/System/Spec114/DirectoryTenantsTest.php',
|
||||
'proves' => 'Directory-view capability remains required before the detail route becomes visible.',
|
||||
],
|
||||
],
|
||||
'followUpAction' => 'add_focused_test',
|
||||
'mustRemainBaselineExempt' => false,
|
||||
'mustNotRemainBaselineExempt' => true,
|
||||
],
|
||||
SystemDirectoryViewWorkspace::class => [
|
||||
'surfaceKey' => 'system_directory_view_workspace',
|
||||
'surfaceName' => 'System Directory View Workspace',
|
||||
'pageClass' => SystemDirectoryViewWorkspace::class,
|
||||
'panelPlane' => 'system',
|
||||
'surfaceKind' => 'read_mostly_context',
|
||||
'discoveryState' => 'outside_primary_discovery',
|
||||
'closureDecision' => 'harmless_special_case',
|
||||
'reasonCategory' => 'read_mostly_context_detail',
|
||||
'explicitReason' => 'The workspace directory detail page is a read-mostly drilldown that exposes context and links, not a declaration-backed mutable system workbench.',
|
||||
'evidence' => [
|
||||
[
|
||||
'kind' => 'feature_livewire_test',
|
||||
'reference' => 'tests/Feature/System/Spec195/SystemDirectoryResidualSurfaceTest.php',
|
||||
'proves' => 'The workspace detail page stays capability-gated and renders contextual tenant and run links without mutating actions.',
|
||||
],
|
||||
[
|
||||
'kind' => 'authorization_test',
|
||||
'reference' => 'tests/Feature/System/Spec114/DirectoryWorkspacesTest.php',
|
||||
'proves' => 'Directory-view capability remains required before workspace directory routes become available.',
|
||||
],
|
||||
],
|
||||
'followUpAction' => 'add_focused_test',
|
||||
'mustRemainBaselineExempt' => false,
|
||||
'mustNotRemainBaselineExempt' => true,
|
||||
],
|
||||
BreakGlassRecovery::class => [
|
||||
'surfaceKey' => 'break_glass_recovery',
|
||||
'surfaceName' => 'Break Glass Recovery',
|
||||
'pageClass' => BreakGlassRecovery::class,
|
||||
'panelPlane' => 'admin',
|
||||
'surfaceKind' => 'recovery_flow',
|
||||
'discoveryState' => 'primary_discovered',
|
||||
'closureDecision' => 'retired_no_longer_relevant',
|
||||
'reasonCategory' => 'disabled_or_actionless_surface',
|
||||
'explicitReason' => 'The page currently denies access and exposes no header actions, so it should not remain a live baseline exemption.',
|
||||
'evidence' => [
|
||||
[
|
||||
'kind' => 'audit_test',
|
||||
'reference' => 'app/Filament/Pages/BreakGlassRecovery.php',
|
||||
'proves' => 'The page returns false from canAccess() and exposes no header actions.',
|
||||
],
|
||||
[
|
||||
'kind' => 'feature_livewire_test',
|
||||
'reference' => 'tests/Feature/Auth/BreakGlassWorkspaceOwnerRecoveryTest.php',
|
||||
'proves' => 'The active recovery path now lives on the system dashboard and repair utility instead of this retired page shell.',
|
||||
],
|
||||
],
|
||||
'followUpAction' => 'tighten_reason',
|
||||
'mustRemainBaselineExempt' => false,
|
||||
'mustNotRemainBaselineExempt' => true,
|
||||
],
|
||||
ChooseWorkspace::class => [
|
||||
'surfaceKey' => 'choose_workspace',
|
||||
'surfaceName' => 'Choose Workspace',
|
||||
'pageClass' => ChooseWorkspace::class,
|
||||
'panelPlane' => 'admin',
|
||||
'surfaceKind' => 'selector',
|
||||
'discoveryState' => 'primary_discovered_baseline_exempt',
|
||||
'closureDecision' => 'harmless_special_case',
|
||||
'reasonCategory' => 'selector_routing_only',
|
||||
'explicitReason' => 'The workspace chooser is a routing-only selector with explicit membership checks and audit logging, not a declaration-backed action table.',
|
||||
'evidence' => [
|
||||
[
|
||||
'kind' => 'feature_livewire_test',
|
||||
'reference' => 'tests/Feature/Workspaces/ChooseWorkspacePageTest.php',
|
||||
'proves' => 'The chooser keeps membership-scoped selection, redirect behavior, and deny-as-not-found semantics.',
|
||||
],
|
||||
[
|
||||
'kind' => 'audit_test',
|
||||
'reference' => 'tests/Feature/Workspaces/WorkspaceAuditTrailTest.php',
|
||||
'proves' => 'Manual workspace selection remains explicitly audited.',
|
||||
],
|
||||
],
|
||||
'followUpAction' => 'none',
|
||||
'mustRemainBaselineExempt' => true,
|
||||
'mustNotRemainBaselineExempt' => false,
|
||||
],
|
||||
ChooseTenant::class => [
|
||||
'surfaceKey' => 'choose_tenant',
|
||||
'surfaceName' => 'Choose Tenant',
|
||||
'pageClass' => ChooseTenant::class,
|
||||
'panelPlane' => 'tenant',
|
||||
'surfaceKind' => 'selector',
|
||||
'discoveryState' => 'primary_discovered_baseline_exempt',
|
||||
'closureDecision' => 'harmless_special_case',
|
||||
'reasonCategory' => 'selector_routing_only',
|
||||
'explicitReason' => 'The tenant chooser is a selector-only surface that filters operable tenants and routes to the tenant dashboard without its own contract-style action surface.',
|
||||
'evidence' => [
|
||||
[
|
||||
'kind' => 'feature_livewire_test',
|
||||
'reference' => 'tests/Feature/Auth/TenantChooserSelectionTest.php',
|
||||
'proves' => 'The chooser redirects only for active selectable tenants and rejects non-operable selections with 404.',
|
||||
],
|
||||
[
|
||||
'kind' => 'authorization_test',
|
||||
'reference' => 'tests/Feature/TenantRBAC/TenantSwitcherScopeTest.php',
|
||||
'proves' => 'Selector eligibility remains narrower than global tenant discoverability and stays tenant-scope aware.',
|
||||
],
|
||||
],
|
||||
'followUpAction' => 'none',
|
||||
'mustRemainBaselineExempt' => true,
|
||||
'mustNotRemainBaselineExempt' => false,
|
||||
],
|
||||
RegisterTenant::class => [
|
||||
'surfaceKey' => 'register_tenant',
|
||||
'surfaceName' => 'Register Tenant',
|
||||
'pageClass' => RegisterTenant::class,
|
||||
'panelPlane' => 'admin',
|
||||
'surfaceKind' => 'wizard',
|
||||
'discoveryState' => 'primary_discovered_baseline_exempt',
|
||||
'closureDecision' => 'separately_governed',
|
||||
'reasonCategory' => 'registration_form_with_dedicated_rbac',
|
||||
'explicitReason' => 'Tenant registration is a dedicated creation workflow with its own visibility rules, bootstrap membership side effects, and audit logging.',
|
||||
'evidence' => [
|
||||
[
|
||||
'kind' => 'authorization_test',
|
||||
'reference' => 'tests/Feature/Rbac/RegisterTenantAuthorizationTest.php',
|
||||
'proves' => 'Registration visibility remains explicitly capability-sensitive for owner versus readonly members.',
|
||||
],
|
||||
[
|
||||
'kind' => 'feature_livewire_test',
|
||||
'reference' => 'tests/Feature/TenantRBAC/TenantBootstrapAssignTest.php',
|
||||
'proves' => 'Registration still bootstraps tenant ownership and audit behavior through the dedicated flow.',
|
||||
],
|
||||
],
|
||||
'followUpAction' => 'none',
|
||||
'mustRemainBaselineExempt' => true,
|
||||
'mustNotRemainBaselineExempt' => false,
|
||||
],
|
||||
ManagedTenantOnboardingWizard::class => [
|
||||
'surfaceKey' => 'managed_tenant_onboarding_wizard',
|
||||
'surfaceName' => 'Managed Tenant Onboarding Wizard',
|
||||
'pageClass' => ManagedTenantOnboardingWizard::class,
|
||||
'panelPlane' => 'admin',
|
||||
'surfaceKind' => 'wizard',
|
||||
'discoveryState' => 'primary_discovered_baseline_exempt',
|
||||
'closureDecision' => 'separately_governed',
|
||||
'reasonCategory' => 'workflow_specific_governance',
|
||||
'explicitReason' => 'The onboarding wizard is a workflow-specific surface with draft continuity, capability-gated steps, confirmations, and dedicated audit coverage.',
|
||||
'evidence' => [
|
||||
[
|
||||
'kind' => 'authorization_test',
|
||||
'reference' => 'tests/Feature/Rbac/OnboardingWizardUiEnforcementTest.php',
|
||||
'proves' => 'The wizard enforces capability checks on its interactive paths instead of inheriting the generic declaration contract.',
|
||||
],
|
||||
[
|
||||
'kind' => 'authorization_test',
|
||||
'reference' => 'tests/Feature/Onboarding/OnboardingDraftAccessTest.php',
|
||||
'proves' => 'Workspace and tenant continuity for onboarding drafts remains guarded by dedicated 404 and 403 semantics.',
|
||||
],
|
||||
],
|
||||
'followUpAction' => 'none',
|
||||
'mustRemainBaselineExempt' => true,
|
||||
'mustNotRemainBaselineExempt' => false,
|
||||
],
|
||||
ManagedTenantsLanding::class => [
|
||||
'surfaceKey' => 'managed_tenants_landing',
|
||||
'surfaceName' => 'Managed Tenants Landing',
|
||||
'pageClass' => ManagedTenantsLanding::class,
|
||||
'panelPlane' => 'admin',
|
||||
'surfaceKind' => 'landing',
|
||||
'discoveryState' => 'primary_discovered_baseline_exempt',
|
||||
'closureDecision' => 'harmless_special_case',
|
||||
'reasonCategory' => 'landing_routing_surface',
|
||||
'explicitReason' => 'The managed-tenants landing is a workspace routing shell that keeps discoverability and open-tenant navigation explicit without pretending to be a generic declaration-backed table page.',
|
||||
'evidence' => [
|
||||
[
|
||||
'kind' => 'feature_livewire_test',
|
||||
'reference' => 'tests/Feature/Workspaces/Spec195ManagedTenantsLandingTest.php',
|
||||
'proves' => 'The landing stays membership-scoped, preserves selector routing, and rejects outsider tenant openings.',
|
||||
],
|
||||
[
|
||||
'kind' => 'feature_livewire_test',
|
||||
'reference' => 'tests/Feature/Filament/ManagedTenantsLandingLifecycleTest.php',
|
||||
'proves' => 'The landing intentionally exposes broader administrative discoverability than the tenant chooser.',
|
||||
],
|
||||
],
|
||||
'followUpAction' => 'add_focused_test',
|
||||
'mustRemainBaselineExempt' => true,
|
||||
'mustNotRemainBaselineExempt' => false,
|
||||
],
|
||||
TenantDashboard::class => [
|
||||
'surfaceKey' => 'tenant_dashboard',
|
||||
'surfaceName' => 'Tenant Dashboard',
|
||||
'pageClass' => TenantDashboard::class,
|
||||
'panelPlane' => 'tenant',
|
||||
'surfaceKind' => 'dashboard_shell',
|
||||
'discoveryState' => 'primary_discovered_baseline_exempt',
|
||||
'closureDecision' => 'harmless_special_case',
|
||||
'reasonCategory' => 'dashboard_shell_widget_owned',
|
||||
'explicitReason' => 'The tenant dashboard is a widget shell whose meaningful mutations and visibility rules live in its widgets and follow-up routes rather than in page-level generic actions.',
|
||||
'evidence' => [
|
||||
[
|
||||
'kind' => 'db_only_surface_test',
|
||||
'reference' => 'tests/Feature/Filament/TenantDashboardDbOnlyTest.php',
|
||||
'proves' => 'The dashboard shell renders DB-only and keeps its main behavior in widget rendering rather than page-level actions.',
|
||||
],
|
||||
[
|
||||
'kind' => 'authorization_test',
|
||||
'reference' => 'tests/Feature/Rbac/TenantDashboardArrivalContextVisibilityTest.php',
|
||||
'proves' => 'Arrival context CTAs remain permission-aware and deny-as-not-found for non-members.',
|
||||
],
|
||||
],
|
||||
'followUpAction' => 'none',
|
||||
'mustRemainBaselineExempt' => true,
|
||||
'mustNotRemainBaselineExempt' => false,
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{
|
||||
* surfaceKey: string,
|
||||
* surfaceName: string,
|
||||
* pageClass: string,
|
||||
* panelPlane: string,
|
||||
* surfaceKind: string,
|
||||
* discoveryState: string,
|
||||
* closureDecision: string,
|
||||
* reasonCategory: ?string,
|
||||
* explicitReason: string,
|
||||
* evidence: array<int, array{
|
||||
* kind: string,
|
||||
* reference: string,
|
||||
* proves: string
|
||||
* }>,
|
||||
* followUpAction: string,
|
||||
* mustRemainBaselineExempt: bool,
|
||||
* mustNotRemainBaselineExempt: bool
|
||||
* }|null
|
||||
*/
|
||||
public static function spec195ResidualSurface(string $className): ?array
|
||||
{
|
||||
return self::spec195ResidualSurfaceInventory()[$className] ?? null;
|
||||
}
|
||||
}
|
||||
|
||||
@ -6,6 +6,10 @@
|
||||
|
||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceInspectAffordance;
|
||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceSlot;
|
||||
use Filament\Pages\Page;
|
||||
use RecursiveDirectoryIterator;
|
||||
use RecursiveIteratorIterator;
|
||||
use SplFileInfo;
|
||||
|
||||
final class ActionSurfaceValidator
|
||||
{
|
||||
@ -53,9 +57,20 @@ public function validate(): ActionSurfaceValidationResult
|
||||
public function validateComponents(array $components): ActionSurfaceValidationResult
|
||||
{
|
||||
$issues = [];
|
||||
$discoveredClassNames = array_values(array_unique(array_merge(
|
||||
array_map(
|
||||
static fn (ActionSurfaceDiscoveredComponent $component): string => $component->className,
|
||||
$this->discovery->discover(),
|
||||
),
|
||||
array_map(
|
||||
static fn (ActionSurfaceDiscoveredComponent $component): string => $component->className,
|
||||
$components,
|
||||
),
|
||||
)));
|
||||
|
||||
$this->validateSpec193MonitoringSurfaceInventory($issues);
|
||||
$this->validateSpec192RecordPageInventory($issues);
|
||||
$this->validateSpec195ResidualSurfaceInventory($issues, $discoveredClassNames);
|
||||
|
||||
foreach ($components as $component) {
|
||||
if (! class_exists($component->className)) {
|
||||
@ -371,6 +386,341 @@ className: $className,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, ActionSurfaceValidationIssue> $issues
|
||||
* @param array<int, string> $discoveredClassNames
|
||||
*/
|
||||
private function validateSpec195ResidualSurfaceInventory(array &$issues, array $discoveredClassNames): void
|
||||
{
|
||||
$issues = array_merge(
|
||||
$issues,
|
||||
self::validateSpec195ResidualInventoryFixture(
|
||||
inventory: ActionSurfaceExemptions::spec195ResidualSurfaceInventory(),
|
||||
discoveredClasses: $discoveredClassNames,
|
||||
baselineExemptions: ActionSurfaceExemptions::baseline()->all(),
|
||||
residualCandidateClasses: $this->spec195ResidualCandidateClasses($discoveredClassNames),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, array<string, mixed>> $inventory
|
||||
* @param array<int, string> $discoveredClasses
|
||||
* @param array<string, string> $baselineExemptions
|
||||
* @param array<int, string> $residualCandidateClasses
|
||||
* @return array<int, ActionSurfaceValidationIssue>
|
||||
*/
|
||||
public static function validateSpec195ResidualInventoryFixture(
|
||||
array $inventory,
|
||||
array $discoveredClasses,
|
||||
array $baselineExemptions,
|
||||
array $residualCandidateClasses = [],
|
||||
): array {
|
||||
$issues = [];
|
||||
$allowedDiscoveryStates = [
|
||||
'primary_discovered',
|
||||
'primary_discovered_baseline_exempt',
|
||||
'outside_primary_discovery',
|
||||
];
|
||||
$allowedClosureDecisions = [
|
||||
'generic_contract_enrollment',
|
||||
'intentional_exemption',
|
||||
'separately_governed',
|
||||
'retired_no_longer_relevant',
|
||||
'harmless_special_case',
|
||||
];
|
||||
$allowedReasonCategories = [
|
||||
'system_triage_surface',
|
||||
'workflow_specific_governance',
|
||||
'break_glass_repair_utility',
|
||||
'read_mostly_context_detail',
|
||||
'disabled_or_actionless_surface',
|
||||
'selector_routing_only',
|
||||
'registration_form_with_dedicated_rbac',
|
||||
'landing_routing_surface',
|
||||
'dashboard_shell_widget_owned',
|
||||
'security_flow_exception',
|
||||
];
|
||||
$allowedPanelPlanes = ['admin', 'tenant', 'system'];
|
||||
$allowedSurfaceKinds = [
|
||||
'system_detail',
|
||||
'system_utility',
|
||||
'selector',
|
||||
'wizard',
|
||||
'landing',
|
||||
'dashboard_shell',
|
||||
'recovery_flow',
|
||||
'read_mostly_context',
|
||||
];
|
||||
$allowedFollowUpActions = [
|
||||
'none',
|
||||
'tighten_reason',
|
||||
'add_guard_only',
|
||||
'add_focused_test',
|
||||
'consider_enrollment',
|
||||
];
|
||||
$allowedEvidenceKinds = [
|
||||
'guard_test',
|
||||
'feature_livewire_test',
|
||||
'authorization_test',
|
||||
'workflow_spec',
|
||||
'audit_test',
|
||||
'db_only_surface_test',
|
||||
];
|
||||
$discoveredLookup = array_fill_keys($discoveredClasses, true);
|
||||
$surfaceKeys = [];
|
||||
|
||||
foreach ($inventory as $className => $surface) {
|
||||
if (! class_exists($className)) {
|
||||
$issues[] = new ActionSurfaceValidationIssue(
|
||||
className: $className,
|
||||
message: 'Spec 195 residual inventory references a surface class that does not exist.',
|
||||
hint: 'Keep ActionSurfaceExemptions::spec195ResidualSurfaceInventory() aligned with the in-scope residual surface classes.',
|
||||
);
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$surfaceKey = (string) ($surface['surfaceKey'] ?? '');
|
||||
|
||||
if ($surfaceKey === '') {
|
||||
$issues[] = new ActionSurfaceValidationIssue(
|
||||
className: $className,
|
||||
message: 'Spec 195 residual inventory entry is missing a non-empty surface key.',
|
||||
hint: 'Provide the stable spec surface key for this residual surface.',
|
||||
);
|
||||
} elseif (isset($surfaceKeys[$surfaceKey])) {
|
||||
$issues[] = new ActionSurfaceValidationIssue(
|
||||
className: $className,
|
||||
message: sprintf('Spec 195 residual surface key "%s" is declared more than once.', $surfaceKey),
|
||||
hint: 'Each residual surface must have a unique stable key.',
|
||||
);
|
||||
} else {
|
||||
$surfaceKeys[$surfaceKey] = true;
|
||||
}
|
||||
|
||||
if (($surface['pageClass'] ?? null) !== $className) {
|
||||
$issues[] = new ActionSurfaceValidationIssue(
|
||||
className: $className,
|
||||
message: 'Spec 195 residual inventory pageClass must exactly match the keyed class.',
|
||||
hint: 'Keep the array key and pageClass field aligned for reviewer clarity.',
|
||||
);
|
||||
}
|
||||
|
||||
if (! is_string($surface['surfaceName'] ?? null) || trim((string) $surface['surfaceName']) === '') {
|
||||
$issues[] = new ActionSurfaceValidationIssue(
|
||||
className: $className,
|
||||
message: 'Spec 195 residual inventory surfaceName must be non-empty.',
|
||||
hint: 'Use a human-readable review label such as "System Ops View Run".',
|
||||
);
|
||||
}
|
||||
|
||||
if (! in_array($surface['panelPlane'] ?? null, $allowedPanelPlanes, true)) {
|
||||
$issues[] = new ActionSurfaceValidationIssue(
|
||||
className: $className,
|
||||
message: 'Spec 195 panel plane is invalid or missing.',
|
||||
hint: 'Use admin, tenant, or system.',
|
||||
);
|
||||
}
|
||||
|
||||
if (! in_array($surface['surfaceKind'] ?? null, $allowedSurfaceKinds, true)) {
|
||||
$issues[] = new ActionSurfaceValidationIssue(
|
||||
className: $className,
|
||||
message: 'Spec 195 surface kind is invalid or missing.',
|
||||
hint: 'Use one of the documented residual surface kinds from the logical contract.',
|
||||
);
|
||||
}
|
||||
|
||||
if (! in_array($surface['closureDecision'] ?? null, $allowedClosureDecisions, true)) {
|
||||
$issues[] = new ActionSurfaceValidationIssue(
|
||||
className: $className,
|
||||
message: 'Spec 195 closure decision is invalid or missing.',
|
||||
hint: 'Use generic_contract_enrollment, intentional_exemption, separately_governed, retired_no_longer_relevant, or harmless_special_case.',
|
||||
);
|
||||
}
|
||||
|
||||
$expectedDiscoveryState = isset($discoveredLookup[$className])
|
||||
? (array_key_exists($className, $baselineExemptions) ? 'primary_discovered_baseline_exempt' : 'primary_discovered')
|
||||
: 'outside_primary_discovery';
|
||||
|
||||
if (($surface['discoveryState'] ?? null) !== $expectedDiscoveryState) {
|
||||
$issues[] = new ActionSurfaceValidationIssue(
|
||||
className: $className,
|
||||
message: sprintf(
|
||||
'Spec 195 discovery state is not truthful. Expected "%s".',
|
||||
$expectedDiscoveryState,
|
||||
),
|
||||
hint: 'Keep discoveryState aligned with the primary validator discovery result and baseline exemption registry.',
|
||||
);
|
||||
}
|
||||
|
||||
if (! in_array($surface['discoveryState'] ?? null, $allowedDiscoveryStates, true)) {
|
||||
$issues[] = new ActionSurfaceValidationIssue(
|
||||
className: $className,
|
||||
message: 'Spec 195 discovery state is invalid or missing.',
|
||||
hint: 'Use primary_discovered, primary_discovered_baseline_exempt, or outside_primary_discovery.',
|
||||
);
|
||||
}
|
||||
|
||||
if (! is_string($surface['explicitReason'] ?? null) || trim((string) $surface['explicitReason']) === '') {
|
||||
$issues[] = new ActionSurfaceValidationIssue(
|
||||
className: $className,
|
||||
message: 'Spec 195 explicit reason must be non-empty.',
|
||||
hint: 'Document the concrete operator or review reason for the chosen closure decision.',
|
||||
);
|
||||
}
|
||||
|
||||
$closureDecision = (string) ($surface['closureDecision'] ?? '');
|
||||
$reasonCategory = $surface['reasonCategory'] ?? null;
|
||||
|
||||
if ($closureDecision === 'generic_contract_enrollment') {
|
||||
if (is_string($reasonCategory) && trim($reasonCategory) !== '') {
|
||||
$issues[] = new ActionSurfaceValidationIssue(
|
||||
className: $className,
|
||||
message: 'Generic-contract enrollment entries must not carry a reason category.',
|
||||
hint: 'Clear reasonCategory when the residual surface is fully enrolled into the generic contract.',
|
||||
);
|
||||
}
|
||||
|
||||
if (! method_exists($className, 'actionSurfaceDeclaration')) {
|
||||
$issues[] = new ActionSurfaceValidationIssue(
|
||||
className: $className,
|
||||
message: 'Generic-contract enrollment requires actionSurfaceDeclaration().',
|
||||
hint: 'Enroll the surface into the declaration-backed contract before classifying it as generic_contract_enrollment.',
|
||||
);
|
||||
}
|
||||
} elseif (! in_array($reasonCategory, $allowedReasonCategories, true)) {
|
||||
$issues[] = new ActionSurfaceValidationIssue(
|
||||
className: $className,
|
||||
message: 'Spec 195 reason category is invalid or missing for a non-enrolled residual surface.',
|
||||
hint: 'Use one of the allowed Spec 195 reason categories for intentional exemptions, separate governance, retired surfaces, and harmless special cases.',
|
||||
);
|
||||
}
|
||||
|
||||
if (! in_array($surface['followUpAction'] ?? null, $allowedFollowUpActions, true)) {
|
||||
$issues[] = new ActionSurfaceValidationIssue(
|
||||
className: $className,
|
||||
message: 'Spec 195 follow-up action is invalid or missing.',
|
||||
hint: 'Use none, tighten_reason, add_guard_only, add_focused_test, or consider_enrollment.',
|
||||
);
|
||||
}
|
||||
|
||||
if (! is_bool($surface['mustRemainBaselineExempt'] ?? null)) {
|
||||
$issues[] = new ActionSurfaceValidationIssue(
|
||||
className: $className,
|
||||
message: 'Spec 195 mustRemainBaselineExempt must be boolean.',
|
||||
hint: 'Use true only when the discovered page must remain in baseline().',
|
||||
);
|
||||
}
|
||||
|
||||
if (! is_bool($surface['mustNotRemainBaselineExempt'] ?? null)) {
|
||||
$issues[] = new ActionSurfaceValidationIssue(
|
||||
className: $className,
|
||||
message: 'Spec 195 mustNotRemainBaselineExempt must be boolean.',
|
||||
hint: 'Use true when the residual surface must stay out of baseline().',
|
||||
);
|
||||
}
|
||||
|
||||
if (($surface['mustRemainBaselineExempt'] ?? false) === true && ($surface['mustNotRemainBaselineExempt'] ?? false) === true) {
|
||||
$issues[] = new ActionSurfaceValidationIssue(
|
||||
className: $className,
|
||||
message: 'Spec 195 baseline flags cannot both be true.',
|
||||
hint: 'A residual surface can either stay in baseline() or be required to stay out of it, but not both.',
|
||||
);
|
||||
}
|
||||
|
||||
if (($surface['mustRemainBaselineExempt'] ?? false) === true && ! array_key_exists($className, $baselineExemptions)) {
|
||||
$issues[] = new ActionSurfaceValidationIssue(
|
||||
className: $className,
|
||||
message: 'Spec 195 says this residual surface must remain baseline-exempt, but baseline() does not include it.',
|
||||
hint: 'Keep discovered special surfaces aligned between baseline() and spec195ResidualSurfaceInventory().',
|
||||
);
|
||||
}
|
||||
|
||||
if (($surface['mustNotRemainBaselineExempt'] ?? false) === true && array_key_exists($className, $baselineExemptions)) {
|
||||
$issues[] = new ActionSurfaceValidationIssue(
|
||||
className: $className,
|
||||
message: 'Spec 195 says this residual surface must not remain baseline-exempt, but baseline() still includes it.',
|
||||
hint: 'Remove the stale baseline exemption or change the residual closure classification.',
|
||||
);
|
||||
}
|
||||
|
||||
$evidence = $surface['evidence'] ?? null;
|
||||
|
||||
if (! is_array($evidence) || $evidence === []) {
|
||||
$issues[] = new ActionSurfaceValidationIssue(
|
||||
className: $className,
|
||||
message: 'Spec 195 residual surfaces require at least one structured evidence descriptor.',
|
||||
hint: 'Add one or more evidence entries with kind, reference, and proves.',
|
||||
);
|
||||
} else {
|
||||
foreach ($evidence as $index => $descriptor) {
|
||||
if (! is_array($descriptor)) {
|
||||
$issues[] = new ActionSurfaceValidationIssue(
|
||||
className: $className,
|
||||
message: sprintf('Spec 195 evidence entry #%d must be an array.', $index + 1),
|
||||
hint: 'Use structured evidence descriptors with kind, reference, and proves.',
|
||||
);
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
if (! in_array($descriptor['kind'] ?? null, $allowedEvidenceKinds, true)) {
|
||||
$issues[] = new ActionSurfaceValidationIssue(
|
||||
className: $className,
|
||||
message: sprintf('Spec 195 evidence entry #%d kind is invalid or missing.', $index + 1),
|
||||
hint: 'Use guard_test, feature_livewire_test, authorization_test, workflow_spec, audit_test, or db_only_surface_test.',
|
||||
);
|
||||
}
|
||||
|
||||
if (! is_string($descriptor['reference'] ?? null) || trim((string) $descriptor['reference']) === '') {
|
||||
$issues[] = new ActionSurfaceValidationIssue(
|
||||
className: $className,
|
||||
message: sprintf('Spec 195 evidence entry #%d reference must be non-empty.', $index + 1),
|
||||
hint: 'Point reviewers at the concrete test file or source artifact that proves the classification.',
|
||||
);
|
||||
}
|
||||
|
||||
if (! is_string($descriptor['proves'] ?? null) || trim((string) $descriptor['proves']) === '') {
|
||||
$issues[] = new ActionSurfaceValidationIssue(
|
||||
className: $className,
|
||||
message: sprintf('Spec 195 evidence entry #%d proves text must be non-empty.', $index + 1),
|
||||
hint: 'Explain what the referenced evidence actually proves about the residual surface.',
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$candidateClasses = array_values(array_unique(array_merge(
|
||||
$residualCandidateClasses,
|
||||
self::spec195BaselineResidualCandidateClasses($baselineExemptions),
|
||||
)));
|
||||
sort($candidateClasses);
|
||||
|
||||
foreach ($candidateClasses as $className) {
|
||||
if (array_key_exists($className, $inventory)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$classPath = self::spec195ClassPath($className);
|
||||
|
||||
$issues[] = new ActionSurfaceValidationIssue(
|
||||
className: $className,
|
||||
message: 'Residual action surface is missing a Spec 195 closure entry.',
|
||||
hint: $classPath !== null
|
||||
? sprintf(
|
||||
'Add %s to ActionSurfaceExemptions::spec195ResidualSurfaceInventory() and classify it via the reviewer workflow. File: %s',
|
||||
$className,
|
||||
$classPath,
|
||||
)
|
||||
: 'Add the missing residual surface to ActionSurfaceExemptions::spec195ResidualSurfaceInventory() and classify it via the reviewer workflow.',
|
||||
);
|
||||
}
|
||||
|
||||
return $issues;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, ActionSurfaceValidationIssue> $issues
|
||||
*/
|
||||
@ -419,10 +769,26 @@ private function validateClassExemptionOrFail(string $className, array &$issues)
|
||||
$reason = $this->exemptions->reasonForClass($className);
|
||||
|
||||
if ($reason === null) {
|
||||
$residualSurface = ActionSurfaceExemptions::spec195ResidualSurface($className);
|
||||
|
||||
if ($residualSurface !== null) {
|
||||
$closureDecision = (string) ($residualSurface['closureDecision'] ?? '');
|
||||
|
||||
if ($closureDecision === 'generic_contract_enrollment') {
|
||||
$issues[] = new ActionSurfaceValidationIssue(
|
||||
className: $className,
|
||||
message: 'Residual surface is marked for generic-contract enrollment but still lacks actionSurfaceDeclaration().',
|
||||
hint: 'Add actionSurfaceDeclaration() or change the Spec 195 closure decision if the surface is intentionally staying separate.',
|
||||
);
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$issues[] = new ActionSurfaceValidationIssue(
|
||||
className: $className,
|
||||
message: 'Missing action-surface declaration and no component exemption exists.',
|
||||
hint: 'Add actionSurfaceDeclaration() or register a baseline exemption with a non-empty reason.',
|
||||
message: 'Missing action-surface declaration, baseline exemption, and Spec 195 residual closure entry.',
|
||||
hint: 'Add actionSurfaceDeclaration(), register a baseline exemption with a non-empty reason, or classify the surface in spec195ResidualSurfaceInventory().',
|
||||
);
|
||||
|
||||
return;
|
||||
@ -653,4 +1019,142 @@ className: $className,
|
||||
hint: 'Keep exportIsDefaultBulkActionForReadOnly=true or exempt ListBulkMoreGroup with a reason.',
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, string> $discoveredClassNames
|
||||
* @return array<int, string>
|
||||
*/
|
||||
private function spec195ResidualCandidateClasses(array $discoveredClassNames): array
|
||||
{
|
||||
$candidates = $this->spec195SystemResidualCandidateClasses($discoveredClassNames);
|
||||
|
||||
foreach (self::spec195BaselineResidualCandidateClasses(ActionSurfaceExemptions::baseline()->all()) as $className) {
|
||||
$candidates[] = $className;
|
||||
}
|
||||
|
||||
$candidates = array_values(array_unique($candidates));
|
||||
sort($candidates);
|
||||
|
||||
return $candidates;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, string> $discoveredClassNames
|
||||
* @return array<int, string>
|
||||
*/
|
||||
private function spec195SystemResidualCandidateClasses(array $discoveredClassNames): array
|
||||
{
|
||||
$discoveredLookup = array_fill_keys($discoveredClassNames, true);
|
||||
$classes = [];
|
||||
|
||||
foreach ($this->collectPhpClasses($this->appFilamentSystemPagesPath()) as $className) {
|
||||
if (isset($discoveredLookup[$className])) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($className === 'App\\Filament\\System\\Pages\\Auth\\Login') {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (! class_exists($className) || ! is_subclass_of($className, Page::class)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$classes[] = $className;
|
||||
}
|
||||
|
||||
sort($classes);
|
||||
|
||||
return $classes;
|
||||
}
|
||||
|
||||
private function appFilamentSystemPagesPath(): string
|
||||
{
|
||||
return base_path('app/Filament/System/Pages');
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, string> $baselineExemptions
|
||||
* @return array<int, string>
|
||||
*/
|
||||
private static function spec195BaselineResidualCandidateClasses(array $baselineExemptions): array
|
||||
{
|
||||
$classes = [];
|
||||
|
||||
foreach (array_keys($baselineExemptions) as $className) {
|
||||
if (! self::qualifiesAsSpec195BaselineResidualCandidate($className)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$classes[] = $className;
|
||||
}
|
||||
|
||||
sort($classes);
|
||||
|
||||
return $classes;
|
||||
}
|
||||
|
||||
private static function qualifiesAsSpec195BaselineResidualCandidate(string $className): bool
|
||||
{
|
||||
if (in_array($className, [
|
||||
'App\\Filament\\Pages\\BreakGlassRecovery',
|
||||
'App\\Filament\\Pages\\ChooseTenant',
|
||||
'App\\Filament\\Pages\\ChooseWorkspace',
|
||||
'App\\Filament\\Pages\\TenantDashboard',
|
||||
], true)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return str_starts_with($className, 'App\\Filament\\Pages\\Tenancy\\')
|
||||
|| str_starts_with($className, 'App\\Filament\\Pages\\Workspaces\\');
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, string>
|
||||
*/
|
||||
private function collectPhpClasses(string $directory): array
|
||||
{
|
||||
if (! is_dir($directory)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$iterator = new RecursiveIteratorIterator(
|
||||
new RecursiveDirectoryIterator($directory, RecursiveDirectoryIterator::SKIP_DOTS),
|
||||
);
|
||||
|
||||
$classes = [];
|
||||
|
||||
/** @var SplFileInfo $file */
|
||||
foreach ($iterator as $file) {
|
||||
if (! $file->isFile() || ! str_ends_with($file->getFilename(), '.php')) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$classes[] = $this->classNameFromPath($file->getPathname());
|
||||
}
|
||||
|
||||
sort($classes);
|
||||
|
||||
return $classes;
|
||||
}
|
||||
|
||||
private function classNameFromPath(string $path): string
|
||||
{
|
||||
$normalizedPath = str_replace('\\', '/', $path);
|
||||
$normalizedAppPath = str_replace('\\', '/', app_path());
|
||||
$relativePath = ltrim(substr($normalizedPath, strlen($normalizedAppPath)), '/');
|
||||
|
||||
return 'App\\'.str_replace('/', '\\', substr($relativePath, 0, -4));
|
||||
}
|
||||
|
||||
private static function spec195ClassPath(string $className): ?string
|
||||
{
|
||||
if (! str_starts_with($className, 'App\\')) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$path = base_path('app/'.str_replace('\\', '/', substr($className, 4)).'.php');
|
||||
|
||||
return is_file($path) ? $path : null;
|
||||
}
|
||||
}
|
||||
|
||||
@ -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.');
|
||||
});
|
||||
@ -2,17 +2,29 @@
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
require_once __DIR__.'/Support/FakeCompareStrategy.php';
|
||||
|
||||
use App\Jobs\CompareBaselineToTenantJob;
|
||||
use App\Models\BaselineProfile;
|
||||
use App\Models\BaselineSnapshot;
|
||||
use App\Models\BaselineSnapshotItem;
|
||||
use App\Models\BaselineTenantAssignment;
|
||||
use App\Models\InventoryItem;
|
||||
use App\Models\OperationRun;
|
||||
use App\Services\Baselines\BaselineCompareService;
|
||||
use App\Services\Baselines\BaselineSnapshotIdentity;
|
||||
use App\Services\Intune\AuditLogger;
|
||||
use App\Services\OperationRunService;
|
||||
use App\Support\Baselines\BaselineCompareReasonCode;
|
||||
use App\Support\Baselines\BaselineReasonCodes;
|
||||
use App\Support\Baselines\Compare\CompareStrategyRegistry;
|
||||
use App\Support\Governance\GovernanceSubjectTaxonomyRegistry;
|
||||
use App\Support\OperationRunOutcome;
|
||||
use App\Support\OperationRunStatus;
|
||||
use App\Support\OperationRunType;
|
||||
use Illuminate\Support\Facades\Queue;
|
||||
use Tests\Feature\Baselines\Support\FailingCompareStrategy;
|
||||
use Tests\Feature\Baselines\Support\FakeGovernanceSubjectTaxonomyRegistry;
|
||||
|
||||
it('blocks compare execution when the queued snapshot is incomplete', function (): void {
|
||||
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||
@ -113,3 +125,86 @@
|
||||
->and(data_get($context, 'baseline_compare.latest_attempted_snapshot_id'))->toBe((int) $currentSnapshot->getKey())
|
||||
->and(data_get($run->failure_summary, '0.reason_code'))->toBe(BaselineReasonCodes::COMPARE_SNAPSHOT_SUPERSEDED);
|
||||
});
|
||||
|
||||
it('marks compare runs as partially succeeded when strategy-owned processing fails before subject classification completes', function (): void {
|
||||
Queue::fake();
|
||||
|
||||
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||
|
||||
app()->instance(GovernanceSubjectTaxonomyRegistry::class, new FakeGovernanceSubjectTaxonomyRegistry);
|
||||
app()->instance(CompareStrategyRegistry::class, new CompareStrategyRegistry([
|
||||
app(FailingCompareStrategy::class),
|
||||
]));
|
||||
|
||||
$profile = BaselineProfile::factory()->active()->create([
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'scope_jsonb' => [
|
||||
'version' => 2,
|
||||
'entries' => [[
|
||||
'domain_key' => 'entra',
|
||||
'subject_class' => 'control',
|
||||
'subject_type_keys' => ['conditionalAccessPolicy'],
|
||||
'filters' => [],
|
||||
]],
|
||||
],
|
||||
]);
|
||||
|
||||
$snapshot = BaselineSnapshot::factory()->create([
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'baseline_profile_id' => (int) $profile->getKey(),
|
||||
'captured_at' => now()->subMinute(),
|
||||
]);
|
||||
|
||||
$profile->update(['active_snapshot_id' => (int) $snapshot->getKey()]);
|
||||
|
||||
BaselineTenantAssignment::factory()->create([
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'baseline_profile_id' => (int) $profile->getKey(),
|
||||
]);
|
||||
|
||||
BaselineSnapshotItem::factory()->create([
|
||||
'baseline_snapshot_id' => (int) $snapshot->getKey(),
|
||||
'subject_type' => 'control',
|
||||
'subject_external_id' => 'conditional-access-policy-1',
|
||||
'subject_key' => 'conditional-access-policy-1',
|
||||
'policy_type' => 'conditionalAccessPolicy',
|
||||
'baseline_hash' => hash('sha256', 'baseline'),
|
||||
'meta_jsonb' => ['display_name' => 'Conditional Access Policy'],
|
||||
]);
|
||||
|
||||
$inventorySyncRun = createInventorySyncOperationRunWithCoverage($tenant, ['conditionalAccessPolicy' => 'succeeded']);
|
||||
|
||||
InventoryItem::factory()->create([
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'external_id' => 'conditional-access-policy-1',
|
||||
'policy_type' => 'conditionalAccessPolicy',
|
||||
'display_name' => 'Conditional Access Policy',
|
||||
'meta_jsonb' => ['display_name' => 'Conditional Access Policy'],
|
||||
'last_seen_operation_run_id' => (int) $inventorySyncRun->getKey(),
|
||||
'last_seen_at' => now(),
|
||||
]);
|
||||
|
||||
$result = app(BaselineCompareService::class)->startCompare($tenant, $user);
|
||||
|
||||
expect($result['ok'])->toBeTrue();
|
||||
|
||||
/** @var OperationRun $run */
|
||||
$run = $result['run'];
|
||||
|
||||
(new CompareBaselineToTenantJob($run))->handle(
|
||||
app(BaselineSnapshotIdentity::class),
|
||||
app(AuditLogger::class),
|
||||
app(OperationRunService::class),
|
||||
);
|
||||
|
||||
$run->refresh();
|
||||
|
||||
expect($run->outcome)->toBe(OperationRunOutcome::PartiallySucceeded->value)
|
||||
->and(data_get($run->context, 'baseline_compare.reason_code'))->toBe(BaselineCompareReasonCode::StrategyFailed->value)
|
||||
->and(data_get($run->context, 'baseline_compare.strategy.execution_diagnostics.failed'))->toBeTrue()
|
||||
->and(data_get($run->context, 'baseline_compare.strategy.execution_diagnostics.exception_class'))->toBe(RuntimeException::class)
|
||||
->and(data_get($run->context, 'baseline_compare.strategy.state_counts'))->toBe([])
|
||||
->and(data_get($run->context, 'baseline_compare.evidence_gaps.by_reason.strategy_failed'))->toBe(1);
|
||||
});
|
||||
|
||||
@ -2,16 +2,24 @@
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
require_once __DIR__.'/Support/FakeCompareStrategy.php';
|
||||
|
||||
use App\Filament\Pages\BaselineCompareMatrix;
|
||||
use App\Jobs\CompareBaselineToTenantJob;
|
||||
use App\Models\OperationRun;
|
||||
use App\Models\Tenant;
|
||||
use App\Services\Baselines\BaselineCompareService;
|
||||
use App\Support\Baselines\BaselineReasonCodes;
|
||||
use App\Support\Baselines\Compare\CompareStrategyRegistry;
|
||||
use App\Support\Baselines\Compare\IntuneCompareStrategy;
|
||||
use App\Support\Governance\GovernanceSubjectTaxonomyRegistry;
|
||||
use App\Support\OperationRunOutcome;
|
||||
use App\Support\OperationRunStatus;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Queue;
|
||||
use Livewire\Livewire;
|
||||
use Tests\Feature\Baselines\Support\FakeCompareStrategy;
|
||||
use Tests\Feature\Baselines\Support\FakeGovernanceSubjectTaxonomyRegistry;
|
||||
use Tests\Feature\Concerns\BuildsBaselineCompareMatrixFixtures;
|
||||
|
||||
uses(RefreshDatabase::class, BuildsBaselineCompareMatrixFixtures::class);
|
||||
@ -93,3 +101,82 @@
|
||||
->whereNull('tenant_id')
|
||||
->count())->toBe(0);
|
||||
});
|
||||
|
||||
it('blocks visible assignment fanout when the baseline scope spans multiple compare strategy families', function (): void {
|
||||
Queue::fake();
|
||||
|
||||
$fixture = $this->makeBaselineCompareMatrixFixture();
|
||||
|
||||
app()->instance(GovernanceSubjectTaxonomyRegistry::class, new FakeGovernanceSubjectTaxonomyRegistry);
|
||||
app()->instance(CompareStrategyRegistry::class, new CompareStrategyRegistry([
|
||||
app(IntuneCompareStrategy::class),
|
||||
app(FakeCompareStrategy::class),
|
||||
]));
|
||||
|
||||
$fixture['profile']->update([
|
||||
'scope_jsonb' => [
|
||||
'version' => 2,
|
||||
'entries' => [
|
||||
[
|
||||
'domain_key' => 'intune',
|
||||
'subject_class' => 'policy',
|
||||
'subject_type_keys' => ['deviceConfiguration'],
|
||||
'filters' => [],
|
||||
],
|
||||
[
|
||||
'domain_key' => 'entra',
|
||||
'subject_class' => 'control',
|
||||
'subject_type_keys' => ['conditionalAccessPolicy'],
|
||||
'filters' => [],
|
||||
],
|
||||
],
|
||||
],
|
||||
]);
|
||||
|
||||
$result = app(BaselineCompareService::class)->startCompareForVisibleAssignments($fixture['profile'], $fixture['user']);
|
||||
|
||||
expect($result['visibleAssignedTenantCount'])->toBe(2)
|
||||
->and($result['queuedCount'])->toBe(0)
|
||||
->and($result['alreadyQueuedCount'])->toBe(0)
|
||||
->and($result['blockedCount'])->toBe(2)
|
||||
->and(collect($result['targets'])->pluck('reasonCode')->unique()->values()->all())
|
||||
->toBe([BaselineReasonCodes::COMPARE_MIXED_SCOPE]);
|
||||
|
||||
Queue::assertNotPushed(CompareBaselineToTenantJob::class);
|
||||
});
|
||||
|
||||
it('blocks visible assignment fanout when the baseline scope has no compatible compare strategy family', function (): void {
|
||||
Queue::fake();
|
||||
|
||||
$fixture = $this->makeBaselineCompareMatrixFixture();
|
||||
|
||||
app()->instance(GovernanceSubjectTaxonomyRegistry::class, new FakeGovernanceSubjectTaxonomyRegistry);
|
||||
app()->instance(CompareStrategyRegistry::class, new CompareStrategyRegistry([
|
||||
app(IntuneCompareStrategy::class),
|
||||
]));
|
||||
|
||||
$fixture['profile']->update([
|
||||
'scope_jsonb' => [
|
||||
'version' => 2,
|
||||
'entries' => [
|
||||
[
|
||||
'domain_key' => 'entra',
|
||||
'subject_class' => 'control',
|
||||
'subject_type_keys' => ['conditionalAccessPolicy'],
|
||||
'filters' => [],
|
||||
],
|
||||
],
|
||||
],
|
||||
]);
|
||||
|
||||
$result = app(BaselineCompareService::class)->startCompareForVisibleAssignments($fixture['profile'], $fixture['user']);
|
||||
|
||||
expect($result['visibleAssignedTenantCount'])->toBe(2)
|
||||
->and($result['queuedCount'])->toBe(0)
|
||||
->and($result['alreadyQueuedCount'])->toBe(0)
|
||||
->and($result['blockedCount'])->toBe(2)
|
||||
->and(collect($result['targets'])->pluck('reasonCode')->unique()->values()->all())
|
||||
->toBe([BaselineReasonCodes::COMPARE_UNSUPPORTED_SCOPE]);
|
||||
|
||||
Queue::assertNotPushed(CompareBaselineToTenantJob::class);
|
||||
});
|
||||
|
||||
@ -1,15 +1,22 @@
|
||||
<?php
|
||||
|
||||
require_once __DIR__.'/Support/FakeCompareStrategy.php';
|
||||
|
||||
use App\Jobs\CompareBaselineToTenantJob;
|
||||
use App\Models\BaselineProfile;
|
||||
use App\Models\BaselineSnapshot;
|
||||
use App\Models\BaselineTenantAssignment;
|
||||
use App\Models\OperationRun;
|
||||
use App\Services\Baselines\BaselineCompareService;
|
||||
use App\Support\Baselines\Compare\CompareStrategyRegistry;
|
||||
use App\Support\Baselines\BaselineProfileStatus;
|
||||
use App\Support\Baselines\BaselineReasonCodes;
|
||||
use App\Support\Baselines\Compare\IntuneCompareStrategy;
|
||||
use App\Support\Governance\GovernanceSubjectTaxonomyRegistry;
|
||||
use App\Support\OperationRunType;
|
||||
use Illuminate\Support\Facades\Queue;
|
||||
use Tests\Feature\Baselines\Support\FakeCompareStrategy;
|
||||
use Tests\Feature\Baselines\Support\FakeGovernanceSubjectTaxonomyRegistry;
|
||||
|
||||
// --- T040: Compare precondition 422 tests ---
|
||||
|
||||
@ -217,6 +224,158 @@
|
||||
Queue::assertPushed(CompareBaselineToTenantJob::class);
|
||||
});
|
||||
|
||||
it('rejects compare when canonical scope spans multiple compare strategy families', function (): void {
|
||||
Queue::fake();
|
||||
|
||||
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||
|
||||
app()->instance(GovernanceSubjectTaxonomyRegistry::class, new FakeGovernanceSubjectTaxonomyRegistry);
|
||||
app()->instance(CompareStrategyRegistry::class, new CompareStrategyRegistry([
|
||||
app(IntuneCompareStrategy::class),
|
||||
app(FakeCompareStrategy::class),
|
||||
]));
|
||||
|
||||
$profile = BaselineProfile::factory()->active()->create([
|
||||
'workspace_id' => $tenant->workspace_id,
|
||||
'scope_jsonb' => [
|
||||
'version' => 2,
|
||||
'entries' => [
|
||||
[
|
||||
'domain_key' => 'intune',
|
||||
'subject_class' => 'policy',
|
||||
'subject_type_keys' => ['deviceConfiguration'],
|
||||
'filters' => [],
|
||||
],
|
||||
[
|
||||
'domain_key' => 'entra',
|
||||
'subject_class' => 'control',
|
||||
'subject_type_keys' => ['conditionalAccessPolicy'],
|
||||
'filters' => [],
|
||||
],
|
||||
],
|
||||
],
|
||||
]);
|
||||
|
||||
$snapshot = BaselineSnapshot::factory()->create([
|
||||
'workspace_id' => $tenant->workspace_id,
|
||||
'baseline_profile_id' => $profile->getKey(),
|
||||
]);
|
||||
|
||||
$profile->update(['active_snapshot_id' => $snapshot->getKey()]);
|
||||
|
||||
BaselineTenantAssignment::create([
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'baseline_profile_id' => (int) $profile->getKey(),
|
||||
]);
|
||||
|
||||
$service = app(BaselineCompareService::class);
|
||||
$result = $service->startCompare($tenant, $user);
|
||||
|
||||
expect($result['ok'])->toBeFalse();
|
||||
expect($result['reason_code'])->toBe(BaselineReasonCodes::COMPARE_MIXED_SCOPE);
|
||||
|
||||
Queue::assertNotPushed(CompareBaselineToTenantJob::class);
|
||||
expect(OperationRun::query()->where('type', OperationRunType::BaselineCompare->value)->count())->toBe(0);
|
||||
});
|
||||
|
||||
it('rejects compare when canonical scope uses an inactive subject type', function (): void {
|
||||
Queue::fake();
|
||||
|
||||
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||
|
||||
$profile = BaselineProfile::factory()->active()->create([
|
||||
'workspace_id' => $tenant->workspace_id,
|
||||
'scope_jsonb' => ['policy_types' => ['deviceConfiguration'], 'foundation_types' => []],
|
||||
]);
|
||||
|
||||
BaselineProfile::query()
|
||||
->whereKey($profile->getKey())
|
||||
->update([
|
||||
'scope_jsonb' => json_encode([
|
||||
'version' => 2,
|
||||
'entries' => [
|
||||
[
|
||||
'domain_key' => 'platform_foundation',
|
||||
'subject_class' => 'configuration_resource',
|
||||
'subject_type_keys' => ['intuneRoleAssignment'],
|
||||
'filters' => [],
|
||||
],
|
||||
],
|
||||
], JSON_THROW_ON_ERROR),
|
||||
]);
|
||||
|
||||
$snapshot = BaselineSnapshot::factory()->create([
|
||||
'workspace_id' => $tenant->workspace_id,
|
||||
'baseline_profile_id' => $profile->getKey(),
|
||||
]);
|
||||
|
||||
$profile->update(['active_snapshot_id' => $snapshot->getKey()]);
|
||||
|
||||
BaselineTenantAssignment::create([
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'baseline_profile_id' => (int) $profile->getKey(),
|
||||
]);
|
||||
|
||||
$service = app(BaselineCompareService::class);
|
||||
$result = $service->startCompare($tenant, $user);
|
||||
|
||||
expect($result['ok'])->toBeFalse();
|
||||
expect($result['reason_code'])->toBe(BaselineReasonCodes::COMPARE_INVALID_SCOPE);
|
||||
|
||||
Queue::assertNotPushed(CompareBaselineToTenantJob::class);
|
||||
expect(OperationRun::query()->where('type', OperationRunType::BaselineCompare->value)->count())->toBe(0);
|
||||
});
|
||||
|
||||
it('rejects compare when canonical scope has no compatible compare strategy', function (): void {
|
||||
Queue::fake();
|
||||
|
||||
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||
|
||||
app()->instance(GovernanceSubjectTaxonomyRegistry::class, new FakeGovernanceSubjectTaxonomyRegistry);
|
||||
app()->instance(CompareStrategyRegistry::class, new CompareStrategyRegistry([
|
||||
app(IntuneCompareStrategy::class),
|
||||
]));
|
||||
|
||||
$profile = BaselineProfile::factory()->active()->create([
|
||||
'workspace_id' => $tenant->workspace_id,
|
||||
'scope_jsonb' => [
|
||||
'version' => 2,
|
||||
'entries' => [
|
||||
[
|
||||
'domain_key' => 'entra',
|
||||
'subject_class' => 'control',
|
||||
'subject_type_keys' => ['conditionalAccessPolicy'],
|
||||
'filters' => [],
|
||||
],
|
||||
],
|
||||
],
|
||||
]);
|
||||
|
||||
$snapshot = BaselineSnapshot::factory()->create([
|
||||
'workspace_id' => $tenant->workspace_id,
|
||||
'baseline_profile_id' => $profile->getKey(),
|
||||
]);
|
||||
|
||||
$profile->update(['active_snapshot_id' => $snapshot->getKey()]);
|
||||
|
||||
BaselineTenantAssignment::create([
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'baseline_profile_id' => (int) $profile->getKey(),
|
||||
]);
|
||||
|
||||
$service = app(BaselineCompareService::class);
|
||||
$result = $service->startCompare($tenant, $user);
|
||||
|
||||
expect($result['ok'])->toBeFalse();
|
||||
expect($result['reason_code'])->toBe(BaselineReasonCodes::COMPARE_UNSUPPORTED_SCOPE);
|
||||
|
||||
Queue::assertNotPushed(CompareBaselineToTenantJob::class);
|
||||
expect(OperationRun::query()->where('type', OperationRunType::BaselineCompare->value)->count())->toBe(0);
|
||||
});
|
||||
|
||||
it('uses an explicit snapshot override instead of baseline_profiles.active_snapshot_id when provided', function () {
|
||||
Queue::fake();
|
||||
|
||||
|
||||
@ -0,0 +1,160 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
require_once __DIR__.'/Support/FakeCompareStrategy.php';
|
||||
|
||||
use App\Jobs\CompareBaselineToTenantJob;
|
||||
use App\Models\BaselineProfile;
|
||||
use App\Models\BaselineSnapshot;
|
||||
use App\Models\BaselineTenantAssignment;
|
||||
use App\Models\Finding;
|
||||
use App\Models\InventoryItem;
|
||||
use App\Models\OperationRun;
|
||||
use App\Services\Baselines\BaselineCompareService;
|
||||
use App\Services\Baselines\BaselineSnapshotIdentity;
|
||||
use App\Services\Intune\AuditLogger;
|
||||
use App\Services\OperationRunService;
|
||||
use App\Support\Baselines\Compare\CompareStrategyRegistry;
|
||||
use App\Support\OperationRunOutcome;
|
||||
use App\Support\OperationRunStatus;
|
||||
use App\Support\OperationRunType;
|
||||
use App\Support\Governance\GovernanceSubjectTaxonomyRegistry;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Queue;
|
||||
use Tests\Feature\Baselines\Support\FakeCompareStrategy;
|
||||
use Tests\Feature\Baselines\Support\FakeGovernanceSubjectTaxonomyRegistry;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
it('runs a future-domain compare strategy through the shared lifecycle without implicit intune fallback', function (): void {
|
||||
Queue::fake();
|
||||
|
||||
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||
|
||||
app()->instance(GovernanceSubjectTaxonomyRegistry::class, new FakeGovernanceSubjectTaxonomyRegistry);
|
||||
app()->instance(CompareStrategyRegistry::class, new CompareStrategyRegistry([
|
||||
app(FakeCompareStrategy::class),
|
||||
]));
|
||||
|
||||
$profile = BaselineProfile::factory()->active()->create([
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'scope_jsonb' => [
|
||||
'version' => 2,
|
||||
'entries' => [[
|
||||
'domain_key' => 'entra',
|
||||
'subject_class' => 'control',
|
||||
'subject_type_keys' => ['conditionalAccessPolicy'],
|
||||
'filters' => [],
|
||||
]],
|
||||
],
|
||||
]);
|
||||
|
||||
$snapshot = BaselineSnapshot::factory()->create([
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'baseline_profile_id' => (int) $profile->getKey(),
|
||||
'captured_at' => now()->subMinute(),
|
||||
]);
|
||||
|
||||
$profile->update(['active_snapshot_id' => (int) $snapshot->getKey()]);
|
||||
|
||||
BaselineTenantAssignment::factory()->create([
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'baseline_profile_id' => (int) $profile->getKey(),
|
||||
]);
|
||||
|
||||
$displayName = 'Conditional Access Global Block';
|
||||
$externalId = 'conditional-access-policy-1';
|
||||
$subjectKey = app(BaselineSnapshotIdentity::class)->subjectKey(
|
||||
policyType: 'conditionalAccessPolicy',
|
||||
displayName: $displayName,
|
||||
subjectExternalId: $externalId,
|
||||
) ?? $externalId;
|
||||
|
||||
$baselineMeta = [
|
||||
'display_name' => $displayName,
|
||||
'conditions' => ['users' => ['includeGuestsOrExternalUsers' => true]],
|
||||
];
|
||||
$currentMeta = [
|
||||
'display_name' => $displayName,
|
||||
'conditions' => ['users' => ['includeGuestsOrExternalUsers' => false]],
|
||||
];
|
||||
|
||||
$baselineHash = app(BaselineSnapshotIdentity::class)->hashItemContent(
|
||||
policyType: 'conditionalAccessPolicy',
|
||||
subjectExternalId: $externalId,
|
||||
metaJsonb: $baselineMeta,
|
||||
);
|
||||
|
||||
\App\Models\BaselineSnapshotItem::factory()->create([
|
||||
'baseline_snapshot_id' => (int) $snapshot->getKey(),
|
||||
'subject_type' => 'control',
|
||||
'subject_external_id' => $externalId,
|
||||
'subject_key' => $subjectKey,
|
||||
'policy_type' => 'conditionalAccessPolicy',
|
||||
'baseline_hash' => $baselineHash,
|
||||
'meta_jsonb' => $baselineMeta,
|
||||
]);
|
||||
|
||||
$inventorySyncRun = OperationRun::factory()->create([
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'type' => OperationRunType::InventorySync->value,
|
||||
'status' => OperationRunStatus::Completed->value,
|
||||
'outcome' => OperationRunOutcome::Succeeded->value,
|
||||
'completed_at' => now(),
|
||||
'context' => [
|
||||
'inventory' => [
|
||||
'coverage' => [
|
||||
'policy_types' => [
|
||||
'conditionalAccessPolicy' => ['status' => 'succeeded'],
|
||||
],
|
||||
'foundation_types' => [],
|
||||
],
|
||||
],
|
||||
],
|
||||
]);
|
||||
|
||||
InventoryItem::factory()->create([
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'external_id' => $externalId,
|
||||
'policy_type' => 'conditionalAccessPolicy',
|
||||
'display_name' => $displayName,
|
||||
'meta_jsonb' => $currentMeta,
|
||||
'last_seen_operation_run_id' => (int) $inventorySyncRun->getKey(),
|
||||
'last_seen_at' => now(),
|
||||
]);
|
||||
|
||||
$service = app(BaselineCompareService::class);
|
||||
$result = $service->startCompare($tenant, $user);
|
||||
|
||||
expect($result['ok'] ?? false)->toBeTrue();
|
||||
Queue::assertPushed(CompareBaselineToTenantJob::class);
|
||||
|
||||
/** @var OperationRun $run */
|
||||
$run = $result['run'];
|
||||
|
||||
(new CompareBaselineToTenantJob($run))->handle(
|
||||
app(\App\Services\Baselines\BaselineSnapshotIdentity::class),
|
||||
app(AuditLogger::class),
|
||||
app(OperationRunService::class),
|
||||
);
|
||||
|
||||
$run->refresh();
|
||||
|
||||
expect($run->status)->toBe(OperationRunStatus::Completed->value)
|
||||
->and($run->outcome)->toBe(OperationRunOutcome::Succeeded->value)
|
||||
->and(data_get($run->context, 'baseline_compare.strategy.key'))->toBe('future_control')
|
||||
->and(data_get($run->context, 'baseline_compare.strategy.selection_state'))->toBe('supported')
|
||||
->and(data_get($run->context, 'findings.counts_by_change_type.different_version'))->toBe(1)
|
||||
->and(data_get($run->context, 'result.findings_total'))->toBe(1);
|
||||
|
||||
$finding = Finding::query()->where('tenant_id', (int) $tenant->getKey())->first();
|
||||
|
||||
expect($finding)->not->toBeNull()
|
||||
->and($finding?->subject_type)->toBe('control')
|
||||
->and(data_get($finding?->evidence_jsonb, 'summary.kind'))->toBe('control_snapshot')
|
||||
->and(data_get($finding?->evidence_jsonb, 'policy_type'))->toBe('conditionalAccessPolicy');
|
||||
});
|
||||
@ -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();
|
||||
});
|
||||
@ -0,0 +1,454 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tests\Feature\Baselines\Support;
|
||||
|
||||
use App\Models\Tenant;
|
||||
use App\Services\Baselines\Evidence\EvidenceProvenance;
|
||||
use App\Services\Baselines\Evidence\ResolvedEvidence;
|
||||
use App\Support\Baselines\Compare\CompareFindingCandidate;
|
||||
use App\Support\Baselines\Compare\CompareOrchestrationContext;
|
||||
use App\Support\Baselines\Compare\CompareState;
|
||||
use App\Support\Baselines\Compare\CompareStrategy;
|
||||
use App\Support\Baselines\Compare\CompareStrategyCapability;
|
||||
use App\Support\Baselines\Compare\CompareStrategyKey;
|
||||
use App\Support\Baselines\Compare\CompareSubjectIdentity;
|
||||
use App\Support\Baselines\Compare\CompareSubjectProjection;
|
||||
use App\Support\Baselines\Compare\CompareSubjectResult;
|
||||
use App\Support\Baselines\OperatorActionCategory;
|
||||
use App\Support\Baselines\ResolutionOutcome;
|
||||
use App\Support\Baselines\ResolutionPath;
|
||||
use App\Support\Baselines\SubjectClass;
|
||||
use App\Support\Governance\GovernanceDomainKey;
|
||||
use App\Support\Governance\GovernanceSubjectClass;
|
||||
use App\Support\Governance\GovernanceSubjectTaxonomyRegistry;
|
||||
use App\Support\Governance\GovernanceSubjectType;
|
||||
|
||||
final class FakeCompareStrategy implements CompareStrategy
|
||||
{
|
||||
public function key(): CompareStrategyKey
|
||||
{
|
||||
return CompareStrategyKey::from('future_control');
|
||||
}
|
||||
|
||||
public function capabilities(): array
|
||||
{
|
||||
return [
|
||||
new CompareStrategyCapability(
|
||||
strategyKey: $this->key(),
|
||||
domainKeys: [GovernanceDomainKey::Entra->value],
|
||||
subjectClasses: [GovernanceSubjectClass::Control->value],
|
||||
subjectTypeKeys: ['conditionalAccessPolicy'],
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
public function compare(
|
||||
CompareOrchestrationContext $context,
|
||||
Tenant $tenant,
|
||||
array $baselineItems,
|
||||
array $currentItems,
|
||||
array $resolvedCurrentEvidence,
|
||||
array $severityMapping,
|
||||
): array {
|
||||
$subjectResults = [];
|
||||
|
||||
foreach ($baselineItems as $key => $baselineItem) {
|
||||
$currentItem = $currentItems[$key] ?? null;
|
||||
|
||||
if (! is_array($currentItem)) {
|
||||
$subjectResults[] = $this->driftResult(
|
||||
context: $context,
|
||||
baselineItem: $baselineItem,
|
||||
currentItem: null,
|
||||
currentEvidence: null,
|
||||
changeType: 'missing_policy',
|
||||
severity: 'high',
|
||||
);
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$currentEvidence = $resolvedCurrentEvidence[$key] ?? null;
|
||||
|
||||
if (! $currentEvidence instanceof ResolvedEvidence) {
|
||||
$subjectResults[] = $this->gapResult(
|
||||
policyType: (string) $baselineItem['policy_type'],
|
||||
subjectKey: (string) $baselineItem['subject_key'],
|
||||
externalSubjectId: (string) $baselineItem['subject_external_id'],
|
||||
operatorLabel: (string) (($currentItem['meta_jsonb']['display_name'] ?? $baselineItem['meta_jsonb']['display_name'] ?? $baselineItem['subject_key']) ?: $baselineItem['subject_key']),
|
||||
compareState: CompareState::Incomplete,
|
||||
reasonCode: 'missing_current',
|
||||
baselineAvailability: 'available',
|
||||
currentStateAvailability: 'unknown',
|
||||
trustLevel: 'unusable',
|
||||
evidenceQuality: 'missing',
|
||||
);
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$baselineMeta = is_array($baselineItem['meta_jsonb'] ?? null) ? $baselineItem['meta_jsonb'] : [];
|
||||
$currentMeta = is_array($currentItem['meta_jsonb'] ?? null) ? $currentItem['meta_jsonb'] : [];
|
||||
|
||||
if ($this->metaFingerprint($baselineMeta) !== $this->metaFingerprint($currentMeta)) {
|
||||
$subjectResults[] = $this->driftResult(
|
||||
context: $context,
|
||||
baselineItem: $baselineItem,
|
||||
currentItem: $currentItem,
|
||||
currentEvidence: $currentEvidence,
|
||||
changeType: 'different_version',
|
||||
severity: 'medium',
|
||||
);
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$subjectResults[] = new CompareSubjectResult(
|
||||
subjectIdentity: $this->identity(
|
||||
policyType: (string) $baselineItem['policy_type'],
|
||||
externalSubjectId: (string) $baselineItem['subject_external_id'],
|
||||
subjectKey: (string) $baselineItem['subject_key'],
|
||||
),
|
||||
projection: $this->projection(
|
||||
policyType: (string) $baselineItem['policy_type'],
|
||||
operatorLabel: (string) (($currentItem['meta_jsonb']['display_name'] ?? $baselineItem['meta_jsonb']['display_name'] ?? $baselineItem['subject_key']) ?: $baselineItem['subject_key']),
|
||||
),
|
||||
baselineAvailability: 'available',
|
||||
currentStateAvailability: 'available',
|
||||
compareState: CompareState::NoDrift,
|
||||
trustLevel: 'trustworthy',
|
||||
evidenceQuality: $currentEvidence->fidelity,
|
||||
);
|
||||
}
|
||||
|
||||
foreach ($currentItems as $key => $currentItem) {
|
||||
if (array_key_exists($key, $baselineItems)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$currentEvidence = $resolvedCurrentEvidence[$key] ?? null;
|
||||
|
||||
if (! $currentEvidence instanceof ResolvedEvidence) {
|
||||
$subjectResults[] = $this->gapResult(
|
||||
policyType: (string) $currentItem['policy_type'],
|
||||
subjectKey: (string) $currentItem['subject_key'],
|
||||
externalSubjectId: (string) $currentItem['subject_external_id'],
|
||||
operatorLabel: (string) (($currentItem['meta_jsonb']['display_name'] ?? $currentItem['subject_key']) ?: $currentItem['subject_key']),
|
||||
compareState: CompareState::Incomplete,
|
||||
reasonCode: 'missing_current',
|
||||
baselineAvailability: 'missing',
|
||||
currentStateAvailability: 'unknown',
|
||||
trustLevel: 'unusable',
|
||||
evidenceQuality: 'missing',
|
||||
);
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$subjectResults[] = $this->driftResult(
|
||||
context: $context,
|
||||
baselineItem: null,
|
||||
currentItem: $currentItem,
|
||||
currentEvidence: $currentEvidence,
|
||||
changeType: 'unexpected_policy',
|
||||
severity: 'low',
|
||||
);
|
||||
}
|
||||
|
||||
return [
|
||||
'subject_results' => $subjectResults,
|
||||
'diagnostics' => [
|
||||
'strategy_family' => 'future_control',
|
||||
'state_counts' => [
|
||||
'drift' => count(array_filter($subjectResults, static fn (CompareSubjectResult $result): bool => $result->compareState === CompareState::Drift)),
|
||||
],
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $meta
|
||||
*/
|
||||
private function metaFingerprint(array $meta): string
|
||||
{
|
||||
unset($meta['display_name'], $meta['category'], $meta['platform']);
|
||||
|
||||
return hash('sha256', json_encode($this->sortRecursive($meta), JSON_THROW_ON_ERROR | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE));
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $value
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function sortRecursive(array $value): array
|
||||
{
|
||||
foreach ($value as $key => $nestedValue) {
|
||||
if (! is_array($nestedValue)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$value[$key] = $this->sortRecursive($nestedValue);
|
||||
}
|
||||
|
||||
ksort($value, SORT_STRING);
|
||||
|
||||
return $value;
|
||||
}
|
||||
|
||||
private function driftResult(
|
||||
CompareOrchestrationContext $context,
|
||||
?array $baselineItem,
|
||||
?array $currentItem,
|
||||
?ResolvedEvidence $currentEvidence,
|
||||
string $changeType,
|
||||
string $severity,
|
||||
): CompareSubjectResult {
|
||||
$source = $baselineItem ?? $currentItem ?? [];
|
||||
$policyType = (string) ($source['policy_type'] ?? 'conditionalAccessPolicy');
|
||||
$subjectKey = (string) ($source['subject_key'] ?? 'unknown');
|
||||
$externalSubjectId = (string) ($source['subject_external_id'] ?? 'unknown');
|
||||
$operatorLabel = (string) ((($currentItem['meta_jsonb']['display_name'] ?? null) ?: ($baselineItem['meta_jsonb']['display_name'] ?? null) ?: $subjectKey) ?: $subjectKey);
|
||||
$fidelity = $currentEvidence?->fidelity ?? EvidenceProvenance::FidelityMeta;
|
||||
$baselineProvenance = EvidenceProvenance::build(
|
||||
fidelity: EvidenceProvenance::FidelityMeta,
|
||||
source: EvidenceProvenance::SourceInventory,
|
||||
observedAt: null,
|
||||
observedOperationRunId: null,
|
||||
);
|
||||
$currentProvenance = $currentEvidence?->tenantProvenance() ?? EvidenceProvenance::build(
|
||||
fidelity: $fidelity,
|
||||
source: EvidenceProvenance::SourceInventory,
|
||||
observedAt: null,
|
||||
observedOperationRunId: $context->inventorySyncRunId(),
|
||||
);
|
||||
|
||||
return new CompareSubjectResult(
|
||||
subjectIdentity: $this->identity($policyType, $externalSubjectId, $subjectKey),
|
||||
projection: $this->projection($policyType, $operatorLabel),
|
||||
baselineAvailability: $baselineItem === null ? 'missing' : 'available',
|
||||
currentStateAvailability: $currentItem === null ? 'missing' : 'available',
|
||||
compareState: CompareState::Drift,
|
||||
trustLevel: $fidelity === EvidenceProvenance::FidelityContent ? 'trustworthy' : 'limited_confidence',
|
||||
evidenceQuality: $fidelity,
|
||||
severityRecommendation: $severity,
|
||||
findingCandidate: new CompareFindingCandidate(
|
||||
changeType: $changeType,
|
||||
severity: $severity,
|
||||
fingerprintBasis: [
|
||||
'policy_type' => $policyType,
|
||||
'subject_key' => $subjectKey,
|
||||
'change_type' => $changeType,
|
||||
],
|
||||
evidencePayload: [
|
||||
'change_type' => $changeType,
|
||||
'policy_type' => $policyType,
|
||||
'subject_key' => $subjectKey,
|
||||
'display_name' => $operatorLabel,
|
||||
'summary' => ['kind' => 'control_snapshot'],
|
||||
'baseline' => [
|
||||
'hash' => $baselineItem['baseline_hash'] ?? null,
|
||||
'provenance' => $baselineProvenance,
|
||||
],
|
||||
'current' => [
|
||||
'hash' => $currentEvidence?->hash,
|
||||
'provenance' => $currentProvenance,
|
||||
],
|
||||
'fidelity' => $fidelity,
|
||||
'provenance' => [
|
||||
'baseline_profile_id' => $context->baselineProfileId,
|
||||
'baseline_snapshot_id' => $context->baselineSnapshotId,
|
||||
'compare_operation_run_id' => $context->operationRunId,
|
||||
'inventory_sync_run_id' => $context->inventorySyncRunId(),
|
||||
],
|
||||
],
|
||||
),
|
||||
diagnostics: [
|
||||
'strategy_key' => $this->key()->value,
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
private function gapResult(
|
||||
string $policyType,
|
||||
string $subjectKey,
|
||||
string $externalSubjectId,
|
||||
string $operatorLabel,
|
||||
CompareState $compareState,
|
||||
string $reasonCode,
|
||||
string $baselineAvailability,
|
||||
string $currentStateAvailability,
|
||||
string $trustLevel,
|
||||
string $evidenceQuality,
|
||||
): CompareSubjectResult {
|
||||
return new CompareSubjectResult(
|
||||
subjectIdentity: $this->identity($policyType, $externalSubjectId, $subjectKey),
|
||||
projection: $this->projection($policyType, $operatorLabel),
|
||||
baselineAvailability: $baselineAvailability,
|
||||
currentStateAvailability: $currentStateAvailability,
|
||||
compareState: $compareState,
|
||||
trustLevel: $trustLevel,
|
||||
evidenceQuality: $evidenceQuality,
|
||||
diagnostics: [
|
||||
'reason_code' => $reasonCode,
|
||||
'gap_record' => [
|
||||
'policy_type' => $policyType,
|
||||
'subject_key' => $subjectKey,
|
||||
'subject_class' => SubjectClass::Derived->value,
|
||||
'resolution_path' => ResolutionPath::Derived->value,
|
||||
'resolution_outcome' => ResolutionOutcome::CaptureFailed->value,
|
||||
'operator_action_category' => OperatorActionCategory::RunInventorySync->value,
|
||||
'structural' => false,
|
||||
'retryable' => $reasonCode === 'missing_current',
|
||||
'reason_code' => $reasonCode,
|
||||
'search_text' => strtolower(implode(' ', [$policyType, $subjectKey, $reasonCode])),
|
||||
],
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
private function identity(string $policyType, string $externalSubjectId, string $subjectKey): CompareSubjectIdentity
|
||||
{
|
||||
return new CompareSubjectIdentity(
|
||||
domainKey: GovernanceDomainKey::Entra->value,
|
||||
subjectClass: GovernanceSubjectClass::Control->value,
|
||||
subjectTypeKey: $policyType,
|
||||
externalSubjectId: $externalSubjectId,
|
||||
subjectKey: $subjectKey,
|
||||
);
|
||||
}
|
||||
|
||||
private function projection(string $policyType, string $operatorLabel): CompareSubjectProjection
|
||||
{
|
||||
return new CompareSubjectProjection(
|
||||
platformSubjectClass: 'control',
|
||||
domainKey: GovernanceDomainKey::Entra->value,
|
||||
subjectTypeKey: $policyType,
|
||||
operatorLabel: $operatorLabel,
|
||||
summaryKind: 'control_snapshot',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
final class FailingCompareStrategy implements CompareStrategy
|
||||
{
|
||||
public function key(): CompareStrategyKey
|
||||
{
|
||||
return CompareStrategyKey::from('failing_control');
|
||||
}
|
||||
|
||||
public function capabilities(): array
|
||||
{
|
||||
return [
|
||||
new CompareStrategyCapability(
|
||||
strategyKey: $this->key(),
|
||||
domainKeys: [GovernanceDomainKey::Entra->value],
|
||||
subjectClasses: [GovernanceSubjectClass::Control->value],
|
||||
subjectTypeKeys: ['conditionalAccessPolicy'],
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
public function compare(
|
||||
CompareOrchestrationContext $context,
|
||||
Tenant $tenant,
|
||||
array $baselineItems,
|
||||
array $currentItems,
|
||||
array $resolvedCurrentEvidence,
|
||||
array $severityMapping,
|
||||
): array {
|
||||
throw new \RuntimeException('Synthetic strategy failure for compare testing.');
|
||||
}
|
||||
}
|
||||
|
||||
final class FakeGovernanceSubjectTaxonomyRegistry
|
||||
{
|
||||
private readonly GovernanceSubjectTaxonomyRegistry $inner;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->inner = new GovernanceSubjectTaxonomyRegistry;
|
||||
}
|
||||
|
||||
public function all(): array
|
||||
{
|
||||
return array_values(array_merge($this->inner->all(), [
|
||||
new GovernanceSubjectType(
|
||||
domainKey: GovernanceDomainKey::Entra,
|
||||
subjectClass: GovernanceSubjectClass::Control,
|
||||
subjectTypeKey: 'conditionalAccessPolicy',
|
||||
label: 'Conditional Access Policy',
|
||||
description: 'Synthetic test-only future domain control',
|
||||
captureSupported: true,
|
||||
compareSupported: true,
|
||||
inventorySupported: true,
|
||||
active: true,
|
||||
supportMode: 'supported',
|
||||
legacyBucket: null,
|
||||
),
|
||||
]));
|
||||
}
|
||||
|
||||
public function active(): array
|
||||
{
|
||||
return array_values(array_filter(
|
||||
$this->all(),
|
||||
static fn (GovernanceSubjectType $subjectType): bool => $subjectType->active,
|
||||
));
|
||||
}
|
||||
|
||||
public function activeLegacyBucketKeys(string $legacyBucket): array
|
||||
{
|
||||
$subjectTypes = array_filter(
|
||||
$this->active(),
|
||||
static fn (GovernanceSubjectType $subjectType): bool => $subjectType->legacyBucket === $legacyBucket,
|
||||
);
|
||||
|
||||
$keys = array_map(
|
||||
static fn (GovernanceSubjectType $subjectType): string => $subjectType->subjectTypeKey,
|
||||
$subjectTypes,
|
||||
);
|
||||
|
||||
sort($keys, SORT_STRING);
|
||||
|
||||
return array_values(array_unique($keys));
|
||||
}
|
||||
|
||||
public function find(string $domainKey, string $subjectTypeKey): ?GovernanceSubjectType
|
||||
{
|
||||
foreach ($this->all() as $subjectType) {
|
||||
if ($subjectType->domainKey->value !== trim($domainKey)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($subjectType->subjectTypeKey !== trim($subjectTypeKey)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
return $subjectType;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
public function isKnownDomain(string $domainKey): bool
|
||||
{
|
||||
return $this->inner->isKnownDomain($domainKey);
|
||||
}
|
||||
|
||||
public function allowsSubjectClass(string $domainKey, string $subjectClass): bool
|
||||
{
|
||||
return $this->inner->allowsSubjectClass($domainKey, $subjectClass);
|
||||
}
|
||||
|
||||
public function supportsFilters(string $domainKey, string $subjectClass): bool
|
||||
{
|
||||
return $this->inner->supportsFilters($domainKey, $subjectClass);
|
||||
}
|
||||
|
||||
public function groupLabel(string $domainKey, string $subjectClass): string
|
||||
{
|
||||
return $this->inner->groupLabel($domainKey, $subjectClass);
|
||||
}
|
||||
}
|
||||
@ -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');
|
||||
});
|
||||
|
||||
@ -1,5 +1,7 @@
|
||||
<?php
|
||||
|
||||
require_once dirname(__DIR__).'/Baselines/Support/FakeCompareStrategy.php';
|
||||
|
||||
use App\Filament\Pages\BaselineCompareLanding;
|
||||
use App\Jobs\CompareBaselineToTenantJob;
|
||||
use App\Livewire\BulkOperationProgress;
|
||||
@ -7,6 +9,9 @@
|
||||
use App\Models\BaselineSnapshot;
|
||||
use App\Models\BaselineTenantAssignment;
|
||||
use App\Models\OperationRun;
|
||||
use App\Support\Baselines\Compare\CompareStrategyRegistry;
|
||||
use App\Support\Baselines\Compare\IntuneCompareStrategy;
|
||||
use App\Support\Governance\GovernanceSubjectTaxonomyRegistry;
|
||||
use App\Support\OperationRunOutcome;
|
||||
use App\Support\OperationRunStatus;
|
||||
use App\Support\OperationRunType;
|
||||
@ -15,6 +20,8 @@
|
||||
use Filament\Facades\Filament;
|
||||
use Illuminate\Support\Facades\Queue;
|
||||
use Livewire\Livewire;
|
||||
use Tests\Feature\Baselines\Support\FakeCompareStrategy;
|
||||
use Tests\Feature\Baselines\Support\FakeGovernanceSubjectTaxonomyRegistry;
|
||||
|
||||
it('redirects unauthenticated users (302)', function (): void {
|
||||
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||
@ -186,6 +193,64 @@
|
||||
expect(OperationRun::query()->where('type', 'baseline_compare')->count())->toBe(0);
|
||||
});
|
||||
|
||||
it('shows mixed-strategy compare rejection truth on the tenant landing surface', function (): void {
|
||||
Queue::fake();
|
||||
|
||||
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||
$this->actingAs($user);
|
||||
|
||||
$tenant->makeCurrent();
|
||||
Filament::setTenant($tenant, true);
|
||||
|
||||
app()->instance(GovernanceSubjectTaxonomyRegistry::class, new FakeGovernanceSubjectTaxonomyRegistry);
|
||||
app()->instance(CompareStrategyRegistry::class, new CompareStrategyRegistry([
|
||||
app(IntuneCompareStrategy::class),
|
||||
app(FakeCompareStrategy::class),
|
||||
]));
|
||||
|
||||
$profile = BaselineProfile::factory()->active()->create([
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'scope_jsonb' => [
|
||||
'version' => 2,
|
||||
'entries' => [
|
||||
[
|
||||
'domain_key' => 'intune',
|
||||
'subject_class' => 'policy',
|
||||
'subject_type_keys' => ['deviceConfiguration'],
|
||||
'filters' => [],
|
||||
],
|
||||
[
|
||||
'domain_key' => 'entra',
|
||||
'subject_class' => 'control',
|
||||
'subject_type_keys' => ['conditionalAccessPolicy'],
|
||||
'filters' => [],
|
||||
],
|
||||
],
|
||||
],
|
||||
]);
|
||||
|
||||
$snapshot = BaselineSnapshot::factory()->create([
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'baseline_profile_id' => (int) $profile->getKey(),
|
||||
]);
|
||||
|
||||
$profile->update(['active_snapshot_id' => (int) $snapshot->getKey()]);
|
||||
|
||||
BaselineTenantAssignment::factory()->create([
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'baseline_profile_id' => (int) $profile->getKey(),
|
||||
]);
|
||||
|
||||
Livewire::test(BaselineCompareLanding::class)
|
||||
->callAction('compareNow')
|
||||
->assertNotified('Cannot start comparison')
|
||||
->assertStatus(200);
|
||||
|
||||
Queue::assertNotPushed(CompareBaselineToTenantJob::class);
|
||||
expect(OperationRun::query()->where('type', 'baseline_compare')->count())->toBe(0);
|
||||
});
|
||||
|
||||
it('can refresh stats without calling mount directly', function (): void {
|
||||
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||
$this->actingAs($user);
|
||||
|
||||
@ -182,3 +182,75 @@
|
||||
->assertDontSee('Evidence gap details')
|
||||
->assertSee('Baseline compare evidence');
|
||||
});
|
||||
|
||||
it('includes strategy diagnostics in the landing evidence payload when strategy-owned compare processing fails', function (): void {
|
||||
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||
$this->actingAs($user);
|
||||
|
||||
$tenant->makeCurrent();
|
||||
Filament::setTenant($tenant, true);
|
||||
|
||||
$profile = BaselineProfile::factory()->active()->create([
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
]);
|
||||
|
||||
$snapshot = BaselineSnapshot::factory()->create([
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'baseline_profile_id' => (int) $profile->getKey(),
|
||||
]);
|
||||
|
||||
$profile->update(['active_snapshot_id' => (int) $snapshot->getKey()]);
|
||||
|
||||
BaselineTenantAssignment::factory()->create([
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'baseline_profile_id' => (int) $profile->getKey(),
|
||||
]);
|
||||
|
||||
OperationRun::factory()->create([
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'type' => OperationRunType::BaselineCompare->value,
|
||||
'status' => OperationRunStatus::Completed->value,
|
||||
'outcome' => OperationRunOutcome::PartiallySucceeded->value,
|
||||
'completed_at' => now(),
|
||||
'context' => [
|
||||
'baseline_profile_id' => (int) $profile->getKey(),
|
||||
'baseline_snapshot_id' => (int) $snapshot->getKey(),
|
||||
'baseline_compare' => [
|
||||
'reason_code' => BaselineCompareReasonCode::StrategyFailed->value,
|
||||
'coverage' => [
|
||||
'effective_types' => ['deviceConfiguration'],
|
||||
'covered_types' => ['deviceConfiguration'],
|
||||
'uncovered_types' => [],
|
||||
'proof' => true,
|
||||
],
|
||||
'fidelity' => 'meta',
|
||||
'strategy' => [
|
||||
'key' => 'intune_policy',
|
||||
'selection_state' => 'supported',
|
||||
'operator_reason' => 'Compare strategy resolved successfully.',
|
||||
'execution_diagnostics' => [
|
||||
'failed' => true,
|
||||
'exception_class' => RuntimeException::class,
|
||||
],
|
||||
'state_counts' => [
|
||||
'failed' => 1,
|
||||
],
|
||||
],
|
||||
'evidence_gaps' => [
|
||||
'count' => 1,
|
||||
'by_reason' => [
|
||||
'strategy_failed' => 1,
|
||||
],
|
||||
],
|
||||
],
|
||||
],
|
||||
]);
|
||||
|
||||
Livewire::test(BaselineCompareLanding::class)
|
||||
->assertSee('Baseline compare evidence')
|
||||
->assertSee('intune_policy')
|
||||
->assertSee('strategy_failed')
|
||||
->assertSee('RuntimeException');
|
||||
});
|
||||
|
||||
@ -2,12 +2,20 @@
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
require_once dirname(__DIR__).'/Baselines/Support/FakeCompareStrategy.php';
|
||||
|
||||
use App\Filament\Pages\BaselineCompareMatrix;
|
||||
use App\Filament\Resources\BaselineProfileResource;
|
||||
use App\Models\User;
|
||||
use App\Models\WorkspaceMembership;
|
||||
use App\Support\Baselines\Compare\CompareStrategyRegistry;
|
||||
use App\Support\Baselines\Compare\IntuneCompareStrategy;
|
||||
use App\Support\Governance\GovernanceSubjectTaxonomyRegistry;
|
||||
use Filament\Actions\Action;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Livewire\Livewire;
|
||||
use Tests\Feature\Baselines\Support\FakeCompareStrategy;
|
||||
use Tests\Feature\Baselines\Support\FakeGovernanceSubjectTaxonomyRegistry;
|
||||
use Tests\Feature\Concerns\BuildsBaselineCompareMatrixFixtures;
|
||||
|
||||
uses(RefreshDatabase::class, BuildsBaselineCompareMatrixFixtures::class);
|
||||
@ -199,6 +207,44 @@
|
||||
->assertSee('Capture a complete baseline snapshot before using the compare matrix.');
|
||||
});
|
||||
|
||||
it('disables compare-assigned-tenants when the scope spans multiple compare strategy families', function (): void {
|
||||
$fixture = $this->makeBaselineCompareMatrixFixture();
|
||||
|
||||
app()->instance(GovernanceSubjectTaxonomyRegistry::class, new FakeGovernanceSubjectTaxonomyRegistry);
|
||||
app()->instance(CompareStrategyRegistry::class, new CompareStrategyRegistry([
|
||||
app(IntuneCompareStrategy::class),
|
||||
app(FakeCompareStrategy::class),
|
||||
]));
|
||||
|
||||
$fixture['profile']->update([
|
||||
'scope_jsonb' => [
|
||||
'version' => 2,
|
||||
'entries' => [
|
||||
[
|
||||
'domain_key' => 'intune',
|
||||
'subject_class' => 'policy',
|
||||
'subject_type_keys' => ['deviceConfiguration'],
|
||||
'filters' => [],
|
||||
],
|
||||
[
|
||||
'domain_key' => 'entra',
|
||||
'subject_class' => 'control',
|
||||
'subject_type_keys' => ['conditionalAccessPolicy'],
|
||||
'filters' => [],
|
||||
],
|
||||
],
|
||||
],
|
||||
]);
|
||||
|
||||
$this->setAdminWorkspaceContext($fixture['user'], $fixture['workspace']);
|
||||
|
||||
Livewire::actingAs($fixture['user'])
|
||||
->test(BaselineCompareMatrix::class, ['record' => $fixture['profile']->getKey()])
|
||||
->assertActionVisible('compareAssignedTenants')
|
||||
->assertActionDisabled('compareAssignedTenants')
|
||||
->assertActionExists('compareAssignedTenants', fn (Action $action): bool => $action->getTooltip() === 'The selected governed subjects span multiple compare strategy families and must be narrowed before comparing assigned tenants.');
|
||||
});
|
||||
|
||||
it('renders an empty state when the baseline profile has no assigned tenants', function (): void {
|
||||
$fixture = $this->makeBaselineCompareMatrixFixture();
|
||||
|
||||
|
||||
@ -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);
|
||||
});
|
||||
|
||||
@ -1,5 +1,7 @@
|
||||
<?php
|
||||
|
||||
require_once dirname(__DIR__).'/Baselines/Support/FakeCompareStrategy.php';
|
||||
|
||||
use App\Filament\Resources\BaselineProfileResource;
|
||||
use App\Filament\Resources\BaselineProfileResource\Pages\ViewBaselineProfile;
|
||||
use App\Jobs\CompareBaselineToTenantJob;
|
||||
@ -8,12 +10,18 @@
|
||||
use App\Models\BaselineTenantAssignment;
|
||||
use App\Models\OperationRun;
|
||||
use App\Support\Baselines\BaselineCaptureMode;
|
||||
use App\Support\Baselines\Compare\CompareStrategyRegistry;
|
||||
use App\Support\Baselines\Compare\IntuneCompareStrategy;
|
||||
use App\Support\Governance\GovernanceSubjectTaxonomyRegistry;
|
||||
use App\Support\Workspaces\WorkspaceContext;
|
||||
use Filament\Actions\Action;
|
||||
use Filament\Actions\ActionGroup;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Queue;
|
||||
use Livewire\Features\SupportTesting\Testable;
|
||||
use Livewire\Livewire;
|
||||
use Tests\Feature\Baselines\Support\FakeCompareStrategy;
|
||||
use Tests\Feature\Baselines\Support\FakeGovernanceSubjectTaxonomyRegistry;
|
||||
|
||||
function baselineProfileHeaderActions(Testable $component): array
|
||||
{
|
||||
@ -148,6 +156,64 @@ function baselineProfileHeaderActions(Testable $component): array
|
||||
expect(OperationRun::query()->where('type', 'baseline_compare')->count())->toBe(0);
|
||||
});
|
||||
|
||||
it('shows mixed-strategy compare rejection truth on the workspace start surface', function (): void {
|
||||
Queue::fake();
|
||||
|
||||
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||
|
||||
app()->instance(GovernanceSubjectTaxonomyRegistry::class, new FakeGovernanceSubjectTaxonomyRegistry);
|
||||
app()->instance(CompareStrategyRegistry::class, new CompareStrategyRegistry([
|
||||
app(IntuneCompareStrategy::class),
|
||||
app(FakeCompareStrategy::class),
|
||||
]));
|
||||
|
||||
$profile = BaselineProfile::factory()->active()->create([
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'scope_jsonb' => [
|
||||
'version' => 2,
|
||||
'entries' => [
|
||||
[
|
||||
'domain_key' => 'intune',
|
||||
'subject_class' => 'policy',
|
||||
'subject_type_keys' => ['deviceConfiguration'],
|
||||
'filters' => [],
|
||||
],
|
||||
[
|
||||
'domain_key' => 'entra',
|
||||
'subject_class' => 'control',
|
||||
'subject_type_keys' => ['conditionalAccessPolicy'],
|
||||
'filters' => [],
|
||||
],
|
||||
],
|
||||
],
|
||||
]);
|
||||
|
||||
$snapshot = BaselineSnapshot::factory()->create([
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'baseline_profile_id' => (int) $profile->getKey(),
|
||||
]);
|
||||
|
||||
$profile->update(['active_snapshot_id' => (int) $snapshot->getKey()]);
|
||||
|
||||
BaselineTenantAssignment::factory()->create([
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'baseline_profile_id' => (int) $profile->getKey(),
|
||||
]);
|
||||
|
||||
session()->put(WorkspaceContext::SESSION_KEY, (int) $tenant->workspace_id);
|
||||
|
||||
Livewire::actingAs($user)
|
||||
->test(ViewBaselineProfile::class, ['record' => $profile->getKey()])
|
||||
->assertSee('Mixed strategy scope')
|
||||
->callAction('compareNow', data: ['target_tenant_id' => (int) $tenant->getKey()])
|
||||
->assertNotified('Cannot start comparison')
|
||||
->assertStatus(200);
|
||||
|
||||
Queue::assertNotPushed(CompareBaselineToTenantJob::class);
|
||||
expect(OperationRun::query()->where('type', 'baseline_compare')->count())->toBe(0);
|
||||
});
|
||||
|
||||
it('moves compare-matrix navigation into related context while keeping compare-assigned-tenants secondary', function (): void {
|
||||
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||
$this->actingAs($user);
|
||||
@ -229,3 +295,86 @@ function baselineProfileHeaderActions(Testable $component): array
|
||||
expect(collect(BaselineProfileResource::detailRelatedContextEntries($profile))->pluck('key')->all())
|
||||
->toContain('compare_matrix');
|
||||
});
|
||||
|
||||
it('shows readiness copy without exposing raw canonical scope json on the compare start surface', function (): void {
|
||||
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||
|
||||
$profile = BaselineProfile::factory()->active()->create([
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'scope_jsonb' => ['policy_types' => ['deviceConfiguration'], 'foundation_types' => []],
|
||||
]);
|
||||
|
||||
$snapshot = BaselineSnapshot::factory()->create([
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'baseline_profile_id' => (int) $profile->getKey(),
|
||||
]);
|
||||
|
||||
$profile->update(['active_snapshot_id' => (int) $snapshot->getKey()]);
|
||||
|
||||
BaselineTenantAssignment::factory()->create([
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'baseline_profile_id' => (int) $profile->getKey(),
|
||||
]);
|
||||
|
||||
session()->put(WorkspaceContext::SESSION_KEY, (int) $tenant->workspace_id);
|
||||
|
||||
Livewire::actingAs($user)
|
||||
->test(ViewBaselineProfile::class, ['record' => $profile->getKey()])
|
||||
->assertSee('Support readiness')
|
||||
->assertSee('Capture: ready. Compare: ready.')
|
||||
->assertDontSee('subject_type_keys')
|
||||
->assertDontSee('canonical_scope');
|
||||
});
|
||||
|
||||
it('does not start baseline compare when the stored canonical scope is invalid', function (): void {
|
||||
Queue::fake();
|
||||
|
||||
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||
|
||||
$profile = BaselineProfile::factory()->active()->create([
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
]);
|
||||
|
||||
$snapshot = BaselineSnapshot::factory()->create([
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'baseline_profile_id' => (int) $profile->getKey(),
|
||||
]);
|
||||
|
||||
$profile->update(['active_snapshot_id' => (int) $snapshot->getKey()]);
|
||||
|
||||
BaselineTenantAssignment::factory()->create([
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'baseline_profile_id' => (int) $profile->getKey(),
|
||||
]);
|
||||
|
||||
DB::table('baseline_profiles')
|
||||
->where('id', (int) $profile->getKey())
|
||||
->update([
|
||||
'scope_jsonb' => json_encode([
|
||||
'version' => 2,
|
||||
'entries' => [
|
||||
[
|
||||
'domain_key' => 'platform_foundation',
|
||||
'subject_class' => 'configuration_resource',
|
||||
'subject_type_keys' => ['intuneRoleAssignment'],
|
||||
'filters' => [],
|
||||
],
|
||||
],
|
||||
], JSON_THROW_ON_ERROR),
|
||||
'updated_at' => now(),
|
||||
]);
|
||||
|
||||
session()->put(WorkspaceContext::SESSION_KEY, (int) $tenant->workspace_id);
|
||||
|
||||
Livewire::actingAs($user)
|
||||
->test(ViewBaselineProfile::class, ['record' => $profile->getKey()])
|
||||
->assertActionVisible('compareNow')
|
||||
->callAction('compareNow', data: ['target_tenant_id' => (int) $tenant->getKey()])
|
||||
->assertNotified('Cannot start comparison')
|
||||
->assertStatus(200);
|
||||
|
||||
Queue::assertNotPushed(CompareBaselineToTenantJob::class);
|
||||
expect(OperationRun::query()->where('type', 'baseline_compare')->count())->toBe(0);
|
||||
});
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -147,6 +151,73 @@ function visibleLivewireText(Testable $component): string
|
||||
->and(mb_strpos($pageText, 'Decision'))->toBeLessThan(mb_strpos($pageText, 'Artifact truth details'));
|
||||
});
|
||||
|
||||
it('shows strategy diagnostics and operator-safe failure meaning for strategy-owned compare failures', function (): void {
|
||||
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||
|
||||
$run = OperationRun::factory()->create([
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'type' => 'baseline_compare',
|
||||
'status' => 'completed',
|
||||
'outcome' => 'partially_succeeded',
|
||||
'context' => [
|
||||
'baseline_compare' => [
|
||||
'reason_code' => BaselineCompareReasonCode::StrategyFailed->value,
|
||||
'coverage' => [
|
||||
'proof' => true,
|
||||
'effective_types' => ['conditionalAccessPolicy'],
|
||||
'covered_types' => ['conditionalAccessPolicy'],
|
||||
'uncovered_types' => [],
|
||||
],
|
||||
'evidence_gaps' => [
|
||||
'count' => 1,
|
||||
'by_reason' => [
|
||||
'strategy_failed' => 1,
|
||||
],
|
||||
],
|
||||
'strategy' => [
|
||||
'key' => 'intune_policy',
|
||||
'selection_state' => 'supported',
|
||||
'operator_reason' => 'Compare strategy resolved successfully.',
|
||||
'execution_diagnostics' => [
|
||||
'failed' => true,
|
||||
'exception_class' => RuntimeException::class,
|
||||
],
|
||||
'state_counts' => [
|
||||
'failed' => 2,
|
||||
],
|
||||
],
|
||||
],
|
||||
],
|
||||
'summary_counts' => [
|
||||
'total' => 0,
|
||||
'processed' => 0,
|
||||
'errors_recorded' => 1,
|
||||
],
|
||||
'completed_at' => now(),
|
||||
]);
|
||||
|
||||
$truth = app(ArtifactTruthPresenter::class)->forOperationRun($run->fresh());
|
||||
$explanation = $truth->operatorExplanation;
|
||||
|
||||
Filament::setTenant(null, true);
|
||||
$this->withSession([WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id]);
|
||||
session([WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id]);
|
||||
|
||||
Livewire::actingAs($user)
|
||||
->test(TenantlessOperationRunViewer::class, ['run' => $run])
|
||||
->assertSee($explanation?->headline ?? '')
|
||||
->assertSee($explanation?->nextActionText ?? '')
|
||||
->assertSee('Compare strategy')
|
||||
->assertSee('Intune Policy')
|
||||
->assertSee('Strategy selection')
|
||||
->assertSee('Supported')
|
||||
->assertSee('Strategy subject states')
|
||||
->assertSee('Failed 2')
|
||||
->assertSee('Baseline compare evidence')
|
||||
->assertSee('RuntimeException');
|
||||
});
|
||||
|
||||
it('deduplicates repeated artifact truth explanation text for follow-up runs without a usable artifact', function (): void {
|
||||
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||
|
||||
@ -248,3 +319,85 @@ function visibleLivewireText(Testable $component): string
|
||||
->assertSee($explanation?->trustworthinessLabel() ?? '')
|
||||
->assertDontSee('No confirmed drift in the latest baseline compare.');
|
||||
});
|
||||
|
||||
it('records canonical effective scope and compatibility projection for baseline capture runs', function (): void {
|
||||
Queue::fake();
|
||||
|
||||
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||
|
||||
$profile = BaselineProfile::factory()->active()->create([
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'scope_jsonb' => ['policy_types' => ['deviceConfiguration'], 'foundation_types' => []],
|
||||
]);
|
||||
|
||||
$result = app(BaselineCaptureService::class)->startCapture($profile, $tenant, $user);
|
||||
|
||||
expect($result['ok'])->toBeTrue();
|
||||
|
||||
$run = $result['run'];
|
||||
$effectiveScope = is_array(data_get($run->context, 'effective_scope')) ? data_get($run->context, 'effective_scope') : [];
|
||||
|
||||
expect(data_get($effectiveScope, 'canonical_scope.version'))->toBe(2)
|
||||
->and(data_get($effectiveScope, 'canonical_scope.entries.0.domain_key'))->toBe('intune')
|
||||
->and(data_get($effectiveScope, 'canonical_scope.entries.0.subject_class'))->toBe('policy')
|
||||
->and(data_get($effectiveScope, 'canonical_scope.entries.0.subject_type_keys'))->toBe(['deviceConfiguration'])
|
||||
->and(data_get($effectiveScope, 'legacy_projection.policy_types'))->toBe(['deviceConfiguration'])
|
||||
->and(data_get($effectiveScope, 'legacy_projection.foundation_types'))->toBe([])
|
||||
->and(data_get($effectiveScope, 'selected_type_keys'))->toBe(['deviceConfiguration'])
|
||||
->and(data_get($effectiveScope, 'allowed_type_keys'))->toBe(['deviceConfiguration'])
|
||||
->and(data_get($effectiveScope, 'unsupported_type_keys'))->toBe([]);
|
||||
});
|
||||
|
||||
it('normalizes legacy compare assignment overrides into canonical effective scope without rewriting the override row', function (): void {
|
||||
Queue::fake();
|
||||
|
||||
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||
|
||||
$profile = BaselineProfile::factory()->active()->create([
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'scope_jsonb' => ['policy_types' => ['deviceConfiguration', 'deviceCompliancePolicy'], 'foundation_types' => []],
|
||||
]);
|
||||
|
||||
$snapshot = BaselineSnapshot::factory()->complete()->create([
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'baseline_profile_id' => (int) $profile->getKey(),
|
||||
]);
|
||||
|
||||
$profile->update(['active_snapshot_id' => (int) $snapshot->getKey()]);
|
||||
|
||||
$assignment = BaselineTenantAssignment::factory()->create([
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'baseline_profile_id' => (int) $profile->getKey(),
|
||||
'override_scope_jsonb' => null,
|
||||
]);
|
||||
|
||||
DB::table('baseline_tenant_assignments')
|
||||
->where('id', (int) $assignment->getKey())
|
||||
->update([
|
||||
'override_scope_jsonb' => json_encode([
|
||||
'policy_types' => ['deviceConfiguration'],
|
||||
'foundation_types' => [],
|
||||
], JSON_THROW_ON_ERROR),
|
||||
'updated_at' => now(),
|
||||
]);
|
||||
|
||||
$result = app(BaselineCompareService::class)->startCompare($tenant, $user);
|
||||
|
||||
expect($result['ok'])->toBeTrue();
|
||||
|
||||
$run = $result['run'];
|
||||
$effectiveScope = is_array(data_get($run->context, 'effective_scope')) ? data_get($run->context, 'effective_scope') : [];
|
||||
$rawOverride = DB::table('baseline_tenant_assignments')
|
||||
->where('id', (int) $assignment->getKey())
|
||||
->value('override_scope_jsonb');
|
||||
|
||||
expect(data_get($effectiveScope, 'canonical_scope.version'))->toBe(2)
|
||||
->and(data_get($effectiveScope, 'canonical_scope.entries.0.subject_type_keys'))->toBe(['deviceConfiguration'])
|
||||
->and(data_get($effectiveScope, 'legacy_projection.policy_types'))->toBe(['deviceConfiguration'])
|
||||
->and(data_get($effectiveScope, 'selected_type_keys'))->toBe(['deviceConfiguration'])
|
||||
->and(json_decode((string) $rawOverride, true, flags: JSON_THROW_ON_ERROR))->toBe([
|
||||
'policy_types' => ['deviceConfiguration'],
|
||||
'foundation_types' => [],
|
||||
]);
|
||||
});
|
||||
|
||||
@ -10,6 +10,7 @@
|
||||
use App\Models\BackupSet;
|
||||
use App\Models\Finding;
|
||||
use App\Models\OperationRun;
|
||||
use App\Models\User;
|
||||
use Filament\Facades\Filament;
|
||||
use Illuminate\Support\Facades\Bus;
|
||||
use Livewire\Livewire;
|
||||
@ -76,3 +77,12 @@
|
||||
|
||||
Bus::assertNothingDispatched();
|
||||
});
|
||||
|
||||
it('keeps tenant dashboard access deny-as-not-found for non-members', function (): void {
|
||||
[, $tenant] = createUserWithTenant(role: 'owner');
|
||||
$outsider = User::factory()->create();
|
||||
|
||||
$this->actingAs($outsider)
|
||||
->get(TenantDashboard::getUrl(tenant: $tenant))
|
||||
->assertNotFound();
|
||||
});
|
||||
|
||||
@ -932,6 +932,64 @@ function actionSurfaceSystemPanelContext(array $capabilities): PlatformUser
|
||||
->toContain('OnboardingVerificationV1_5UxTest');
|
||||
});
|
||||
|
||||
it('documents the spec 195 residual inventory, human-readable names, and baseline alignment', function (): void {
|
||||
$inventory = ActionSurfaceExemptions::spec195ResidualSurfaceInventory();
|
||||
$baselineExemptions = ActionSurfaceExemptions::baseline()->all();
|
||||
|
||||
expect(array_keys($inventory))->toEqualCanonicalizing([
|
||||
\App\Filament\System\Pages\Dashboard::class,
|
||||
\App\Filament\System\Pages\Ops\ViewRun::class,
|
||||
\App\Filament\System\Pages\Ops\Runbooks::class,
|
||||
\App\Filament\System\Pages\RepairWorkspaceOwners::class,
|
||||
\App\Filament\System\Pages\Directory\ViewTenant::class,
|
||||
\App\Filament\System\Pages\Directory\ViewWorkspace::class,
|
||||
\App\Filament\Pages\BreakGlassRecovery::class,
|
||||
\App\Filament\Pages\ChooseWorkspace::class,
|
||||
\App\Filament\Pages\ChooseTenant::class,
|
||||
\App\Filament\Pages\Tenancy\RegisterTenant::class,
|
||||
\App\Filament\Pages\Workspaces\ManagedTenantOnboardingWizard::class,
|
||||
\App\Filament\Pages\Workspaces\ManagedTenantsLanding::class,
|
||||
\App\Filament\Pages\TenantDashboard::class,
|
||||
]);
|
||||
|
||||
foreach ($inventory as $className => $surface) {
|
||||
expect(trim((string) ($surface['surfaceName'] ?? '')))
|
||||
->not->toBe('', "{$className} must keep a human-readable surfaceName in Spec 195.")
|
||||
->and($surface['pageClass'] ?? null)->toBe($className)
|
||||
->and($surface['evidence'] ?? [])->not->toBeEmpty("{$className} must keep structured Spec 195 evidence.");
|
||||
}
|
||||
|
||||
$mustRemainBaselineExempt = collect($inventory)
|
||||
->filter(fn (array $surface): bool => ($surface['mustRemainBaselineExempt'] ?? false) === true)
|
||||
->keys()
|
||||
->values()
|
||||
->all();
|
||||
$mustNotRemainBaselineExempt = collect($inventory)
|
||||
->filter(fn (array $surface): bool => ($surface['mustNotRemainBaselineExempt'] ?? false) === true)
|
||||
->keys()
|
||||
->values()
|
||||
->all();
|
||||
|
||||
expect($mustRemainBaselineExempt)->toEqualCanonicalizing([
|
||||
\App\Filament\Pages\ChooseWorkspace::class,
|
||||
\App\Filament\Pages\ChooseTenant::class,
|
||||
\App\Filament\Pages\Tenancy\RegisterTenant::class,
|
||||
\App\Filament\Pages\Workspaces\ManagedTenantOnboardingWizard::class,
|
||||
\App\Filament\Pages\Workspaces\ManagedTenantsLanding::class,
|
||||
\App\Filament\Pages\TenantDashboard::class,
|
||||
]);
|
||||
|
||||
foreach ($mustRemainBaselineExempt as $className) {
|
||||
expect(array_key_exists($className, $baselineExemptions))
|
||||
->toBeTrue("{$className} should stay aligned between baseline() and Spec 195.");
|
||||
}
|
||||
|
||||
foreach ($mustNotRemainBaselineExempt as $className) {
|
||||
expect(array_key_exists($className, $baselineExemptions))
|
||||
->toBeFalse("{$className} must not keep a stale baseline exemption under Spec 195.");
|
||||
}
|
||||
});
|
||||
|
||||
it('keeps enrolled system panel pages declaration-backed without stale baseline exemptions', function (): void {
|
||||
$baselineExemptions = ActionSurfaceExemptions::baseline();
|
||||
|
||||
@ -976,6 +1034,50 @@ function actionSurfaceSystemPanelContext(array $capabilities): PlatformUser
|
||||
}
|
||||
});
|
||||
|
||||
it('keeps residual system pages outside primary discovery but inside the spec 195 closure inventory', function (): void {
|
||||
$components = collect(ActionSurfaceValidator::withBaselineExemptions()->discoveredComponents())
|
||||
->keyBy('className');
|
||||
|
||||
foreach ([
|
||||
\App\Filament\System\Pages\Dashboard::class,
|
||||
\App\Filament\System\Pages\Ops\ViewRun::class,
|
||||
\App\Filament\System\Pages\Ops\Runbooks::class,
|
||||
\App\Filament\System\Pages\RepairWorkspaceOwners::class,
|
||||
\App\Filament\System\Pages\Directory\ViewTenant::class,
|
||||
\App\Filament\System\Pages\Directory\ViewWorkspace::class,
|
||||
] as $className) {
|
||||
expect($components->has($className))
|
||||
->toBeFalse("{$className} should stay outside the primary declaration-backed discovery scope.")
|
||||
->and(ActionSurfaceExemptions::spec195ResidualSurface($className))
|
||||
->not->toBeNull("{$className} must still carry an explicit Spec 195 closure entry.");
|
||||
}
|
||||
});
|
||||
|
||||
it('reports actionable file context when a residual surface is missing from the spec 195 inventory', function (): void {
|
||||
$inventory = ActionSurfaceExemptions::spec195ResidualSurfaceInventory();
|
||||
unset($inventory[\App\Filament\System\Pages\Dashboard::class]);
|
||||
|
||||
$issues = ActionSurfaceValidator::validateSpec195ResidualInventoryFixture(
|
||||
inventory: $inventory,
|
||||
discoveredClasses: array_map(
|
||||
static fn ($component): string => $component->className,
|
||||
ActionSurfaceValidator::withBaselineExemptions()->discoveredComponents(),
|
||||
),
|
||||
baselineExemptions: ActionSurfaceExemptions::baseline()->all(),
|
||||
residualCandidateClasses: [\App\Filament\System\Pages\Dashboard::class],
|
||||
);
|
||||
|
||||
$formattedIssues = implode("\n", array_map(
|
||||
static fn ($issue): string => $issue->format(),
|
||||
$issues,
|
||||
));
|
||||
|
||||
expect($formattedIssues)
|
||||
->toContain(\App\Filament\System\Pages\Dashboard::class)
|
||||
->toContain('Residual action surface is missing a Spec 195 closure entry')
|
||||
->toContain('Dashboard.php');
|
||||
});
|
||||
|
||||
it('keeps enrolled relation managers declaration-backed without stale baseline exemptions', function (): void {
|
||||
$baselineExemptions = ActionSurfaceExemptions::baseline();
|
||||
|
||||
|
||||
@ -122,6 +122,28 @@ className: $className,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, string>
|
||||
*/
|
||||
function repositoryDiscoveredActionSurfaceClasses(): array
|
||||
{
|
||||
return array_map(
|
||||
static fn (ActionSurfaceDiscoveredComponent $component): string => $component->className,
|
||||
ActionSurfaceValidator::withBaselineExemptions()->discoveredComponents(),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, \App\Support\Ui\ActionSurface\ActionSurfaceValidationIssue> $issues
|
||||
*/
|
||||
function formatActionSurfaceIssues(array $issues): string
|
||||
{
|
||||
return implode("\n", array_map(
|
||||
static fn ($issue): string => $issue->format(),
|
||||
$issues,
|
||||
));
|
||||
}
|
||||
|
||||
it('passes when all required slots are declared', function (): void {
|
||||
$validator = new ActionSurfaceValidator(
|
||||
profileDefinition: new ActionSurfaceProfileDefinition,
|
||||
@ -245,3 +267,73 @@ className: $className,
|
||||
|
||||
expect($result->hasIssues())->toBeFalse($result->formatForAssertion());
|
||||
});
|
||||
|
||||
it('accepts the repository spec 195 residual inventory even when only inventory validation runs', function (): void {
|
||||
$validator = ActionSurfaceValidator::withBaselineExemptions();
|
||||
|
||||
$result = $validator->validateComponents([]);
|
||||
|
||||
expect($result->hasIssues())->toBeFalse($result->formatForAssertion());
|
||||
});
|
||||
|
||||
it('fails when a residual system candidate is missing a spec 195 closure entry', function (): void {
|
||||
$inventory = ActionSurfaceExemptions::spec195ResidualSurfaceInventory();
|
||||
unset($inventory[\App\Filament\System\Pages\Dashboard::class]);
|
||||
|
||||
$issues = ActionSurfaceValidator::validateSpec195ResidualInventoryFixture(
|
||||
inventory: $inventory,
|
||||
discoveredClasses: repositoryDiscoveredActionSurfaceClasses(),
|
||||
baselineExemptions: ActionSurfaceExemptions::baseline()->all(),
|
||||
residualCandidateClasses: [\App\Filament\System\Pages\Dashboard::class],
|
||||
);
|
||||
|
||||
expect(formatActionSurfaceIssues($issues))
|
||||
->toContain(\App\Filament\System\Pages\Dashboard::class)
|
||||
->toContain('Residual action surface is missing a Spec 195 closure entry');
|
||||
});
|
||||
|
||||
it('fails when a non-enrolled spec 195 residual surface has no reason category', function (): void {
|
||||
$inventory = ActionSurfaceExemptions::spec195ResidualSurfaceInventory();
|
||||
$inventory[\App\Filament\Pages\ChooseWorkspace::class]['reasonCategory'] = null;
|
||||
|
||||
$issues = ActionSurfaceValidator::validateSpec195ResidualInventoryFixture(
|
||||
inventory: $inventory,
|
||||
discoveredClasses: repositoryDiscoveredActionSurfaceClasses(),
|
||||
baselineExemptions: ActionSurfaceExemptions::baseline()->all(),
|
||||
);
|
||||
|
||||
expect(formatActionSurfaceIssues($issues))
|
||||
->toContain(\App\Filament\Pages\ChooseWorkspace::class)
|
||||
->toContain('reason category is invalid or missing');
|
||||
});
|
||||
|
||||
it('fails when a spec 195 residual surface is missing structured evidence', function (): void {
|
||||
$inventory = ActionSurfaceExemptions::spec195ResidualSurfaceInventory();
|
||||
$inventory[\App\Filament\Pages\Workspaces\ManagedTenantsLanding::class]['evidence'] = [];
|
||||
|
||||
$issues = ActionSurfaceValidator::validateSpec195ResidualInventoryFixture(
|
||||
inventory: $inventory,
|
||||
discoveredClasses: repositoryDiscoveredActionSurfaceClasses(),
|
||||
baselineExemptions: ActionSurfaceExemptions::baseline()->all(),
|
||||
);
|
||||
|
||||
expect(formatActionSurfaceIssues($issues))
|
||||
->toContain(\App\Filament\Pages\Workspaces\ManagedTenantsLanding::class)
|
||||
->toContain('require at least one structured evidence descriptor');
|
||||
});
|
||||
|
||||
it('fails when a retired spec 195 residual surface still remains baseline-exempt', function (): void {
|
||||
$inventory = ActionSurfaceExemptions::spec195ResidualSurfaceInventory();
|
||||
$baselineExemptions = ActionSurfaceExemptions::baseline()->all();
|
||||
$baselineExemptions[\App\Filament\Pages\BreakGlassRecovery::class] = 'Stale retired exemption.';
|
||||
|
||||
$issues = ActionSurfaceValidator::validateSpec195ResidualInventoryFixture(
|
||||
inventory: $inventory,
|
||||
discoveredClasses: repositoryDiscoveredActionSurfaceClasses(),
|
||||
baselineExemptions: $baselineExemptions,
|
||||
);
|
||||
|
||||
expect(formatActionSurfaceIssues($issues))
|
||||
->toContain(\App\Filament\Pages\BreakGlassRecovery::class)
|
||||
->toContain('must not remain baseline-exempt');
|
||||
});
|
||||
|
||||
@ -0,0 +1,156 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Support\Ui\ActionSurface\ActionSurfaceDiscoveredComponent;
|
||||
use App\Support\Ui\ActionSurface\ActionSurfaceExemptions;
|
||||
use App\Support\Ui\ActionSurface\ActionSurfaceValidator;
|
||||
|
||||
/**
|
||||
* @return array<int, string>
|
||||
*/
|
||||
function spec195DiscoveredClasses(): array
|
||||
{
|
||||
return array_map(
|
||||
static fn (ActionSurfaceDiscoveredComponent $component): string => $component->className,
|
||||
ActionSurfaceValidator::withBaselineExemptions()->discoveredComponents(),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, \App\Support\Ui\ActionSurface\ActionSurfaceValidationIssue> $issues
|
||||
*/
|
||||
function spec195FormattedIssues(array $issues): string
|
||||
{
|
||||
return implode("\n", array_map(
|
||||
static fn ($issue): string => $issue->format(),
|
||||
$issues,
|
||||
));
|
||||
}
|
||||
|
||||
it('keeps every spec 195 residual surface classified exactly once with structured evidence', function (): void {
|
||||
$inventory = ActionSurfaceExemptions::spec195ResidualSurfaceInventory();
|
||||
|
||||
expect(array_keys($inventory))->toEqualCanonicalizing([
|
||||
\App\Filament\System\Pages\Dashboard::class,
|
||||
\App\Filament\System\Pages\Ops\ViewRun::class,
|
||||
\App\Filament\System\Pages\Ops\Runbooks::class,
|
||||
\App\Filament\System\Pages\RepairWorkspaceOwners::class,
|
||||
\App\Filament\System\Pages\Directory\ViewTenant::class,
|
||||
\App\Filament\System\Pages\Directory\ViewWorkspace::class,
|
||||
\App\Filament\Pages\BreakGlassRecovery::class,
|
||||
\App\Filament\Pages\ChooseWorkspace::class,
|
||||
\App\Filament\Pages\ChooseTenant::class,
|
||||
\App\Filament\Pages\Tenancy\RegisterTenant::class,
|
||||
\App\Filament\Pages\Workspaces\ManagedTenantOnboardingWizard::class,
|
||||
\App\Filament\Pages\Workspaces\ManagedTenantsLanding::class,
|
||||
\App\Filament\Pages\TenantDashboard::class,
|
||||
]);
|
||||
|
||||
$surfaceKeys = collect($inventory)->pluck('surfaceKey')->all();
|
||||
|
||||
expect($surfaceKeys)->toHaveCount(count(array_unique($surfaceKeys)));
|
||||
|
||||
foreach ($inventory as $className => $surface) {
|
||||
expect($surface['closureDecision'] ?? null)
|
||||
->not->toBeNull("{$className} must keep a closure decision.")
|
||||
->and(trim((string) ($surface['surfaceName'] ?? '')))
|
||||
->not->toBe('', "{$className} must keep a human-readable surfaceName.")
|
||||
->and($surface['evidence'] ?? [])
|
||||
->not->toBeEmpty("{$className} must keep structured evidence.");
|
||||
}
|
||||
});
|
||||
|
||||
it('keeps the residual system tail explicitly classified instead of silently baseline-exempted', function (): void {
|
||||
$inventory = ActionSurfaceExemptions::spec195ResidualSurfaceInventory();
|
||||
$baselineExemptions = ActionSurfaceExemptions::baseline()->all();
|
||||
|
||||
expect($inventory[\App\Filament\System\Pages\Ops\ViewRun::class]['closureDecision'] ?? null)->toBe('separately_governed')
|
||||
->and($inventory[\App\Filament\System\Pages\Ops\Runbooks::class]['closureDecision'] ?? null)->toBe('separately_governed')
|
||||
->and($inventory[\App\Filament\System\Pages\RepairWorkspaceOwners::class]['closureDecision'] ?? null)->toBe('separately_governed')
|
||||
->and($inventory[\App\Filament\System\Pages\Directory\ViewTenant::class]['closureDecision'] ?? null)->toBe('harmless_special_case')
|
||||
->and($inventory[\App\Filament\System\Pages\Directory\ViewWorkspace::class]['closureDecision'] ?? null)->toBe('harmless_special_case')
|
||||
->and($inventory[\App\Filament\System\Pages\Dashboard::class]['closureDecision'] ?? null)->toBe('separately_governed');
|
||||
|
||||
foreach ([
|
||||
\App\Filament\System\Pages\Dashboard::class,
|
||||
\App\Filament\System\Pages\Ops\ViewRun::class,
|
||||
\App\Filament\System\Pages\Ops\Runbooks::class,
|
||||
\App\Filament\System\Pages\RepairWorkspaceOwners::class,
|
||||
\App\Filament\System\Pages\Directory\ViewTenant::class,
|
||||
\App\Filament\System\Pages\Directory\ViewWorkspace::class,
|
||||
] as $className) {
|
||||
expect(array_key_exists($className, $baselineExemptions))
|
||||
->toBeFalse("{$className} must not rely on baseline() for Spec 195 closure.");
|
||||
}
|
||||
});
|
||||
|
||||
it('retires break glass recovery from live baseline handling', function (): void {
|
||||
$surface = ActionSurfaceExemptions::spec195ResidualSurface(\App\Filament\Pages\BreakGlassRecovery::class);
|
||||
|
||||
expect($surface)->not->toBeNull()
|
||||
->and($surface['closureDecision'] ?? null)->toBe('retired_no_longer_relevant')
|
||||
->and($surface['reasonCategory'] ?? null)->toBe('disabled_or_actionless_surface')
|
||||
->and(ActionSurfaceExemptions::baseline()->hasClass(\App\Filament\Pages\BreakGlassRecovery::class))->toBeFalse();
|
||||
});
|
||||
|
||||
it('fails when a residual candidate is missing a closure decision entry', function (): void {
|
||||
$inventory = ActionSurfaceExemptions::spec195ResidualSurfaceInventory();
|
||||
unset($inventory[\App\Filament\System\Pages\Dashboard::class]);
|
||||
|
||||
$issues = ActionSurfaceValidator::validateSpec195ResidualInventoryFixture(
|
||||
inventory: $inventory,
|
||||
discoveredClasses: spec195DiscoveredClasses(),
|
||||
baselineExemptions: ActionSurfaceExemptions::baseline()->all(),
|
||||
residualCandidateClasses: [\App\Filament\System\Pages\Dashboard::class],
|
||||
);
|
||||
|
||||
expect(spec195FormattedIssues($issues))
|
||||
->toContain('Residual action surface is missing a Spec 195 closure entry')
|
||||
->toContain(\App\Filament\System\Pages\Dashboard::class);
|
||||
});
|
||||
|
||||
it('fails when a discovered residual exemption loses its reason category', function (): void {
|
||||
$inventory = ActionSurfaceExemptions::spec195ResidualSurfaceInventory();
|
||||
$inventory[\App\Filament\Pages\ChooseTenant::class]['reasonCategory'] = null;
|
||||
|
||||
$issues = ActionSurfaceValidator::validateSpec195ResidualInventoryFixture(
|
||||
inventory: $inventory,
|
||||
discoveredClasses: spec195DiscoveredClasses(),
|
||||
baselineExemptions: ActionSurfaceExemptions::baseline()->all(),
|
||||
);
|
||||
|
||||
expect(spec195FormattedIssues($issues))
|
||||
->toContain(\App\Filament\Pages\ChooseTenant::class)
|
||||
->toContain('reason category is invalid or missing');
|
||||
});
|
||||
|
||||
it('fails when a residual surface loses its structured evidence', function (): void {
|
||||
$inventory = ActionSurfaceExemptions::spec195ResidualSurfaceInventory();
|
||||
$inventory[\App\Filament\System\Pages\Directory\ViewTenant::class]['evidence'] = [];
|
||||
|
||||
$issues = ActionSurfaceValidator::validateSpec195ResidualInventoryFixture(
|
||||
inventory: $inventory,
|
||||
discoveredClasses: spec195DiscoveredClasses(),
|
||||
baselineExemptions: ActionSurfaceExemptions::baseline()->all(),
|
||||
);
|
||||
|
||||
expect(spec195FormattedIssues($issues))
|
||||
->toContain(\App\Filament\System\Pages\Directory\ViewTenant::class)
|
||||
->toContain('require at least one structured evidence descriptor');
|
||||
});
|
||||
|
||||
it('fails when a retired surface is reintroduced as a stale baseline exemption', function (): void {
|
||||
$baselineExemptions = ActionSurfaceExemptions::baseline()->all();
|
||||
$baselineExemptions[\App\Filament\Pages\BreakGlassRecovery::class] = 'Stale retired page exemption.';
|
||||
|
||||
$issues = ActionSurfaceValidator::validateSpec195ResidualInventoryFixture(
|
||||
inventory: ActionSurfaceExemptions::spec195ResidualSurfaceInventory(),
|
||||
discoveredClasses: spec195DiscoveredClasses(),
|
||||
baselineExemptions: $baselineExemptions,
|
||||
);
|
||||
|
||||
expect(spec195FormattedIssues($issues))
|
||||
->toContain(\App\Filament\Pages\BreakGlassRecovery::class)
|
||||
->toContain('must not remain baseline-exempt');
|
||||
});
|
||||
@ -2,6 +2,7 @@
|
||||
|
||||
use App\Filament\Pages\Tenancy\RegisterTenant;
|
||||
use Filament\Facades\Filament;
|
||||
use Livewire\Livewire;
|
||||
|
||||
describe('Register tenant page authorization', function () {
|
||||
it('is not visible for readonly members', function () {
|
||||
@ -29,4 +30,19 @@
|
||||
|
||||
Filament::setCurrentPanel(null);
|
||||
});
|
||||
|
||||
it('rejects readonly members when they try to mount the register-tenant page', function () {
|
||||
[$user, $tenant] = createUserWithTenant(role: 'readonly');
|
||||
|
||||
$this->actingAs($user);
|
||||
$tenant->makeCurrent();
|
||||
|
||||
Filament::setCurrentPanel(Filament::getPanel('tenant'));
|
||||
|
||||
Livewire::actingAs($user)
|
||||
->test(RegisterTenant::class)
|
||||
->assertNotFound();
|
||||
|
||||
Filament::setCurrentPanel(null);
|
||||
});
|
||||
});
|
||||
|
||||
@ -0,0 +1,127 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Models\OperationRun;
|
||||
use App\Models\PlatformUser;
|
||||
use App\Models\ProviderConnection;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\Workspace;
|
||||
use App\Support\Auth\PlatformCapabilities;
|
||||
use App\Support\Providers\ProviderConsentStatus;
|
||||
use App\Support\Providers\ProviderVerificationStatus;
|
||||
use App\Support\System\SystemDirectoryLinks;
|
||||
use App\Support\System\SystemOperationRunLinks;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
it('requires directory-view capability on residual system directory detail pages', function (): void {
|
||||
$workspace = Workspace::factory()->create();
|
||||
$tenant = Tenant::factory()->create([
|
||||
'workspace_id' => (int) $workspace->getKey(),
|
||||
]);
|
||||
|
||||
$platformUser = PlatformUser::factory()->create([
|
||||
'capabilities' => [
|
||||
PlatformCapabilities::ACCESS_SYSTEM_PANEL,
|
||||
],
|
||||
'is_active' => true,
|
||||
]);
|
||||
|
||||
$this->actingAs($platformUser, 'platform')
|
||||
->get(SystemDirectoryLinks::tenantDetail($tenant))
|
||||
->assertForbidden();
|
||||
|
||||
$this->actingAs($platformUser, 'platform')
|
||||
->get(SystemDirectoryLinks::workspaceDetail($workspace))
|
||||
->assertForbidden();
|
||||
});
|
||||
|
||||
it('keeps the residual system tenant detail page read-mostly and contextual', function (): void {
|
||||
$workspace = Workspace::factory()->create(['name' => 'Residual Directory Workspace']);
|
||||
$tenant = Tenant::factory()->active()->create([
|
||||
'workspace_id' => (int) $workspace->getKey(),
|
||||
'name' => 'Residual Directory Tenant',
|
||||
]);
|
||||
|
||||
ProviderConnection::factory()->create([
|
||||
'workspace_id' => (int) $workspace->getKey(),
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'provider' => 'microsoft',
|
||||
'display_name' => 'Residual Default Connection',
|
||||
'is_default' => true,
|
||||
'is_enabled' => true,
|
||||
'consent_status' => ProviderConsentStatus::Granted->value,
|
||||
'verification_status' => ProviderVerificationStatus::Healthy->value,
|
||||
]);
|
||||
|
||||
$run = OperationRun::factory()->create([
|
||||
'workspace_id' => (int) $workspace->getKey(),
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
]);
|
||||
|
||||
$platformUser = PlatformUser::factory()->create([
|
||||
'capabilities' => [
|
||||
PlatformCapabilities::ACCESS_SYSTEM_PANEL,
|
||||
PlatformCapabilities::DIRECTORY_VIEW,
|
||||
],
|
||||
'is_active' => true,
|
||||
]);
|
||||
|
||||
$this->actingAs($platformUser, 'platform')
|
||||
->get(SystemDirectoryLinks::tenantDetail($tenant))
|
||||
->assertSuccessful()
|
||||
->assertSee('Residual Directory Tenant')
|
||||
->assertSee('Residual Directory Workspace')
|
||||
->assertSee('Connectivity signals')
|
||||
->assertSee('Residual Default Connection')
|
||||
->assertSee('Open in /admin')
|
||||
->assertSee(SystemDirectoryLinks::adminTenant($tenant), false)
|
||||
->assertSee('Open operations runs')
|
||||
->assertSee(SystemOperationRunLinks::index(), false)
|
||||
->assertSee(SystemOperationRunLinks::view($run), false)
|
||||
->assertDontSee('Enter break-glass mode')
|
||||
->assertDontSee('Emergency: Assign Owner');
|
||||
});
|
||||
|
||||
it('keeps the residual system workspace detail page read-mostly and link-driven', function (): void {
|
||||
$workspace = Workspace::factory()->create(['name' => 'Residual Workspace Detail']);
|
||||
$tenant = Tenant::factory()->active()->create([
|
||||
'workspace_id' => (int) $workspace->getKey(),
|
||||
'name' => 'Workspace Detail Tenant',
|
||||
]);
|
||||
|
||||
$run = OperationRun::factory()->create([
|
||||
'workspace_id' => (int) $workspace->getKey(),
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
]);
|
||||
|
||||
$platformUser = PlatformUser::factory()->create([
|
||||
'capabilities' => [
|
||||
PlatformCapabilities::ACCESS_SYSTEM_PANEL,
|
||||
PlatformCapabilities::DIRECTORY_VIEW,
|
||||
],
|
||||
'is_active' => true,
|
||||
]);
|
||||
|
||||
$response = $this->actingAs($platformUser, 'platform')
|
||||
->get(SystemDirectoryLinks::workspaceDetail($workspace))
|
||||
->assertSuccessful()
|
||||
->assertSee('Residual Workspace Detail')
|
||||
->assertSee('Tenants summary')
|
||||
->assertSee('Workspace Detail Tenant')
|
||||
->assertSee(SystemDirectoryLinks::tenantDetail($tenant), false)
|
||||
->assertSee('Open in /admin')
|
||||
->assertSee(SystemDirectoryLinks::adminWorkspace($workspace), false)
|
||||
->assertSee('Open operations runs')
|
||||
->assertSee(SystemOperationRunLinks::index(), false)
|
||||
->assertSee(SystemOperationRunLinks::view($run), false)
|
||||
->assertDontSee('Enter break-glass mode')
|
||||
->assertDontSee('Emergency: Assign Owner');
|
||||
|
||||
$html = $response->getContent();
|
||||
|
||||
expect($html)->toContain('wire:name="Filament\\Livewire\\DatabaseNotifications"');
|
||||
expect($html)->not->toContain('__lazyLoad');
|
||||
});
|
||||
@ -0,0 +1,106 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Filament\Pages\ChooseTenant;
|
||||
use App\Filament\Pages\Workspaces\ManagedTenantsLanding;
|
||||
use App\Filament\Resources\TenantResource;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
use App\Models\Workspace;
|
||||
use App\Models\WorkspaceMembership;
|
||||
use App\Support\Workspaces\WorkspaceContext;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Livewire\Livewire;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
it('keeps the spec 195 managed-tenants landing available without an active tenant context', function (): void {
|
||||
$workspace = Workspace::factory()->create(['slug' => 'spec195-managed-tenants']);
|
||||
$user = User::factory()->create();
|
||||
|
||||
WorkspaceMembership::factory()->create([
|
||||
'workspace_id' => (int) $workspace->getKey(),
|
||||
'user_id' => (int) $user->getKey(),
|
||||
'role' => 'owner',
|
||||
]);
|
||||
|
||||
$tenant = Tenant::factory()->active()->create([
|
||||
'workspace_id' => (int) $workspace->getKey(),
|
||||
'name' => 'Spec195 Landing Tenant',
|
||||
]);
|
||||
|
||||
$user->tenants()->syncWithoutDetaching([
|
||||
$tenant->getKey() => ['role' => 'owner'],
|
||||
]);
|
||||
|
||||
$this->actingAs($user)
|
||||
->withSession([WorkspaceContext::SESSION_KEY => (int) $workspace->getKey()])
|
||||
->get(route('admin.workspace.managed-tenants.index', ['workspace' => $workspace]))
|
||||
->assertSuccessful()
|
||||
->assertSee('Spec195 Landing Tenant')
|
||||
->assertSee('Managed tenants')
|
||||
->assertDontSee('No tenant selected');
|
||||
});
|
||||
|
||||
it('routes the managed-tenants landing back into the chooser flow and open-tenant flow', function (): void {
|
||||
$workspace = Workspace::factory()->create(['slug' => 'spec195-managed-routing']);
|
||||
$user = User::factory()->create();
|
||||
|
||||
WorkspaceMembership::factory()->create([
|
||||
'workspace_id' => (int) $workspace->getKey(),
|
||||
'user_id' => (int) $user->getKey(),
|
||||
'role' => 'owner',
|
||||
]);
|
||||
|
||||
$tenant = Tenant::factory()->active()->create([
|
||||
'workspace_id' => (int) $workspace->getKey(),
|
||||
'name' => 'Spec195 Routed Tenant',
|
||||
]);
|
||||
|
||||
$user->tenants()->syncWithoutDetaching([
|
||||
$tenant->getKey() => ['role' => 'owner'],
|
||||
]);
|
||||
|
||||
$this->withSession([WorkspaceContext::SESSION_KEY => (int) $workspace->getKey()]);
|
||||
session([WorkspaceContext::SESSION_KEY => (int) $workspace->getKey()]);
|
||||
|
||||
$component = Livewire::actingAs($user)
|
||||
->test(ManagedTenantsLanding::class, ['workspace' => $workspace]);
|
||||
|
||||
$component
|
||||
->call('goToChooseTenant')
|
||||
->assertRedirect(ChooseTenant::getUrl());
|
||||
|
||||
$this->withSession([WorkspaceContext::SESSION_KEY => (int) $workspace->getKey()]);
|
||||
session([WorkspaceContext::SESSION_KEY => (int) $workspace->getKey()]);
|
||||
|
||||
Livewire::actingAs($user)
|
||||
->test(ManagedTenantsLanding::class, ['workspace' => $workspace])
|
||||
->call('openTenant', $tenant->getKey())
|
||||
->assertRedirect(TenantResource::getUrl('view', ['record' => $tenant]));
|
||||
});
|
||||
|
||||
it('rejects opening a tenant from the landing when the actor lacks tenant entitlement', function (): void {
|
||||
$workspace = Workspace::factory()->create(['slug' => 'spec195-managed-guard']);
|
||||
$user = User::factory()->create();
|
||||
|
||||
WorkspaceMembership::factory()->create([
|
||||
'workspace_id' => (int) $workspace->getKey(),
|
||||
'user_id' => (int) $user->getKey(),
|
||||
'role' => 'owner',
|
||||
]);
|
||||
|
||||
$tenant = Tenant::factory()->active()->create([
|
||||
'workspace_id' => (int) $workspace->getKey(),
|
||||
'name' => 'Spec195 Guarded Tenant',
|
||||
]);
|
||||
|
||||
$this->withSession([WorkspaceContext::SESSION_KEY => (int) $workspace->getKey()]);
|
||||
session([WorkspaceContext::SESSION_KEY => (int) $workspace->getKey()]);
|
||||
|
||||
Livewire::actingAs($user)
|
||||
->test(ManagedTenantsLanding::class, ['workspace' => $workspace])
|
||||
->call('openTenant', $tenant->getKey())
|
||||
->assertNotFound();
|
||||
});
|
||||
@ -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,217 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Models\Tenant;
|
||||
use App\Support\Baselines\BaselineScope;
|
||||
use App\Support\Baselines\Compare\CompareOrchestrationContext;
|
||||
use App\Support\Baselines\Compare\CompareStrategy;
|
||||
use App\Support\Baselines\Compare\CompareStrategyCapability;
|
||||
use App\Support\Baselines\Compare\CompareStrategyKey;
|
||||
use App\Support\Baselines\Compare\CompareStrategyRegistry;
|
||||
use App\Support\Governance\GovernanceDomainKey;
|
||||
use App\Support\Governance\GovernanceSubjectClass;
|
||||
|
||||
it('selects a single compatible strategy family for a canonical scope entry', function (): void {
|
||||
$registry = new CompareStrategyRegistry([
|
||||
compareStrategyStub(
|
||||
key: 'intune_policy',
|
||||
capabilities: [
|
||||
new CompareStrategyCapability(
|
||||
strategyKey: CompareStrategyKey::intunePolicy(),
|
||||
domainKeys: [GovernanceDomainKey::Intune->value, GovernanceDomainKey::PlatformFoundation->value],
|
||||
subjectClasses: [
|
||||
GovernanceSubjectClass::Policy->value,
|
||||
GovernanceSubjectClass::ConfigurationResource->value,
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
]);
|
||||
|
||||
$scope = BaselineScope::fromJsonb([
|
||||
'version' => 2,
|
||||
'entries' => [
|
||||
[
|
||||
'domain_key' => GovernanceDomainKey::Intune->value,
|
||||
'subject_class' => GovernanceSubjectClass::Policy->value,
|
||||
'subject_type_keys' => ['deviceConfiguration'],
|
||||
'filters' => [],
|
||||
],
|
||||
[
|
||||
'domain_key' => GovernanceDomainKey::PlatformFoundation->value,
|
||||
'subject_class' => GovernanceSubjectClass::ConfigurationResource->value,
|
||||
'subject_type_keys' => ['assignmentFilter'],
|
||||
'filters' => [],
|
||||
],
|
||||
],
|
||||
]);
|
||||
|
||||
$selection = $registry->select($scope);
|
||||
|
||||
expect($selection->isSupported())->toBeTrue()
|
||||
->and($selection->strategyKey?->value)->toBe('intune_policy')
|
||||
->and($selection->matchedScopeEntries)->toHaveCount(2)
|
||||
->and($selection->rejectedScopeEntries)->toBe([]);
|
||||
});
|
||||
|
||||
it('rejects canonical scope entries when no strategy supports them', function (): void {
|
||||
$registry = new CompareStrategyRegistry([
|
||||
compareStrategyStub(
|
||||
key: 'intune_policy',
|
||||
capabilities: [
|
||||
new CompareStrategyCapability(
|
||||
strategyKey: CompareStrategyKey::intunePolicy(),
|
||||
domainKeys: [GovernanceDomainKey::Intune->value],
|
||||
subjectClasses: [GovernanceSubjectClass::Policy->value],
|
||||
),
|
||||
],
|
||||
),
|
||||
]);
|
||||
|
||||
$scope = new BaselineScope(
|
||||
entries: [[
|
||||
'domain_key' => GovernanceDomainKey::Entra->value,
|
||||
'subject_class' => GovernanceSubjectClass::Control->value,
|
||||
'subject_type_keys' => ['conditionalAccessPolicy'],
|
||||
'filters' => [],
|
||||
]],
|
||||
version: 2,
|
||||
);
|
||||
|
||||
$selection = $registry->select($scope);
|
||||
|
||||
expect($selection->isUnsupported())->toBeTrue()
|
||||
->and($selection->strategyKey)->toBeNull()
|
||||
->and($selection->matchedScopeEntries)->toBe([])
|
||||
->and($selection->rejectedScopeEntries)->toHaveCount(1);
|
||||
});
|
||||
|
||||
it('marks scope as mixed when multiple strategy families are required', function (): void {
|
||||
$registry = new CompareStrategyRegistry([
|
||||
compareStrategyStub(
|
||||
key: 'intune_policy',
|
||||
capabilities: [
|
||||
new CompareStrategyCapability(
|
||||
strategyKey: CompareStrategyKey::intunePolicy(),
|
||||
domainKeys: [GovernanceDomainKey::Intune->value],
|
||||
subjectClasses: [GovernanceSubjectClass::Policy->value],
|
||||
),
|
||||
],
|
||||
),
|
||||
compareStrategyStub(
|
||||
key: 'future_control',
|
||||
capabilities: [
|
||||
new CompareStrategyCapability(
|
||||
strategyKey: CompareStrategyKey::from('future_control'),
|
||||
domainKeys: [GovernanceDomainKey::Entra->value],
|
||||
subjectClasses: [GovernanceSubjectClass::Control->value],
|
||||
),
|
||||
],
|
||||
),
|
||||
]);
|
||||
|
||||
$scope = new BaselineScope(
|
||||
entries: [
|
||||
[
|
||||
'domain_key' => GovernanceDomainKey::Intune->value,
|
||||
'subject_class' => GovernanceSubjectClass::Policy->value,
|
||||
'subject_type_keys' => ['deviceConfiguration'],
|
||||
'filters' => [],
|
||||
],
|
||||
[
|
||||
'domain_key' => GovernanceDomainKey::Entra->value,
|
||||
'subject_class' => GovernanceSubjectClass::Control->value,
|
||||
'subject_type_keys' => ['conditionalAccessPolicy'],
|
||||
'filters' => [],
|
||||
],
|
||||
],
|
||||
version: 2,
|
||||
);
|
||||
|
||||
$selection = $registry->select($scope);
|
||||
|
||||
expect($selection->isMixed())->toBeTrue()
|
||||
->and($selection->strategyKey)->toBeNull()
|
||||
->and($selection->matchedScopeEntries)->toHaveCount(2)
|
||||
->and($selection->diagnostics['matched_strategy_keys'] ?? [])->toEqual(['future_control', 'intune_policy']);
|
||||
});
|
||||
|
||||
it('supports deterministic future-domain selection without implicit intune fallback', function (): void {
|
||||
$registry = new CompareStrategyRegistry([
|
||||
compareStrategyStub(
|
||||
key: 'future_control',
|
||||
capabilities: [
|
||||
new CompareStrategyCapability(
|
||||
strategyKey: CompareStrategyKey::from('future_control'),
|
||||
domainKeys: [GovernanceDomainKey::Entra->value],
|
||||
subjectClasses: [GovernanceSubjectClass::Control->value],
|
||||
),
|
||||
],
|
||||
),
|
||||
]);
|
||||
|
||||
$scope = new BaselineScope(
|
||||
entries: [[
|
||||
'domain_key' => GovernanceDomainKey::Entra->value,
|
||||
'subject_class' => GovernanceSubjectClass::Control->value,
|
||||
'subject_type_keys' => ['conditionalAccessPolicy'],
|
||||
'filters' => [],
|
||||
]],
|
||||
version: 2,
|
||||
);
|
||||
|
||||
$selection = $registry->select($scope);
|
||||
|
||||
expect($selection->isSupported())->toBeTrue()
|
||||
->and($selection->strategyKey?->value)->toBe('future_control')
|
||||
->and($registry->resolve('future_control'))->toBeInstanceOf(CompareStrategy::class);
|
||||
});
|
||||
|
||||
it('throws when resolving an unknown strategy key', function (): void {
|
||||
$registry = new CompareStrategyRegistry([]);
|
||||
|
||||
expect(fn (): CompareStrategy => $registry->resolve('missing_strategy'))
|
||||
->toThrow(InvalidArgumentException::class, 'Unknown compare strategy');
|
||||
});
|
||||
|
||||
/**
|
||||
* @param list<CompareStrategyCapability> $capabilities
|
||||
*/
|
||||
function compareStrategyStub(string $key, array $capabilities): CompareStrategy
|
||||
{
|
||||
return new class($key, $capabilities) implements CompareStrategy
|
||||
{
|
||||
/**
|
||||
* @param list<CompareStrategyCapability> $capabilities
|
||||
*/
|
||||
public function __construct(
|
||||
private readonly string $keyValue,
|
||||
private readonly array $capabilities,
|
||||
) {}
|
||||
|
||||
public function key(): CompareStrategyKey
|
||||
{
|
||||
return CompareStrategyKey::from($this->keyValue);
|
||||
}
|
||||
|
||||
public function capabilities(): array
|
||||
{
|
||||
return $this->capabilities;
|
||||
}
|
||||
|
||||
public function compare(
|
||||
CompareOrchestrationContext $context,
|
||||
Tenant $tenant,
|
||||
array $baselineItems,
|
||||
array $currentItems,
|
||||
array $resolvedCurrentEvidence,
|
||||
array $severityMapping,
|
||||
): array {
|
||||
return [
|
||||
'subject_results' => [],
|
||||
'diagnostics' => [],
|
||||
];
|
||||
}
|
||||
};
|
||||
}
|
||||
@ -0,0 +1,122 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Support\Baselines\Compare\CompareFindingCandidate;
|
||||
use App\Support\Baselines\Compare\CompareState;
|
||||
use App\Support\Baselines\Compare\CompareSubjectIdentity;
|
||||
use App\Support\Baselines\Compare\CompareSubjectProjection;
|
||||
use App\Support\Baselines\Compare\CompareSubjectResult;
|
||||
|
||||
it('serializes compare subject results with structured finding and diagnostics payloads', function (): void {
|
||||
$result = new CompareSubjectResult(
|
||||
subjectIdentity: new CompareSubjectIdentity(
|
||||
domainKey: 'entra',
|
||||
subjectClass: 'control',
|
||||
subjectTypeKey: 'conditionalAccessPolicy',
|
||||
externalSubjectId: 'cap-1',
|
||||
subjectKey: 'cap-1',
|
||||
),
|
||||
projection: new CompareSubjectProjection(
|
||||
platformSubjectClass: 'control',
|
||||
domainKey: 'entra',
|
||||
subjectTypeKey: 'conditionalAccessPolicy',
|
||||
operatorLabel: 'Conditional Access Policy',
|
||||
summaryKind: 'control_snapshot',
|
||||
additionalLabels: ['family' => 'identity'],
|
||||
),
|
||||
baselineAvailability: 'available',
|
||||
currentStateAvailability: 'available',
|
||||
compareState: CompareState::Drift,
|
||||
trustLevel: 'limited_confidence',
|
||||
evidenceQuality: 'meta',
|
||||
severityRecommendation: 'medium',
|
||||
findingCandidate: new CompareFindingCandidate(
|
||||
changeType: 'different_version',
|
||||
severity: 'medium',
|
||||
fingerprintBasis: [
|
||||
'policy_type' => 'conditionalAccessPolicy',
|
||||
'subject_key' => 'cap-1',
|
||||
'change_type' => 'different_version',
|
||||
],
|
||||
evidencePayload: [
|
||||
'summary' => ['kind' => 'control_snapshot'],
|
||||
],
|
||||
),
|
||||
diagnostics: [
|
||||
'strategy_key' => 'future_control',
|
||||
],
|
||||
);
|
||||
|
||||
$payload = $result->toArray();
|
||||
|
||||
expect($result->hasFindingCandidate())->toBeTrue()
|
||||
->and($result->isGapState())->toBeFalse()
|
||||
->and($payload['compare_state'])->toBe(CompareState::Drift->value)
|
||||
->and($payload['baseline_availability'])->toBe('available')
|
||||
->and($payload['current_state_availability'])->toBe('available')
|
||||
->and($payload['projection']['platform_subject_class'])->toBe('control')
|
||||
->and($payload['projection']['summary_kind'])->toBe('control_snapshot')
|
||||
->and($payload['finding_candidate']['change_type'])->toBe('different_version')
|
||||
->and($payload['finding_candidate']['severity'])->toBe('medium')
|
||||
->and($payload['diagnostics']['strategy_key'])->toBe('future_control');
|
||||
});
|
||||
|
||||
it('requires a finding candidate for drift results', function (): void {
|
||||
expect(fn (): CompareSubjectResult => new CompareSubjectResult(
|
||||
subjectIdentity: new CompareSubjectIdentity('intune', 'policy', 'deviceConfiguration', 'policy-1', 'policy-1'),
|
||||
projection: new CompareSubjectProjection('policy', 'intune', 'deviceConfiguration', 'Policy 1'),
|
||||
baselineAvailability: 'available',
|
||||
currentStateAvailability: 'missing',
|
||||
compareState: CompareState::Drift,
|
||||
trustLevel: 'limited_confidence',
|
||||
evidenceQuality: 'meta',
|
||||
))->toThrow(InvalidArgumentException::class, 'require a finding candidate');
|
||||
});
|
||||
|
||||
it('rejects non-drift results that still try to write findings', function (): void {
|
||||
expect(fn (): CompareSubjectResult => new CompareSubjectResult(
|
||||
subjectIdentity: new CompareSubjectIdentity('entra', 'control', 'conditionalAccessPolicy', 'cap-1', 'cap-1'),
|
||||
projection: new CompareSubjectProjection('control', 'entra', 'conditionalAccessPolicy', 'CAP 1'),
|
||||
baselineAvailability: 'available',
|
||||
currentStateAvailability: 'unknown',
|
||||
compareState: CompareState::Incomplete,
|
||||
trustLevel: 'unusable',
|
||||
evidenceQuality: 'missing',
|
||||
findingCandidate: new CompareFindingCandidate(
|
||||
changeType: 'different_version',
|
||||
severity: 'high',
|
||||
fingerprintBasis: ['subject_key' => 'cap-1'],
|
||||
evidencePayload: [],
|
||||
),
|
||||
))->toThrow(InvalidArgumentException::class, 'Only drift compare subject results');
|
||||
});
|
||||
|
||||
it('treats unsupported, incomplete, ambiguous, and failed states as gap states', function (): void {
|
||||
$states = [
|
||||
CompareState::Unsupported,
|
||||
CompareState::Incomplete,
|
||||
CompareState::Ambiguous,
|
||||
CompareState::Failed,
|
||||
];
|
||||
|
||||
foreach ($states as $state) {
|
||||
$result = new CompareSubjectResult(
|
||||
subjectIdentity: new CompareSubjectIdentity('entra', 'control', 'conditionalAccessPolicy', 'cap-1', 'cap-1'),
|
||||
projection: new CompareSubjectProjection('control', 'entra', 'conditionalAccessPolicy', 'CAP 1'),
|
||||
baselineAvailability: 'available',
|
||||
currentStateAvailability: 'unknown',
|
||||
compareState: $state,
|
||||
trustLevel: 'unusable',
|
||||
evidenceQuality: 'missing',
|
||||
diagnostics: [
|
||||
'reason_code' => 'strategy_failed',
|
||||
'gap_record' => ['reason_code' => 'strategy_failed'],
|
||||
],
|
||||
);
|
||||
|
||||
expect($result->isGapState())->toBeTrue()
|
||||
->and($result->gapReasonCode())->toBe('strategy_failed')
|
||||
->and($result->gapRecord())->toBe(['reason_code' => 'strategy_failed']);
|
||||
}
|
||||
});
|
||||
@ -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',
|
||||
]);
|
||||
});
|
||||
|
||||
36
specs/195-action-surface-closure/checklists/requirements.md
Normal file
36
specs/195-action-surface-closure/checklists/requirements.md
Normal file
@ -0,0 +1,36 @@
|
||||
# Specification Quality Checklist: Action Surface Enforcement, Enrollment, and Exception Closure
|
||||
|
||||
**Purpose**: Validate specification completeness and quality before proceeding to planning
|
||||
**Created**: 2026-04-12
|
||||
**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 on first pass against the spec template and closure requirements.
|
||||
- No open clarification markers remain.
|
||||
- The spec stays bounded to residual closure, discovery limits, exemptions, and regression protection after Specs 192 to 194.
|
||||
@ -0,0 +1,219 @@
|
||||
openapi: 3.1.0
|
||||
info:
|
||||
title: Action Surface Closure Logical Contract
|
||||
version: 0.1.0
|
||||
description: >-
|
||||
Logical design contract for Spec 195 residual action-surface closure.
|
||||
This is a planning artifact that defines the required reviewable shape for
|
||||
residual pages that sit outside or alongside the primary action-surface
|
||||
discovery path.
|
||||
servers:
|
||||
- url: https://logical-spec.local
|
||||
description: Non-runtime planning contract
|
||||
paths:
|
||||
/internal/action-surfaces/residual:
|
||||
get:
|
||||
summary: List Spec 195 residual action-surface closure entries
|
||||
operationId: listResidualActionSurfaceClosures
|
||||
responses:
|
||||
'200':
|
||||
description: Residual closure entries in validator order
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
required:
|
||||
- data
|
||||
properties:
|
||||
data:
|
||||
type: array
|
||||
items:
|
||||
$ref: '#/components/schemas/ResidualActionSurfaceClosure'
|
||||
/internal/action-surfaces/residual/{surfaceKey}:
|
||||
get:
|
||||
summary: Read one Spec 195 residual action-surface closure entry
|
||||
operationId: getResidualActionSurfaceClosure
|
||||
parameters:
|
||||
- name: surfaceKey
|
||||
in: path
|
||||
required: true
|
||||
schema:
|
||||
$ref: '#/components/schemas/SurfaceKey'
|
||||
responses:
|
||||
'200':
|
||||
description: Residual closure entry
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
required:
|
||||
- data
|
||||
properties:
|
||||
data:
|
||||
$ref: '#/components/schemas/ResidualActionSurfaceClosure'
|
||||
components:
|
||||
schemas:
|
||||
SurfaceKey:
|
||||
type: string
|
||||
pattern: '^[a-z0-9_]+$'
|
||||
description: Stable machine-readable key for one residual surface. The initial seed list is recorded in x-spec-195-notes.seedSurfaceKeys and may be extended by audit.
|
||||
DiscoveryState:
|
||||
type: string
|
||||
enum:
|
||||
- primary_discovered
|
||||
- primary_discovered_baseline_exempt
|
||||
- outside_primary_discovery
|
||||
ClosureDecision:
|
||||
type: string
|
||||
enum:
|
||||
- generic_contract_enrollment
|
||||
- intentional_exemption
|
||||
- separately_governed
|
||||
- retired_no_longer_relevant
|
||||
- harmless_special_case
|
||||
ReasonCategory:
|
||||
type: string
|
||||
enum:
|
||||
- system_triage_surface
|
||||
- workflow_specific_governance
|
||||
- break_glass_repair_utility
|
||||
- read_mostly_context_detail
|
||||
- disabled_or_actionless_surface
|
||||
- selector_routing_only
|
||||
- registration_form_with_dedicated_rbac
|
||||
- landing_routing_surface
|
||||
- dashboard_shell_widget_owned
|
||||
- security_flow_exception
|
||||
FollowUpAction:
|
||||
type: string
|
||||
enum:
|
||||
- none
|
||||
- tighten_reason
|
||||
- add_guard_only
|
||||
- add_focused_test
|
||||
- consider_enrollment
|
||||
EvidenceDescriptor:
|
||||
type: object
|
||||
required:
|
||||
- reference
|
||||
- proves
|
||||
properties:
|
||||
reference:
|
||||
type: string
|
||||
proves:
|
||||
type: string
|
||||
kind:
|
||||
type: string
|
||||
enum:
|
||||
- guard_test
|
||||
- feature_livewire_test
|
||||
- authorization_test
|
||||
- workflow_spec
|
||||
- audit_test
|
||||
- db_only_surface_test
|
||||
ResidualActionSurfaceClosureBase:
|
||||
type: object
|
||||
required:
|
||||
- surfaceKey
|
||||
- surfaceName
|
||||
- pageClass
|
||||
- panelPlane
|
||||
- surfaceKind
|
||||
- discoveryState
|
||||
- closureDecision
|
||||
- explicitReason
|
||||
- evidence
|
||||
- followUpAction
|
||||
- mustRemainBaselineExempt
|
||||
- mustNotRemainBaselineExempt
|
||||
properties:
|
||||
surfaceKey:
|
||||
$ref: '#/components/schemas/SurfaceKey'
|
||||
surfaceName:
|
||||
type: string
|
||||
description: Human-readable review name for the residual surface
|
||||
pageClass:
|
||||
type: string
|
||||
panelPlane:
|
||||
type: string
|
||||
enum:
|
||||
- admin
|
||||
- tenant
|
||||
- system
|
||||
surfaceKind:
|
||||
type: string
|
||||
enum:
|
||||
- system_detail
|
||||
- system_utility
|
||||
- selector
|
||||
- wizard
|
||||
- landing
|
||||
- dashboard_shell
|
||||
- recovery_flow
|
||||
- read_mostly_context
|
||||
discoveryState:
|
||||
$ref: '#/components/schemas/DiscoveryState'
|
||||
closureDecision:
|
||||
$ref: '#/components/schemas/ClosureDecision'
|
||||
reasonCategory:
|
||||
anyOf:
|
||||
- $ref: '#/components/schemas/ReasonCategory'
|
||||
- type: 'null'
|
||||
explicitReason:
|
||||
type: string
|
||||
evidence:
|
||||
type: array
|
||||
minItems: 1
|
||||
items:
|
||||
$ref: '#/components/schemas/EvidenceDescriptor'
|
||||
followUpAction:
|
||||
$ref: '#/components/schemas/FollowUpAction'
|
||||
mustRemainBaselineExempt:
|
||||
type: boolean
|
||||
mustNotRemainBaselineExempt:
|
||||
type: boolean
|
||||
ResidualActionSurfaceClosure:
|
||||
allOf:
|
||||
- $ref: '#/components/schemas/ResidualActionSurfaceClosureBase'
|
||||
- oneOf:
|
||||
- properties:
|
||||
closureDecision:
|
||||
const: generic_contract_enrollment
|
||||
- required:
|
||||
- reasonCategory
|
||||
properties:
|
||||
closureDecision:
|
||||
type: string
|
||||
enum:
|
||||
- intentional_exemption
|
||||
- separately_governed
|
||||
- retired_no_longer_relevant
|
||||
- harmless_special_case
|
||||
reasonCategory:
|
||||
$ref: '#/components/schemas/ReasonCategory'
|
||||
x-spec-195-notes:
|
||||
seedSurfaceKeys:
|
||||
- system_dashboard
|
||||
- system_ops_view_run
|
||||
- system_ops_runbooks
|
||||
- repair_workspace_owners
|
||||
- system_directory_view_tenant
|
||||
- system_directory_view_workspace
|
||||
- break_glass_recovery
|
||||
- choose_workspace
|
||||
- choose_tenant
|
||||
- register_tenant
|
||||
- managed_tenant_onboarding_wizard
|
||||
- managed_tenants_landing
|
||||
- tenant_dashboard
|
||||
consumers:
|
||||
- apps/platform/app/Support/Ui/ActionSurface/ActionSurfaceExemptions.php
|
||||
- apps/platform/app/Support/Ui/ActionSurface/ActionSurfaceValidator.php
|
||||
- apps/platform/tests/Feature/Guards/ActionSurfaceContractTest.php
|
||||
- apps/platform/tests/Feature/Guards/ActionSurfaceValidatorTest.php
|
||||
- apps/platform/tests/Feature/Guards/Spec195ResidualActionSurfaceClosureGuardTest.php
|
||||
nonGoals:
|
||||
- runtime API exposure
|
||||
- new persistence
|
||||
- new provider or routing structure
|
||||
- widening primary action-surface discovery to every Filament page class
|
||||
149
specs/195-action-surface-closure/data-model.md
Normal file
149
specs/195-action-surface-closure/data-model.md
Normal file
@ -0,0 +1,149 @@
|
||||
# Data Model: Action Surface Enforcement, Enrollment, and Exception Closure
|
||||
|
||||
## Overview
|
||||
|
||||
This feature introduces no new persisted entity, table, or user-facing workflow model. It adds a derived repository-governance model for residual action-bearing surfaces that currently sit outside clearly catalogued generic-contract coverage.
|
||||
|
||||
The goal of the model is to answer five questions for every residual surface:
|
||||
|
||||
1. Is the surface discovered by the primary validator path?
|
||||
2. If not, is the gap explicit?
|
||||
3. What is the final closure decision?
|
||||
4. Why is that decision justified?
|
||||
5. Which existing tests or guards prove the decision is real?
|
||||
|
||||
Each entry must also stay human-reviewable, so the inventory carries both a stable machine key and a human-readable surface name.
|
||||
|
||||
## Existing Source Truths Reused Without Change
|
||||
|
||||
The following truths remain authoritative and are not redefined by this feature:
|
||||
|
||||
- existing page and route classes
|
||||
- existing authorization semantics, capability registries, and `UiEnforcement` rules
|
||||
- existing `OperationRun`, audit, and break-glass behavior
|
||||
- existing `ActionSurfaceDiscovery` behavior for declaration-backed generic surfaces
|
||||
- existing baseline exemptions for discovered pages that are intentionally outside the generic contract
|
||||
- existing system, onboarding, chooser, and dashboard test suites
|
||||
|
||||
This feature changes classification and regression proof only.
|
||||
|
||||
## New Derived Planning Models
|
||||
|
||||
### ResidualSurfaceInventoryEntry
|
||||
|
||||
**Type**: Spec 195 inventory entry
|
||||
**Source**: one structured in-code inventory plus validator checks
|
||||
|
||||
| Field | Type | Notes |
|
||||
|------|------|-------|
|
||||
| `surfaceKey` | string | Stable identifier such as `system_ops_view_run` or `choose_workspace` |
|
||||
| `surfaceName` | string | Human-readable review name such as `System Ops View Run` or `Choose Workspace` |
|
||||
| `pageClass` | string | Concrete Filament page class |
|
||||
| `panelPlane` | string | `admin`, `tenant`, or `system` |
|
||||
| `surfaceKind` | string | `system_detail`, `system_utility`, `selector`, `wizard`, `landing`, `dashboard_shell`, `recovery_flow`, or `read_mostly_context` |
|
||||
| `discoveryState` | string | `primary_discovered`, `primary_discovered_baseline_exempt`, or `outside_primary_discovery` |
|
||||
| `closureDecision` | string | `generic_contract_enrollment`, `intentional_exemption`, `separately_governed`, `retired_no_longer_relevant`, or `harmless_special_case` |
|
||||
| `reasonCategory` | string or null | Required for every decision except pure enrollment |
|
||||
| `explicitReason` | string | Short reviewable explanation |
|
||||
| `evidence` | array<CoverageEvidenceDescriptor> | Structured evidence descriptors that justify the decision |
|
||||
| `followUpAction` | string | `none`, `tighten_reason`, `add_guard_only`, `add_focused_test`, or `consider_enrollment` |
|
||||
| `mustRemainBaselineExempt` | boolean | True when the discovered page must stay in `baseline()` |
|
||||
| `mustNotRemainBaselineExempt` | boolean | True when the surface must not remain in `baseline()` |
|
||||
|
||||
### CoverageEvidenceDescriptor
|
||||
|
||||
**Type**: derived proof entry
|
||||
**Source**: existing test and spec references
|
||||
|
||||
| Field | Type | Notes |
|
||||
|------|------|-------|
|
||||
| `surfaceKey` | string | Links the evidence to one residual surface |
|
||||
| `kind` | string | `guard_test`, `feature_livewire_test`, `authorization_test`, `workflow_spec`, `audit_test`, or `db_only_surface_test` |
|
||||
| `reference` | string | Relative file path or stable spec reference |
|
||||
| `proves` | string | What the evidence actually proves |
|
||||
| `gapIfMissing` | boolean | True when Spec 195 should add or tighten coverage |
|
||||
|
||||
### DiscoveryBoundaryRule
|
||||
|
||||
**Type**: derived validator rule
|
||||
**Source**: existing `ActionSurfaceDiscovery` behavior plus Spec 195 clarification
|
||||
|
||||
| Field | Type | Notes |
|
||||
|------|------|-------|
|
||||
| `boundaryKey` | string | Stable identifier for one primary-discovery boundary |
|
||||
| `appliesTo` | string | `resources`, `relation_managers`, `pages`, `system_table_pages`, or `non_discovered_special_pages` |
|
||||
| `currentRule` | string | Human-readable statement of what discovery includes or excludes |
|
||||
| `silentGapRisk` | boolean | Whether a surface can currently evade review if the rule stays implicit |
|
||||
| `spec195Mitigation` | string | How the residual inventory or guard closes that gap |
|
||||
|
||||
### ResidualSurfaceRegressionExpectation
|
||||
|
||||
**Type**: guard expectation entry
|
||||
**Source**: Spec 195 validation rules derived from `mustRemainBaselineExempt`, `mustNotRemainBaselineExempt`, and the other closure-entry fields
|
||||
|
||||
| Field | Type | Notes |
|
||||
|------|------|-------|
|
||||
| `surfaceKey` | string | Residual surface under guard |
|
||||
| `mustHaveClosureDecision` | boolean | Always true for in-scope residuals |
|
||||
| `mustHaveReasonCategory` | boolean | True when not enrolled |
|
||||
| `mustHaveEvidence` | boolean | True for every non-retired residual |
|
||||
| `mustRemainInBaselineExemptions` | boolean | True only for discovered pages still intentionally outside the generic contract |
|
||||
| `mustNotRemainInBaselineExemptions` | boolean | True for retired surfaces and non-discovered system pages |
|
||||
| `needsFocusedTest` | boolean | True when existing evidence is not yet strong enough |
|
||||
|
||||
## Initial Seed Inventory for Spec 195
|
||||
|
||||
This is the planned closure inventory derived from current code and test evidence. The implementation audit added `system_dashboard` to the original 12-row seed because it is an action-bearing system surface that also sits outside primary discovery.
|
||||
|
||||
| Surface Key | Surface Name | Page Class | Current State | Planned Closure Decision | Reason Category | Strongest Evidence | Planned Follow-up |
|
||||
|---|---|---|---|---|---|---|---|
|
||||
| `system_dashboard` | `System Console Dashboard` | `App\Filament\System\Pages\Dashboard` | outside primary discovery, not exempt | `separately_governed` | `workflow_specific_governance` | `tests/Feature/System/Spec114/ControlTowerDashboardTest.php`, `tests/Feature/Auth/BreakGlassModeTest.php` | `add_guard_only` |
|
||||
| `system_ops_view_run` | `System Ops View Run` | `App\Filament\System\Pages\Ops\ViewRun` | outside primary discovery, not exempt | `separately_governed` | `system_triage_surface` | `tests/Feature/System/Spec114/OpsTriageActionsTest.php`, `tests/Feature/Guards/Spec194GovernanceActionSemanticsGuardTest.php` | `add_guard_only` |
|
||||
| `system_ops_runbooks` | `System Ops Runbooks` | `App\Filament\System\Pages\Ops\Runbooks` | outside primary discovery, not exempt | `separately_governed` | `workflow_specific_governance` | `tests/Feature/System/OpsRunbooks/FindingsLifecycleBackfillStartTest.php`, `tests/Feature/System/Spec113/AuthorizationSemanticsTest.php`, `tests/Feature/Guards/LivewireTrustedStateGuardTest.php` | `add_guard_only` |
|
||||
| `repair_workspace_owners` | `Repair Workspace Owners` | `App\Filament\System\Pages\RepairWorkspaceOwners` | outside primary discovery, not exempt | `separately_governed` | `break_glass_repair_utility` | `tests/Feature/Auth/BreakGlassWorkspaceOwnerRecoveryTest.php`, `tests/Feature/Guards/FilamentTableStandardsGuardTest.php` | `add_guard_only` |
|
||||
| `system_directory_view_tenant` | `System Directory View Tenant` | `App\Filament\System\Pages\Directory\ViewTenant` | outside primary discovery, not exempt | `harmless_special_case` | `read_mostly_context_detail` | current code is read-mostly with contextual links only | `add_focused_test` |
|
||||
| `system_directory_view_workspace` | `System Directory View Workspace` | `App\Filament\System\Pages\Directory\ViewWorkspace` | outside primary discovery, not exempt | `harmless_special_case` | `read_mostly_context_detail` | current code is read-mostly with contextual links only | `add_focused_test` |
|
||||
| `break_glass_recovery` | `Break Glass Recovery` | `App\Filament\Pages\BreakGlassRecovery` | primary discovered + baseline exempt, but currently inaccessible and actionless | `retired_no_longer_relevant` | `disabled_or_actionless_surface` | current page code: `canAccess() === false`, empty header actions | `tighten_reason` |
|
||||
| `choose_workspace` | `Choose Workspace` | `App\Filament\Pages\ChooseWorkspace` | primary discovered + baseline exempt | `harmless_special_case` | `selector_routing_only` | `tests/Feature/Workspaces/ChooseWorkspacePageTest.php`, `tests/Feature/Workspaces/WorkspaceAuditTrailTest.php` | `none` |
|
||||
| `choose_tenant` | `Choose Tenant` | `App\Filament\Pages\ChooseTenant` | primary discovered + baseline exempt | `harmless_special_case` | `selector_routing_only` | `tests/Feature/Auth/TenantChooserSelectionTest.php`, `tests/Feature/TenantRBAC/TenantSwitcherScopeTest.php` | `none` |
|
||||
| `register_tenant` | `Register Tenant` | `App\Filament\Pages\Tenancy\RegisterTenant` | primary discovered + baseline exempt | `separately_governed` | `registration_form_with_dedicated_rbac` | `tests/Feature/Rbac/RegisterTenantAuthorizationTest.php`, `tests/Feature/TenantRBAC/TenantBootstrapAssignTest.php` | `none` |
|
||||
| `managed_tenant_onboarding_wizard` | `Managed Tenant Onboarding Wizard` | `App\Filament\Pages\Workspaces\ManagedTenantOnboardingWizard` | primary discovered + baseline exempt | `separately_governed` | `workflow_specific_governance` | Spec 172 + extensive onboarding, audit, RBAC, and secret-safety tests | `none` |
|
||||
| `managed_tenants_landing` | `Managed Tenants Landing` | `App\Filament\Pages\Workspaces\ManagedTenantsLanding` | primary discovered + baseline exempt | `harmless_special_case` | `landing_routing_surface` | current page code plus indirect workspace routing coverage | `add_focused_test` |
|
||||
| `tenant_dashboard` | `Tenant Dashboard` | `App\Filament\Pages\TenantDashboard` | primary discovered + baseline exempt | `harmless_special_case` | `dashboard_shell_widget_owned` | `tests/Feature/Filament/TenantDashboardDbOnlyTest.php`, arrival-context and visibility tests | `none` |
|
||||
|
||||
## Discovery Boundary Rules
|
||||
|
||||
### Rule 1 — Generic primary discovery stays declaration-first
|
||||
|
||||
- Resources, relation managers, and normal pages remain discovered through the existing primary validator path.
|
||||
- System pages remain discovered only when they are table-backed and declaration-backed.
|
||||
- Spec 195 does not change that rule; it documents and guards it.
|
||||
|
||||
### Rule 2 — Residual non-discovered system/detail/workflow pages require supplemental closure inventory
|
||||
|
||||
- Any in-scope residual page outside primary discovery must still appear in the Spec 195 inventory.
|
||||
- Being outside primary discovery no longer implies being outside governance.
|
||||
|
||||
### Rule 3 — Baseline exemptions remain only for discovered pages still intentionally outside the generic contract
|
||||
|
||||
- `baseline()` remains the compatibility mechanism for discovered pages without generic declarations.
|
||||
- Spec 195 inventory adds the stronger closure semantics and structured evidence.
|
||||
- Retired pages should leave `baseline()`.
|
||||
|
||||
## Resolution Rules
|
||||
|
||||
1. Every residual surface gets exactly one closure decision.
|
||||
2. Non-enrolled residual surfaces must include a reason category, explicit reason, and at least one structured evidence descriptor.
|
||||
3. Discovered pages that stay outside the generic contract may still remain in `baseline()`, but only if the Spec 195 inventory explains why.
|
||||
4. Non-discovered pages must never rely on `baseline()` alone for closure; the residual inventory is the authoritative closure record.
|
||||
5. `retired_no_longer_relevant` surfaces must not keep active baseline exemptions.
|
||||
6. `harmless_special_case` is reserved for routing-only, read-mostly, or shell-like surfaces whose risk stays low and explicit.
|
||||
7. `separately_governed` is reserved for surfaces with dedicated workflow rules, tests, or guards that already meaningfully constrain behavior.
|
||||
8. If audit reveals a new residual surface outside the initial seed, it must be added to the inventory, contract surface key set, and guard expectations before the feature is considered complete. `system_dashboard` is the first such audited addition in this spec.
|
||||
|
||||
## Safety Rules
|
||||
|
||||
- No residual closure entry may weaken existing route scope, capability enforcement, audit behavior, or confirmation semantics.
|
||||
- No residual surface may be marked harmless merely because it has low coverage; low coverage requires new tests, not a softer category.
|
||||
- No special workflow may remain exempt only by historical memory; explicit evidence is required.
|
||||
- No new residual page in the relevant namespaces may merge without a closure decision and structured evidence.
|
||||
286
specs/195-action-surface-closure/plan.md
Normal file
286
specs/195-action-surface-closure/plan.md
Normal file
@ -0,0 +1,286 @@
|
||||
# Implementation Plan: Action Surface Enforcement, Enrollment, and Exception Closure
|
||||
|
||||
**Branch**: `195-action-surface-closure` | **Date**: 2026-04-12 | **Spec**: `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/specs/195-action-surface-closure/spec.md`
|
||||
**Input**: Feature specification from `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/specs/195-action-surface-closure/spec.md`
|
||||
|
||||
**Note**: This plan keeps the implementation inside the existing Filament v5 / Livewire v4 page layer, the current `ActionSurfaceDiscovery` + `ActionSurfaceValidator` + `ActionSurfaceExemptions` infrastructure, and the current focused RBAC, system-ops, onboarding, chooser, and dashboard test suites. It explicitly avoids adding a new runtime action-surface framework or new persistence.
|
||||
|
||||
## Summary
|
||||
|
||||
Close the residual action-surface governance gap left after Specs 192 to 194 by preserving the current primary discovery boundary, adding one explicit residual-closure inventory for non-discovered and baseline-exempt special surfaces, assigning every remaining residual page exactly one closure decision, tightening stale exemptions, and extending guard coverage so no new action-bearing residual surface can enter the repo without an explicit decision. The plan favors explicit inventory plus focused tests over forcing every system, wizard, selector, or dashboard surface into the generic `actionSurfaceDeclaration()` contract.
|
||||
|
||||
## Technical Context
|
||||
|
||||
**Language/Version**: PHP 8.4.15
|
||||
**Primary Dependencies**: Laravel 12, Filament v5, Livewire v4, Pest v4, Tailwind CSS v4, existing `ActionSurfaceDiscovery`, `ActionSurfaceValidator`, `ActionSurfaceExemptions`, `GovernanceActionCatalog`, `UiEnforcement`, `WorkspaceContext`, and existing system/onboarding/auth helpers
|
||||
**Storage**: PostgreSQL through existing workspace-owned, tenant-owned, and system-visible models; no schema change planned
|
||||
**Testing**: Pest feature tests, existing guard tests, existing Livewire page tests, and focused browser smoke only if a residual surface genuinely needs it; all run through Laravel Sail
|
||||
**Target Platform**: Laravel monolith web application under `apps/platform`, spanning admin `/admin`, tenant-context `/admin/t/{tenant}/...`, and system `/system` surfaces
|
||||
**Project Type**: web application
|
||||
**Performance Goals**: Keep residual-surface validation repo-local and deterministic, preserve DB-only render behavior on existing monitoring and dashboard surfaces, avoid new render-time outbound I/O, and avoid extra polling or runtime indirection
|
||||
**Constraints**: No new persistence, no new action-surface runtime framework, no provider or route-family changes, no authorization-plane changes, no silent exemptions, no weakening of 404/403 semantics, no change to existing destructive-action confirmation or audit behavior, and no new PHP enum unless validator-checked strings prove insufficient
|
||||
**Scale/Scope**: an initial seed of 12 residual target surfaces, plus the additionally audited `system_dashboard` residual and any future residual pages uncovered by audit, including 6 current system/detail or utility surfaces outside primary discovery and not baseline-exempt, plus 7 currently baseline-exempt special flows or dashboard-like surfaces with uneven dedicated 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 governs residual UI enforcement only and does not change inventory, backup, or snapshot truth. |
|
||||
| Read/write separation | PASS | PASS | Residual surfaces reuse existing writes only; confirmation, audit, and focused tests remain unchanged. |
|
||||
| Graph contract path | N/A | N/A | No Graph contract or provider endpoint change is introduced. |
|
||||
| Deterministic capabilities | PASS | PASS | Existing capability registries and server-side checks stay authoritative. |
|
||||
| Workspace + tenant isolation | PASS | PASS | Closure decisions do not widen scope; non-member access remains `404`, member-without-capability remains `403`. |
|
||||
| RBAC-UX authorization semantics | PASS | PASS | Existing Gates, Policies, capability helpers, and destructive confirmations remain in force. |
|
||||
| Run observability / Ops-UX | PASS | PASS | Existing `OperationRun` surfaces and DB-only repairs remain governed exactly as they are today. |
|
||||
| Data minimization | PASS | PASS | No new persistence or mirrored truth is planned; all closure metadata stays derived in code and tests. |
|
||||
| Proportionality / anti-bloat | PASS | PASS | The plan adds one bounded residual inventory and validator pass, not a new framework. |
|
||||
| UI semantics / few layers | PASS | PASS | The solution uses explicit inventory records and tests rather than presenters or a new semantic stack. |
|
||||
| Filament-native UI | PASS | PASS | Existing Filament pages, actions, tables, and page tests remain the implementation path. |
|
||||
| Surface taxonomy / action-surface discipline | PASS | PASS | The plan closes uncatalogued residuals explicitly without redefining Specs 192 to 194. |
|
||||
| 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 panel/provider registration change is planned; Laravel 11+ provider registration remains in `bootstrap/providers.php`. |
|
||||
| Global search hard rule | PASS | PASS | No globally searchable resource is added or modified. |
|
||||
| Destructive action safety | PASS | PASS | Existing destructive or recovery actions keep `->requiresConfirmation()` and current authorization. |
|
||||
| Asset strategy | PASS | PASS | No new global or on-demand assets are required; existing `cd apps/platform && php artisan filament:assets` deploy handling remains sufficient. |
|
||||
|
||||
## Filament-Specific Compliance Notes
|
||||
|
||||
- **Livewire v4.0+ compliance**: The plan stays entirely on Filament v5 + Livewire v4 and introduces no legacy API mix.
|
||||
- **Provider registration location**: No provider changes are required; Laravel 11+ panel providers remain in `bootstrap/providers.php`.
|
||||
- **Global search**: No resource search behavior changes. Residual surfaces are pages, dashboards, selectors, or system utilities, not new searchable resources.
|
||||
- **Destructive actions**: Existing dangerous actions such as `Cancel`, `Repair owner state`, onboarding completion steps, and registration or recovery mutations remain routed through confirmed Filament actions with server-side authorization and existing audit behavior.
|
||||
- **Asset strategy**: No new assets are planned. Existing deployment handling of `cd apps/platform && php artisan filament:assets` remains unchanged.
|
||||
- **Testing plan**: Extend the current guard layer, reuse the existing focused system, auth, onboarding, dashboard, and RBAC suites as explicit coverage evidence, and add only the minimum new tests needed to close weak or currently uncatalogued residuals.
|
||||
|
||||
## Phase 0 Research
|
||||
|
||||
Research outcomes are captured in `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/specs/195-action-surface-closure/research.md`.
|
||||
|
||||
Key decisions:
|
||||
|
||||
- Preserve the current primary discovery boundary instead of auto-discovering every system or workflow page.
|
||||
- Add one parallel `spec195ResidualSurfaceInventory()` to `ActionSurfaceExemptions` rather than rewriting `baseline()` or stretching `ActionSurfaceDeclaration()` to every surface type.
|
||||
- Model closure decisions and reason categories as validator-checked strings in the inventory instead of adding new PHP enums or persistence.
|
||||
- Default residual system pages to `separately_governed` or `harmless_special_case` unless a surface already fits the existing declaration-backed list/detail contract naturally.
|
||||
- Reuse existing dedicated tests as coverage evidence for onboarding, selectors, runbooks, system triage, and dashboard shells; add focused tests only for weakly covered pages such as `ManagedTenantsLanding` and system directory detail pages.
|
||||
- Treat `BreakGlassRecovery` as a stale exemption candidate and retire it if it remains inaccessible and actionless.
|
||||
|
||||
## Phase 1 Design
|
||||
|
||||
Design artifacts are created under `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/specs/195-action-surface-closure/`:
|
||||
|
||||
- `research.md`: decisions and rejected alternatives for residual closure, discovery boundaries, and exemption cleanup
|
||||
- `data-model.md`: derived closure inventory, evidence, and regression-expectation models
|
||||
- `contracts/action-surface-closure.logical.openapi.yaml`: internal logical contract for residual-surface closure decisions and guard expectations
|
||||
- `quickstart.md`: implementation and verification sequence for the feature
|
||||
|
||||
Design highlights:
|
||||
|
||||
- Keep the generic `ActionSurfaceDeclaration()` system limited to the surfaces it already fits well: declaration-backed resources, pages, relation managers, and explicitly enrolled system table pages.
|
||||
- Represent every residual surface through one explicit closure inventory entry recording class, plane, discovery status, closure decision, reason category, explicit reason, structured evidence, and follow-up testing needs.
|
||||
- Keep `ActionSurfaceDiscovery` explicit about what it does and does not discover; close the gap through supplemental validator inventory rather than broad auto-discovery.
|
||||
- Use existing page-local behavior and focused tests for system triage, runbooks, onboarding, chooser flows, and dashboard shells instead of creating shared runtime resolvers.
|
||||
- Remove or reclassify stale baseline exemptions rather than renaming historical drift.
|
||||
|
||||
## Phase 1 — Agent Context Update
|
||||
|
||||
Planned command:
|
||||
|
||||
- `.specify/scripts/bash/update-agent-context.sh copilot`
|
||||
|
||||
This feature does not introduce a new technology stack, but the required agent-context refresh still runs after the technical context and design artifacts are complete.
|
||||
|
||||
## Project Structure
|
||||
|
||||
### Documentation (this feature)
|
||||
|
||||
```text
|
||||
specs/195-action-surface-closure/
|
||||
├── plan.md
|
||||
├── research.md
|
||||
├── data-model.md
|
||||
├── quickstart.md
|
||||
├── spec.md
|
||||
├── contracts/
|
||||
│ └── action-surface-closure.logical.openapi.yaml
|
||||
└── checklists/
|
||||
└── requirements.md
|
||||
```
|
||||
|
||||
### Source Code (repository root)
|
||||
|
||||
```text
|
||||
apps/platform/
|
||||
├── app/
|
||||
│ ├── Filament/
|
||||
│ │ ├── Pages/
|
||||
│ │ │ ├── BreakGlassRecovery.php # AUDIT / likely retire as stale exemption
|
||||
│ │ │ ├── ChooseWorkspace.php # REUSE / classify as harmless special case
|
||||
│ │ │ ├── ChooseTenant.php # REUSE / classify as harmless special case
|
||||
│ │ │ ├── TenantDashboard.php # REUSE / classify page shell explicitly
|
||||
│ │ │ ├── Tenancy/
|
||||
│ │ │ │ └── RegisterTenant.php # REUSE / separately governed
|
||||
│ │ │ └── Workspaces/
|
||||
│ │ │ ├── ManagedTenantOnboardingWizard.php # REUSE / separately governed
|
||||
│ │ │ └── ManagedTenantsLanding.php # AUDIT / likely add focused coverage
|
||||
│ │ └── System/
|
||||
│ │ └── Pages/
|
||||
│ │ ├── RepairWorkspaceOwners.php # AUDIT / separately governed closure
|
||||
│ │ ├── Directory/
|
||||
│ │ │ ├── ViewTenant.php # AUDIT / likely harmless or separate governance
|
||||
│ │ │ └── ViewWorkspace.php # AUDIT / likely harmless or separate governance
|
||||
│ │ └── Ops/
|
||||
│ │ ├── Runbooks.php # REUSE / separately governed closure
|
||||
│ │ └── ViewRun.php # REUSE / separately governed closure
|
||||
│ └── Support/
|
||||
│ └── Ui/
|
||||
│ └── ActionSurface/
|
||||
│ ├── ActionSurfaceDiscovery.php # REUSE / boundary remains explicit
|
||||
│ ├── ActionSurfaceExemptions.php # MODIFY
|
||||
│ └── ActionSurfaceValidator.php # MODIFY
|
||||
└── tests/
|
||||
└── Feature/
|
||||
├── Guards/
|
||||
│ ├── ActionSurfaceContractTest.php # MODIFY
|
||||
│ ├── ActionSurfaceValidatorTest.php # MODIFY
|
||||
│ ├── Spec194GovernanceActionSemanticsGuardTest.php # REUSE
|
||||
│ └── Spec195ResidualActionSurfaceClosureGuardTest.php # NEW
|
||||
├── Auth/
|
||||
│ ├── BreakGlassWorkspaceOwnerRecoveryTest.php # REUSE / possible extend
|
||||
│ └── TenantChooserSelectionTest.php # REUSE
|
||||
├── Workspaces/
|
||||
│ ├── ChooseWorkspacePageTest.php # REUSE
|
||||
│ ├── ManagedTenantsWorkspaceRoutingTest.php # REUSE / possible extend
|
||||
│ └── Spec195ManagedTenantsLandingTest.php # NEW
|
||||
├── Rbac/
|
||||
│ ├── RegisterTenantAuthorizationTest.php # REUSE
|
||||
│ ├── OnboardingWizardUiEnforcementTest.php # REUSE
|
||||
│ └── TenantDashboardArrivalContextVisibilityTest.php # REUSE
|
||||
├── System/
|
||||
│ ├── Spec113/AuthorizationSemanticsTest.php # REUSE / runbooks auth semantics
|
||||
│ ├── Spec114/OpsTriageActionsTest.php # REUSE / system triage semantics
|
||||
│ ├── OpsRunbooks/FindingsLifecycleBackfillStartTest.php # REUSE
|
||||
│ └── Spec195/SystemDirectoryResidualSurfaceTest.php # NEW
|
||||
├── Filament/
|
||||
│ └── TenantDashboardDbOnlyTest.php # REUSE
|
||||
└── Onboarding/
|
||||
└── OnboardingDraftAccessTest.php # REUSE / explicit wizard governance evidence
|
||||
```
|
||||
|
||||
**Structure Decision**: Keep all work inside the existing Laravel/Filament monolith under `apps/platform`. Modify only the existing action-surface support layer plus targeted tests. Do not create a new runtime registry, new persistence, or new shared page abstraction.
|
||||
|
||||
## Complexity Tracking
|
||||
|
||||
| Violation | Why Needed | Simpler Alternative Rejected Because |
|
||||
|-----------|------------|-------------------------------------|
|
||||
| Cross-surface residual closure inventory and reason-category vocabulary (BLOAT-001 trigger) | The feature must explicitly distinguish enrolled, intentionally exempt, separately governed, retired, and harmless residual surfaces across code paths that the primary discovery system does not cover. | Leaving only free-form baseline reason strings and scattered tests would not let CI distinguish stale exemptions, uncatalogued system pages, or legitimate separately governed workflows. |
|
||||
|
||||
## Proportionality Review
|
||||
|
||||
- **Current operator problem**: Reviewers cannot tell whether residual system, utility, workflow, selector, landing, and dashboard surfaces are intentionally outside the generic contract or simply missed by discovery.
|
||||
- **Existing structure is insufficient because**: `ActionSurfaceDiscovery` plus `baseline()` exemptions cover declaration-backed surfaces and a small discovered-exempt set, but they do not explain non-discovered system/detail pages or distinguish harmless, separate, retired, and true exemption states.
|
||||
- **Narrowest correct implementation**: Add one bounded residual inventory plus validator checks, keep the current discovery boundary explicit, reuse existing dedicated test suites as evidence, and add only the minimum new tests for weakly covered residuals.
|
||||
- **Ownership cost created**: One more derived inventory in the action-surface support layer, one new guard test, a few focused closure tests, and ongoing review discipline for future residual pages.
|
||||
- **Alternative intentionally rejected**: Auto-discovering every system and workflow page or forcing every residual surface into `actionSurfaceDeclaration()` was rejected because the current contract is list/detail-oriented and many residual surfaces are legitimate special workflows rather than malformed generic surfaces.
|
||||
- **Release truth**: current-release governance closure and regression prevention
|
||||
|
||||
## Implementation Strategy
|
||||
|
||||
### Phase A — Codify the residual closure inventory and explicit discovery boundary
|
||||
|
||||
Goal: make every residual surface reviewable in CI without widening the runtime framework.
|
||||
|
||||
Changes:
|
||||
|
||||
- Add `spec195ResidualSurfaceInventory()` to `ActionSurfaceExemptions` with one entry per seeded or newly audited residual target surface.
|
||||
- Extend `ActionSurfaceValidator` with Spec 195 validation for allowed closure decisions, allowed reason categories, duplicate keys, required structured evidence, and explicit primary-discovery status.
|
||||
- Keep `ActionSurfaceDiscovery` behavior unchanged, but make the validator assert when a residual surface is outside primary discovery and lacks a supplemental closure decision.
|
||||
- Add `Spec195ResidualActionSurfaceClosureGuardTest.php` and extend existing guard tests so the residual inventory becomes mandatory.
|
||||
- Keep `baseline()` for backward-compatible discovered-page exemptions, but align its live entries with the new Spec 195 inventory.
|
||||
|
||||
Tests:
|
||||
|
||||
- Extend `ActionSurfaceContractTest.php` and `ActionSurfaceValidatorTest.php` with Spec 195 expectations.
|
||||
- Add `Spec195ResidualActionSurfaceClosureGuardTest.php`.
|
||||
|
||||
### Phase B — Close uncatalogued system and utility surfaces
|
||||
|
||||
Goal: explicitly classify the system pages that are currently neither discovered nor baseline-exempt.
|
||||
|
||||
Changes:
|
||||
|
||||
- Classify `Dashboard` as `separately_governed`, backed by the existing control-tower and break-glass test suites rather than forcing it into the generic declaration contract.
|
||||
- Classify `ViewRun` as `separately_governed`, backed by Spec 114 triage tests and Spec 194 governance-action guards.
|
||||
- Classify `Runbooks` as `separately_governed`, backed by Spec 113 auth semantics, runbook start/preflight tests, trusted-state guards, and Ops-UX coverage.
|
||||
- Classify `RepairWorkspaceOwners` as `separately_governed`, backed by break-glass recovery and table-standard tests.
|
||||
- Classify `System Directory ViewTenant` and `System Directory ViewWorkspace` as `harmless_special_case` if they remain read-mostly contextual drilldowns; otherwise promote them to `separately_governed` with focused tests.
|
||||
- Do not force these pages into `actionSurfaceDeclaration()` unless implementation audit finds a natural, already-fitting declaration shape.
|
||||
|
||||
Tests:
|
||||
|
||||
- Reuse `Spec114/OpsTriageActionsTest.php`, `Spec113/AuthorizationSemanticsTest.php`, `FindingsLifecycleBackfillStartTest.php`, and `BreakGlassWorkspaceOwnerRecoveryTest.php`.
|
||||
- Add `Spec195/SystemDirectoryResidualSurfaceTest.php` for the current weakest system-detail coverage.
|
||||
|
||||
### Phase C — Reclassify special workflows, selectors, landings, and dashboard shells
|
||||
|
||||
Goal: turn existing baseline exemptions into explicit closure decisions rather than historical placeholders.
|
||||
|
||||
Changes:
|
||||
|
||||
- Reclassify `BreakGlassRecovery` as `retired_no_longer_relevant` if it remains inaccessible and actionless; otherwise keep it as `intentional_exemption` with a security-flow reason category.
|
||||
- Classify `ChooseWorkspace` and `ChooseTenant` as `harmless_special_case` routing surfaces.
|
||||
- Classify `RegisterTenant` as `separately_governed` because its mutation path, authorization, and bootstrap audit behavior already have focused coverage.
|
||||
- Keep `ManagedTenantOnboardingWizard` as `separately_governed` and explicitly bind it to Spec 172 plus onboarding/RBAC/audit suites.
|
||||
- Classify `ManagedTenantsLanding` explicitly and add focused coverage because it currently has the weakest dedicated test evidence among the special surfaces.
|
||||
- Classify `TenantDashboard` as a `harmless_special_case` page shell or a light `separately_governed` shell, while leaving widget-level governance to the existing widget and arrival-context tests.
|
||||
|
||||
Tests:
|
||||
|
||||
- Reuse chooser, registration, onboarding, and dashboard tests already in `Auth`, `Workspaces`, `Rbac`, `Onboarding`, and `Filament` suites.
|
||||
- Add `Spec195ManagedTenantsLandingTest.php` if current routing coverage is insufficiently explicit for the closure inventory.
|
||||
|
||||
### Phase D — Final guard hardening and verification flow
|
||||
|
||||
Goal: ensure new residual surfaces cannot appear silently after Spec 195 lands.
|
||||
|
||||
Changes:
|
||||
|
||||
- Fail CI when a new residual surface in the relevant namespaces is action-bearing but has no Spec 195 closure inventory entry.
|
||||
- Fail CI when a discovered baseline exemption lacks a reason category or explicit evidence reference in the Spec 195 inventory.
|
||||
- Fail CI when a retired or no-longer-relevant surface still keeps a live baseline exemption.
|
||||
- Run focused verification through Sail and format touched files with Pint.
|
||||
|
||||
Tests:
|
||||
|
||||
- New residual closure guard plus the focused reused suites above.
|
||||
- No full test suite is required to complete the planning phase, but the implementation quickstart defines the minimum targeted verification pack.
|
||||
|
||||
## Risk Assessment
|
||||
|
||||
| Risk | Impact | Likelihood | Mitigation |
|
||||
|------|--------|------------|------------|
|
||||
| Residual closure turns into a second UI framework | Medium | Low | Keep the solution to one derived inventory plus validator checks and focused tests. |
|
||||
| Old exemptions survive with new labels only | High | Medium | Require explicit reason categories, explicit reasons, structured evidence, and stale-entry cleanup in the guard. |
|
||||
| Special workflows are over-normalized into the wrong contract | Medium | Medium | Default special workflows to `separately_governed` or `harmless_special_case` unless the existing declaration model already fits. |
|
||||
| System detail pages remain invisible to reviewers because discovery still skips them | High | Medium | Add explicit residual inventory entries and validator assertions for all out-of-discovery targets. |
|
||||
| ManagedTenantsLanding remains weakly covered and ambiguous | Medium | Medium | Add a focused Spec 195 landing test and explicit classification. |
|
||||
|
||||
## Test Strategy
|
||||
|
||||
- Extend `ActionSurfaceContractTest.php` and `ActionSurfaceValidatorTest.php` so Spec 195 becomes an explicit CI-enforced rule instead of an informal review note.
|
||||
- Add `Spec195ResidualActionSurfaceClosureGuardTest.php` to validate closure completeness, reason-category presence, discovery-state truth, and stale-exemption cleanup.
|
||||
- Reuse existing system triage, runbook, break-glass, chooser, registration, onboarding, and dashboard tests as named coverage evidence for separately governed or harmless surfaces.
|
||||
- Add only the minimum new targeted tests needed for current coverage gaps, expected to be `SystemDirectoryResidualSurfaceTest.php` and `Spec195ManagedTenantsLandingTest.php`.
|
||||
- Keep all verification through Sail and run Pint after focused tests.
|
||||
|
||||
## Constitution Check (Post-Design)
|
||||
|
||||
Re-check result: PASS.
|
||||
|
||||
- Livewire v4.0+ compliance remains intact because all touched surfaces stay inside the existing Filament v5 + Livewire v4 stack.
|
||||
- Provider registration remains unchanged in `bootstrap/providers.php`.
|
||||
- Global search behavior is unchanged because no searchable resource is added or modified.
|
||||
- Destructive and recovery actions keep `->requiresConfirmation()` plus current authorization and audit behavior.
|
||||
- No new assets are introduced; existing `filament:assets` deployment behavior remains sufficient.
|
||||
160
specs/195-action-surface-closure/quickstart.md
Normal file
160
specs/195-action-surface-closure/quickstart.md
Normal file
@ -0,0 +1,160 @@
|
||||
# Quickstart: Action Surface Enforcement, Enrollment, and Exception Closure
|
||||
|
||||
## Goal
|
||||
|
||||
Implement Spec 195 by making every residual action-bearing surface explicitly reviewable without widening the generic action-surface runtime contract.
|
||||
|
||||
## Implementation Sequence
|
||||
|
||||
### 1. Add the Spec 195 residual closure inventory
|
||||
|
||||
Touch:
|
||||
|
||||
- `apps/platform/app/Support/Ui/ActionSurface/ActionSurfaceExemptions.php`
|
||||
- `apps/platform/app/Support/Ui/ActionSurface/ActionSurfaceValidator.php`
|
||||
|
||||
Do:
|
||||
|
||||
- Add `spec195ResidualSurfaceInventory()` with one entry per residual target surface.
|
||||
- Add validator support for:
|
||||
- allowed closure decisions
|
||||
- allowed reason categories
|
||||
- required structured evidence descriptors
|
||||
- duplicate key detection
|
||||
- truthful `discoveryState`
|
||||
- stale baseline-exemption detection
|
||||
|
||||
Do not:
|
||||
|
||||
- auto-discover every system page
|
||||
- introduce new persistence
|
||||
- add a new runtime resolver or registry outside the existing action-surface support layer
|
||||
|
||||
### 2. Close the non-discovered system/detail surfaces explicitly
|
||||
|
||||
Audit and classify:
|
||||
|
||||
- `App\Filament\System\Pages\Dashboard`
|
||||
- `App\Filament\System\Pages\Ops\ViewRun`
|
||||
- `App\Filament\System\Pages\Ops\Runbooks`
|
||||
- `App\Filament\System\Pages\RepairWorkspaceOwners`
|
||||
- `App\Filament\System\Pages\Directory\ViewTenant`
|
||||
- `App\Filament\System\Pages\Directory\ViewWorkspace`
|
||||
|
||||
Expected direction:
|
||||
|
||||
- `Dashboard`, `ViewRun`, `Runbooks`, `RepairWorkspaceOwners` => `separately_governed`
|
||||
- `ViewTenant`, `ViewWorkspace` => `harmless_special_case` if they remain read-mostly contextual drilldowns
|
||||
|
||||
Only move one of these pages into `actionSurfaceDeclaration()` if the implementation audit shows it already fits the existing declaration-backed list/detail model naturally.
|
||||
|
||||
### 3. Reclassify the currently baseline-exempt special pages
|
||||
|
||||
Audit and classify:
|
||||
|
||||
- `App\Filament\Pages\BreakGlassRecovery`
|
||||
- `App\Filament\Pages\ChooseWorkspace`
|
||||
- `App\Filament\Pages\ChooseTenant`
|
||||
- `App\Filament\Pages\Tenancy\RegisterTenant`
|
||||
- `App\Filament\Pages\Workspaces\ManagedTenantOnboardingWizard`
|
||||
- `App\Filament\Pages\Workspaces\ManagedTenantsLanding`
|
||||
- `App\Filament\Pages\TenantDashboard`
|
||||
|
||||
Expected direction:
|
||||
|
||||
- `BreakGlassRecovery` => retire if it remains inaccessible and actionless; otherwise keep it as `intentional_exemption` with `security_flow_exception`
|
||||
- `ChooseWorkspace`, `ChooseTenant` => `harmless_special_case`
|
||||
- `RegisterTenant`, `ManagedTenantOnboardingWizard` => `separately_governed`
|
||||
- `ManagedTenantsLanding` => explicit closure plus focused test
|
||||
- `TenantDashboard` => `harmless_special_case` for the page shell while widget behavior stays governed by existing tests
|
||||
|
||||
### 4. Add CI regression protection
|
||||
|
||||
Touch:
|
||||
|
||||
- `tests/Feature/Guards/ActionSurfaceContractTest.php`
|
||||
- `tests/Feature/Guards/ActionSurfaceValidatorTest.php`
|
||||
- `tests/Feature/Guards/Spec195ResidualActionSurfaceClosureGuardTest.php`
|
||||
|
||||
Do:
|
||||
|
||||
- fail when a residual target has no closure entry
|
||||
- fail when a non-enrolled residual target has no reason category
|
||||
- fail when a discovered residual page remains in `baseline()` but has no matching Spec 195 inventory entry
|
||||
- fail when a retired residual surface still keeps a baseline exemption
|
||||
|
||||
### Reviewer Classification Workflow
|
||||
|
||||
Use this path whenever an audit uncovers a residual page that is not already in the seed inventory.
|
||||
|
||||
1. Identify whether the page is inside primary discovery or outside it.
|
||||
2. Add or update the residual inventory entry with `surfaceKey`, `surfaceName`, `pageClass`, `panelPlane`, `surfaceKind`, `discoveryState`, `closureDecision`, `reasonCategory` where relevant, `explicitReason`, structured `evidence`, and `followUpAction`.
|
||||
3. If the page legitimately fits the generic contract, extend the existing contract tests instead of inventing a new local rule.
|
||||
4. If the page remains separate, harmless, exempt, or retired, add or update focused guard and page-level evidence before merge.
|
||||
5. Run the focused verification pack and confirm the reviewer can classify the page from guard output alone.
|
||||
6. Treat the validator failure output as part of the workflow: missing Spec 195 entries should name the class and the concrete file path reviewers must classify next.
|
||||
|
||||
### 5. Fill current coverage gaps only where needed
|
||||
|
||||
Likely new tests:
|
||||
|
||||
- `tests/Feature/System/Spec195/SystemDirectoryResidualSurfaceTest.php`
|
||||
- `tests/Feature/Workspaces/Spec195ManagedTenantsLandingTest.php`
|
||||
|
||||
Reuse existing evidence instead of duplicating it for:
|
||||
|
||||
- `ViewRun`
|
||||
- `Runbooks`
|
||||
- `RepairWorkspaceOwners`
|
||||
- chooser flows
|
||||
- tenant registration
|
||||
- onboarding wizard
|
||||
- tenant dashboard shell
|
||||
|
||||
## 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/Guards/ActionSurfaceContractTest.php
|
||||
cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Guards/ActionSurfaceValidatorTest.php
|
||||
cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Guards/Spec195ResidualActionSurfaceClosureGuardTest.php
|
||||
cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Guards/Spec194GovernanceActionSemanticsGuardTest.php
|
||||
cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Guards/LivewireTrustedStateGuardTest.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/Feature/System/Spec113/AuthorizationSemanticsTest.php
|
||||
cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/System/Spec114/OpsTriageActionsTest.php
|
||||
cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/System/OpsRunbooks/FindingsLifecycleBackfillStartTest.php
|
||||
cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Auth/BreakGlassWorkspaceOwnerRecoveryTest.php
|
||||
cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Auth/TenantChooserSelectionTest.php
|
||||
cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Workspaces/ChooseWorkspacePageTest.php
|
||||
cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Workspaces/WorkspaceAuditTrailTest.php
|
||||
cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/TenantRBAC/TenantSwitcherScopeTest.php
|
||||
cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Rbac/RegisterTenantAuthorizationTest.php
|
||||
cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/TenantRBAC/TenantBootstrapAssignTest.php
|
||||
cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Rbac/OnboardingWizardUiEnforcementTest.php
|
||||
cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Onboarding/OnboardingDraftAccessTest.php
|
||||
cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Filament/TenantDashboardDbOnlyTest.php
|
||||
cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Rbac/TenantDashboardArrivalContextVisibilityTest.php
|
||||
cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/System/Spec195/SystemDirectoryResidualSurfaceTest.php
|
||||
cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Workspaces/Spec195ManagedTenantsLandingTest.php
|
||||
cd apps/platform && ./vendor/bin/sail bin pint --dirty --format agent
|
||||
```
|
||||
|
||||
## Manual Review Checklist
|
||||
|
||||
1. Confirm that the primary discovery count does not grow unexpectedly when Spec 195 lands.
|
||||
2. Confirm that every in-scope residual surface has exactly one closure decision.
|
||||
3. Confirm that non-discovered system/detail pages are now reviewable through the residual inventory and guard output.
|
||||
4. Confirm that discovered special pages still exempted from the generic contract now carry explicit reason categories, explicit reasons, and structured evidence.
|
||||
5. Confirm that `BreakGlassRecovery` is either retired from active exemptions or explicitly kept as `intentional_exemption` with `security_flow_exception` and current evidence.
|
||||
6. Confirm that the weakest surfaces, expected to be `ManagedTenantsLanding` and system directory detail pages, have explicit focused tests.
|
||||
7. Confirm that any newly audited residual surface beyond the initial seed was added to the inventory, contract, and guard expectations.
|
||||
|
||||
## 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` deploy handling remains sufficient but unchanged.
|
||||
103
specs/195-action-surface-closure/research.md
Normal file
103
specs/195-action-surface-closure/research.md
Normal file
@ -0,0 +1,103 @@
|
||||
# Research: Action Surface Enforcement, Enrollment, and Exception Closure
|
||||
|
||||
## Decision: Preserve the current primary discovery boundary and close the gap with a supplemental residual inventory
|
||||
|
||||
### Rationale
|
||||
|
||||
`ActionSurfaceDiscovery` already has a clear shape: it discovers resources, relation managers, normal pages, and system pages only when those system pages are declaration-backed table surfaces. The real problem in Spec 195 is not that this boundary exists, but that the repo does not currently make the boundary explicit enough for residual system/detail/workflow surfaces that live outside it.
|
||||
|
||||
Keeping the primary discovery boundary stable avoids turning the generic contract into a catch-all framework and lets Spec 195 solve the actual problem: uncatalogued outliers.
|
||||
|
||||
### Alternatives considered
|
||||
|
||||
- Auto-discover every class under `app/Filament/System/Pages`: rejected because many of those pages are not list/detail contract surfaces and would force the generic contract into shapes it does not currently model well.
|
||||
- Expand `ActionSurfaceDiscovery` to scan every wizard, dashboard, and selector page in all namespaces: rejected because it would blur the difference between generic contract coverage and legitimate special workflows.
|
||||
|
||||
## Decision: Add a parallel `spec195ResidualSurfaceInventory()` instead of refactoring `baseline()` or stretching `ActionSurfaceDeclaration()`
|
||||
|
||||
### Rationale
|
||||
|
||||
The existing `baseline()` API is a simple string-reason allowlist for discovered pages that intentionally lack declarations. It is useful, but too narrow for Spec 195 because Spec 195 must also classify non-discovered system/detail pages and distinguish closure outcomes like `separately_governed`, `harmless_special_case`, and `retired_no_longer_relevant`.
|
||||
|
||||
The narrowest solution is to add one parallel inventory specifically for residual closure. That keeps the current baseline exemption behavior stable while giving the validator the structured data it needs.
|
||||
|
||||
### Alternatives considered
|
||||
|
||||
- Change `baseline()` into a structured object registry: rejected because it would create avoidable churn in existing tests and validator behavior for a problem that only Spec 195 needs to solve.
|
||||
- Encode the entire Spec 195 closure model inside `ActionSurfaceDeclaration()`: rejected because many residual surfaces are not natural declaration-backed list/detail surfaces.
|
||||
|
||||
## Decision: Represent closure decisions and reason categories as validator-checked strings, not new PHP enums or persisted data
|
||||
|
||||
### Rationale
|
||||
|
||||
Spec 195 adds review and CI truth, not product-domain behavior. The closure states matter for planning and enforcement, but they do not need to become persisted entities or first-class runtime business state.
|
||||
|
||||
Using validator-checked string values in the inventory mirrors the existing Spec 192 and Spec 193 inventory style and avoids adding new runtime types whose only purpose would be internal categorization.
|
||||
|
||||
### Alternatives considered
|
||||
|
||||
- Add new PHP enums for closure decisions and reason categories: rejected because the validator can enforce the allowed string values without importing extra runtime structure.
|
||||
- Persist residual closure rows in the database: rejected because this is repository governance truth, not user-facing data truth.
|
||||
|
||||
## Decision: Default residual system pages to `separately_governed` or `harmless_special_case` instead of forcing generic contract enrollment
|
||||
|
||||
### Rationale
|
||||
|
||||
The system residuals called out by the spec do not currently behave like the declaration-backed table/resource surfaces that Specs 192 and 193 govern. `ViewRun` is a system decision detail page, `Runbooks` is a workflow utility hub, `RepairWorkspaceOwners` is a break-glass repair utility, and the directory detail pages are read-mostly context pages.
|
||||
|
||||
Existing focused tests already exercise many of these surfaces directly. The narrowest correct implementation is therefore explicit classification plus explicit evidence, not generic normalization for its own sake.
|
||||
|
||||
### Alternatives considered
|
||||
|
||||
- Enroll `ViewRun`, `Runbooks`, and `RepairWorkspaceOwners` into the current `actionSurfaceDeclaration()` system immediately: rejected because the current contract is list/detail-slot oriented and would fit some of these surfaces awkwardly.
|
||||
- Leave the system pages uncatalogued because dedicated tests already exist: rejected because that is exactly the gray zone Spec 195 exists to close.
|
||||
|
||||
## Decision: Use existing focused test suites as closure evidence and add only gap tests
|
||||
|
||||
### Rationale
|
||||
|
||||
The repo already has strong dedicated coverage for `Runbooks`, `ViewRun`, `RepairWorkspaceOwners`, registration, choosers, onboarding, and dashboard behavior. Spec 195 should leverage that fact instead of duplicating equivalent tests under a new surface framework.
|
||||
|
||||
The only clear weak spot from current evidence is `ManagedTenantsLanding`, and system directory detail pages also need more explicit closure-level assertions than they have today.
|
||||
|
||||
### Alternatives considered
|
||||
|
||||
- Add one brand-new comprehensive browser suite over every residual surface: rejected because many of these surfaces are already deeply covered through feature or Livewire tests.
|
||||
- Add only inventory validation with no page-level follow-up: rejected because the weakest residuals still need focused proof.
|
||||
|
||||
## Decision: Treat `BreakGlassRecovery` as a stale-exemption candidate rather than assuming it is still an active governed surface
|
||||
|
||||
### Rationale
|
||||
|
||||
The current `BreakGlassRecovery` page has `canAccess()` returning false and no header actions. That is strong evidence that it may no longer be an action-bearing surface in the way the old baseline exemption reason implies.
|
||||
|
||||
Spec 195 should explicitly verify whether it is still a live residual surface. If not, it should be retired from the active exemption set instead of remaining as historical noise.
|
||||
|
||||
### Alternatives considered
|
||||
|
||||
- Keep the existing exemption reason unchanged because it references dedicated security specs: rejected because the current code suggests the page may no longer be a live action surface.
|
||||
- Force the page into the residual inventory as a live intentional exemption without re-auditing the code: rejected because stale exemptions are one of the spec’s explicit problems.
|
||||
|
||||
## Decision: Keep selectors and dashboard shells outside the generic contract but classify them explicitly
|
||||
|
||||
### Rationale
|
||||
|
||||
`ChooseWorkspace`, `ChooseTenant`, and `TenantDashboard` are real operator surfaces, but they are not contract-style list/detail pages in the sense governed by the earlier specs. Selectors are routing surfaces; the dashboard page is a shell whose meaningful actions live in widgets and downstream routes.
|
||||
|
||||
Spec 195 should classify them explicitly so they are no longer invisible to review, while still avoiding artificial normalization.
|
||||
|
||||
### Alternatives considered
|
||||
|
||||
- Treat selectors and dashboards as non-surfaces and ignore them: rejected because they clearly influence operator workflows and currently appear in the residual exemption tail.
|
||||
- Enroll them in the generic contract anyway: rejected because the generic contract is not the right fit for routing-only or widget-shell surfaces.
|
||||
|
||||
## Decision: No new provider, asset, route, or persistence work is needed
|
||||
|
||||
### Rationale
|
||||
|
||||
All evidence points to Spec 195 being a repository-governance and test-hardening slice. The existing pages, routes, panels, and tests already provide the runtime behavior. The missing part is explicit closure inventory and regression enforcement.
|
||||
|
||||
### Alternatives considered
|
||||
|
||||
- Add a new service provider or config file just for residual-surface closure: rejected because the existing action-surface support layer already provides the correct home.
|
||||
- Add new assets or UI primitives for special surfaces: rejected because the implementation does not need new rendering infrastructure.
|
||||
323
specs/195-action-surface-closure/spec.md
Normal file
323
specs/195-action-surface-closure/spec.md
Normal file
@ -0,0 +1,323 @@
|
||||
# Feature Specification: Action Surface Enforcement, Enrollment, and Exception Closure
|
||||
|
||||
**Feature Branch**: `195-action-surface-closure`
|
||||
**Created**: 2026-04-12
|
||||
**Status**: Proposed
|
||||
**Input**: User description: "Spec 195 - Action Surface Enforcement, Enrollment & Exception Closure"
|
||||
|
||||
## Spec Candidate Check *(mandatory — SPEC-GATE-001)*
|
||||
|
||||
- **Problem**: After Specs 192, 193, and 194, the remaining action-bearing system, utility, flow, landing, and special workflow surfaces are not all clearly inside one reviewable regime. Some are covered by the generic action-surface contract, some are protected by focused rules elsewhere, and some still sit in historical gray zones.
|
||||
- **Today's failure**: Reviewers cannot always tell whether a residual surface is intentionally enrolled, intentionally exempt, separately governed, retired, or simply missed by discovery. This leaves room for silent outliers and historical exemptions to bypass the central guard.
|
||||
- **User-visible improvement**: Operators and reviewers get a clean closure state for every remaining residual surface. Sensitive system and utility actions stop living in undocumented gray zones, while already good special surfaces remain allowed without being silently outside discipline.
|
||||
- **Smallest enterprise-capable version**: Build one residual inventory, assign exactly one closure decision to every remaining outlier, harden discovery and exemption boundaries, and add lightweight regression protection so new outliers cannot appear silently.
|
||||
- **Explicit non-goals**: No new header hierarchy rules, no new monitoring semantics, no new governance-friction taxonomy beyond what Spec 194 already owns, no universal dashboard or widget framework, no large UI redesign, and no blanket removal of all exemptions without surface-by-surface review.
|
||||
- **Permanent complexity imported**: A small closure-decision vocabulary, explicit exemption reason categories, a reviewable residual inventory, clearer discovery boundaries, and focused regression tests.
|
||||
- **Why now**: The UX and action semantics block is already defined in Specs 192 to 194. What remains is the technical governance closure needed so the repo can stop carrying silent edge cases.
|
||||
- **Why not local**: Local cleanup on one page at a time would not prevent future surfaces, hidden discovery gaps, or inherited exemptions from drifting outside the contract again.
|
||||
- **Approval class**: Core Enterprise
|
||||
- **Red flags triggered**: Cross-surface governance breadth risk and taxonomy creep risk. Defense: the spec is explicitly limited to residual closure, does not introduce a new product workflow, and preserves legitimate separately governed surfaces instead of forcing uniformity.
|
||||
- **Score**: Nutzen: 2 | Dringlichkeit: 2 | Scope: 2 | Komplexitaet: 1 | Produktnaehe: 2 | Wiederverwendung: 2 | **Gesamt: 11/12**
|
||||
- **Decision**: approve
|
||||
|
||||
## Spec Scope Fields *(mandatory)*
|
||||
|
||||
- **Scope**: canonical-view
|
||||
- **Primary Routes**:
|
||||
- Existing system operations run detail and runbooks surfaces
|
||||
- Existing system repair utilities for workspace owner recovery
|
||||
- Existing system directory tenant and workspace detail surfaces
|
||||
- Existing break-glass recovery, chooser, and registration flows
|
||||
- Existing managed-tenant onboarding, landing, and dashboard surfaces
|
||||
- Any residual surface still represented in the current action-surface discovery, exemption, or guard inventory after Specs 192 to 194
|
||||
- **Data Ownership**:
|
||||
- This feature introduces no new domain tables, records, or business entities.
|
||||
- Existing tenant-owned, workspace-owned, and system-visible records remain owned exactly as they are today.
|
||||
- Closure decisions, exemption reasons, and discovery boundaries are review and enforcement truth only; they do not introduce a new user-facing data model.
|
||||
- **RBAC**:
|
||||
- Tenant admin surfaces continue to require tenant membership plus the existing tenant-scoped capabilities.
|
||||
- Workspace admin surfaces continue to require workspace membership plus the existing workspace-scoped capabilities.
|
||||
- System surfaces continue to require platform or system capabilities.
|
||||
- This feature does not widen access; it only forces explicit governance classification for residual action-bearing surfaces.
|
||||
|
||||
For canonical-view specs, the spec MUST define:
|
||||
|
||||
- **Default filter behavior when tenant-context is active**: Tenant-context surfaces remain strictly bound to the active tenant. Workspace surfaces may retain an entitled tenant context as a quiet scope signal or filter, but the closure decision for a surface must never depend on a hidden tenant context. System surfaces never inherit tenant context as an authorization expansion.
|
||||
- **Explicit entitlement checks preventing cross-tenant leakage**: Discovery, enrollment, or exemption review may inventory surfaces across tenant, workspace, and system planes, but any enrolled or separately governed surface must continue to use the current deny-as-not-found and capability checks. Review artifacts must classify surfaces without exposing inaccessible tenant or workspace detail.
|
||||
|
||||
## 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 |
|
||||
|---|---|---|---|---|---|---|---|
|
||||
| System Ops ViewRun | Primary Decision Surface | Decide whether a system run needs retry, cancellation, investigation, or only inspection | Run identity, current outcome, intervention availability, and safety context | Runbooks, deep diagnostics, related operational history | Primary because this is the system-level intervention point for one active run | Follows operations triage, not generic record browsing | Removes ambiguity about whether run interventions are governed or accidental utilities |
|
||||
| System Ops Runbooks | Secondary Context Surface | Choose an approved operational response path after diagnosis | Available runbook choices, current system scope, and whether action launch is allowed | Procedure detail and historical execution context | Not primary because it supports a later intervention decision instead of owning the first diagnosis | Follows guided operational response | Prevents runbook launch actions from living as undocumented side utilities |
|
||||
| Repair Workspace Owners | Primary Decision Surface | Repair a broken ownership state such as missing or duplicate ownership | Workspace identity, defect state, and the currently allowed repair | Membership evidence, prior repair history, and audit context | Primary because the surface exists to support a sensitive repair decision | Follows workspace recovery workflow | Prevents dangerous repair actions from living in historical exception space |
|
||||
| System Directory tenant and workspace detail | Secondary Context Surface | Inspect system-level directory context and open the correct downstream surface | Scope identity, current state, and whether the page is read-only or action-bearing | Related downstream admin context and supporting diagnostics | Not primary because these pages are mostly inspection-first unless a small safe action exists | Follows inspection and routing workflow | Keeps read-mostly system detail pages calm while still classifying any light actions |
|
||||
| Break Glass Recovery | Primary Decision Surface | Recover operator access during an exceptional lockout or recovery event | Recovery state, available recovery path, and safety warnings | Supporting diagnostics and deeper recovery evidence | Primary because the whole surface exists for a formal exceptional-access decision | Follows emergency access recovery workflow | Makes clear that this is a governed exception rather than an accidental bypass surface |
|
||||
| ChooseWorkspace and ChooseTenant | Secondary Context Surface | Choose the correct scope before continuing work | Available scopes and why each scope is selectable | Downstream page detail only after selection | Not primary because selection is routing, not governance mutation | Follows scope entry workflow | Prevents selector surfaces from being silently ignored just because they are not record pages |
|
||||
| RegisterTenant and ManagedTenantOnboardingWizard | Primary Decision Surface | Advance or complete tenant registration and onboarding safely | Current step, blocking prerequisites, and next required action | Supporting evidence, validation detail, and downstream setup context | Primary because the operator is making guided setup decisions step by step | Follows onboarding workflow | Keeps wizard exceptions explicit instead of forcing them into a record-page model |
|
||||
| ManagedTenantsLanding and TenantDashboard | Secondary Context Surface | Open the right tenant or next task from a scoped landing surface | Scope summary, key status, and next safe navigation choice | Deeper evidence and operational detail only after drilldown | Not primary because these are arrival and routing surfaces, not the final decision point | Follows landing and context-setting workflow | Prevents dashboard and landing actions from remaining unclassified just because they are broad entry points |
|
||||
|
||||
## 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 |
|
||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
||||
| System Ops ViewRun | Detail / Decision | System run triage detail | Retry, cancel, mark investigated, or open related runbook | Direct detail page for one run | forbidden | Supporting links and runbooks stay secondary or grouped by meaning | Cancel and other strongest interventions stay visibly separated and confirmed | Existing system operations collection route | Existing system run detail route | System scope, run state, intervention eligibility | Operations / system run | Whether intervention is possible now and which intervention is justified | Must end as enrolled or separately governed; no silent special case |
|
||||
| System Ops Runbooks | Utility / Workflow hub | Guided system intervention utility | Open the appropriate runbook | Direct runbooks surface with explicit open action | forbidden | Supporting utilities stay grouped and subordinate to runbook choice | Any dangerous launch or escalation remains separated within the workflow | Existing system runbooks entry route | Same runbooks surface or guided runbook drilldown | System scope and current operational context | Runbooks / runbook | Which governed intervention paths are available | Separately governed is allowed if it is explicit and tested |
|
||||
| Repair Workspace Owners | Utility / Repair | Sensitive repair utility | Repair missing or duplicate ownership state | Direct repair utility page | forbidden | Refresh and diagnostics stay quiet and secondary | Repair mutations remain isolated, confirmed, and capability-gated | Existing system repair utilities entry route | Same repair surface | Workspace identity and defect state | Workspace ownership repair | Whether a dangerous repair is warranted | Cannot remain a historical or implicit exemption |
|
||||
| System Directory ViewTenant | Detail / Context | System directory tenant detail | Open related admin context or inspect state | Direct tenant detail page | forbidden | Context links remain secondary | No destructive action unless separately justified | Existing system directory tenant collection route | Existing system directory tenant detail route | System scope and tenant identity | Directory tenant | Whether the page is read-only, safe, or mutation-bearing | Harmless special case or separately governed if a light action remains |
|
||||
| System Directory ViewWorkspace | Detail / Context | System directory workspace detail | Open related admin context or inspect state | Direct workspace detail page | forbidden | Context links remain secondary | No destructive action unless separately justified | Existing system directory workspace collection route | Existing system directory workspace detail route | System scope and workspace identity | Directory workspace | Whether the page is read-only, safe, or mutation-bearing | Harmless special case or separately governed if a light action remains |
|
||||
| Break Glass Recovery | Workflow / Exception | Exceptional recovery workflow | Continue governed access recovery | Guided recovery flow | forbidden | Supporting diagnostics and help links stay secondary | Recovery mutations remain isolated and confirmed | Existing break-glass recovery entry route | Same recovery workflow route | Recovery state and access block reason | Break glass recovery | Whether safe recovery is available now | Separate governance is acceptable because this is an exceptional workflow |
|
||||
| ChooseWorkspace and ChooseTenant | Workflow / Selector | Scope selection surface | Choose scope and continue | Row, tile, or explicit select action | required | Navigation and help stay secondary | none | Existing chooser route | Downstream chosen workspace or tenant route | Available scope only | Workspace chooser / tenant chooser | Which scopes are available and selectable | Intentional exemption or harmless special case is acceptable if explicitly recorded |
|
||||
| RegisterTenant and ManagedTenantOnboardingWizard | Workflow / Wizard | Guided onboarding workflow | Continue the next required setup step | Step-based wizard progression | forbidden | Supporting validation and help stay secondary | Any irreversible setup step remains confirmed and authorized | Existing registration or onboarding entry route | Existing onboarding wizard route | Workspace or tenant scope, current step, prerequisite state | Tenant registration / onboarding | Which prerequisite blocks the next step | Separate governance is acceptable because the wizard already owns focused workflow rules |
|
||||
| ManagedTenantsLanding and TenantDashboard | Landing / Dashboard | Scoped landing and routing surface | Open the next tenant or next task | Card, row, or explicit open action | allowed | Navigation shortcuts remain contextual and subordinate to scope summary | none unless a direct mutation exists and is explicitly governed | Existing managed-tenants landing or dashboard route | Downstream tenant detail, onboarding, or task route | Active workspace or tenant scope | Managed tenants / tenant dashboard | What needs attention next without pretending the landing is the final decision point | Separately governed or harmless special case, but never silent |
|
||||
|
||||
## 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 |
|
||||
|---|---|---|---|---|---|---|---|---|---|---|
|
||||
| System Ops ViewRun | Platform operator | Decide whether to intervene on one run | System run triage detail | Does this run need action now, and which action is justified? | Run identity, outcome, retryability, cancellation state, investigation need | Runbooks, linked runs, and deeper diagnostics | execution outcome, lifecycle attention, retryability | Existing run mutation scope only | Retry, Resume, Mark investigated when allowed | Cancel and equivalent hard interventions |
|
||||
| System Ops Runbooks | Platform operator | Choose an approved intervention path | Guided system utility | Which runbook matches the current operational need? | Runbook choices and current context | Procedure detail and downstream execution evidence | intervention readiness, operational context | Existing system utility scope only | Open runbook or start guided intervention | Any launch that performs a strong intervention remains separated |
|
||||
| Repair Workspace Owners | Platform or workspace recovery operator | Repair broken workspace ownership | Repair utility | Is the workspace ownership state broken enough to justify repair? | Missing-owner or duplicate-owner truth and currently allowed repair | Membership detail and prior recovery evidence | defect state, repair eligibility | TenantPilot only | Repair or merge actions when justified | All repair mutations are dangerous and confirmed |
|
||||
| System Directory ViewTenant | Platform operator | Inspect system-level tenant context | Context detail | Is this a read-only inspection surface or a small safe context surface? | Tenant identity, state, and related context | Deeper downstream admin detail | lifecycle or presence state only if relevant | read-only unless a light action is explicitly allowed | Open related admin context | none by default |
|
||||
| System Directory ViewWorkspace | Platform operator | Inspect system-level workspace context | Context detail | Is this a read-only inspection surface or a small safe context surface? | Workspace identity, state, and related context | Deeper downstream admin detail | lifecycle or presence state only if relevant | read-only unless a light action is explicitly allowed | Open related admin context | none by default |
|
||||
| Break Glass Recovery | Exceptional access operator | Recover access safely during lockout or recovery | Exceptional recovery workflow | Which recovery step is allowed now, and what risk does it carry? | Recovery state, available path, and safety warning | Deeper evidence and supporting diagnostics | recovery readiness, access state | TenantPilot only | Continue recovery or confirm recovery step | Any access-granting recovery mutation |
|
||||
| ChooseWorkspace and ChooseTenant | Operator entering scope | Choose the correct scope and continue | Selector surface | Which scope can I enter now? | Available scopes and selection affordance | none until downstream navigation | scope availability only | no mutation beyond selection | Select scope | none |
|
||||
| RegisterTenant and ManagedTenantOnboardingWizard | Workspace operator | Advance registration and onboarding | Guided onboarding workflow | What step is blocked, and what must I do next? | Current step, missing prerequisite, next allowed action | Supporting validation detail | prerequisite readiness, onboarding progress | TenantPilot only | Continue, validate, submit when allowed | Any irreversible registration or setup completion step |
|
||||
| ManagedTenantsLanding and TenantDashboard | Workspace or tenant operator | Route into the right next task | Landing and dashboard surface | What needs attention next, and where should I open it? | Scope summary, next task, current state highlights | Deeper operational evidence after drilldown | readiness, attention state, lifecycle highlights | Usually read-only routing; explicit if any direct mutation exists | Open tenant, continue onboarding, open next task | none by default |
|
||||
|
||||
## Proportionality Review *(mandatory when structural complexity is introduced)*
|
||||
|
||||
- **New source of truth?**: no
|
||||
- **New persisted entity/table/artifact?**: no
|
||||
- **New abstraction?**: yes
|
||||
- **New enum/state/reason family?**: yes
|
||||
- **New cross-domain UI framework/taxonomy?**: yes
|
||||
- **Current operator problem**: The repo still has a small but important tail of residual action-bearing surfaces where nobody can quickly tell whether the generic contract applies, whether a focused exception is legitimate, or whether a surface simply escaped discovery.
|
||||
- **Existing structure is insufficient because**: Specs 192 to 194 define the behavior of governed surfaces, but they do not close the remaining gap between rulebook and discovery coverage. Without explicit closure decisions, historical exemptions and discovery limits can continue to create silent outliers.
|
||||
- **Narrowest correct implementation**: Add one residual inventory, one explicit closure-decision matrix, minimal exemption reason categories, and lightweight regression guards. Do not create a new runtime workflow engine, persistence model, or broad UI framework.
|
||||
- **Ownership cost**: Ongoing review of new residual surfaces, upkeep of explicit exemption reasons, a small amount of CI guard maintenance, and focused tests that prove closure decisions remain intentional.
|
||||
- **Alternative intentionally rejected**: Pure page-by-page cleanup was rejected because it would leave discovery boundaries, exemptions, and future outliers unresolved. Blanket enrollment of every special surface was rejected because it would destroy legitimate separately governed patterns without improving safety.
|
||||
- **Release truth**: current-release governance closure and regression prevention
|
||||
|
||||
## User Scenarios & Testing *(mandatory)*
|
||||
|
||||
### User Story 1 - Close the residual inventory (Priority: P1)
|
||||
|
||||
As a reviewer, I want every remaining action-bearing surface after Specs 192 to 194 to appear in one closure inventory with exactly one final decision, so no gray zone remains.
|
||||
|
||||
**Why this priority**: This is the core purpose of the spec. If any residual surface stays ambiguous, the closure is incomplete.
|
||||
|
||||
**Independent Test**: Review the residual inventory alone and verify that every listed surface has exactly one closure decision plus a rationale or coverage note.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** the repo after Specs 192 to 194, **When** the residual inventory is reviewed, **Then** every in-scope residual surface appears exactly once.
|
||||
2. **Given** an inventoried residual surface, **When** a reviewer checks its record, **Then** the surface is classified as enrolled, intentionally exempt, separately governed, retired, or harmless special case with no ambiguity.
|
||||
|
||||
---
|
||||
|
||||
### User Story 2 - Remove silent system and utility exceptions (Priority: P1)
|
||||
|
||||
As a platform operator, I want sensitive system and utility surfaces to have an explicit governance status, so dangerous actions do not live outside review discipline by accident.
|
||||
|
||||
**Why this priority**: The riskiest remaining residuals are not ordinary record pages. If these surfaces remain implicit exceptions, the main safety gap stays open.
|
||||
|
||||
**Independent Test**: Review the named high-risk system and utility surfaces and verify that each one is either enrolled in the generic contract or explicitly handled as a justified separate case.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** a sensitive residual system or utility surface, **When** its closure decision is reviewed, **Then** the surface no longer relies on an implied or historical exemption.
|
||||
2. **Given** a residual surface stays outside the generic contract, **When** the reviewer checks why, **Then** the reason and separate coverage are explicit and reviewable.
|
||||
|
||||
---
|
||||
|
||||
### User Story 3 - Keep only justified exemptions (Priority: P2)
|
||||
|
||||
As a code reviewer, I want every remaining exemption to carry a reason category and coverage note, so I can distinguish a justified special case from leftover drift.
|
||||
|
||||
**Why this priority**: Exemptions are acceptable only when they are conscious, minimal, and reviewable.
|
||||
|
||||
**Independent Test**: Review the exemption list alone and confirm that each remaining exemption has a short reason category, a clear closure decision, and a note about where its safety or behavior is covered.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** an existing exemption entry, **When** it is reviewed under Spec 195, **Then** it either gains an explicit reason category and coverage note or it is removed.
|
||||
2. **Given** an exemption no longer reflects a real residual surface, **When** the closure pass is complete, **Then** that exemption no longer remains in the active inventory.
|
||||
|
||||
---
|
||||
|
||||
### User Story 4 - Block future unclassified residuals (Priority: P2)
|
||||
|
||||
As a future implementer, I want a lightweight guard and review path that fail fast when a new residual surface or exemption appears without classification, so the repo cannot drift back into silent exceptions.
|
||||
|
||||
**Why this priority**: The spec only closes the block if it stays closed.
|
||||
|
||||
**Independent Test**: Add a representative new residual surface or exemption in a controlled test path and verify that review guidance or CI fails until a closure decision is supplied.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** a newly added action-bearing residual surface, **When** it lacks enrollment or closure classification, **Then** the guard fails.
|
||||
2. **Given** a newly added exemption entry, **When** it lacks a reason category or coverage note, **Then** the guard fails.
|
||||
|
||||
---
|
||||
|
||||
### User Story 5 - Preserve good special surfaces without forced churn (Priority: P3)
|
||||
|
||||
As a product reviewer, I want already well-governed special surfaces to remain outside the generic contract when that is the cleanest choice, so closure does not become needless uniformity.
|
||||
|
||||
**Why this priority**: The spec is about removing gray zones, not punishing legitimate special workflows.
|
||||
|
||||
**Independent Test**: Review an existing wizard, recovery workflow, or dashboard surface that already has focused coverage and verify that it can remain separately governed without being mislabeled as a defect.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** a residual surface already covered by dedicated rules and focused tests, **When** Spec 195 is applied, **Then** the surface may remain separately governed with an explicit note.
|
||||
2. **Given** a harmless read-mostly special case, **When** it is reviewed, **Then** it is recorded as such instead of being forced into the generic contract for aesthetic symmetry.
|
||||
|
||||
### Edge Cases
|
||||
|
||||
- A surface may expose only one apparently harmless action such as routing or light inspection; it still must be explicitly classified and cannot stay outside the inventory by assumption alone.
|
||||
- A wizard, landing, or dashboard may live outside the default discovery path; if it is action-bearing, the discovery boundary must still explain how it is classified.
|
||||
- A historical exemption may point to a surface that is no longer action-bearing or no longer exists; it must be retired instead of remaining as dead noise.
|
||||
- A read-mostly system detail surface may later gain a mutating action; the regression guard must force a new closure review at that moment.
|
||||
- A surface may appear to fit multiple closure categories; the final inventory must still choose exactly one category.
|
||||
- A surface may be separately governed because its actions already have focused tests; that must be recorded explicitly rather than inferred from institutional memory.
|
||||
|
||||
## Requirements *(mandatory)*
|
||||
|
||||
**Constitution alignment (required):** This feature introduces no new Microsoft Graph contract, no new user-facing workflow domain, and no new persisted truth. It classifies and hardens existing action-bearing surfaces and their review path. Existing write actions keep their current preview, confirmation, audit, and run-observability behavior. Any sensitive DB-only repair or recovery action that intentionally skips `OperationRun` must remain auditable and explicitly recorded as separately governed or intentionally exempt.
|
||||
|
||||
**Constitution alignment (PROP-001 / ABSTR-001 / PERSIST-001 / STATE-001 / BLOAT-001):** This feature deliberately adds only the smallest cross-cutting layer required now: a residual closure inventory, a closure-decision vocabulary, explicit exemption reason categories, and regression guards. It does not add new persistence, a generic action framework, or new business states.
|
||||
|
||||
**Constitution alignment (OPS-UX):** Existing actions that already create or reuse `OperationRun` keep their current run lifecycle and notification semantics. Spec 195 only requires that any residual surface remaining outside the generic contract still points to focused run or audit coverage where relevant, so separate governance is never a trust gap.
|
||||
|
||||
**Constitution alignment (RBAC-UX):** The affected authorization planes are workspace admin `/admin`, tenant-context admin `/admin/t/{tenant}/...`, and platform `/system`. Non-members or users lacking entitled scope remain `404`, members lacking capability remain `403`, and moving a surface into enrolled, exempt, or separately governed status does not change server-side authorization. Destructive actions remain confirmed and capability-gated. Regression coverage must include at least one positive and one negative authorization path across residual surfaces touched by the closure pass.
|
||||
|
||||
**Constitution alignment (OPS-EX-AUTH-001):** Not applicable. This feature does not change authentication handshake behavior.
|
||||
|
||||
**Constitution alignment (BADGE-001):** This feature does not introduce a new badge domain. Existing badge semantics remain centralized. Any residual surface that displays status continues to use the current centralized status language.
|
||||
|
||||
**Constitution alignment (UI-FIL-001):** Any UI remediation under this spec continues to use native Filament pages, actions, grouped actions, and existing enforcement helpers. The feature must not introduce a local button framework, page-local danger language, or new styling vocabulary. Approved exceptions remain explicit special workflows such as wizards, recovery flows, or guided utilities.
|
||||
|
||||
**Constitution alignment (UI-NAMING-001):** Existing operator verbs such as `Retry`, `Cancel`, `Mark investigated`, `Repair`, `Merge`, `Recover access`, `Select`, `Register tenant`, and `Continue onboarding` must remain domain-first and consistent across buttons, confirmation copy, notifications, and audit prose. Closure categories such as enrolled or separately governed are review vocabulary, not new operator-facing nouns.
|
||||
|
||||
**Constitution alignment (DECIDE-001):** Every affected residual surface must declare whether it is a Primary Decision Surface, Secondary Context Surface, or a low-risk special surface. The first-decision information for each primary surface must remain visible by default, while diagnostics and deep evidence remain on demand. Separate governance is valid only when one operator task still remains clear inside that surface's own workflow.
|
||||
|
||||
**Constitution alignment (UI-CONST-001 / UI-SURF-001 / ACTSURF-001 / UI-HARD-001 / UI-EX-001 / UI-REVIEW-001 / HDR-001):** This spec classifies each residual surface by action-surface class, detailed surface type, likely next action, inspect model, row-click policy, action placement, canonical route family, scope signals, canonical noun, and exception rationale. Residual surfaces may differ by type, but none may remain uncatalogued.
|
||||
|
||||
**Constitution alignment (ACTSURF-001 - action hierarchy):** Where residual surfaces expose header, row, bulk, selector, or workflow actions, navigation, mutation, contextual signals, and dangerous actions must remain structurally separated. Any grouped action set must remain meaningful rather than a mixed catch-all. System, wizard, recovery, and dashboard exceptions are acceptable only when they are genuine workflow types, not convenience shortcuts.
|
||||
|
||||
**Constitution alignment (OPSURF-001):** Default-visible content on residual surfaces must stay operator-first. System and utility surfaces must show the current operational truth before asking the operator to act. Any mutating action must continue to communicate its scope before execution, and dangerous actions must keep the existing safe-execution pattern of context, safety checks, confirmation, and execution.
|
||||
|
||||
**Constitution alignment (UI-SEM-001 / LAYER-001 / TEST-TRUTH-001):** Direct mapping from page class to contract coverage is insufficient because residual surfaces can fall outside the generic discovery path. The new closure layer must therefore remain explicit and reviewable, but it must avoid duplicating runtime truth or creating a separate user-facing model. Tests should prove that residual surfaces are classified and guarded, not just that a thin registry exists.
|
||||
|
||||
**Constitution alignment (Filament Action Surfaces):** The Action Surface Contract must be explicitly marked as satisfied for any residual surface that enters the generic contract. Surfaces that remain outside it must cite an explicit exemption or separate-governance rationale. Each affected surface must still have one primary inspect or open model, no redundant View affordance, no empty grouped-action placeholder, and destructive actions that continue to use confirmed execution. UI-FIL-001 remains satisfied because the spec reuses native Filament surfaces and explicit exception handling rather than inventing new local primitives.
|
||||
|
||||
**Constitution alignment (UX-001 — Layout & Information Architecture):** This feature is a governance and closure spec, not a layout rewrite. Existing views, forms, wizards, and dashboards keep their current page composition unless a residual surface needs a small targeted alignment to meet the already established action-surface rules. No surface is rebuilt purely for visual symmetry.
|
||||
|
||||
### Closure Principles
|
||||
|
||||
1. **No silent residual surfaces**: Every action-bearing residual surface must be enrolled, intentionally exempt, separately governed, retired, or harmless.
|
||||
2. **Exemption is a decision, not an accident**: An exemption is valid only when it is explicit, justified, minimal, and reviewable.
|
||||
3. **Discovery boundaries must be explicit**: If the generic discovery path cannot see a surface class, that limit must be modeled and protected rather than treated as a repo accident.
|
||||
4. **Separate coverage counts**: A surface may stay outside the generic contract if dedicated specs, tests, or focused guards already protect it sufficiently.
|
||||
5. **No forced sameness**: Good special workflows do not need to be flattened into the generic contract when separate governance is the cleaner and safer answer.
|
||||
6. **Closure over expansion**: This spec closes the remaining block after Specs 192 to 194; it does not open a new architecture program.
|
||||
|
||||
### Closure Decision Matrix
|
||||
|
||||
- **Generic contract enrollment**: The surface is brought into the generic action-surface contract and must satisfy the earlier rules directly.
|
||||
- **Intentional exemption**: The surface stays outside the generic contract with a short, reviewable reason category.
|
||||
- **Separately governed**: The surface stays outside the generic contract because focused specs, tests, or guards already govern it sufficiently.
|
||||
- **Retired / no longer relevant**: The surface is no longer an active residual and must leave the live inventory.
|
||||
- **Harmless special case**: The surface is action-bearing but small, low-risk, and intentionally classified as not needing the full generic contract.
|
||||
|
||||
### Functional Requirements
|
||||
|
||||
- **FR-195-001 Residual inventory**: The repo MUST maintain one complete inventory of every remaining action-bearing residual surface still outside clearly settled coverage from Specs 192 to 194.
|
||||
- **FR-195-002 Exact closure decision**: Every inventoried residual surface MUST have exactly one closure decision: `generic contract enrollment`, `intentional exemption`, `separately governed`, `retired / no longer relevant`, or `harmless special case`.
|
||||
- **FR-195-003 No gray-zone rule**: No action-bearing residual surface in scope MAY remain undocumented, unclassified, or implicitly outside governance.
|
||||
- **FR-195-004 Residual review scope**: The closure pass MUST explicitly review the known residual areas, including system run detail, system runbooks, workspace-owner repair, system directory detail surfaces, break-glass recovery, chooser flows, registration and onboarding flows, landing surfaces, dashboard surfaces, and any equivalent residual surface represented in the current inventory or guard path.
|
||||
- **FR-195-005 System and utility decision rule**: Every residual system or utility surface with mutating actions MUST be either enrolled in the generic contract or explicitly recorded as intentionally exempt or separately governed with clear rationale.
|
||||
- **FR-195-006 Exemption minimization**: Historical or diffuse exemptions that no longer represent a justified residual surface MUST be removed or reclassified.
|
||||
- **FR-195-007 Exemption reason categories**: Every remaining exemption MUST carry one short reason category explaining why enrollment is not the correct closure decision.
|
||||
- **FR-195-008 Separate-governance recognition**: A residual surface MAY remain outside the generic contract only when the inventory explicitly records the dedicated spec, focused tests, or guard path that already govern it.
|
||||
- **FR-195-009 Harmless special-case discipline**: A surface MAY be classified as a harmless special case only when its action scope is limited, its operational risk is low, and that judgment is explicit in the inventory.
|
||||
- **FR-195-010 Discovery-boundary explicitness**: The generic discovery path MUST define its boundaries clearly enough that a reviewer can tell which surface classes are discovered automatically and which require supplemental enrollment or explicit outside-contract classification.
|
||||
- **FR-195-011 No discovery accident**: A residual surface MUST NOT bypass governance solely because it lives in a system panel, wizard, selector, recovery flow, landing surface, dashboard, or another namespace outside the default discovery roots.
|
||||
- **FR-195-012 System-panel closure**: Residual system-panel surfaces MUST explicitly declare whether they are decision surfaces, context surfaces, or separately governed utilities, and any dangerous actions on them MUST remain classified and reviewable.
|
||||
- **FR-195-013 Flow and wizard closure**: Registration, onboarding, chooser, and recovery flows MAY remain outside the generic contract, but their closure decision and focused coverage MUST be explicit.
|
||||
- **FR-195-014 Landing and dashboard closure**: Landing and dashboard surfaces with actions MUST be explicitly classified as enrolled, separately governed, or harmless; being broad or summary-oriented is not itself an exemption.
|
||||
- **FR-195-015 Retired-surface cleanup**: If an exempted or inventoried surface is retired or no longer action-bearing, it MUST be removed from the live residual inventory so dead entries cannot mask real gaps.
|
||||
- **FR-195-016 Review artifact completeness**: Each residual inventory entry MUST record the surface name, authorization plane, surface type, closure decision, reason category where relevant, and where its coverage lives.
|
||||
- **FR-195-017 Guard against unclassified surfaces**: The repo MUST add or extend a lightweight guard that fails when a new action-bearing residual surface appears without an explicit closure decision.
|
||||
- **FR-195-018 Guard against reasonless exemptions**: The repo MUST add or extend a lightweight guard that fails when a new exemption appears without a reason category and coverage note.
|
||||
- **FR-195-019 Review path clarity**: Contributor and reviewer workflows MUST make it obvious how to classify a new residual surface before merge.
|
||||
- **FR-195-020 No forced normalization**: Residual surfaces that are already safely separately governed MUST NOT be forced into the generic contract solely to reduce taxonomy variety.
|
||||
- **FR-195-021 Contract continuity for enrolled surfaces**: Any residual surface moved into the generic contract MUST satisfy the already established contract from the earlier action-surface specs rather than introducing a new local rule set.
|
||||
- **FR-195-022 Dedicated coverage proof**: Any surface marked intentionally exempt, separately governed, or harmless special case MUST have explicit rationale and enough focused coverage to make the decision reviewable in CI and code review.
|
||||
- **FR-195-023 Authorization continuity**: Closure classification, discovery hardening, or exemption cleanup MUST NOT weaken route scope, capability enforcement, deny-as-not-found behavior, or destructive-action confirmation.
|
||||
- **FR-195-024 Audit and safety continuity**: Residual destructive or recovery actions MUST remain auditable, confirmed, and safety-gated regardless of whether the surface is enrolled, exempt, or separately governed.
|
||||
- **FR-195-025 Block completion**: After Spec 195, the residual action-surface block following Specs 192 to 194 MUST have no remaining action-bearing surface without a visible closure decision.
|
||||
|
||||
## Target Outcomes by Key Residual Area
|
||||
|
||||
- **System Ops ViewRun**: No longer a silent special case. It ends either as a generic-contract surface or as an explicitly separately governed system triage surface with focused coverage.
|
||||
- **System Ops Runbooks**: Operational utilities are classified intentionally instead of existing as factual but ungoverned launch points.
|
||||
- **Repair Workspace Owners**: A risky repair utility receives an explicit closure state and does not remain as an inherited historical exception.
|
||||
- **System Directory tenant and workspace detail**: Read-mostly detail pages are explicitly classified as harmless, enrolled, or separately governed rather than left unreviewed.
|
||||
- **Break Glass Recovery**: The exceptional-access workflow remains legitimate only if its special handling is explicit and reviewable.
|
||||
- **Chooser, registration, onboarding, landing, and dashboard surfaces**: Every such surface receives a closure decision so routing and workflow entry points are no longer missing from the action-surface map.
|
||||
|
||||
## Non-Goals
|
||||
|
||||
- Creating a new fourth main rule set after Specs 192 to 194
|
||||
- Redefining header hierarchy, monitoring semantics, or governance-friction classes
|
||||
- Building a universal widget, dashboard, or workflow meta-contract
|
||||
- Rewriting already calm surfaces for stylistic consistency alone
|
||||
- Removing every exemption without checking whether the exemption is legitimate
|
||||
- Introducing new business entities, new workflow states, or new operator concepts unrelated to closure
|
||||
|
||||
## Assumptions
|
||||
|
||||
- Specs 192, 193, and 194 remain the authoritative sources for surface behavior; Spec 195 only closes the residual governance gap around discovery, enrollment, exemptions, and regression protection.
|
||||
- Some recovery, wizard, selector, and dashboard surfaces will remain outside the generic contract because they are inherently special workflow types.
|
||||
- Existing action semantics, authorization logic, audit behavior, and run-observability rules on the underlying actions are already correct and will be preserved rather than redesigned here.
|
||||
- The cleanest outcome is explicit closure, not universal enrollment.
|
||||
|
||||
## Dependencies
|
||||
|
||||
- Constitution rule set for Action Surface Discipline
|
||||
- Spec 192 - Record Page Header Discipline and Contextual Navigation
|
||||
- Spec 193 - Monitoring Surface Action Hierarchy and Workbench Semantics
|
||||
- Spec 194 - Governance Friction Hardening and Operator Vocabulary
|
||||
|
||||
## 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 |
|
||||
|---|---|---|---|---|---|---|---|---|---|---|
|
||||
| System Ops ViewRun | Existing system run detail page | `Refresh`, `Retry` or `Resume` when applicable, `Open runbooks`, `Mark investigated`, `Cancel` when applicable | not applicable | none | none | not applicable | One run intervention may be primary if justified; strongest intervention stays separated | not applicable | yes for interventions | Must be enrolled or separately governed; no silent exemption |
|
||||
| System Ops Runbooks | Existing system runbooks surface | `Open runbook`, guided intervention entry points, quiet navigation | Explicit open action or card selection | `Open runbook` | none | Context-specific single CTA if no runbooks apply | Same guided utility actions | not applicable | yes when a launch mutates state | Separate governance is acceptable if explicit and tested |
|
||||
| Repair Workspace Owners | Existing system repair utility | `Refresh diagnosis`, `Repair owner state`, `Merge duplicate ownership` | not applicable | none | none | not applicable | Repair actions remain grouped and confirmed | not applicable | yes | High-risk utility; must not remain historical exception |
|
||||
| System Directory ViewTenant and ViewWorkspace | Existing system directory detail pages | Quiet contextual links only unless a light safe action exists | not applicable | none | none | not applicable | Read-mostly contextual actions only | not applicable | no for read-only, yes if mutation exists | Candidate harmless special case or separate governance |
|
||||
| Break Glass Recovery | Existing break-glass workflow | `Continue recovery`, `Confirm recovery step`, `Cancel` or equivalent safe exit | Guided step progression | none | none | Single recovery CTA when entering the flow | Recovery-step actions only | Wizard navigation rather than save/cancel | yes | Explicit separate governance expected |
|
||||
| ChooseWorkspace and ChooseTenant | Existing selector surfaces | No competing header mutations; quiet back/help only | Row, tile, or select action is the primary inspect/open model | `Select` | none | Single CTA only when no accessible scopes are available | not applicable | not applicable | no | Intentional exemption or harmless special case acceptable if explicit |
|
||||
| RegisterTenant and ManagedTenantOnboardingWizard | Existing registration and onboarding surfaces | `Continue`, `Validate`, `Submit`, `Cancel` as step-appropriate | Step progression inside the wizard | none | none | Single start CTA from entry state | Wizard step actions only | Save/continue and cancel where the workflow uses form steps | yes for mutating steps | Separate governance expected because the workflow already owns its path |
|
||||
| ManagedTenantsLanding and TenantDashboard | Existing landing and dashboard surfaces | `Open tenant`, `Continue onboarding`, `Open next task` where present | Card, row, or explicit open affordance | One safe shortcut at most | none | One CTA when the landing is otherwise empty | Contextual navigation only | not applicable | usually no, unless a direct mutation exists | Must be explicitly classified as separately governed or harmless if not enrolled |
|
||||
|
||||
### Key Entities *(include if feature involves data)*
|
||||
|
||||
- **Residual Action Surface**: An operator-facing surface that still carries actions after Specs 192 to 194 and therefore requires an explicit closure decision.
|
||||
- **Closure Decision**: The single final classification for a residual surface: generic contract enrollment, intentional exemption, separately governed, retired / no longer relevant, or harmless special case.
|
||||
- **Exemption Reason Category**: A short explanation that makes an exemption reviewable and keeps it from becoming a catch-all for unresolved drift.
|
||||
- **Discovery Boundary**: The explicit statement of which surface classes the generic discovery path can see automatically and which require supplemental handling.
|
||||
|
||||
## Success Criteria *(mandatory)*
|
||||
|
||||
### Measurable Outcomes
|
||||
|
||||
- **SC-001**: 100% of residual action-bearing surfaces remaining after Specs 192 to 194 are listed in the closure inventory with exactly one final closure decision.
|
||||
- **SC-002**: 0 active exemption entries remain without a reason category and explicit coverage note.
|
||||
- **SC-003**: The regression guard fails on every representative test case where a new residual surface or exemption is introduced without classification.
|
||||
- **SC-004**: A reviewer can determine, from the inventory and focused coverage alone, whether any residual surface is enrolled, intentionally exempt, separately governed, retired, or harmless without reconstructing intent from code history.
|
||||
183
specs/195-action-surface-closure/tasks.md
Normal file
183
specs/195-action-surface-closure/tasks.md
Normal file
@ -0,0 +1,183 @@
|
||||
---
|
||||
|
||||
description: "Task list for Spec 195 action-surface closure implementation"
|
||||
|
||||
---
|
||||
|
||||
# Tasks: Action Surface Enforcement, Enrollment, and Exception Closure
|
||||
|
||||
**Input**: Design documents from `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/specs/195-action-surface-closure/`
|
||||
**Prerequisites**: plan.md, spec.md, research.md, data-model.md, quickstart.md, contracts/action-surface-closure.logical.openapi.yaml
|
||||
|
||||
**Tests**: Runtime behavior changes in this repo require Pest coverage. This task list includes guard, feature, and focused residual-surface tests.
|
||||
**Operations**: This feature does not add new long-running or queued work. Existing `OperationRun` and audit behavior on residual surfaces must remain unchanged.
|
||||
**RBAC**: Existing 404 vs 403 semantics, capability checks, and destructive-action confirmations remain mandatory across the admin, tenant-context admin, and system planes.
|
||||
**UI Naming**: No new operator-facing vocabulary is introduced; existing action copy remains domain-first and consistent.
|
||||
**Operator Surfaces**: The affected surfaces are already classified in the spec and must be kept aligned with those classifications during implementation.
|
||||
**Filament UI Action Surfaces**: This feature governs existing Filament pages and utilities without introducing a new page framework.
|
||||
**Proportionality / Anti-Bloat**: Keep the implementation to one bounded residual inventory, validator checks, and focused tests. Do not add new persistence, enums, or runtime registries unless the implementation proves they are unavoidable.
|
||||
|
||||
## Phase 1: Setup (Shared Review Inputs)
|
||||
|
||||
**Purpose**: Confirm the current residual-surface scope and evidence sources before editing the shared support layer.
|
||||
|
||||
- [X] T001 Audit the current primary discovery and exemption entry points in `apps/platform/app/Support/Ui/ActionSurface/ActionSurfaceDiscovery.php`, `apps/platform/app/Support/Ui/ActionSurface/ActionSurfaceExemptions.php`, and `apps/platform/app/Support/Ui/ActionSurface/ActionSurfaceValidator.php`
|
||||
- [X] T002 [P] Audit the existing focused evidence sources for residual surfaces in `apps/platform/tests/Feature/System/Spec114/OpsTriageActionsTest.php`, `apps/platform/tests/Feature/System/OpsRunbooks/FindingsLifecycleBackfillStartTest.php`, `apps/platform/tests/Feature/Auth/BreakGlassWorkspaceOwnerRecoveryTest.php`, `apps/platform/tests/Feature/Auth/TenantChooserSelectionTest.php`, `apps/platform/tests/Feature/Rbac/RegisterTenantAuthorizationTest.php`, `apps/platform/tests/Feature/Rbac/OnboardingWizardUiEnforcementTest.php`, and `apps/platform/tests/Feature/Filament/TenantDashboardDbOnlyTest.php`
|
||||
|
||||
---
|
||||
|
||||
## Phase 2: Foundational (Blocking Prerequisites)
|
||||
|
||||
**Purpose**: Add the shared Spec 195 inventory and validation scaffolding that all user stories depend on.
|
||||
|
||||
**⚠️ CRITICAL**: No user story work should begin until this phase is complete.
|
||||
|
||||
- [X] T003 [P] Add shared residual-inventory schema assertions for Spec 195 in `apps/platform/tests/Feature/Guards/ActionSurfaceValidatorTest.php`
|
||||
- [X] T004 [P] Add shared contract-harness expectations for Spec 195 residual inventory consumption in `apps/platform/tests/Feature/Guards/ActionSurfaceContractTest.php`
|
||||
- [X] T005 Implement the `spec195ResidualSurfaceInventory()` skeleton, required `surfaceName` field, and allowed closure vocabulary in `apps/platform/app/Support/Ui/ActionSurface/ActionSurfaceExemptions.php`
|
||||
- [X] T006 Implement a validator-side residual-candidate boundary helper that preserves the existing primary discovery contract in `apps/platform/app/Support/Ui/ActionSurface/ActionSurfaceValidator.php`
|
||||
- [X] T007 Extend `apps/platform/app/Support/Ui/ActionSurface/ActionSurfaceValidator.php` with reusable Spec 195 schema validation for allowed values, duplicate keys, evidence presence, and truthful discovery state
|
||||
|
||||
**Checkpoint**: Shared residual inventory and validator scaffolding are ready; user story work can now proceed.
|
||||
|
||||
---
|
||||
|
||||
## Phase 3: User Story 1 - Close the residual inventory (Priority: P1) 🎯 MVP
|
||||
|
||||
**Goal**: Make every in-scope residual surface appear exactly once with one closure decision, reason category where needed, and explicit evidence.
|
||||
|
||||
**Independent Test**: Review the final residual inventory and guard output alone and verify that the initial seed plus any additional audited residual surfaces have exactly one decision, no duplicates, and no missing evidence.
|
||||
|
||||
### Tests for User Story 1
|
||||
|
||||
- [X] T008 [P] [US1] Add completeness expectations for all Spec 195 residual surface keys in `apps/platform/tests/Feature/Guards/ActionSurfaceValidatorTest.php`
|
||||
- [X] T009 [P] [US1] Add contract assertions for unique closure decisions, structured evidence descriptors, and baseline-alignment expectations in `apps/platform/tests/Feature/Guards/ActionSurfaceContractTest.php`
|
||||
|
||||
### Implementation for User Story 1
|
||||
|
||||
- [X] T010 [US1] Populate the initial seed and any newly audited Spec 195 residual closure entries with `surfaceName`, discovery state, closure decision, reason category, `explicitReason`, structured `evidence`, and `followUpAction` in `apps/platform/app/Support/Ui/ActionSurface/ActionSurfaceExemptions.php`
|
||||
- [X] T011 [US1] Wire Spec 195 residual inventory consumption into `apps/platform/app/Support/Ui/ActionSurface/ActionSurfaceValidator.php` so every residual surface is checked exactly once
|
||||
|
||||
**Checkpoint**: User Story 1 is complete when the repo has a single complete Spec 195 inventory and its completeness is guard-tested.
|
||||
|
||||
---
|
||||
|
||||
## Phase 4: User Story 2 - Remove silent system and utility exceptions (Priority: P1)
|
||||
|
||||
**Goal**: Explicitly classify the currently uncatalogued system and utility surfaces so dangerous or decision-bearing system pages no longer live outside review discipline.
|
||||
|
||||
**Independent Test**: Review the system residual entries plus focused system tests and verify that `ViewRun`, `Runbooks`, `RepairWorkspaceOwners`, `ViewTenant`, and `ViewWorkspace` all have explicit closure states and no silent fallback path.
|
||||
|
||||
### Tests for User Story 2
|
||||
|
||||
- [X] T012 [P] [US2] Add residual guard coverage for `App\Filament\System\Pages\Ops\ViewRun`, `App\Filament\System\Pages\Ops\Runbooks`, and `App\Filament\System\Pages\RepairWorkspaceOwners` in `apps/platform/tests/Feature/Guards/Spec195ResidualActionSurfaceClosureGuardTest.php`
|
||||
- [X] T013 [P] [US2] Add focused read-mostly closure coverage for `App\Filament\System\Pages\Directory\ViewTenant` and `App\Filament\System\Pages\Directory\ViewWorkspace` in `apps/platform/tests/Feature/System/Spec195/SystemDirectoryResidualSurfaceTest.php`
|
||||
|
||||
### Implementation for User Story 2
|
||||
|
||||
- [X] T014 [US2] Audit `apps/platform/app/Filament/System/Pages/Ops/ViewRun.php`, `apps/platform/app/Filament/System/Pages/Ops/Runbooks.php`, and `apps/platform/app/Filament/System/Pages/RepairWorkspaceOwners.php` and, if implementation reality differs from the design seed, update the matching entries in `apps/platform/app/Support/Ui/ActionSurface/ActionSurfaceExemptions.php`, `apps/platform/app/Support/Ui/ActionSurface/ActionSurfaceValidator.php`, and `apps/platform/tests/Feature/Guards/Spec195ResidualActionSurfaceClosureGuardTest.php`
|
||||
- [X] T015 [US2] Audit `apps/platform/app/Filament/System/Pages/Directory/ViewTenant.php` and `apps/platform/app/Filament/System/Pages/Directory/ViewWorkspace.php` and, if the pages are not truly read-mostly harmless cases, update the matching entries in `apps/platform/app/Support/Ui/ActionSurface/ActionSurfaceExemptions.php`, `apps/platform/app/Support/Ui/ActionSurface/ActionSurfaceValidator.php`, and `apps/platform/tests/Feature/System/Spec195/SystemDirectoryResidualSurfaceTest.php`
|
||||
- [X] T016 [US2] Tighten `apps/platform/app/Support/Ui/ActionSurface/ActionSurfaceValidator.php` so outside-primary-discovery system and utility pages cannot pass without Spec 195 closure data and, if any audited system page is promoted into generic contract enrollment, extend `apps/platform/tests/Feature/Guards/ActionSurfaceContractTest.php` and `apps/platform/tests/Feature/Guards/ActionSurfaceValidatorTest.php` to enforce inherited contract continuity
|
||||
|
||||
**Checkpoint**: User Story 2 is complete when the system/detail residual tail is explicitly classified and validated instead of relying on discovery accidents.
|
||||
|
||||
---
|
||||
|
||||
## Phase 5: User Story 3 - Keep only justified exemptions (Priority: P2)
|
||||
|
||||
**Goal**: Remove stale exemptions and require every remaining discovered exception to carry an explicit reason category and coverage note.
|
||||
|
||||
**Independent Test**: Review the discovered-page exemption set alone and verify that every remaining entry has a reason category, explicit reason, structured evidence, and no stale `BreakGlassRecovery` carry-over.
|
||||
|
||||
### Tests for User Story 3
|
||||
|
||||
- [X] T017 [P] [US3] Add stale-exemption and reason-category failure cases in `apps/platform/tests/Feature/Guards/Spec195ResidualActionSurfaceClosureGuardTest.php`
|
||||
- [X] T018 [P] [US3] Add stale-exemption and baseline-removal assertions for `apps/platform/app/Filament/Pages/BreakGlassRecovery.php` in `apps/platform/tests/Feature/Guards/Spec195ResidualActionSurfaceClosureGuardTest.php` and `apps/platform/tests/Feature/Guards/ActionSurfaceContractTest.php`
|
||||
|
||||
### Implementation for User Story 3
|
||||
|
||||
- [X] T019 [US3] Audit `apps/platform/app/Filament/Pages/BreakGlassRecovery.php` and either retire it from live baseline handling or keep it as a live `intentional_exemption` with `security_flow_exception` in `apps/platform/app/Support/Ui/ActionSurface/ActionSurfaceExemptions.php`, `apps/platform/app/Support/Ui/ActionSurface/ActionSurfaceValidator.php`, and `apps/platform/tests/Feature/Guards/Spec195ResidualActionSurfaceClosureGuardTest.php`
|
||||
- [X] T020 [US3] Tighten stale-baseline cleanup rules for retired or reasonless discovered exceptions in `apps/platform/app/Support/Ui/ActionSurface/ActionSurfaceExemptions.php` and `apps/platform/app/Support/Ui/ActionSurface/ActionSurfaceValidator.php`
|
||||
- [X] T021 [US3] Extend `apps/platform/app/Support/Ui/ActionSurface/ActionSurfaceValidator.php` to reject reasonless discovered exemptions and retired surfaces that still remain in `baseline()`
|
||||
|
||||
**Checkpoint**: User Story 3 is complete when every surviving discovered-page exception is explicit, reasoned, and evidence-backed.
|
||||
|
||||
---
|
||||
|
||||
## Phase 6: User Story 4 - Block future unclassified residuals (Priority: P2)
|
||||
|
||||
**Goal**: Fail fast when future residual surfaces or exemptions appear without an explicit closure decision.
|
||||
|
||||
**Independent Test**: Introduce representative missing-classification and missing-reason cases in the guard suite and verify CI-style failures occur with actionable output.
|
||||
|
||||
### Tests for User Story 4
|
||||
|
||||
- [X] T022 [P] [US4] Create regression cases for missing closure decision, missing reason category, missing structured evidence, and stale baseline entries in `apps/platform/tests/Feature/Guards/Spec195ResidualActionSurfaceClosureGuardTest.php`
|
||||
- [X] T023 [P] [US4] Extend `apps/platform/tests/Feature/Guards/ActionSurfaceContractTest.php` with a representative uncatalogued residual-surface failure path
|
||||
|
||||
### Implementation for User Story 4
|
||||
|
||||
- [X] T024 [US4] Extend `apps/platform/app/Support/Ui/ActionSurface/ActionSurfaceValidator.php` with namespace-scoped residual-candidate checks that preserve the existing generic discovery boundary and require Spec 195 inventory coverage for qualifying pages
|
||||
- [X] T025 [US4] Improve `apps/platform/app/Support/Ui/ActionSurface/ActionSurfaceValidator.php` failure output and update `specs/195-action-surface-closure/quickstart.md` so missing Spec 195 classifications report actionable class and file context plus the reviewer classification workflow
|
||||
|
||||
**Checkpoint**: User Story 4 is complete when new residual surfaces or exemptions cannot merge silently without a closure decision.
|
||||
|
||||
---
|
||||
|
||||
## Phase 7: User Story 5 - Preserve good special surfaces without forced churn (Priority: P3)
|
||||
|
||||
**Goal**: Keep legitimately special selector, registration, onboarding, landing, and dashboard surfaces outside the generic contract where that is the safer and clearer choice.
|
||||
|
||||
**Independent Test**: Review the special-surface entries plus focused tests and verify that selectors, onboarding, registration, landing, and dashboard shells can remain separately governed or harmless without being mislabeled as defects.
|
||||
|
||||
### Tests for User Story 5
|
||||
|
||||
- [X] T026 [P] [US5] Create focused landing-surface coverage in `apps/platform/tests/Feature/Workspaces/Spec195ManagedTenantsLandingTest.php`
|
||||
- [X] T027 [P] [US5] Extend special-surface evidence coverage with explicit positive and negative authorization paths in `apps/platform/tests/Feature/Rbac/RegisterTenantAuthorizationTest.php`, `apps/platform/tests/Feature/Rbac/OnboardingWizardUiEnforcementTest.php`, and `apps/platform/tests/Feature/Filament/TenantDashboardDbOnlyTest.php`
|
||||
- [X] T028 [P] [US5] Extend selector-surface evidence coverage in `apps/platform/tests/Feature/Workspaces/ChooseWorkspacePageTest.php` and `apps/platform/tests/Feature/Auth/TenantChooserSelectionTest.php`
|
||||
|
||||
### Implementation for User Story 5
|
||||
|
||||
- [X] T029 [US5] If audit changes the design-seed classification for `apps/platform/app/Filament/Pages/ChooseWorkspace.php`, `apps/platform/app/Filament/Pages/ChooseTenant.php`, `apps/platform/app/Filament/Pages/TenantDashboard.php`, `apps/platform/app/Filament/Pages/Tenancy/RegisterTenant.php`, `apps/platform/app/Filament/Pages/Workspaces/ManagedTenantOnboardingWizard.php`, or `apps/platform/app/Filament/Pages/Workspaces/ManagedTenantsLanding.php`, update the matching entry in `apps/platform/app/Support/Ui/ActionSurface/ActionSurfaceExemptions.php` and the paired expectation in `apps/platform/tests/Feature/Guards/Spec195ResidualActionSurfaceClosureGuardTest.php`
|
||||
- [X] T030 [US5] Align `apps/platform/app/Support/Ui/ActionSurface/ActionSurfaceValidator.php` so discovered special surfaces can remain `separately_governed` or `harmless_special_case` without forced `actionSurfaceDeclaration()` enrollment
|
||||
|
||||
**Checkpoint**: User Story 5 is complete when good special surfaces remain explicitly governed without unnecessary normalization.
|
||||
|
||||
---
|
||||
|
||||
## Phase 8: Polish & Cross-Cutting Verification
|
||||
|
||||
**Purpose**: Run the focused verification pack, format the touched files, and confirm the implementation matches the planning contract.
|
||||
|
||||
- [X] T031 Run the focused Spec 195 Sail verification pack from `specs/195-action-surface-closure/quickstart.md` against `apps/platform/tests/Feature/Guards/ActionSurfaceContractTest.php`, `apps/platform/tests/Feature/Guards/ActionSurfaceValidatorTest.php`, `apps/platform/tests/Feature/Guards/Spec195ResidualActionSurfaceClosureGuardTest.php`, `apps/platform/tests/Feature/Guards/Spec194GovernanceActionSemanticsGuardTest.php`, `apps/platform/tests/Feature/Guards/LivewireTrustedStateGuardTest.php`, `apps/platform/tests/Feature/Guards/FilamentTableStandardsGuardTest.php`, `apps/platform/tests/Feature/System/Spec113/AuthorizationSemanticsTest.php`, `apps/platform/tests/Feature/System/Spec114/OpsTriageActionsTest.php`, `apps/platform/tests/Feature/System/OpsRunbooks/FindingsLifecycleBackfillStartTest.php`, `apps/platform/tests/Feature/Auth/BreakGlassWorkspaceOwnerRecoveryTest.php`, `apps/platform/tests/Feature/Auth/TenantChooserSelectionTest.php`, `apps/platform/tests/Feature/Workspaces/ChooseWorkspacePageTest.php`, `apps/platform/tests/Feature/Workspaces/WorkspaceAuditTrailTest.php`, `apps/platform/tests/Feature/TenantRBAC/TenantSwitcherScopeTest.php`, `apps/platform/tests/Feature/Rbac/RegisterTenantAuthorizationTest.php`, `apps/platform/tests/Feature/TenantRBAC/TenantBootstrapAssignTest.php`, `apps/platform/tests/Feature/Rbac/OnboardingWizardUiEnforcementTest.php`, `apps/platform/tests/Feature/Onboarding/OnboardingDraftAccessTest.php`, `apps/platform/tests/Feature/Filament/TenantDashboardDbOnlyTest.php`, `apps/platform/tests/Feature/Rbac/TenantDashboardArrivalContextVisibilityTest.php`, `apps/platform/tests/Feature/System/Spec195/SystemDirectoryResidualSurfaceTest.php`, and `apps/platform/tests/Feature/Workspaces/Spec195ManagedTenantsLandingTest.php`
|
||||
- [X] T032 Run `cd apps/platform && ./vendor/bin/sail bin pint --dirty --format agent` and resolve formatting issues in `apps/platform/app/Support/Ui/ActionSurface/ActionSurfaceDiscovery.php`, `apps/platform/app/Support/Ui/ActionSurface/ActionSurfaceExemptions.php`, `apps/platform/app/Support/Ui/ActionSurface/ActionSurfaceValidator.php`, `apps/platform/tests/Feature/Guards/ActionSurfaceContractTest.php`, `apps/platform/tests/Feature/Guards/ActionSurfaceValidatorTest.php`, `apps/platform/tests/Feature/Guards/Spec195ResidualActionSurfaceClosureGuardTest.php`, `apps/platform/tests/Feature/System/Spec195/SystemDirectoryResidualSurfaceTest.php`, and `apps/platform/tests/Feature/Workspaces/Spec195ManagedTenantsLandingTest.php`
|
||||
- [X] T033 Verify the final residual inventory matches `specs/195-action-surface-closure/contracts/action-surface-closure.logical.openapi.yaml`, includes human-readable `surfaceName` values, and covers the initial seed plus any newly audited residual surfaces described in `specs/195-action-surface-closure/data-model.md`
|
||||
|
||||
---
|
||||
|
||||
## Dependencies
|
||||
|
||||
- Setup tasks T001-T002 precede all implementation work.
|
||||
- Foundational tasks T003-T007 block all user stories.
|
||||
- User Story 1 depends on Phase 2 and unlocks the actual residual inventory.
|
||||
- User Story 2, User Story 3, and User Story 5 depend on User Story 1 because they classify concrete residual entries inside the shared inventory.
|
||||
- User Story 4 depends on User Story 1, User Story 2, User Story 3, and User Story 5 because the regression guard must validate the final closure state.
|
||||
- Polish tasks T031-T033 depend on all user stories being complete.
|
||||
|
||||
## Parallel Execution Examples
|
||||
|
||||
- After T001, run T002 in parallel with any remaining setup review.
|
||||
- In Phase 2, T003 and T004 can run in parallel before T005-T007 land.
|
||||
- In User Story 1, T008 and T009 can run in parallel.
|
||||
- In User Story 2, T012 and T013 can run in parallel.
|
||||
- In User Story 3, T017 and T018 can run in parallel.
|
||||
- In User Story 4, T022 and T023 can run in parallel.
|
||||
- In User Story 5, T026, T027, and T028 can run in parallel.
|
||||
|
||||
## Implementation Strategy
|
||||
|
||||
- Start with Phase 2 and User Story 1 to establish the residual inventory and validator contract.
|
||||
- Deliver User Story 2 next to close the highest-risk system and utility surfaces first.
|
||||
- Follow with User Story 3 and User Story 5 to normalize discovered exceptions and preserve legitimate special workflows.
|
||||
- Complete User Story 4 only after the closure states are finalized so the new guard enforces the finished model instead of a moving target.
|
||||
- Finish with the focused Sail verification pack and Pint formatting from Phase 8.
|
||||
@ -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.
|
||||
@ -0,0 +1,35 @@
|
||||
# Specification Quality Checklist: Baseline Compare Engine Strategy Extraction
|
||||
|
||||
**Purpose**: Validate specification completeness and quality before proceeding to planning
|
||||
**Created**: 2026-04-13
|
||||
**Feature**: [spec.md](../spec.md)
|
||||
|
||||
## Content Quality
|
||||
|
||||
- [x] No implementation details (languages, frameworks, APIs)
|
||||
- [x] Focused on user value and business needs
|
||||
- [x] Written for non-technical stakeholders
|
||||
- [x] All mandatory sections completed
|
||||
|
||||
## Requirement Completeness
|
||||
|
||||
- [x] No [NEEDS CLARIFICATION] markers remain
|
||||
- [x] Requirements are testable and unambiguous
|
||||
- [x] Success criteria are measurable
|
||||
- [x] Success criteria are technology-agnostic (no implementation details)
|
||||
- [x] All acceptance scenarios are defined
|
||||
- [x] Edge cases are identified
|
||||
- [x] Scope is clearly bounded
|
||||
- [x] Dependencies and assumptions identified
|
||||
|
||||
## Feature Readiness
|
||||
|
||||
- [x] All functional requirements have clear acceptance criteria
|
||||
- [x] User scenarios cover primary flows
|
||||
- [x] Feature meets measurable outcomes defined in Success Criteria
|
||||
- [x] No implementation details leak into specification
|
||||
|
||||
## Notes
|
||||
|
||||
- Validated against the draft spec on 2026-04-13 after a wording pass on success criteria to keep them outcome-focused.
|
||||
- No clarification markers remain. The spec is ready for `/speckit.plan`.
|
||||
@ -0,0 +1,442 @@
|
||||
openapi: 3.1.0
|
||||
info:
|
||||
title: Baseline Compare Strategy Extraction Internal Contract
|
||||
version: 0.1.0
|
||||
summary: Internal logical contract for compare strategy selection, compare launch validation, and strategy-owned subject results
|
||||
description: |
|
||||
This contract is an internal planning artifact for Spec 203. The affected
|
||||
compare surfaces still render through Filament and Livewire, and compare
|
||||
execution continues to run through the existing Laravel services and jobs.
|
||||
The paths below are logical boundary identifiers for existing service, job,
|
||||
and surface entry points only; they do not imply new HTTP controllers or
|
||||
routes.
|
||||
x-logical-artifact: true
|
||||
x-baseline-compare-strategy-consumers:
|
||||
- surface: baseline.compare.start
|
||||
sourceFiles:
|
||||
- apps/platform/app/Services/Baselines/BaselineCompareService.php
|
||||
mustConsume:
|
||||
- canonical_scope_v2
|
||||
- deterministic_strategy_selection
|
||||
- unsupported_or_mixed_scope_rejection
|
||||
- strategy_key_recorded_in_run_context
|
||||
- surface: baseline.compare.fanout
|
||||
sourceFiles:
|
||||
- apps/platform/app/Services/Baselines/BaselineCompareService.php
|
||||
- apps/platform/app/Filament/Pages/BaselineCompareMatrix.php
|
||||
- apps/platform/app/Filament/Resources/BaselineProfileResource/Pages/ViewBaselineProfile.php
|
||||
mustConsume:
|
||||
- visible_set_strategy_validation
|
||||
- single_strategy_per_run_rule
|
||||
- tenant_owned_compare_runs_only
|
||||
- surface: baseline.compare.execution
|
||||
sourceFiles:
|
||||
- apps/platform/app/Jobs/CompareBaselineToTenantJob.php
|
||||
- apps/platform/app/Services/Baselines/CurrentStateHashResolver.php
|
||||
- apps/platform/app/Services/Drift/DriftHasher.php
|
||||
mustConsume:
|
||||
- compare_orchestration_context
|
||||
- compare_subject_result
|
||||
- strategy_provided_subject_projection
|
||||
- surface: baseline.compare.review
|
||||
sourceFiles:
|
||||
- apps/platform/app/Filament/Pages/BaselineCompareLanding.php
|
||||
- apps/platform/app/Support/Baselines/BaselineCompareSummaryAssessor.php
|
||||
- apps/platform/app/Support/Baselines/BaselineCompareExplanationRegistry.php
|
||||
mustRender:
|
||||
- unsupported_scope_truth
|
||||
- incomplete_or_ambiguous_truth
|
||||
- failed_strategy_truth
|
||||
paths:
|
||||
/internal/tenants/{tenant}/baseline-profiles/{profile}/compare/validate:
|
||||
post:
|
||||
summary: Resolve one compatible compare strategy family before compare is enqueued
|
||||
operationId: validateBaselineCompareStrategySelection
|
||||
parameters:
|
||||
- name: tenant
|
||||
in: path
|
||||
required: true
|
||||
schema:
|
||||
type: integer
|
||||
- name: profile
|
||||
in: path
|
||||
required: true
|
||||
schema:
|
||||
type: integer
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/CompareValidationRequest'
|
||||
responses:
|
||||
'200':
|
||||
description: One compatible strategy family was selected for the requested compare scope
|
||||
content:
|
||||
application/vnd.tenantpilot.baseline-compare-strategy-selection+json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/CompareStrategySelection'
|
||||
'422':
|
||||
description: Scope is unsupported or requires more than one strategy family
|
||||
content:
|
||||
application/vnd.tenantpilot.baseline-compare-strategy-errors+json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/CompareStrategySelection'
|
||||
'403':
|
||||
description: Actor is in scope but lacks capability to start compare
|
||||
'404':
|
||||
description: Tenant or baseline profile is outside actor scope
|
||||
/internal/tenants/{tenant}/baseline-profiles/{profile}/compare:
|
||||
post:
|
||||
summary: Start baseline compare using one selected strategy family
|
||||
operationId: startBaselineCompareWithStrategySelection
|
||||
parameters:
|
||||
- name: tenant
|
||||
in: path
|
||||
required: true
|
||||
schema:
|
||||
type: integer
|
||||
- name: profile
|
||||
in: path
|
||||
required: true
|
||||
schema:
|
||||
type: integer
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/CompareValidationRequest'
|
||||
responses:
|
||||
'202':
|
||||
description: Compare accepted with a deterministic strategy family recorded in the existing run context
|
||||
content:
|
||||
application/vnd.tenantpilot.baseline-compare-run+json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/CompareLaunchEnvelope'
|
||||
'422':
|
||||
description: Scope is unsupported or mixed and compare was not started
|
||||
'403':
|
||||
description: Actor is in scope but lacks capability to start compare
|
||||
'404':
|
||||
description: Tenant or baseline profile is outside actor scope
|
||||
/internal/workspaces/{workspace}/baseline-profiles/{profile}/compare-visible-assignments:
|
||||
post:
|
||||
summary: Start compare for the visible assigned tenant set only when one strategy family supports the shared scope
|
||||
operationId: startVisibleAssignmentCompareWithStrategySelection
|
||||
parameters:
|
||||
- name: workspace
|
||||
in: path
|
||||
required: true
|
||||
schema:
|
||||
type: integer
|
||||
- name: profile
|
||||
in: path
|
||||
required: true
|
||||
schema:
|
||||
type: integer
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/VisibleAssignmentCompareRequest'
|
||||
responses:
|
||||
'202':
|
||||
description: Visible tenant compare fan-out accepted with the same selected strategy family for each tenant-owned run
|
||||
content:
|
||||
application/vnd.tenantpilot.baseline-compare-fanout+json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/VisibleAssignmentCompareEnvelope'
|
||||
'422':
|
||||
description: Visible set scope is unsupported or mixed and no tenant compare runs were started
|
||||
'403':
|
||||
description: Actor is in scope but lacks workspace baseline manage capability
|
||||
'404':
|
||||
description: Workspace or baseline profile is outside actor scope
|
||||
/internal/baseline-compare/subjects/classify:
|
||||
post:
|
||||
summary: Logical strategy-owned boundary for classifying one compare subject inside the compare job
|
||||
operationId: classifyCompareSubject
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/CompareSubjectInput'
|
||||
responses:
|
||||
'200':
|
||||
description: Structured compare-subject result consumable by platform orchestration
|
||||
content:
|
||||
application/vnd.tenantpilot.compare-subject-result+json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/CompareSubjectResult'
|
||||
components:
|
||||
schemas:
|
||||
CompareStrategyKey:
|
||||
type: string
|
||||
examples:
|
||||
- intune_policy
|
||||
- entra_configuration
|
||||
StrategySelectionState:
|
||||
type: string
|
||||
enum:
|
||||
- supported
|
||||
- unsupported
|
||||
- mixed
|
||||
ScopeEntryReference:
|
||||
type: object
|
||||
additionalProperties: false
|
||||
required:
|
||||
- domain_key
|
||||
- subject_class
|
||||
- subject_type_keys
|
||||
properties:
|
||||
domain_key:
|
||||
type: string
|
||||
subject_class:
|
||||
type: string
|
||||
subject_type_keys:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
CompareStrategyCapability:
|
||||
type: object
|
||||
additionalProperties: false
|
||||
required:
|
||||
- strategy_key
|
||||
- domain_keys
|
||||
- subject_classes
|
||||
- compare_supported
|
||||
- active
|
||||
properties:
|
||||
strategy_key:
|
||||
$ref: '#/components/schemas/CompareStrategyKey'
|
||||
domain_keys:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
subject_classes:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
subject_type_keys:
|
||||
oneOf:
|
||||
- type: string
|
||||
enum:
|
||||
- all
|
||||
- type: array
|
||||
items:
|
||||
type: string
|
||||
compare_supported:
|
||||
type: boolean
|
||||
active:
|
||||
type: boolean
|
||||
CompareStrategySelection:
|
||||
type: object
|
||||
additionalProperties: false
|
||||
required:
|
||||
- selection_state
|
||||
- matched_scope_entries
|
||||
- rejected_scope_entries
|
||||
- operator_reason
|
||||
- diagnostics
|
||||
properties:
|
||||
selection_state:
|
||||
$ref: '#/components/schemas/StrategySelectionState'
|
||||
strategy_key:
|
||||
oneOf:
|
||||
- $ref: '#/components/schemas/CompareStrategyKey'
|
||||
- type: 'null'
|
||||
matched_scope_entries:
|
||||
type: array
|
||||
items:
|
||||
$ref: '#/components/schemas/ScopeEntryReference'
|
||||
rejected_scope_entries:
|
||||
type: array
|
||||
items:
|
||||
$ref: '#/components/schemas/ScopeEntryReference'
|
||||
operator_reason:
|
||||
type: string
|
||||
diagnostics:
|
||||
type: object
|
||||
additionalProperties: true
|
||||
CompareValidationRequest:
|
||||
type: object
|
||||
additionalProperties: false
|
||||
required:
|
||||
- baseline_profile_id
|
||||
- normalized_scope
|
||||
properties:
|
||||
baseline_profile_id:
|
||||
type: integer
|
||||
baseline_snapshot_id:
|
||||
type:
|
||||
- integer
|
||||
- 'null'
|
||||
normalized_scope:
|
||||
type: object
|
||||
additionalProperties: true
|
||||
CompareLaunchEnvelope:
|
||||
type: object
|
||||
additionalProperties: false
|
||||
required:
|
||||
- operation_run_id
|
||||
- strategy_selection
|
||||
properties:
|
||||
operation_run_id:
|
||||
type: integer
|
||||
strategy_selection:
|
||||
$ref: '#/components/schemas/CompareStrategySelection'
|
||||
VisibleAssignmentCompareRequest:
|
||||
type: object
|
||||
additionalProperties: false
|
||||
required:
|
||||
- baseline_profile_id
|
||||
- normalized_scope
|
||||
- visible_tenant_ids
|
||||
properties:
|
||||
baseline_profile_id:
|
||||
type: integer
|
||||
normalized_scope:
|
||||
type: object
|
||||
additionalProperties: true
|
||||
visible_tenant_ids:
|
||||
type: array
|
||||
items:
|
||||
type: integer
|
||||
VisibleAssignmentCompareEnvelope:
|
||||
type: object
|
||||
additionalProperties: false
|
||||
required:
|
||||
- strategy_selection
|
||||
- started_runs
|
||||
- skipped_tenants
|
||||
properties:
|
||||
strategy_selection:
|
||||
$ref: '#/components/schemas/CompareStrategySelection'
|
||||
started_runs:
|
||||
type: array
|
||||
items:
|
||||
type: integer
|
||||
skipped_tenants:
|
||||
type: array
|
||||
items:
|
||||
type: integer
|
||||
CompareSubjectInput:
|
||||
type: object
|
||||
additionalProperties: false
|
||||
required:
|
||||
- strategy_key
|
||||
- subject_identity
|
||||
- baseline_state
|
||||
- current_state
|
||||
properties:
|
||||
strategy_key:
|
||||
$ref: '#/components/schemas/CompareStrategyKey'
|
||||
subject_identity:
|
||||
type: object
|
||||
additionalProperties: true
|
||||
baseline_state:
|
||||
type: object
|
||||
additionalProperties: true
|
||||
current_state:
|
||||
oneOf:
|
||||
- type: object
|
||||
additionalProperties: true
|
||||
- type: 'null'
|
||||
CompareSubjectState:
|
||||
type: string
|
||||
enum:
|
||||
- no_drift
|
||||
- drift
|
||||
- unsupported
|
||||
- incomplete
|
||||
- ambiguous
|
||||
- failed
|
||||
CompareSubjectProjection:
|
||||
type: object
|
||||
additionalProperties: false
|
||||
required:
|
||||
- platform_subject_class
|
||||
- domain_key
|
||||
- subject_type_key
|
||||
- operator_label
|
||||
properties:
|
||||
platform_subject_class:
|
||||
type: string
|
||||
domain_key:
|
||||
type: string
|
||||
subject_type_key:
|
||||
type: string
|
||||
operator_label:
|
||||
type: string
|
||||
summary_kind:
|
||||
type:
|
||||
- string
|
||||
- 'null'
|
||||
additional_labels:
|
||||
type: object
|
||||
additionalProperties:
|
||||
type: string
|
||||
CompareFindingCandidate:
|
||||
type: object
|
||||
additionalProperties: false
|
||||
required:
|
||||
- change_type
|
||||
- severity
|
||||
- fingerprint_basis
|
||||
- evidence_payload
|
||||
- auto_close_eligible
|
||||
properties:
|
||||
change_type:
|
||||
type: string
|
||||
severity:
|
||||
type: string
|
||||
fingerprint_basis:
|
||||
type: object
|
||||
additionalProperties: true
|
||||
evidence_payload:
|
||||
type: object
|
||||
additionalProperties: true
|
||||
auto_close_eligible:
|
||||
type: boolean
|
||||
CompareSubjectResult:
|
||||
type: object
|
||||
additionalProperties: false
|
||||
required:
|
||||
- subject_identity
|
||||
- projection
|
||||
- baseline_availability
|
||||
- current_state_availability
|
||||
- compare_state
|
||||
- trust_level
|
||||
- evidence_quality
|
||||
- diagnostics
|
||||
properties:
|
||||
subject_identity:
|
||||
type: object
|
||||
additionalProperties: true
|
||||
projection:
|
||||
$ref: '#/components/schemas/CompareSubjectProjection'
|
||||
baseline_availability:
|
||||
type: string
|
||||
current_state_availability:
|
||||
type: string
|
||||
compare_state:
|
||||
$ref: '#/components/schemas/CompareSubjectState'
|
||||
trust_level:
|
||||
type: string
|
||||
evidence_quality:
|
||||
type: string
|
||||
severity_recommendation:
|
||||
type:
|
||||
- string
|
||||
- 'null'
|
||||
finding_candidate:
|
||||
oneOf:
|
||||
- $ref: '#/components/schemas/CompareFindingCandidate'
|
||||
- type: 'null'
|
||||
diagnostics:
|
||||
type: object
|
||||
additionalProperties: true
|
||||
224
specs/203-baseline-compare-strategy/data-model.md
Normal file
224
specs/203-baseline-compare-strategy/data-model.md
Normal file
@ -0,0 +1,224 @@
|
||||
# Data Model: Baseline Compare Engine Strategy Extraction
|
||||
|
||||
## Overview
|
||||
|
||||
This feature introduces no new top-level persisted entity. It reuses the existing baseline compare start path, compare run persistence, finding lifecycle, and evidence storage, but inserts a new internal strategy boundary between platform compare orchestration and domain-specific compare logic.
|
||||
|
||||
## Existing Persisted Truth Reused Without Change
|
||||
|
||||
### Workspace-owned baseline truth
|
||||
|
||||
- `baseline_profiles`
|
||||
- `baseline_snapshots`
|
||||
- `baseline_snapshot_items`
|
||||
- Canonical Baseline Scope V2 from Spec 202
|
||||
|
||||
These remain the reference truth that compare reads.
|
||||
|
||||
### Tenant-owned operational truth
|
||||
|
||||
- `operation_runs` for `baseline_compare`
|
||||
- existing drift findings and recurrence tracking
|
||||
- existing evidence and compare diagnostics stored in current finding and run payloads
|
||||
|
||||
These remain the long-lived operator truth written by compare.
|
||||
|
||||
### Existing subject and evidence inputs
|
||||
|
||||
- inventory-backed current state
|
||||
- policy version and baseline snapshot evidence used by the current Intune path
|
||||
- compare coverage and trust context already stored in the run and summary layers
|
||||
|
||||
This feature changes how domain logic is organized, not where those truths live.
|
||||
|
||||
## New Internal Contracts
|
||||
|
||||
### CompareStrategyKey
|
||||
|
||||
**Type**: string enum or equivalent value object
|
||||
**Purpose**: identify one compare strategy family
|
||||
|
||||
| Value | Status | Notes |
|
||||
|------|--------|-------|
|
||||
| `intune_policy` | active | First explicit compare strategy family |
|
||||
| future values | reserved | Additional families may be added later without changing the platform orchestration shape |
|
||||
|
||||
### CompareStrategyCapability
|
||||
|
||||
**Type**: derived registry record
|
||||
**Purpose**: declare which canonical scope families a strategy supports
|
||||
|
||||
| Field | Type | Notes |
|
||||
|------|------|-------|
|
||||
| `strategy_key` | string | `CompareStrategyKey` value |
|
||||
| `domain_keys` | array<string> | Supported governance domains |
|
||||
| `subject_classes` | array<string> | Supported canonical subject classes |
|
||||
| `subject_type_keys` | array<string> or `all` | Optional narrowing to known subject families |
|
||||
| `compare_supported` | boolean | Whether compare may be started for the declared family |
|
||||
| `active` | boolean | Whether the strategy is currently available |
|
||||
|
||||
### StrategySelectionState
|
||||
|
||||
**Type**: internal enum
|
||||
**Purpose**: capture compare preflight compatibility outcome
|
||||
|
||||
| Value | Meaning |
|
||||
|------|---------|
|
||||
| `supported` | Exactly one compatible strategy family matches the requested scope |
|
||||
| `unsupported` | No compatible strategy supports the requested scope |
|
||||
| `mixed` | More than one strategy family would be required by the requested scope |
|
||||
|
||||
This is an orchestration contract, not a new top-level persisted domain status family.
|
||||
|
||||
### CompareStrategySelection
|
||||
|
||||
**Type**: internal orchestration record
|
||||
**Purpose**: represent preflight strategy resolution for a compare start
|
||||
|
||||
| Field | Type | Notes |
|
||||
|------|------|-------|
|
||||
| `selection_state` | `StrategySelectionState` | Required |
|
||||
| `strategy_key` | string or `null` | Present only when `selection_state = supported` |
|
||||
| `matched_scope_entries` | array<object> | Canonical scope entries accepted by the selected strategy |
|
||||
| `rejected_scope_entries` | array<object> | Scope entries rejected as unsupported or mixed |
|
||||
| `operator_reason` | string | Short operator-safe explanation |
|
||||
| `diagnostics` | array<string, mixed> | Secondary detail for logs and run detail |
|
||||
|
||||
### CompareOrchestrationContext
|
||||
|
||||
**Type**: internal execution record
|
||||
**Purpose**: represent the platform-owned inputs to one compare run
|
||||
|
||||
| Field | Type | Notes |
|
||||
|------|------|-------|
|
||||
| `workspace_id` | integer | Required |
|
||||
| `tenant_id` | integer | Required for tenant-owned compare runs |
|
||||
| `baseline_profile_id` | integer | Required |
|
||||
| `baseline_snapshot_id` | integer | Required reference snapshot |
|
||||
| `operation_run_id` | integer | Required |
|
||||
| `normalized_scope` | canonical scope document | Required |
|
||||
| `strategy_selection` | `CompareStrategySelection` | Required |
|
||||
| `coverage_context` | array<string, mixed> | Existing compare coverage truth |
|
||||
| `launch_context` | array<string, mixed> | Existing start-surface context such as tenant or matrix origin |
|
||||
|
||||
### CompareSubjectIdentity
|
||||
|
||||
**Type**: internal value object
|
||||
**Purpose**: represent one subject being compared without assuming policy-only semantics
|
||||
|
||||
| Field | Type | Notes |
|
||||
|------|------|-------|
|
||||
| `domain_key` | string | Canonical governance domain |
|
||||
| `subject_class` | string | Canonical subject class |
|
||||
| `subject_type_key` | string | Domain-owned subject family discriminator |
|
||||
| `external_subject_id` | string | Stable external identifier where available |
|
||||
| `subject_key` | string | Stable compare key used for deduplication and finding identity |
|
||||
|
||||
### CompareSubjectProjection
|
||||
|
||||
**Type**: internal value object
|
||||
**Purpose**: provide the platform with operator-safe subject metadata for findings and summaries
|
||||
|
||||
| Field | Type | Notes |
|
||||
|------|------|-------|
|
||||
| `platform_subject_class` | string | Canonical class for platform writers |
|
||||
| `domain_key` | string | Canonical governance domain |
|
||||
| `subject_type_key` | string | Strategy-owned subject family |
|
||||
| `operator_label` | string | Operator-facing subject label |
|
||||
| `summary_kind` | string or `null` | Optional summary discriminator |
|
||||
| `additional_labels` | array<string, string> | Optional secondary labels for diagnostics or detail surfaces |
|
||||
|
||||
### CompareState
|
||||
|
||||
**Type**: internal enum
|
||||
**Purpose**: represent per-subject compare outcome
|
||||
|
||||
| Value | Meaning |
|
||||
|------|---------|
|
||||
| `no_drift` | Subject compared successfully and no drift was confirmed |
|
||||
| `drift` | Subject compared successfully and drift was confirmed |
|
||||
| `unsupported` | Subject or family is not supported by the selected strategy |
|
||||
| `incomplete` | Evidence was insufficient to classify the subject fully |
|
||||
| `ambiguous` | Identity or evidence ambiguity prevented a trustworthy compare decision |
|
||||
| `failed` | Processing failed for this subject |
|
||||
|
||||
These values map to existing compare truth and do not imply a new top-level persisted run state family.
|
||||
|
||||
### CompareSubjectResult
|
||||
|
||||
**Type**: internal orchestration contract
|
||||
**Purpose**: one structured output row from a compare strategy back to platform orchestration
|
||||
|
||||
| Field | Type | Notes |
|
||||
|------|------|-------|
|
||||
| `subject_identity` | `CompareSubjectIdentity` | Required |
|
||||
| `projection` | `CompareSubjectProjection` | Required |
|
||||
| `baseline_availability` | string | Required; e.g. `available`, `missing`, `not_applicable` |
|
||||
| `current_state_availability` | string | Required; e.g. `available`, `missing`, `unknown` |
|
||||
| `compare_state` | `CompareState` | Required |
|
||||
| `trust_level` | string | Required; maps to current trust semantics |
|
||||
| `evidence_quality` | string | Required; maps to existing evidence completeness semantics |
|
||||
| `severity_recommendation` | string or `null` | Optional |
|
||||
| `finding_candidate` | `CompareFindingCandidate` or `null` | Present when a finding should be written or updated |
|
||||
| `diagnostics` | array<string, mixed> | Secondary detail for run context and troubleshooting |
|
||||
|
||||
### CompareFindingCandidate
|
||||
|
||||
**Type**: internal value object
|
||||
**Purpose**: provide the unified finding writer with strategy-neutral mutation input
|
||||
|
||||
| Field | Type | Notes |
|
||||
|------|------|-------|
|
||||
| `change_type` | string | Existing finding change type or equivalent classification |
|
||||
| `severity` | string | Existing severity family |
|
||||
| `fingerprint_basis` | array<string, mixed> | Stable values used by finding recurrence logic |
|
||||
| `evidence_payload` | array<string, mixed> | Strategy-owned evidence detail |
|
||||
| `auto_close_eligible` | boolean | Whether absence in the current run should close the finding |
|
||||
|
||||
## Relationships
|
||||
|
||||
- One `CompareStrategyCapability` belongs to one `CompareStrategyKey`.
|
||||
- One `CompareStrategySelection` is produced for each compare start attempt.
|
||||
- One `CompareOrchestrationContext` contains exactly one supported `CompareStrategySelection` when a compare run is allowed to start.
|
||||
- One strategy processes many `CompareSubjectIdentity` values and emits many `CompareSubjectResult` values for one `CompareOrchestrationContext`.
|
||||
- One `CompareSubjectResult` may yield zero or one `CompareFindingCandidate`.
|
||||
- Existing finding and summary writers consume `CompareSubjectResult` and `CompareFindingCandidate` without needing to inspect strategy internals directly.
|
||||
|
||||
## Validation Rules
|
||||
|
||||
### Strategy selection
|
||||
|
||||
1. Canonical scope must already be normalized to Baseline Scope V2.
|
||||
2. Exactly one active strategy family must support all included scope entries.
|
||||
3. Inactive or unsupported subject types fail selection before compare is enqueued.
|
||||
4. Mixed strategy families fail selection before compare is enqueued.
|
||||
5. No implicit fallback to `intune_policy` is allowed.
|
||||
|
||||
### Compare-subject result contract
|
||||
|
||||
1. Every processed subject must return one `CompareSubjectResult`.
|
||||
2. Every result must include identity, projection, availability, compare state, trust, evidence quality, and diagnostics.
|
||||
3. `finding_candidate` may be omitted only when the compare state does not warrant finding persistence.
|
||||
4. The platform must not need raw strategy-private fields to build summaries, findings, or run diagnostics.
|
||||
|
||||
## Transition Rules
|
||||
|
||||
### Compare start to strategy selection
|
||||
|
||||
1. Receive canonical scope from the baseline profile plus any compare narrowing already allowed by the current workflow.
|
||||
2. Resolve the compatible strategy family from the registry.
|
||||
3. Reject unsupported or mixed scope before creating subject work.
|
||||
4. Persist the selected strategy family and compatibility diagnostics into existing compare run context only when a run is created.
|
||||
|
||||
### Strategy selection to subject processing
|
||||
|
||||
1. Build `CompareOrchestrationContext` from existing baseline, tenant, snapshot, and run truth.
|
||||
2. Hand domain-specific subject discovery and classification work to the selected strategy.
|
||||
3. Reuse generic current-state and hashing helpers where they remain strategy-neutral.
|
||||
|
||||
### Subject results to existing run and finding truth
|
||||
|
||||
1. Aggregate `CompareSubjectResult` values into existing compare summary counts and trust semantics.
|
||||
2. Write or update findings through the existing unified finding lifecycle using `CompareFindingCandidate`.
|
||||
3. Persist strategy diagnostics only as secondary run or evidence detail inside existing persistence shapes.
|
||||
4. Preserve existing run outcome ownership and current operator-visible compare semantics.
|
||||
225
specs/203-baseline-compare-strategy/plan.md
Normal file
225
specs/203-baseline-compare-strategy/plan.md
Normal file
@ -0,0 +1,225 @@
|
||||
# Implementation Plan: Baseline Compare Engine Strategy Extraction
|
||||
|
||||
**Branch**: `203-baseline-compare-strategy` | **Date**: 2026-04-13 | **Spec**: `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/specs/203-baseline-compare-strategy/spec.md`
|
||||
**Input**: Feature specification from `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/specs/203-baseline-compare-strategy/spec.md`
|
||||
|
||||
**Note**: This plan keeps the existing baseline compare workflow, `OperationRun` model, and finding lifecycle intact while extracting the current Intune-specific compare behavior behind one explicit strategy seam.
|
||||
|
||||
## Summary
|
||||
|
||||
Keep `BaselineCompareService` and `CompareBaselineToTenantJob` as the platform compare orchestration flow, add one deterministic compare-strategy registry keyed by canonical Baseline Scope V2, validate single-strategy compatibility before enqueue, extract current Intune-specific subject processing into an explicit `IntuneCompareStrategy`, and feed a strategy-neutral per-subject compare-result contract into the existing summary, finding, trust, and run-outcome writers. No new `OperationRun` type, no new top-level compare persistence model, and no multi-strategy-per-run orchestration are planned.
|
||||
|
||||
## Technical Context
|
||||
|
||||
**Language/Version**: PHP 8.4.15
|
||||
**Primary Dependencies**: Laravel 12, Filament v5, Livewire v4, Pest v4, Laravel Sail, existing `BaselineCompareService`, `CompareBaselineToTenantJob`, `SubjectResolver`, `CurrentStateHashResolver`, `DriftHasher`, `BaselineCompareSummaryAssessor`, and finding lifecycle services
|
||||
**Storage**: PostgreSQL via existing baseline snapshots, baseline snapshot items, `operation_runs`, findings, and baseline scope JSON; no new top-level tables planned
|
||||
**Testing**: Pest unit, feature, Filament Livewire, and existing browser smoke coverage run through Laravel Sail
|
||||
**Target Platform**: Laravel web application under `apps/platform` with queue-backed compare execution in Sail/Docker
|
||||
**Project Type**: web application in a monorepo (`apps/platform` plus `apps/website`)
|
||||
**Performance Goals**: Keep compare start enqueue-only, keep strategy selection and compatibility validation in-process and cheap, add no extra remote calls on start surfaces, and preserve current compare throughput and matrix rendering behavior
|
||||
**Constraints**: Single-strategy-per-run only, no new compare UI surface, no new `OperationRun` type, no new plugin framework, no silent Intune fallback, preserve current Intune compare behavior, and keep current finding lifecycle and trust semantics
|
||||
**Scale/Scope**: One baseline compare start service, one compare job, existing workspace and tenant compare surfaces, one explicit Intune strategy, one strategy registry, one internal compare-result contract, and focused regression extensions across compare feature and Filament suites
|
||||
|
||||
## Constitution Check
|
||||
|
||||
*GATE: Passed before Phase 0 research. Re-checked after Phase 1 design and still passing with one explicit proportionality justification recorded below.*
|
||||
|
||||
| Principle | Pre-Research | Post-Design | Notes |
|
||||
|-----------|--------------|-------------|-------|
|
||||
| Inventory-first / snapshots-second | PASS | PASS | Compare continues to read existing inventory-backed current state and workspace-owned baseline snapshots; no new source of compare truth is introduced. |
|
||||
| Read/write separation | PASS | PASS | Compare launch surfaces remain simulation-only start actions; compare still writes only existing run, finding, and audit truth. |
|
||||
| Graph contract path | PASS | PASS | The feature introduces no new Microsoft Graph path; the extracted Intune strategy continues to consume existing contract-backed evidence sources. |
|
||||
| Deterministic capabilities | PASS | PASS | Strategy capability matching is explicit, testable, and driven by canonical scope plus registry metadata. |
|
||||
| Workspace + tenant isolation | PASS | PASS | Workspace baseline surfaces and tenant compare surfaces keep current isolation boundaries and deny-as-not-found semantics. |
|
||||
| RBAC-UX authorization semantics | PASS | PASS | Existing capability checks remain authoritative for compare starts and drilldowns; unsupported or mixed scope is a compare truth outcome, not an auth shortcut. |
|
||||
| Run observability / Ops-UX | PASS | PASS | Existing `baseline_compare` runs remain the only run truth; status/outcome ownership, summary-count rules, and terminal notification behavior remain unchanged. |
|
||||
| Data minimization | PASS | PASS | No new persisted compare artifact is added; strategy-neutral context extends existing run/finding payloads only where necessary. |
|
||||
| Proportionality / anti-bloat | PASS | PASS | One narrow strategy seam is justified by a current compare-truth problem and recorded in Complexity Tracking; no broader workflow framework is added. |
|
||||
| No premature abstraction | JUSTIFIED | JUSTIFIED | This is a deliberate, narrow exception: one strategy contract and registry are introduced before a second production domain exists because Spec 202 makes unsupported or mixed compare scope a current-release truth problem. The alternative thin wrapper is recorded and rejected below. |
|
||||
| Persisted truth / behavioral state | PASS | PASS | The new compare-result states remain internal orchestration semantics and map to existing operator-visible compare outcomes; no new top-level persisted state family is introduced. |
|
||||
| UI semantics / few layers | PASS | PASS | Existing compare surfaces remain the same; the new compare-result contract feeds current summaries and explanations instead of creating a new UI interpretation framework. |
|
||||
| Filament v5 / Livewire v4 compliance | PASS | PASS | The affected compare surfaces remain Filament v5 + Livewire v4 surfaces with no legacy API introduction. |
|
||||
| Provider registration location | PASS | PASS | No panel or provider change is required; Laravel 11+ provider registration remains in `bootstrap/providers.php`. |
|
||||
| Global search hard rule | PASS | PASS | No globally searchable resource is added or changed by this feature. |
|
||||
| Destructive action safety | PASS | PASS | No new destructive action is introduced. Existing archive actions stay confirmed; compare start actions remain confirmed and capability-gated. |
|
||||
| Asset strategy | PASS | PASS | No new assets or panel registrations are required; existing Filament asset deployment remains unchanged. |
|
||||
|
||||
## Filament-Specific Compliance Notes
|
||||
|
||||
- **Livewire v4.0+ compliance**: The touched compare surfaces remain on Filament v5 + Livewire v4 and no deprecated Filament API is introduced by the plan.
|
||||
- **Provider registration location**: No new panel or provider is required; `bootstrap/providers.php` remains the only relevant provider registration location.
|
||||
- **Global search**: No new searchable resource is added. Existing compare pages remain standalone pages and current global-search behavior is unaffected.
|
||||
- **Destructive actions**: This feature adds no new destructive action. Existing destructive actions on baseline surfaces remain unchanged and must keep `->requiresConfirmation()`.
|
||||
- **Asset strategy**: No panel-only or shared asset registration is needed. Deployment handling of `cd apps/platform && php artisan filament:assets` remains unchanged.
|
||||
- **Testing plan**: Extend compare feature tests, Filament start-surface tests, and focused matrix or landing coverage for unsupported or mixed-scope truth. Keep existing compare smoke coverage green.
|
||||
|
||||
## Phase 0 Research
|
||||
|
||||
Research outcomes are captured in `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/specs/203-baseline-compare-strategy/research.md`.
|
||||
|
||||
Key decisions:
|
||||
|
||||
- Keep `BaselineCompareService` as the compare start orchestration layer and `CompareBaselineToTenantJob` as the async executor instead of redesigning the run lifecycle.
|
||||
- Add a deterministic compare-strategy registry that resolves one compatible strategy family from canonical Baseline Scope V2 before a run is enqueued.
|
||||
- Extract the strategy seam at the current subject-processing boundary inside `CompareBaselineToTenantJob` rather than replacing current queue, finding, or summary flows.
|
||||
- Reuse existing generic helpers such as `DriftHasher`, `CurrentStateHashResolver`, finding lifecycle services, and summary assessors; move Intune-shaped normalizer selection, policy-type special cases, and subject-projection detail behind `IntuneCompareStrategy`.
|
||||
- Model the per-subject compare output as a structured internal contract instead of raw ad-hoc arrays or a new persisted compare-result table.
|
||||
- Keep workspace fan-out compare as repeated tenant-owned runs with the same single-strategy validation and no new workspace umbrella run.
|
||||
|
||||
## Phase 1 Design
|
||||
|
||||
Design artifacts are created under `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/specs/203-baseline-compare-strategy/`:
|
||||
|
||||
- `research.md`: architecture decisions and rejected alternatives for strategy extraction
|
||||
- `data-model.md`: internal compare strategy, strategy selection, compare-subject result, and projection contracts
|
||||
- `contracts/baseline-compare-strategy.logical.openapi.yaml`: logical internal contract for compare validation, compare launch, fan-out launch, and strategy-owned subject classification
|
||||
- `quickstart.md`: implementation and verification sequence for the feature
|
||||
|
||||
Design decisions:
|
||||
|
||||
- Keep compare launch orchestration in `BaselineCompareService` and run execution in `CompareBaselineToTenantJob`.
|
||||
- Introduce one narrow compare-support namespace under `app/Support/Baselines/Compare/` for the strategy contract, registry, selection result, and compare-subject result value objects.
|
||||
- Keep Intune-specific section normalizers and policy-type branching inside the explicit Intune strategy rather than in platform orchestration.
|
||||
- Preserve existing finding persistence, run summary assessment, and explanation surfaces by feeding them strategy-neutral projection metadata instead of raw strategy internals.
|
||||
- Reject unsupported or mixed strategy scope before subject processing, and keep strategy failure truth visible through existing compare landing and canonical run-detail surfaces.
|
||||
|
||||
## Project Structure
|
||||
|
||||
### Documentation (this feature)
|
||||
|
||||
```text
|
||||
specs/203-baseline-compare-strategy/
|
||||
├── plan.md
|
||||
├── research.md
|
||||
├── data-model.md
|
||||
├── quickstart.md
|
||||
├── spec.md
|
||||
├── contracts/
|
||||
│ └── baseline-compare-strategy.logical.openapi.yaml
|
||||
└── checklists/
|
||||
└── requirements.md
|
||||
```
|
||||
|
||||
### Source Code (repository root)
|
||||
|
||||
```text
|
||||
apps/platform/
|
||||
├── app/
|
||||
│ ├── Filament/
|
||||
│ │ ├── Pages/
|
||||
│ │ │ ├── BaselineCompareLanding.php
|
||||
│ │ │ └── BaselineCompareMatrix.php
|
||||
│ │ └── Resources/
|
||||
│ │ └── BaselineProfileResource/
|
||||
│ │ └── Pages/
|
||||
│ │ └── ViewBaselineProfile.php
|
||||
│ ├── Jobs/
|
||||
│ │ └── CompareBaselineToTenantJob.php
|
||||
│ ├── Services/
|
||||
│ │ ├── Baselines/
|
||||
│ │ │ ├── BaselineCompareService.php
|
||||
│ │ │ ├── CurrentStateHashResolver.php
|
||||
│ │ │ └── Evidence/
|
||||
│ │ └── Drift/
|
||||
│ │ ├── DriftHasher.php
|
||||
│ │ └── Normalizers/
|
||||
│ └── Support/
|
||||
│ └── Baselines/
|
||||
│ ├── BaselineCompareExplanationRegistry.php
|
||||
│ ├── BaselineCompareSummaryAssessor.php
|
||||
│ ├── ResolutionOutcome.php
|
||||
│ ├── SubjectClass.php
|
||||
│ ├── SubjectResolver.php
|
||||
│ └── Compare/
|
||||
│ └── [new strategy contract, registry, and result objects]
|
||||
└── tests/
|
||||
├── Feature/
|
||||
│ ├── Baselines/
|
||||
│ │ ├── BaselineComparePreconditionsTest.php
|
||||
│ │ ├── BaselineCompareExecutionGuardTest.php
|
||||
│ │ ├── BaselineCompareFindingsTest.php
|
||||
│ │ ├── BaselineCompareMatrixCompareAllActionTest.php
|
||||
│ │ ├── BaselineCompareRbacRoleDefinitionsTest.php
|
||||
│ │ ├── BaselineCompareGapClassificationTest.php
|
||||
│ │ └── [new strategy-selection and unsupported-scope coverage]
|
||||
│ ├── BaselineDriftEngine/
|
||||
│ │ ├── CompareContentEvidenceTest.php
|
||||
│ │ └── CompareFidelityMismatchTest.php
|
||||
│ └── Filament/
|
||||
│ ├── BaselineProfileCompareStartSurfaceTest.php
|
||||
│ ├── BaselineCompareLandingStartSurfaceTest.php
|
||||
│ ├── BaselineCompareMatrixPageTest.php
|
||||
│ └── [new unsupported or mixed-scope surface tests]
|
||||
└── Browser/
|
||||
└── [existing compare-related smoke tests remain green]
|
||||
```
|
||||
|
||||
**Structure Decision**: Keep the change inside the existing baseline compare service, compare job, and compare surfaces. Add one narrow `app/Support/Baselines/Compare` namespace for the strategy contract, registry, selection, and result objects, but avoid a wider plugin or provider framework.
|
||||
|
||||
## Complexity Tracking
|
||||
|
||||
| Violation | Why Needed | Simpler Alternative Rejected Because |
|
||||
|-----------|------------|-------------------------------------|
|
||||
| Compare strategy contract and registry before a second production domain exists | Canonical Baseline Scope V2 makes unsupported or mixed-domain compare a current-release truth problem. The platform must stop treating Intune policy semantics as the hidden universal default now. | A thin wrapper around `CompareBaselineToTenantJob` would leave Intune assumptions in the platform core and would not make preflight rejection or future-domain participation honest. |
|
||||
| Structured compare-subject result contract | Existing summary, finding, and run writers need domain-neutral compare output to avoid reverse-engineering Intune-shaped evidence arrays. | Passing raw arrays through the job would recreate hidden policy-only defaults and force downstream orchestration to keep inferring domain meaning from Intune payload shapes. |
|
||||
|
||||
## Proportionality Review
|
||||
|
||||
- **Current operator problem**: Compare truth is currently reliable only because platform orchestration and Intune-specific logic are still mixed. As canonical scope broadens, unsupported or mixed compare scope cannot be rejected honestly without a clean boundary.
|
||||
- **Existing structure is insufficient because**: The current compare job still makes policy-shaped assumptions during subject processing and evidence projection, so the platform cannot safely claim one canonical compare workflow across future subject families.
|
||||
- **Narrowest correct implementation**: Keep the current start service, queue job, run model, finding model, and compare surfaces. Introduce one compare strategy contract, one deterministic resolver, and one strategy-neutral subject-result contract, then move only the Intune-specific processing behind the explicit strategy boundary.
|
||||
- **Ownership cost created**: One new compare-support namespace, stricter contract discipline, additional strategy-resolution and regression coverage, and ongoing review to keep platform orchestration free of reintroduced Intune assumptions.
|
||||
- **Alternative intentionally rejected**: A superficial wrapper around the current job or a broad compare plugin framework. The wrapper would not solve the current truth boundary, and the framework would import speculative complexity before a second real production domain exists.
|
||||
- **Release truth**: current-release compare correctness and future-domain readiness at the specific compare seam, not generalized platform totalization
|
||||
|
||||
## Implementation Strategy
|
||||
|
||||
### Phase A - Strategy Selection and Compare Preconditions
|
||||
|
||||
- Add the compare strategy capability contract and deterministic registry keyed by canonical Baseline Scope V2.
|
||||
- Validate one compatible strategy family at compare start in `BaselineCompareService` for both tenant compare and workspace fan-out compare.
|
||||
- Return explicit unsupported or mixed-scope outcomes before a run is enqueued.
|
||||
|
||||
### Phase B - Intune Strategy Extraction
|
||||
|
||||
- Extract the current Intune-specific subject-processing logic from `CompareBaselineToTenantJob` into `IntuneCompareStrategy`.
|
||||
- Move Intune-only normalizer selection, policy-type special cases, and subject projection details behind the new strategy boundary.
|
||||
- Keep generic helpers such as `CurrentStateHashResolver`, `DriftHasher`, and finding lifecycle orchestration reusable by the job.
|
||||
|
||||
### Phase C - Strategy-Neutral Result Projection
|
||||
|
||||
- Replace raw per-subject drift arrays with a structured compare-subject result contract.
|
||||
- Feed existing finding, summary, and trust writers from strategy-provided subject projection metadata rather than universal policy defaults.
|
||||
- Keep existing run outcome, summary counts, and explanation surfaces stable.
|
||||
|
||||
### Phase D - Surface Truth Hardening
|
||||
|
||||
- Update compare start surfaces to explain unsupported or mixed-scope preconditions through existing helper text, confirmation copy, or start-result messaging.
|
||||
- Keep compare landing and canonical run detail truthful for unsupported, incomplete, ambiguous, and failed strategy outcomes without adding a new page.
|
||||
|
||||
### Phase E - Regression and Verification
|
||||
|
||||
- Extend compare precondition, finding, summary, and matrix compare-all coverage for strategy resolution and unsupported or mixed-scope behavior.
|
||||
- Keep current Intune compare classification, finding recurrence, and evidence contract coverage green to detect silent behavior drift.
|
||||
- Validate the touched compare start surfaces and run-detail messaging with focused Filament tests and existing smoke tests.
|
||||
|
||||
## Risk Assessment
|
||||
|
||||
| Risk | Impact | Likelihood | Mitigation |
|
||||
|------|--------|------------|------------|
|
||||
| The strategy seam stays superficial and platform code still depends on Intune assumptions | High | Medium | Extract policy-type branching, section normalizer selection, and strategy-owned projection metadata out of the job body; review the remaining orchestration path for universal policy defaults. |
|
||||
| Intune compare behavior regresses during extraction | High | Medium | Extend existing compare feature tests before and during extraction; keep current finding, gap, and summary suites green as the acceptance bar. |
|
||||
| The compare-subject result contract is too weak | High | Medium | Define explicit projection, diagnostics, and availability fields in Phase 1 design and require summary/finding writers to consume them without reading strategy internals directly. |
|
||||
| Unsupported or mixed-scope rejection happens too late | Medium | Medium | Resolve and validate one strategy family in `BaselineCompareService` before run creation and reuse the same logic for workspace fan-out compare. |
|
||||
| The change expands into a general compare framework | Medium | Low | Limit the new namespace and contract to baseline compare only, keep single-strategy-per-run, and defer any multi-strategy orchestration or second-domain UI work. |
|
||||
|
||||
## Test Strategy
|
||||
|
||||
- Extend `tests/Feature/Baselines/BaselineComparePreconditionsTest.php` for unsupported strategy, mixed-strategy, and inactive subject-type rejection before run creation.
|
||||
- Extend `tests/Feature/Baselines/BaselineCompareMatrixCompareAllActionTest.php` to ensure workspace fan-out compare applies the same single-strategy validation and truthful rejection behavior.
|
||||
- Extend `tests/Feature/Baselines/BaselineCompareFindingsTest.php`, `BaselineCompareGapClassificationTest.php`, and `BaselineCompareRbacRoleDefinitionsTest.php` to prove Intune finding projection and classification behavior stay stable through the extracted strategy.
|
||||
- Extend `tests/Feature/Baselines/BaselineCompareExecutionGuardTest.php` and `BaselineCompareSummaryAssessmentTest.php` to keep `OperationRun` outcome, summary, and trust semantics unchanged.
|
||||
- Add focused unit coverage for the compare strategy registry, strategy selection result, and compare-subject result mapping.
|
||||
- Extend `tests/Feature/Filament/BaselineProfileCompareStartSurfaceTest.php`, `BaselineCompareLandingStartSurfaceTest.php`, and `BaselineCompareMatrixPageTest.php` for unsupported or mixed-scope start-surface truth and no-regression launch behavior.
|
||||
- Keep existing compare browser smoke coverage green to detect accidental surface regressions in the matrix or landing experience.
|
||||
116
specs/203-baseline-compare-strategy/quickstart.md
Normal file
116
specs/203-baseline-compare-strategy/quickstart.md
Normal file
@ -0,0 +1,116 @@
|
||||
# Quickstart: Baseline Compare Engine Strategy Extraction
|
||||
|
||||
## Goal
|
||||
|
||||
Extract the current Intune-shaped compare processing behind one explicit compare strategy while preserving the existing baseline compare run lifecycle, finding lifecycle, trust semantics, and operator-facing compare story.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
1. Work on branch `203-baseline-compare-strategy`.
|
||||
2. Ensure the platform containers are available:
|
||||
|
||||
```bash
|
||||
cd apps/platform && ./vendor/bin/sail up -d
|
||||
```
|
||||
|
||||
3. Keep Spec 202's canonical scope contract available because strategy selection depends on Baseline Scope V2.
|
||||
|
||||
## Recommended Implementation Order
|
||||
|
||||
### 1. Lock the current compare behavior with focused regression tests
|
||||
|
||||
Run the existing compare-focused suite before extracting anything:
|
||||
|
||||
```bash
|
||||
cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Baselines/BaselineComparePreconditionsTest.php
|
||||
cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Baselines/BaselineCompareFindingsTest.php
|
||||
cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Baselines/BaselineCompareGapClassificationTest.php
|
||||
cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Baselines/BaselineCompareRbacRoleDefinitionsTest.php
|
||||
```
|
||||
|
||||
Add any missing tests for unsupported scope, mixed-strategy scope, and current Intune compare classification parity before moving major compare logic.
|
||||
|
||||
### 2. Introduce the compare strategy contract and selection result
|
||||
|
||||
Add the narrow compare-support namespace under `app/Support/Baselines/Compare/` with:
|
||||
|
||||
- compare strategy contract
|
||||
- strategy capability registry
|
||||
- strategy selection result
|
||||
- compare subject result contract
|
||||
|
||||
Keep these objects internal and derived. Do not add a new table or new `OperationRun` type.
|
||||
|
||||
### 3. Wire strategy validation into compare start surfaces
|
||||
|
||||
Update `BaselineCompareService` so both:
|
||||
|
||||
- tenant compare start
|
||||
- workspace compare-matrix fan-out compare
|
||||
|
||||
resolve one compatible strategy family from canonical scope before any run is enqueued.
|
||||
|
||||
Unsupported or mixed-scope requests should fail clearly before subject work begins.
|
||||
|
||||
### 4. Extract the current Intune compare implementation behind `IntuneCompareStrategy`
|
||||
|
||||
Move the current Intune-shaped subject-processing logic out of the core path in `CompareBaselineToTenantJob`, including:
|
||||
|
||||
- policy-type-specific normalizer selection
|
||||
- section or evidence shaping that assumes Intune policy structure
|
||||
- special-case subject handling such as RBAC role-definition compare rules
|
||||
- strategy-owned subject projection metadata
|
||||
|
||||
Keep generic helpers such as `CurrentStateHashResolver`, `DriftHasher`, and finding lifecycle orchestration reusable by the job.
|
||||
|
||||
### 5. Feed existing finding and summary writers from the new result contract
|
||||
|
||||
Replace raw per-subject drift arrays with the structured compare-subject result contract where orchestration needs:
|
||||
|
||||
- summary aggregation
|
||||
- finding write or update
|
||||
- diagnostics persistence
|
||||
- operator-safe degraded or failed state explanation
|
||||
|
||||
Do not create a new compare-result table.
|
||||
|
||||
### 6. Harden existing compare surfaces
|
||||
|
||||
Update the existing compare launch and review surfaces so they remain truthful for:
|
||||
|
||||
- unsupported scope
|
||||
- mixed-strategy scope
|
||||
- incomplete evidence
|
||||
- ambiguous identity
|
||||
- strategy failure
|
||||
|
||||
This work should stay within the existing baseline profile detail, compare matrix, tenant compare landing, and canonical run-detail surfaces.
|
||||
|
||||
## Focused Verification
|
||||
|
||||
Run the most relevant suites after each phase:
|
||||
|
||||
```bash
|
||||
cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Baselines/BaselineComparePreconditionsTest.php
|
||||
cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Baselines/BaselineCompareExecutionGuardTest.php
|
||||
cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Baselines/BaselineCompareFindingsTest.php
|
||||
cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Baselines/BaselineCompareMatrixCompareAllActionTest.php
|
||||
cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Baselines/BaselineCompareRbacRoleDefinitionsTest.php
|
||||
cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Filament/BaselineProfileCompareStartSurfaceTest.php
|
||||
cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Filament/BaselineCompareLandingStartSurfaceTest.php
|
||||
cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Filament/BaselineCompareMatrixPageTest.php
|
||||
```
|
||||
|
||||
If the compare landing or matrix messaging changes materially, keep existing browser smoke coverage green as a final confidence pass.
|
||||
|
||||
## Final Validation
|
||||
|
||||
1. Run formatting:
|
||||
|
||||
```bash
|
||||
cd apps/platform && ./vendor/bin/sail bin pint --dirty --format agent
|
||||
```
|
||||
|
||||
2. Re-run the focused compare test pack.
|
||||
3. Confirm that unsupported or mixed-scope compare requests fail before enqueue.
|
||||
4. Confirm that the current Intune compare path still produces the same operator-visible finding, summary, and trust outcomes.
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user