Compare commits

...

3 Commits

Author SHA1 Message Date
c0f4587d90 Spec 197: standardize shared detail family contracts (#237)
## Summary
- standardize the shared verification report family across operation detail, onboarding, and tenant verification widget hosts
- standardize normalized settings and normalized diff family wrappers across policy, policy version, and finding detail hosts
- add parity and guard coverage plus the full Spec 197 artifacts, including recorded manual smoke evidence

## Testing
- focused Sail regression pack from `specs/197-shared-detail-contract/quickstart.md`
- local integrated-browser manual smoke for SC-197-003 and SC-197-004

Co-authored-by: Ahmed Darrazi <ahmed.darrazi@live.de>
Reviewed-on: #237
2026-04-15 09:51:42 +00:00
4699f13a72 Spec 196: restore native Filament table contracts (#236)
## Summary
- replace the inventory dependency GET/apply flow with an embedded native Filament `TableComponent`
- convert tenant required permissions and evidence overview to native page-owned Filament tables with mount-only query seeding and preserved scope authority
- extend focused Pest, Livewire, RBAC, and guard coverage, and update the Spec 196 artifacts and release close-out notes

## Verification
- `cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/InventoryItemDependenciesTest.php tests/Feature/Filament/InventoryItemDependencyEdgesTableTest.php tests/Feature/Rbac/TenantRequiredPermissionsTrustedStateTest.php tests/Feature/Filament/TenantRequiredPermissionsPageTest.php tests/Feature/Evidence/EvidenceOverviewPageTest.php tests/Feature/Filament/EvidenceOverviewDerivedStateMemoizationTest.php tests/Feature/Guards/FilamentTableStandardsGuardTest.php tests/Unit/TenantRequiredPermissionsFilteringTest.php tests/Unit/TenantRequiredPermissionsOverallStatusTest.php tests/Unit/TenantRequiredPermissionsFeatureImpactTest.php tests/Unit/TenantRequiredPermissionsFreshnessTest.php tests/Unit/TenantRequiredPermissionsCopyPayloadTest.php` (`45` tests, `177` assertions)
- `cd apps/platform && ./vendor/bin/sail bin pint --dirty --format agent`
- integrated-browser smoke on localhost for inventory detail dependencies, tenant required permissions, and evidence overview

Co-authored-by: Ahmed Darrazi <ahmed.darrazi@live.de>
Reviewed-on: #236
2026-04-14 23:30:53 +00:00
bb72a54e84 Refactor: remove compare job legacy drift path (#235)
## Summary
- remove the dead legacy drift-computation path from `CompareBaselineToTenantJob` so the strategy-driven compare engine is the only execution path left in the orchestration file
- tighten compare guard and regression coverage around strategy selection, strategy execution context, findings, gaps, and no-drift outcomes
- fix the repo-wide suite blockers uncovered during validation by making the governance taxonomy registry test-double compatible and aligning the capture capability guard test with current unsupported-scope behavior
- add the Spec 205 planning artifacts and mark the implementation tasks complete

## Verification
- `cd apps/platform && ./vendor/bin/sail bin pint --dirty --format agent`
- `cd apps/platform && ./vendor/bin/sail artisan test --compact tests --stop-on-failure`
  - result: `3659 passed, 8 skipped (21016 assertions)`
- browser smoke test passed on the Baseline Compare landing surface via the local smoke-login flow

## Notes
- no Filament resource, panel, global search, destructive action, or asset registration behavior was changed
- provider registration remains unchanged in `apps/platform/bootstrap/providers.php`
- the compare path remains strategy-driven and Livewire v4 / Filament v5 assumptions are unchanged

Co-authored-by: Ahmed Darrazi <ahmed.darrazi@live.de>
Reviewed-on: #235
2026-04-14 21:54:37 +00:00
95 changed files with 7867 additions and 3923 deletions

View File

@ -184,6 +184,10 @@ ## Active Technologies
- 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 + Laravel 12, Filament v5, Livewire v4, Pest v4, Laravel Sail, existing `GovernanceSubjectTaxonomyRegistry`, `BaselineScope`, `CompareStrategyRegistry`, `OperationCatalog`, `OperationRunType`, `ReasonTranslator`, `ReasonResolutionEnvelope`, `ProviderReasonTranslator`, and current Filament monitoring or review surfaces (204-platform-core-vocabulary-hardening)
- PostgreSQL via existing `operation_runs.type`, `operation_runs.context`, `baseline_profiles.scope_jsonb`, `baseline_snapshot_items`, findings, evidence payloads, and current config-backed registries; no new top-level tables planned (204-platform-core-vocabulary-hardening)
- PHP 8.4.15 + Laravel 12, Filament v5, Livewire v4, Pest v4, Laravel Sail, existing `BaselineCompareService`, `CompareBaselineToTenantJob`, `CompareStrategyRegistry`, `IntuneCompareStrategy`, `CurrentStateHashResolver`, and current finding lifecycle services (205-compare-job-cleanup)
- PostgreSQL via existing baseline snapshots, baseline snapshot items, inventory items, `operation_runs`, findings, and current run-context JSON; no new storage planned (205-compare-job-cleanup)
- PHP 8.4.15 + Laravel 12, Filament v5, Livewire v4, Pest v4, Tailwind CSS v4, existing `VerificationReportViewer`, `VerificationReportChangeIndicator`, `PolicyNormalizer`, `VersionDiff`, `DriftFindingDiffBuilder`, and `SettingsCatalogSettingsTable` (197-shared-detail-contract)
- PostgreSQL unchanged; no new persistence, cache store, or durable UI artifact (197-shared-detail-contract)
- PHP 8.4.15 (feat/005-bulk-operations)
@ -218,8 +222,8 @@ ## Code Style
PHP 8.4.15: Follow standard conventions
## Recent Changes
- 197-shared-detail-contract: Added PHP 8.4.15 + Laravel 12, Filament v5, Livewire v4, Pest v4, Tailwind CSS v4, existing `VerificationReportViewer`, `VerificationReportChangeIndicator`, `PolicyNormalizer`, `VersionDiff`, `DriftFindingDiffBuilder`, and `SettingsCatalogSettingsTable`
- 205-compare-job-cleanup: Added PHP 8.4.15 + Laravel 12, Filament v5, Livewire v4, Pest v4, Laravel Sail, existing `BaselineCompareService`, `CompareBaselineToTenantJob`, `CompareStrategyRegistry`, `IntuneCompareStrategy`, `CurrentStateHashResolver`, and current finding lifecycle services
- 204-platform-core-vocabulary-hardening: Added PHP 8.4.15 + Laravel 12, Filament v5, Livewire v4, Pest v4, Laravel Sail, existing `GovernanceSubjectTaxonomyRegistry`, `BaselineScope`, `CompareStrategyRegistry`, `OperationCatalog`, `OperationRunType`, `ReasonTranslator`, `ReasonResolutionEnvelope`, `ProviderReasonTranslator`, and current Filament monitoring or review surfaces
- 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`
<!-- MANUAL ADDITIONS START -->
<!-- MANUAL ADDITIONS END -->

View File

@ -20,14 +20,25 @@
use App\Support\Ui\GovernanceArtifactTruth\ArtifactTruthEnvelope;
use App\Support\Ui\GovernanceArtifactTruth\ArtifactTruthPresenter;
use App\Support\Workspaces\WorkspaceContext;
use App\Support\Filament\TablePaginationProfiles;
use BackedEnum;
use Filament\Actions\Action;
use Filament\Pages\Page;
use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Concerns\InteractsWithTable;
use Filament\Tables\Contracts\HasTable;
use Filament\Tables\Filters\SelectFilter;
use Filament\Tables\Table;
use Illuminate\Auth\AuthenticationException;
use Illuminate\Pagination\LengthAwarePaginator;
use Illuminate\Support\Collection;
use Illuminate\Support\Str;
use UnitEnum;
class EvidenceOverview extends Page
class EvidenceOverview extends Page implements HasTable
{
use InteractsWithTable;
protected static bool $isDiscovered = false;
protected static bool $shouldRegisterNavigation = false;
@ -45,7 +56,12 @@ class EvidenceOverview extends Page
*/
public array $rows = [];
public ?int $tenantFilter = null;
/**
* @var array<int, Tenant>|null
*/
private ?array $accessibleTenants = null;
private ?Collection $cachedSnapshots = null;
public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration
{
@ -59,85 +75,92 @@ public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration
public function mount(): void
{
$user = auth()->user();
$this->authorizeWorkspaceAccess();
$this->seedTableStateFromQuery();
$this->rows = $this->rowsForState($this->tableFilters ?? [], $this->tableSearch)->values()->all();
if (! $user instanceof User) {
throw new AuthenticationException;
}
$this->mountInteractsWithTable();
}
$workspaceContext = app(WorkspaceContext::class);
$workspace = $workspaceContext->currentWorkspaceForMemberOrFail($user, request());
$workspaceId = (int) $workspace->getKey();
public function table(Table $table): Table
{
return $table
->defaultSort('tenant_name')
->defaultPaginationPageOption(25)
->paginated(TablePaginationProfiles::customPage())
->persistFiltersInSession()
->persistSearchInSession()
->persistSortInSession()
->searchable()
->searchPlaceholder('Search tenant or next step')
->records(function (
?string $sortColumn,
?string $sortDirection,
?string $search,
array $filters,
int $page,
int $recordsPerPage
): LengthAwarePaginator {
$rows = $this->rowsForState($filters, $search);
$rows = $this->sortRows($rows, $sortColumn, $sortDirection);
$accessibleTenants = $user->tenants()
->where('tenants.workspace_id', $workspaceId)
->orderBy('tenants.name')
->get()
->filter(fn (Tenant $tenant): bool => (int) $tenant->workspace_id === $workspaceId && $user->can('evidence.view', $tenant))
->values();
$this->tenantFilter = is_numeric(request()->query('tenant_id')) ? (int) request()->query('tenant_id') : null;
$tenantIds = $accessibleTenants->pluck('id')->map(static fn (mixed $id): int => (int) $id)->all();
$query = EvidenceSnapshot::query()
->with('tenant')
->where('workspace_id', $workspaceId)
->whereIn('tenant_id', $tenantIds)
->where('status', 'active')
->latest('generated_at');
if ($this->tenantFilter !== null) {
$query->where('tenant_id', $this->tenantFilter);
}
$snapshots = $query->get()->unique('tenant_id')->values();
$currentReviewTenantIds = TenantReview::query()
->where('workspace_id', $workspaceId)
->whereIn('tenant_id', $snapshots->pluck('tenant_id')->map(static fn (mixed $tenantId): int => (int) $tenantId)->all())
->whereIn('status', [
TenantReviewStatus::Draft->value,
TenantReviewStatus::Ready->value,
TenantReviewStatus::Published->value,
return $this->paginateRows($rows, $page, $recordsPerPage);
})
->filters([
SelectFilter::make('tenant_id')
->label('Tenant')
->options(fn (): array => $this->tenantFilterOptions())
->searchable(),
])
->pluck('tenant_id')
->mapWithKeys(static fn (mixed $tenantId): array => [(int) $tenantId => true])
->all();
$this->rows = $snapshots->map(function (EvidenceSnapshot $snapshot) use ($currentReviewTenantIds): array {
$truth = $this->snapshotTruth($snapshot);
$freshnessSpec = BadgeCatalog::spec(BadgeDomain::GovernanceArtifactFreshness, $truth->freshnessState);
$tenantId = (int) $snapshot->tenant_id;
$hasCurrentReview = $currentReviewTenantIds[$tenantId] ?? false;
$nextStep = ! $hasCurrentReview && $truth->contentState === 'trusted' && $truth->freshnessState === 'current'
? 'Create a current review from this evidence snapshot'
: $truth->nextStepText();
return [
'tenant_name' => $snapshot->tenant?->name ?? 'Unknown tenant',
'tenant_id' => $tenantId,
'snapshot_id' => (int) $snapshot->getKey(),
'completeness_state' => (string) $snapshot->completeness_state,
'generated_at' => $snapshot->generated_at?->toDateTimeString(),
'missing_dimensions' => (int) (($snapshot->summary['missing_dimensions'] ?? 0)),
'stale_dimensions' => (int) (($snapshot->summary['stale_dimensions'] ?? 0)),
'artifact_truth' => [
'label' => $truth->primaryLabel,
'color' => $truth->primaryBadgeSpec()->color,
'icon' => $truth->primaryBadgeSpec()->icon,
'explanation' => $truth->primaryExplanation,
],
'freshness' => [
'label' => $freshnessSpec->label,
'color' => $freshnessSpec->color,
'icon' => $freshnessSpec->icon,
],
'next_step' => $nextStep,
'view_url' => $snapshot->tenant
? EvidenceSnapshotResource::getUrl('view', ['record' => $snapshot], tenant: $snapshot->tenant)
: null,
];
})->all();
->columns([
TextColumn::make('tenant_name')
->label('Tenant')
->sortable(),
TextColumn::make('artifact_truth_label')
->label('Artifact truth')
->badge()
->color(fn (array $record): string => (string) ($record['artifact_truth_color'] ?? 'gray'))
->icon(fn (array $record): ?string => is_string($record['artifact_truth_icon'] ?? null) ? $record['artifact_truth_icon'] : null)
->description(fn (array $record): ?string => is_string($record['artifact_truth_explanation'] ?? null) ? $record['artifact_truth_explanation'] : null)
->sortable()
->wrap(),
TextColumn::make('freshness_label')
->label('Freshness')
->badge()
->color(fn (array $record): string => (string) ($record['freshness_color'] ?? 'gray'))
->icon(fn (array $record): ?string => is_string($record['freshness_icon'] ?? null) ? $record['freshness_icon'] : null)
->sortable(),
TextColumn::make('generated_at')
->label('Generated')
->placeholder('—')
->sortable(),
TextColumn::make('missing_dimensions')
->label('Not collected yet')
->numeric()
->sortable(),
TextColumn::make('stale_dimensions')
->label('Refresh recommended')
->numeric()
->sortable(),
TextColumn::make('next_step')
->label('Next step')
->wrap(),
])
->recordUrl(fn ($record): ?string => is_array($record) ? (is_string($record['view_url'] ?? null) ? $record['view_url'] : null) : null)
->actions([])
->bulkActions([])
->emptyStateHeading('No evidence snapshots in this scope')
->emptyStateDescription(fn (): string => $this->hasActiveOverviewFilters()
? 'Clear the current filters to return to the full workspace evidence overview.'
: 'Adjust filters or create a tenant snapshot to populate the workspace overview.')
->emptyStateActions([
Action::make('clear_filters')
->label('Clear filters')
->icon('heroicon-o-x-mark')
->color('gray')
->visible(fn (): bool => $this->hasActiveOverviewFilters())
->action(fn (): mixed => $this->clearOverviewFilters()),
]);
}
/**
@ -149,11 +172,26 @@ protected function getHeaderActions(): array
Action::make('clear_filters')
->label('Clear filters')
->color('gray')
->visible(fn (): bool => $this->tenantFilter !== null)
->url(route('admin.evidence.overview')),
->visible(fn (): bool => $this->hasActiveOverviewFilters())
->action(fn (): mixed => $this->clearOverviewFilters()),
];
}
public function clearOverviewFilters(): void
{
$this->tableFilters = [
'tenant_id' => ['value' => null],
];
$this->tableDeferredFilters = $this->tableFilters;
$this->tableSearch = '';
$this->rows = $this->rowsForState($this->tableFilters, $this->tableSearch)->values()->all();
session()->put($this->getTableFiltersSessionKey(), $this->tableFilters);
session()->put($this->getTableSearchSessionKey(), $this->tableSearch);
$this->resetPage();
}
private function snapshotTruth(EvidenceSnapshot $snapshot, bool $fresh = false): ArtifactTruthEnvelope
{
$presenter = app(ArtifactTruthPresenter::class);
@ -162,4 +200,290 @@ private function snapshotTruth(EvidenceSnapshot $snapshot, bool $fresh = false):
? $presenter->forEvidenceSnapshotFresh($snapshot)
: $presenter->forEvidenceSnapshot($snapshot);
}
private function authorizeWorkspaceAccess(): void
{
$user = auth()->user();
if (! $user instanceof User) {
throw new AuthenticationException;
}
app(WorkspaceContext::class)->currentWorkspaceForMemberOrFail($user, request());
}
/**
* @return array<int, Tenant>
*/
private function accessibleTenants(): array
{
if (is_array($this->accessibleTenants)) {
return $this->accessibleTenants;
}
$user = auth()->user();
if (! $user instanceof User) {
return $this->accessibleTenants = [];
}
$workspaceId = $this->workspaceId();
return $this->accessibleTenants = $user->tenants()
->where('tenants.workspace_id', $workspaceId)
->orderBy('tenants.name')
->get()
->filter(fn (Tenant $tenant): bool => (int) $tenant->workspace_id === $workspaceId && $user->can('evidence.view', $tenant))
->values()
->all();
}
/**
* @return array<string, string>
*/
private function tenantFilterOptions(): array
{
return collect($this->accessibleTenants())
->mapWithKeys(static fn (Tenant $tenant): array => [
(string) $tenant->getKey() => $tenant->name,
])
->all();
}
/**
* @param array<string, mixed> $filters
* @return Collection<string, array<string, mixed>>
*/
private function rowsForState(array $filters = [], ?string $search = null): Collection
{
$rows = $this->baseRows();
$tenantFilter = $this->normalizeTenantFilter($filters['tenant_id']['value'] ?? data_get($this->tableFilters, 'tenant_id.value'));
$normalizedSearch = Str::lower(trim((string) ($search ?? $this->tableSearch)));
if ($tenantFilter !== null) {
$rows = $rows->where('tenant_id', $tenantFilter);
}
if ($normalizedSearch === '') {
return $rows;
}
return $rows->filter(function (array $row) use ($normalizedSearch): bool {
$haystack = implode(' ', [
(string) ($row['tenant_name'] ?? ''),
(string) ($row['artifact_truth_label'] ?? ''),
(string) ($row['artifact_truth_explanation'] ?? ''),
(string) ($row['freshness_label'] ?? ''),
(string) ($row['next_step'] ?? ''),
]);
return str_contains(Str::lower($haystack), $normalizedSearch);
});
}
/**
* @return Collection<string, array<string, mixed>>
*/
private function baseRows(): Collection
{
$snapshots = $this->latestAccessibleSnapshots();
$currentReviewTenantIds = $this->currentReviewTenantIds($snapshots);
return $snapshots->mapWithKeys(function (EvidenceSnapshot $snapshot) use ($currentReviewTenantIds): array {
return [(string) $snapshot->getKey() => $this->rowForSnapshot($snapshot, $currentReviewTenantIds)];
});
}
/**
* @return Collection<int, EvidenceSnapshot>
*/
private function latestAccessibleSnapshots(): Collection
{
if ($this->cachedSnapshots instanceof Collection) {
return $this->cachedSnapshots;
}
$tenantIds = collect($this->accessibleTenants())
->map(static fn (Tenant $tenant): int => (int) $tenant->getKey())
->all();
$query = EvidenceSnapshot::query()
->with('tenant')
->where('workspace_id', $this->workspaceId())
->where('status', 'active')
->latest('generated_at');
if ($tenantIds === []) {
$query->whereRaw('1 = 0');
} else {
$query->whereIn('tenant_id', $tenantIds);
}
return $this->cachedSnapshots = $query->get()->unique('tenant_id')->values();
}
/**
* @param Collection<int, EvidenceSnapshot> $snapshots
* @return array<int, bool>
*/
private function currentReviewTenantIds(Collection $snapshots): array
{
return TenantReview::query()
->where('workspace_id', $this->workspaceId())
->whereIn('tenant_id', $snapshots->pluck('tenant_id')->map(static fn (mixed $tenantId): int => (int) $tenantId)->all())
->whereIn('status', [
TenantReviewStatus::Draft->value,
TenantReviewStatus::Ready->value,
TenantReviewStatus::Published->value,
])
->pluck('tenant_id')
->mapWithKeys(static fn (mixed $tenantId): array => [(int) $tenantId => true])
->all();
}
/**
* @param array<int, bool> $currentReviewTenantIds
* @return array<string, mixed>
*/
private function rowForSnapshot(EvidenceSnapshot $snapshot, array $currentReviewTenantIds): array
{
$truth = $this->snapshotTruth($snapshot);
$freshnessSpec = BadgeCatalog::spec(BadgeDomain::GovernanceArtifactFreshness, $truth->freshnessState);
$tenantId = (int) $snapshot->tenant_id;
$hasCurrentReview = $currentReviewTenantIds[$tenantId] ?? false;
$nextStep = ! $hasCurrentReview && $truth->contentState === 'trusted' && $truth->freshnessState === 'current'
? 'Create a current review from this evidence snapshot'
: $truth->nextStepText();
return [
'tenant_name' => $snapshot->tenant?->name ?? 'Unknown tenant',
'tenant_id' => $tenantId,
'snapshot_id' => (int) $snapshot->getKey(),
'generated_at' => $snapshot->generated_at?->toDateTimeString(),
'missing_dimensions' => (int) ($snapshot->summary['missing_dimensions'] ?? 0),
'stale_dimensions' => (int) ($snapshot->summary['stale_dimensions'] ?? 0),
'artifact_truth_label' => $truth->primaryLabel,
'artifact_truth_color' => $truth->primaryBadgeSpec()->color,
'artifact_truth_icon' => $truth->primaryBadgeSpec()->icon,
'artifact_truth_explanation' => $truth->primaryExplanation,
'artifact_truth' => [
'label' => $truth->primaryLabel,
'color' => $truth->primaryBadgeSpec()->color,
'icon' => $truth->primaryBadgeSpec()->icon,
'explanation' => $truth->primaryExplanation,
],
'freshness_label' => $freshnessSpec->label,
'freshness_color' => $freshnessSpec->color,
'freshness_icon' => $freshnessSpec->icon,
'freshness' => [
'label' => $freshnessSpec->label,
'color' => $freshnessSpec->color,
'icon' => $freshnessSpec->icon,
],
'next_step' => $nextStep,
'view_url' => $snapshot->tenant
? EvidenceSnapshotResource::getUrl('view', ['record' => $snapshot], tenant: $snapshot->tenant)
: null,
];
}
/**
* @param Collection<string, array<string, mixed>> $rows
* @return Collection<string, array<string, mixed>>
*/
private function sortRows(Collection $rows, ?string $sortColumn, ?string $sortDirection): Collection
{
$sortColumn = in_array($sortColumn, ['tenant_name', 'artifact_truth_label', 'freshness_label', 'generated_at', 'missing_dimensions', 'stale_dimensions'], true)
? $sortColumn
: 'tenant_name';
$descending = Str::lower((string) ($sortDirection ?? 'asc')) === 'desc';
$records = $rows->all();
uasort($records, static function (array $left, array $right) use ($sortColumn, $descending): int {
$comparison = in_array($sortColumn, ['missing_dimensions', 'stale_dimensions'], true)
? ((int) ($left[$sortColumn] ?? 0)) <=> ((int) ($right[$sortColumn] ?? 0))
: strnatcasecmp((string) ($left[$sortColumn] ?? ''), (string) ($right[$sortColumn] ?? ''));
if ($comparison === 0) {
$comparison = strnatcasecmp((string) ($left['tenant_name'] ?? ''), (string) ($right['tenant_name'] ?? ''));
}
return $descending ? ($comparison * -1) : $comparison;
});
return collect($records);
}
/**
* @param Collection<string, array<string, mixed>> $rows
*/
private function paginateRows(Collection $rows, int $page, int $recordsPerPage): LengthAwarePaginator
{
return new LengthAwarePaginator(
items: $rows->forPage($page, $recordsPerPage),
total: $rows->count(),
perPage: $recordsPerPage,
currentPage: $page,
);
}
private function seedTableStateFromQuery(): void
{
$query = request()->query();
if (array_key_exists('search', $query)) {
$this->tableSearch = trim((string) request()->query('search', ''));
}
if (! array_key_exists('tenant_id', $query)) {
return;
}
$tenantFilter = $this->normalizeTenantFilter(request()->query('tenant_id'));
if ($tenantFilter === null) {
return;
}
$this->tableFilters = [
'tenant_id' => ['value' => (string) $tenantFilter],
];
$this->tableDeferredFilters = $this->tableFilters;
}
private function normalizeTenantFilter(mixed $value): ?int
{
if (! is_numeric($value)) {
return null;
}
$requestedTenantId = (int) $value;
$allowedTenantIds = collect($this->accessibleTenants())
->map(static fn (Tenant $tenant): int => (int) $tenant->getKey())
->all();
return in_array($requestedTenantId, $allowedTenantIds, true)
? $requestedTenantId
: null;
}
private function hasActiveOverviewFilters(): bool
{
return filled(data_get($this->tableFilters, 'tenant_id.value'))
|| trim((string) $this->tableSearch) !== '';
}
private function workspaceId(): int
{
$user = auth()->user();
if (! $user instanceof User) {
throw new AuthenticationException;
}
return (int) app(WorkspaceContext::class)
->currentWorkspaceForMemberOrFail($user, request())
->getKey();
}
}

View File

@ -10,16 +10,30 @@
use App\Models\User;
use App\Models\WorkspaceMembership;
use App\Services\Intune\TenantRequiredPermissionsViewModelBuilder;
use App\Support\Badges\BadgeDomain;
use App\Support\Badges\BadgeRenderer;
use App\Support\Filament\TablePaginationProfiles;
use App\Support\Ui\ActionSurface\ActionSurfaceDeclaration;
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceProfile;
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceSlot;
use App\Support\Workspaces\WorkspaceContext;
use Filament\Actions\Action;
use Filament\Pages\Page;
use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Concerns\InteractsWithTable;
use Filament\Tables\Contracts\HasTable;
use Filament\Tables\Filters\SelectFilter;
use Filament\Tables\Table;
use Illuminate\Pagination\LengthAwarePaginator;
use Illuminate\Support\Collection;
use Illuminate\Support\Str;
use Livewire\Attributes\Locked;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
class TenantRequiredPermissions extends Page
class TenantRequiredPermissions extends Page implements HasTable
{
use InteractsWithTable;
protected static bool $isDiscovered = false;
protected static bool $shouldRegisterNavigation = false;
@ -40,25 +54,16 @@ public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration
->satisfy(ActionSurfaceSlot::ListEmptyState, 'The inline permissions matrix provides purposeful no-data, all-clear, and no-matches states with verification or reset guidance.');
}
public string $status = 'missing';
public string $type = 'all';
/**
* @var array<int, string>
*/
public array $features = [];
public string $search = '';
/**
* @var array<string, mixed>
*/
public array $viewModel = [];
#[Locked]
public ?int $scopedTenantId = null;
/**
* @var array<string, mixed>|null
*/
private ?array $cachedViewModel = null;
private ?string $cachedViewModelStateKey = null;
public static function canAccess(): bool
{
return static::hasScopedTenantAccess(static::resolveScopedTenant());
@ -69,9 +74,9 @@ public function currentTenant(): ?Tenant
return $this->trustedScopedTenant();
}
public function mount(): void
public function mount(Tenant|string|null $tenant = null): void
{
$tenant = static::resolveScopedTenant();
$tenant = static::resolveScopedTenant($tenant);
if (! $tenant instanceof Tenant || ! static::hasScopedTenantAccess($tenant)) {
abort(404);
@ -81,109 +86,120 @@ public function mount(): void
$this->heading = $tenant->getFilamentName();
$this->subheading = 'Required permissions';
$queryFeatures = request()->query('features', $this->features);
$state = TenantRequiredPermissionsViewModelBuilder::normalizeFilterState([
'status' => request()->query('status', $this->status),
'type' => request()->query('type', $this->type),
'features' => is_array($queryFeatures) ? $queryFeatures : [],
'search' => request()->query('search', $this->search),
]);
$this->status = $state['status'];
$this->type = $state['type'];
$this->features = $state['features'];
$this->search = $state['search'];
$this->refreshViewModel();
$this->seedTableStateFromQuery();
$this->mountInteractsWithTable();
}
public function updatedStatus(): void
public function table(Table $table): Table
{
$this->refreshViewModel();
return $table
->defaultSort('sort_priority')
->defaultPaginationPageOption(25)
->paginated(TablePaginationProfiles::customPage())
->persistFiltersInSession()
->persistSearchInSession()
->persistSortInSession()
->searchable()
->searchPlaceholder('Search permission key or description…')
->records(function (
?string $sortColumn,
?string $sortDirection,
?string $search,
array $filters,
int $page,
int $recordsPerPage
): LengthAwarePaginator {
$state = $this->filterState(filters: $filters, search: $search);
$rows = $this->permissionRowsForState($state);
$rows = $this->sortPermissionRows($rows, $sortColumn, $sortDirection);
return $this->paginatePermissionRows($rows, $page, $recordsPerPage);
})
->filters([
SelectFilter::make('status')
->label('Status')
->default('missing')
->options([
'missing' => 'Missing',
'present' => 'Present',
'all' => 'All',
]),
SelectFilter::make('type')
->label('Type')
->default('all')
->options([
'all' => 'All',
'application' => 'Application',
'delegated' => 'Delegated',
]),
SelectFilter::make('features')
->label('Features')
->multiple()
->options(fn (): array => $this->featureFilterOptions())
->searchable(),
])
->columns([
TextColumn::make('key')
->label('Permission')
->description(fn (array $record): ?string => is_string($record['description'] ?? null) ? $record['description'] : null)
->wrap()
->sortable(),
TextColumn::make('type_label')
->label('Type')
->badge()
->color('gray')
->sortable(),
TextColumn::make('status')
->label('Status')
->badge()
->formatStateUsing(BadgeRenderer::label(BadgeDomain::TenantPermissionStatus))
->color(BadgeRenderer::color(BadgeDomain::TenantPermissionStatus))
->icon(BadgeRenderer::icon(BadgeDomain::TenantPermissionStatus))
->iconColor(BadgeRenderer::iconColor(BadgeDomain::TenantPermissionStatus))
->sortable(),
TextColumn::make('features_label')
->label('Features')
->wrap()
->toggleable(),
])
->actions([])
->bulkActions([])
->emptyStateHeading(fn (): string => $this->permissionsEmptyStateHeading())
->emptyStateDescription(fn (): string => $this->permissionsEmptyStateDescription())
->emptyStateActions([
Action::make('clear_filters')
->label('Clear filters')
->icon('heroicon-o-x-mark')
->color('gray')
->visible(fn (): bool => $this->hasActivePermissionFilters())
->action(fn (): mixed => $this->clearPermissionFilters()),
]);
}
public function updatedType(): void
/**
* @return array<string, mixed>
*/
public function viewModel(): array
{
$this->refreshViewModel();
return $this->viewModelForState($this->filterState());
}
public function updatedFeatures(): void
public function clearPermissionFilters(): void
{
$this->refreshViewModel();
}
$this->tableFilters = [
'status' => ['value' => 'missing'],
'type' => ['value' => 'all'],
'features' => ['values' => []],
];
$this->tableDeferredFilters = $this->tableFilters;
$this->tableSearch = '';
$this->cachedViewModel = null;
$this->cachedViewModelStateKey = null;
public function updatedSearch(): void
{
$this->refreshViewModel();
}
session()->put($this->getTableFiltersSessionKey(), $this->tableFilters);
session()->put($this->getTableSearchSessionKey(), $this->tableSearch);
public function applyFeatureFilter(string $feature): void
{
$feature = trim($feature);
if ($feature === '') {
return;
}
if (in_array($feature, $this->features, true)) {
$this->features = array_values(array_filter(
$this->features,
static fn (string $value): bool => $value !== $feature,
));
} else {
$this->features[] = $feature;
}
$this->features = array_values(array_unique($this->features));
$this->refreshViewModel();
}
public function clearFeatureFilter(): void
{
$this->features = [];
$this->refreshViewModel();
}
public function resetFilters(): void
{
$this->status = 'missing';
$this->type = 'all';
$this->features = [];
$this->search = '';
$this->refreshViewModel();
}
private function refreshViewModel(): void
{
$tenant = $this->trustedScopedTenant();
if (! $tenant instanceof Tenant) {
$this->viewModel = [];
return;
}
$builder = app(TenantRequiredPermissionsViewModelBuilder::class);
$this->viewModel = $builder->build($tenant, [
'status' => $this->status,
'type' => $this->type,
'features' => $this->features,
'search' => $this->search,
]);
$filters = $this->viewModel['filters'] ?? null;
if (is_array($filters)) {
$this->status = (string) ($filters['status'] ?? $this->status);
$this->type = (string) ($filters['type'] ?? $this->type);
$this->features = is_array($filters['features'] ?? null) ? $filters['features'] : $this->features;
$this->search = (string) ($filters['search'] ?? $this->search);
}
$this->resetPage();
}
public function reRunVerificationUrl(): string
@ -208,8 +224,18 @@ public function manageProviderConnectionUrl(): ?string
return ProviderConnectionResource::getUrl('index', ['tenant' => $tenant], panel: 'admin');
}
protected static function resolveScopedTenant(): ?Tenant
protected static function resolveScopedTenant(Tenant|string|null $tenant = null): ?Tenant
{
if ($tenant instanceof Tenant) {
return $tenant;
}
if (is_string($tenant) && $tenant !== '') {
return Tenant::query()
->where('external_id', $tenant)
->first();
}
$routeTenant = request()->route('tenant');
if ($routeTenant instanceof Tenant) {
@ -222,6 +248,14 @@ protected static function resolveScopedTenant(): ?Tenant
->first();
}
$queryTenant = request()->query('tenant');
if (is_string($queryTenant) && $queryTenant !== '') {
return Tenant::query()
->where('external_id', $queryTenant)
->first();
}
return null;
}
@ -293,4 +327,216 @@ private function trustedScopedTenant(): ?Tenant
return null;
}
}
/**
* @param array<string, mixed> $filters
* @return array{status:'missing'|'present'|'all',type:'application'|'delegated'|'all',features:array<int, string>,search:string}
*/
private function filterState(array $filters = [], ?string $search = null): array
{
return TenantRequiredPermissionsViewModelBuilder::normalizeFilterState([
'status' => $filters['status']['value'] ?? data_get($this->tableFilters, 'status.value'),
'type' => $filters['type']['value'] ?? data_get($this->tableFilters, 'type.value'),
'features' => $filters['features']['values'] ?? data_get($this->tableFilters, 'features.values', []),
'search' => $search ?? $this->tableSearch,
]);
}
/**
* @param array{status:'missing'|'present'|'all',type:'application'|'delegated'|'all',features:array<int, string>,search:string} $state
* @return array<string, mixed>
*/
private function viewModelForState(array $state): array
{
$tenant = $this->trustedScopedTenant();
if (! $tenant instanceof Tenant) {
return [];
}
$stateKey = json_encode([$tenant->getKey(), $state]);
if ($this->cachedViewModelStateKey === $stateKey && is_array($this->cachedViewModel)) {
return $this->cachedViewModel;
}
$builder = app(TenantRequiredPermissionsViewModelBuilder::class);
$this->cachedViewModelStateKey = $stateKey ?: null;
$this->cachedViewModel = $builder->build($tenant, $state);
return $this->cachedViewModel;
}
/**
* @return Collection<string, array<string, mixed>>
*/
private function permissionRowsForState(array $state): Collection
{
return collect($this->viewModelForState($state)['permissions'] ?? [])
->filter(fn (mixed $row): bool => is_array($row) && is_string($row['key'] ?? null))
->mapWithKeys(function (array $row): array {
$key = (string) $row['key'];
return [
$key => [
'key' => $key,
'description' => is_string($row['description'] ?? null) ? $row['description'] : null,
'type' => (string) ($row['type'] ?? 'application'),
'type_label' => ($row['type'] ?? 'application') === 'delegated' ? 'Delegated' : 'Application',
'status' => (string) ($row['status'] ?? 'missing'),
'features_label' => implode(', ', array_filter((array) ($row['features'] ?? []), 'is_string')),
'sort_priority' => $this->statusSortWeight((string) ($row['status'] ?? 'missing')),
],
];
});
}
/**
* @param Collection<string, array<string, mixed>> $rows
* @return Collection<string, array<string, mixed>>
*/
private function sortPermissionRows(Collection $rows, ?string $sortColumn, ?string $sortDirection): Collection
{
$sortColumn = in_array($sortColumn, ['sort_priority', 'key', 'type_label', 'status', 'features_label'], true)
? $sortColumn
: 'sort_priority';
$descending = Str::lower((string) ($sortDirection ?? 'asc')) === 'desc';
$records = $rows->all();
uasort($records, static function (array $left, array $right) use ($sortColumn, $descending): int {
$comparison = match ($sortColumn) {
'sort_priority' => ((int) ($left['sort_priority'] ?? 0)) <=> ((int) ($right['sort_priority'] ?? 0)),
default => strnatcasecmp(
(string) ($left[$sortColumn] ?? ''),
(string) ($right[$sortColumn] ?? ''),
),
};
if ($comparison === 0) {
$comparison = strnatcasecmp(
(string) ($left['key'] ?? ''),
(string) ($right['key'] ?? ''),
);
}
return $descending ? ($comparison * -1) : $comparison;
});
return collect($records);
}
/**
* @param Collection<string, array<string, mixed>> $rows
*/
private function paginatePermissionRows(Collection $rows, int $page, int $recordsPerPage): LengthAwarePaginator
{
return new LengthAwarePaginator(
items: $rows->forPage($page, $recordsPerPage),
total: $rows->count(),
perPage: $recordsPerPage,
currentPage: $page,
);
}
/**
* @return array<string, string>
*/
private function featureFilterOptions(): array
{
return collect(data_get($this->viewModelForState([
'status' => 'all',
'type' => 'all',
'features' => [],
'search' => '',
]), 'overview.feature_impacts', []))
->filter(fn (mixed $impact): bool => is_array($impact) && is_string($impact['feature'] ?? null))
->mapWithKeys(fn (array $impact): array => [
(string) $impact['feature'] => (string) $impact['feature'],
])
->all();
}
private function permissionsEmptyStateHeading(): string
{
$viewModel = $this->viewModel();
$counts = is_array(data_get($viewModel, 'overview.counts')) ? data_get($viewModel, 'overview.counts') : [];
$state = $this->filterState();
$allPermissions = data_get($this->viewModelForState([
'status' => 'all',
'type' => 'all',
'features' => [],
'search' => '',
]), 'permissions', []);
$missingTotal = (int) ($counts['missing_application'] ?? 0)
+ (int) ($counts['missing_delegated'] ?? 0)
+ (int) ($counts['error'] ?? 0);
$requiredTotal = $missingTotal + (int) ($counts['present'] ?? 0);
if (! is_array($allPermissions) || $allPermissions === []) {
return 'No permissions configured';
}
if ($state['status'] === 'missing' && $missingTotal === 0 && $state['type'] === 'all' && $state['features'] === [] && trim($state['search']) === '') {
return 'All required permissions are present';
}
return 'No matches';
}
private function permissionsEmptyStateDescription(): string
{
return match ($this->permissionsEmptyStateHeading()) {
'No permissions configured' => 'No required permissions are currently configured in config/intune_permissions.php.',
'All required permissions are present' => 'Switch Status to All if you want to review the full matrix.',
default => 'No permissions match the current filters.',
};
}
private function hasActivePermissionFilters(): bool
{
$state = $this->filterState();
return $state['status'] !== 'missing'
|| $state['type'] !== 'all'
|| $state['features'] !== []
|| trim($state['search']) !== '';
}
private function seedTableStateFromQuery(): void
{
$query = request()->query();
if (! array_key_exists('status', $query) && ! array_key_exists('type', $query) && ! array_key_exists('features', $query) && ! array_key_exists('search', $query)) {
return;
}
$queryFeatures = request()->query('features', []);
$state = TenantRequiredPermissionsViewModelBuilder::normalizeFilterState([
'status' => request()->query('status', 'missing'),
'type' => request()->query('type', 'all'),
'features' => is_array($queryFeatures) ? $queryFeatures : [],
'search' => request()->query('search', ''),
]);
$this->tableFilters = [
'status' => ['value' => $state['status']],
'type' => ['value' => $state['type']],
'features' => ['values' => $state['features']],
];
$this->tableDeferredFilters = $this->tableFilters;
$this->tableSearch = $state['search'];
}
private function statusSortWeight(string $status): int
{
return match ($status) {
'missing' => 0,
'error' => 1,
default => 2,
};
}
}

View File

@ -1760,6 +1760,8 @@ private function verificationReportViewData(): array
'previousRunUrl' => null,
'canAcknowledge' => false,
'acknowledgements' => [],
'surface' => [],
'redactionNotes' => [],
'assistVisibility' => $assistVisibility,
'assistActionName' => 'wizardVerificationRequiredPermissionsAssist',
'technicalDetailsActionName' => 'wizardVerificationTechnicalDetails',
@ -1809,7 +1811,28 @@ private function verificationReportViewData(): array
$targetScope = is_array($targetScope) ? $targetScope : [];
$failures = is_array($run->failure_summary ?? null) ? $run->failure_summary : [];
$verificationReport = VerificationReportViewer::report($run);
$surface = VerificationReportViewer::surface($run, $acknowledgements, [
'hostKind' => 'onboarding_wizard',
'changeIndicator' => $changeIndicator,
'previousRunUrl' => $previousRunUrl,
'nextStepPlacement' => ($assistVisibility['is_visible'] ?? false) ? 'host_action_zone' : 'shared_zone',
'hostActions' => array_values(array_filter([
($assistVisibility['is_visible'] ?? false)
? ['kind' => 'assist', 'label' => 'View required permissions', 'ownedByHost' => true]
: null,
['kind' => 'technical_details', 'label' => 'Technical details', 'ownedByHost' => true],
$canAcknowledge
? ['kind' => 'acknowledge', 'label' => 'Acknowledge', 'ownedByHost' => true]
: null,
])),
'hostVariation' => [
'ownsNoRunState' => true,
'ownsActiveState' => true,
'supportsAssist' => (bool) ($assistVisibility['is_visible'] ?? false),
'supportsAcknowledge' => $canAcknowledge,
'supportsTechnicalDetailsTrigger' => true,
],
]);
return [
'run' => [
@ -1832,6 +1855,8 @@ private function verificationReportViewData(): array
'previousRunUrl' => $previousRunUrl,
'canAcknowledge' => $canAcknowledge,
'acknowledgements' => $acknowledgements,
'surface' => $surface,
'redactionNotes' => VerificationReportViewer::redactionNotes($report),
'assistVisibility' => $assistVisibility,
'assistActionName' => 'wizardVerificationRequiredPermissionsAssist',
'technicalDetailsActionName' => 'wizardVerificationTechnicalDetails',

View File

@ -5,6 +5,7 @@
use App\Filament\Concerns\InteractsWithTenantOwnedRecords;
use App\Filament\Concerns\ResolvesPanelTenantContext;
use App\Filament\Resources\FindingResource\Pages;
use App\Filament\Support\NormalizedDiffSurface;
use App\Models\Finding;
use App\Models\FindingException;
use App\Models\PolicyVersion;
@ -412,11 +413,6 @@ public static function infolist(Schema $schema): Schema
Section::make('Diff')
->visible(fn (Finding $record): bool => $record->finding_type === Finding::FINDING_TYPE_DRIFT)
->schema([
TextEntry::make('diff_unavailable')
->label('')
->state(fn (Finding $record): string => static::driftDiffUnavailableMessage($record))
->visible(fn (Finding $record): bool => ! static::canRenderDriftDiff($record))
->columnSpanFull(),
ViewEntry::make('rbac_role_definition_diff')
->label('')
->view('filament.infolists.entries.rbac-role-definition-diff')
@ -429,13 +425,13 @@ public static function infolist(Schema $schema): Schema
->state(function (Finding $record): array {
$tenant = static::resolveTenantContextForCurrentPanel();
if (! $tenant) {
return static::unavailableDiffState('No tenant context');
return NormalizedDiffSurface::build(static::unavailableDiffState('No tenant context'), 'finding');
}
[$baselineVersion, $currentVersion] = static::resolveDriftDiffVersions($record, $tenant);
if (! static::hasRequiredDiffVersions($record, $baselineVersion, $currentVersion)) {
return static::unavailableDiffState('Diff unavailable — referenced policy versions are missing.');
return NormalizedDiffSurface::build(static::unavailableDiffState('Diff unavailable — referenced policy versions are missing.'), 'finding');
}
$diff = app(DriftFindingDiffBuilder::class)->buildSettingsDiff($baselineVersion, $currentVersion);
@ -452,9 +448,9 @@ public static function infolist(Schema $schema): Schema
);
}
return $diff;
return NormalizedDiffSurface::build($diff, 'finding');
})
->visible(fn (Finding $record): bool => static::canRenderDriftDiff($record) && Arr::get($record->evidence_jsonb ?? [], 'summary.kind') === 'policy_snapshot')
->visible(fn (Finding $record): bool => Arr::get($record->evidence_jsonb ?? [], 'summary.kind') === 'policy_snapshot')
->columnSpanFull(),
ViewEntry::make('scope_tags_diff')

View File

@ -10,14 +10,11 @@
use App\Models\Tenant;
use App\Models\User;
use App\Services\Auth\CapabilityResolver;
use App\Services\Inventory\DependencyQueryService;
use App\Services\Inventory\DependencyTargets\DependencyTargetResolver;
use App\Support\Auth\Capabilities;
use App\Support\Badges\BadgeDomain;
use App\Support\Badges\BadgeRenderer;
use App\Support\Badges\TagBadgeDomain;
use App\Support\Badges\TagBadgeRenderer;
use App\Support\Enums\RelationshipType;
use App\Support\Filament\FilterOptionCatalog;
use App\Support\Inventory\InventoryPolicyTypeMeta;
use App\Support\Ui\ActionSurface\ActionSurfaceDeclaration;
@ -179,29 +176,6 @@ public static function infolist(Schema $schema): Schema
ViewEntry::make('dependencies')
->label('')
->view('filament.components.dependency-edges')
->state(function (InventoryItem $record) {
$direction = request()->query('direction', 'all');
$relationshipType = request()->query('relationship_type', 'all');
$relationshipType = is_string($relationshipType) ? $relationshipType : 'all';
$relationshipType = $relationshipType === 'all'
? null
: RelationshipType::tryFrom($relationshipType)?->value;
$service = app(DependencyQueryService::class);
$resolver = app(DependencyTargetResolver::class);
$tenant = static::resolveTenantContextForCurrentPanel();
$edges = collect();
if ($direction === 'inbound' || $direction === 'all') {
$edges = $edges->merge($service->getInboundEdges($record, $relationshipType));
}
if ($direction === 'outbound' || $direction === 'all') {
$edges = $edges->merge($service->getOutboundEdges($record, $relationshipType));
}
return $resolver->attachRenderedTargets($edges->take(100), $tenant); // both directions combined
})
->columnSpanFull(),
])
->columnSpanFull(),

View File

@ -1178,6 +1178,18 @@ private static function verificationReportViewData(OperationRun $record): array
'changeIndicator' => $changeIndicator,
'previousRunUrl' => $previousRunUrl,
'acknowledgements' => $acknowledgements,
'surface' => VerificationReportViewer::surface($record, $acknowledgements, [
'hostKind' => 'operation_run_detail',
'changeIndicator' => $changeIndicator,
'previousRunUrl' => $previousRunUrl,
'hostVariation' => [
'ownsNoRunState' => false,
'ownsActiveState' => false,
'supportsAssist' => false,
'supportsAcknowledge' => false,
'supportsTechnicalDetailsTrigger' => false,
],
]),
'redactionNotes' => VerificationReportViewer::redactionNotes($report),
];
}

View File

@ -7,6 +7,7 @@
use App\Filament\Concerns\ScopesGlobalSearchToTenant;
use App\Filament\Resources\PolicyResource\Pages;
use App\Filament\Resources\PolicyResource\RelationManagers\VersionsRelationManager;
use App\Filament\Support\NormalizedSettingsSurface;
use App\Jobs\BulkPolicyDeleteJob;
use App\Jobs\BulkPolicyExportJob;
use App\Jobs\BulkPolicyUnignoreJob;
@ -238,25 +239,13 @@ public static function infolist(Schema $schema): Schema
Tab::make('Settings')
->id('settings')
->schema([
ViewEntry::make('settings_catalog')
ViewEntry::make('settings')
->label('')
->view('filament.infolists.entries.normalized-settings')
->state(function (Policy $record) {
return static::settingsTabState($record);
return NormalizedSettingsSurface::build(static::settingsTabState($record), 'policy');
})
->visible(fn (Policy $record) => static::hasSettingsTable($record) &&
$record->versions()->exists()
),
ViewEntry::make('settings_standard')
->label('')
->view('filament.infolists.entries.policy-settings-standard')
->state(function (Policy $record) {
return static::settingsTabState($record);
})
->visible(fn (Policy $record) => ! static::hasSettingsTable($record) &&
$record->versions()->exists()
),
->visible(fn (Policy $record) => $record->versions()->exists()),
TextEntry::make('no_settings_available')
->label('Settings')
@ -301,16 +290,7 @@ public static function infolist(Schema $schema): Schema
->label('')
->view('filament.infolists.entries.normalized-settings')
->state(function (Policy $record) {
$normalized = app(PolicyNormalizer::class)->normalize(
static::latestSnapshot($record),
$record->policy_type ?? '',
$record->platform
);
$normalized['context'] = 'policy';
$normalized['record_id'] = (string) $record->getKey();
return $normalized;
return NormalizedSettingsSurface::build(static::settingsTabState($record), 'policy');
}),
])
->columnSpanFull()

View File

@ -6,6 +6,8 @@
use App\Filament\Concerns\ResolvesPanelTenantContext;
use App\Filament\Concerns\ScopesGlobalSearchToTenant;
use App\Filament\Resources\PolicyVersionResource\Pages;
use App\Filament\Support\NormalizedDiffSurface;
use App\Filament\Support\NormalizedSettingsSurface;
use App\Jobs\BulkPolicyVersionForceDeleteJob;
use App\Jobs\BulkPolicyVersionPruneJob;
use App\Jobs\BulkPolicyVersionRestoreJob;
@ -180,7 +182,7 @@ public static function infolist(Schema $schema): Schema
Tab::make('Normalized settings')
->id('normalized-settings')
->schema([
Infolists\Components\ViewEntry::make('normalized_settings_catalog')
Infolists\Components\ViewEntry::make('normalized_settings')
->view('filament.infolists.entries.normalized-settings')
->state(function (PolicyVersion $record) {
$normalized = app(PolicyNormalizer::class)->normalize(
@ -189,29 +191,12 @@ public static function infolist(Schema $schema): Schema
$record->platform
);
$normalized['context'] = 'version';
$normalized['record_id'] = (string) $record->getKey();
return $normalized;
})
->visible(fn (PolicyVersion $record) => in_array($record->policy_type, ['settingsCatalogPolicy', 'endpointSecurityPolicy', 'securityBaselinePolicy'], true)),
Infolists\Components\ViewEntry::make('normalized_settings_standard')
->view('filament.infolists.entries.policy-settings-standard')
->state(function (PolicyVersion $record) {
$normalized = app(PolicyNormalizer::class)->normalize(
is_array($record->snapshot) ? $record->snapshot : [],
$record->policy_type ?? '',
$record->platform
);
$normalized['context'] = 'version';
$normalized['record_id'] = (string) $record->getKey();
$normalized['policy_type'] = $record->policy_type;
return $normalized;
})
->visible(fn (PolicyVersion $record) => ! in_array($record->policy_type, ['settingsCatalogPolicy', 'endpointSecurityPolicy', 'securityBaselinePolicy'], true)),
return NormalizedSettingsSurface::build($normalized, 'policy_version');
}),
]),
Tab::make('Raw JSON')
->id('raw-json')
@ -238,7 +223,7 @@ public static function infolist(Schema $schema): Schema
$result = $diff->compare($from, $to);
$result['policy_type'] = $record->policy_type;
return $result;
return NormalizedDiffSurface::build($result, 'policy_version');
}),
Infolists\Components\ViewEntry::make('diff_json')
->label('Raw diff (advanced)')

View File

@ -0,0 +1,155 @@
<?php
declare(strict_types=1);
namespace App\Filament\Support;
final class NormalizedDiffSurface
{
/**
* @param array<string, mixed> $diff
* @return array<string, mixed>
*/
public static function build(array $diff, string $hostKind): array
{
$summary = is_array($diff['summary'] ?? null) ? $diff['summary'] : [];
$added = is_array($diff['added'] ?? null) ? $diff['added'] : [];
$removed = is_array($diff['removed'] ?? null) ? $diff['removed'] : [];
$changed = is_array($diff['changed'] ?? null) ? $diff['changed'] : [];
$message = is_string($summary['message'] ?? null) && trim((string) $summary['message']) !== ''
? trim((string) $summary['message'])
: null;
$addedCount = is_numeric($summary['added'] ?? null) ? (int) $summary['added'] : count($added);
$removedCount = is_numeric($summary['removed'] ?? null) ? (int) $summary['removed'] : count($removed);
$changedCount = is_numeric($summary['changed'] ?? null) ? (int) $summary['changed'] : count($changed);
$availabilityState = self::availabilityState($message, $addedCount, $removedCount, $changedCount);
return [
'hostKind' => $hostKind,
'availabilityState' => $availabilityState,
'summary' => [
'added' => $addedCount,
'removed' => $removedCount,
'changed' => $changedCount,
'message' => $message,
],
'viewModes' => [
['key' => 'grouped', 'label' => 'Grouped diff', 'default' => true],
],
'sectionBehavior' => [
'preservesGroupOrder' => true,
'supportsExpansion' => true,
'supportsFullscreen' => true,
],
'renderExpectations' => [
'ownsAvailabilityState' => true,
'ownsZeroDiffMessaging' => true,
'keepsHostFramingOutsideCore' => true,
],
'groups' => [
self::group('changed', 'Changed', $changed, false),
self::group('added', 'Added', $added, true),
self::group('removed', 'Removed', $removed, true),
],
'scriptRendering' => [
'policyType' => $diff['policy_type'] ?? null,
'showScriptContent' => (bool) config('tenantpilot.display.show_script_content', false),
],
'emptyState' => self::emptyState($availabilityState, $message, $addedCount, $removedCount, $changedCount),
'raw' => [
'added' => $added,
'removed' => $removed,
'changed' => $changed,
],
];
}
/**
* @param array<string, mixed> $items
* @return array<string, mixed>
*/
private static function group(string $key, string $label, array $items, bool $collapsed): array
{
return [
'key' => $key,
'label' => $label,
'collapsed' => $collapsed,
'count' => count($items),
'items' => self::groupByBlock($items),
];
}
/**
* @param array<string, mixed> $items
* @return array<string, array<string, mixed>>
*/
private static function groupByBlock(array $items): array
{
$groups = [];
foreach ($items as $path => $value) {
if (! is_string($path) || $path === '') {
continue;
}
$parts = explode(' > ', $path, 2);
$group = count($parts) === 2 ? $parts[0] : 'Other';
$label = count($parts) === 2 ? $parts[1] : $path;
$groups[$group][$label] = $value;
}
ksort($groups);
return $groups;
}
private static function availabilityState(?string $message, int $addedCount, int $removedCount, int $changedCount): string
{
if ($message !== null && str_contains(strtolower($message), 'unavailable')) {
return 'unavailable';
}
if ($message !== null && str_contains(strtolower($message), 'partial')) {
return 'partial';
}
return 'available';
}
/**
* @return array{title: string, message: string}|null
*/
private static function emptyState(
string $availabilityState,
?string $message,
int $addedCount,
int $removedCount,
int $changedCount,
): ?array
{
if ($availabilityState === 'unavailable' && $message !== null) {
return [
'title' => 'Diff unavailable',
'message' => $message,
];
}
if ($availabilityState === 'partial' && $message !== null) {
return [
'title' => 'Diff partially available',
'message' => $message,
];
}
if ($availabilityState === 'available' && ($addedCount + $removedCount + $changedCount) === 0) {
return [
'title' => 'No normalized changes',
'message' => $message ?? 'No normalized changes were found.',
];
}
return null;
}
}

View File

@ -0,0 +1,64 @@
<?php
declare(strict_types=1);
namespace App\Filament\Support;
final class NormalizedSettingsSurface
{
/**
* @param array<string, mixed> $normalized
* @return array<string, mixed>
*/
public static function build(array $normalized, string $hostKind): array
{
$warnings = collect($normalized['warnings'] ?? [])
->filter(static fn (mixed $warning): bool => is_string($warning) && trim($warning) !== '')
->map(static fn (string $warning): string => trim($warning))
->values()
->all();
$settingsTable = is_array($normalized['settings_table'] ?? null) ? $normalized['settings_table'] : null;
$settingsTableRows = is_array($settingsTable['rows'] ?? null) ? $settingsTable['rows'] : [];
$blocks = collect($normalized['settings'] ?? [])
->filter(static fn (mixed $block): bool => is_array($block))
->values()
->all();
$context = is_string($normalized['context'] ?? null) && $normalized['context'] !== ''
? (string) $normalized['context']
: 'policy';
$variant = $settingsTableRows !== [] ? 'settings_catalog_table' : 'standard_blocks';
return [
'hostKind' => $hostKind,
'context' => $context,
'variant' => $variant,
'warnings' => $warnings,
'settingsTable' => $settingsTableRows !== [] ? $settingsTable : null,
'blocks' => $blocks,
'sectionBehavior' => [
'preservesSectionOrder' => true,
'supportsExpansion' => true,
'ownsEmptyState' => true,
],
'renderExpectations' => [
'ownsWarningsInWrapper' => true,
'ownsSubtypeDelegation' => true,
'keepsHostFramingOutsideCore' => true,
],
'emptyState' => $settingsTableRows === [] && $blocks === []
? [
'title' => 'No settings available.',
'message' => 'No normalized settings payload is available for this host.',
]
: null,
'titlePolicy' => [
'showWrapperTitle' => false,
],
'recordId' => $normalized['record_id'] ?? null,
'policyType' => $normalized['policy_type'] ?? null,
];
}
}

View File

@ -5,6 +5,8 @@
namespace App\Filament\Support;
use App\Models\OperationRun;
use App\Support\Badges\BadgeDomain;
use App\Support\Badges\BadgeRenderer;
use App\Support\RedactionIntegrity;
use App\Support\Verification\VerificationReportFingerprint;
use App\Support\Verification\VerificationReportSanitizer;
@ -91,6 +93,276 @@ public static function shouldRenderForRun(OperationRun $run): bool
return in_array((string) $run->type, ['provider.connection.check'], true);
}
/**
* @param array<string, array<string, mixed>> $acknowledgements
* @param array{
* hostKind?: string,
* changeIndicator?: array{state: string, previous_report_id: int}|null,
* previousRunUrl?: string|null,
* nextStepPlacement?: 'shared_zone'|'host_action_zone',
* hostActions?: array<int, array{kind: string, label: string, ownedByHost: bool}>,
* hostVariation?: array{
* ownsNoRunState?: bool,
* ownsActiveState?: bool,
* supportsAssist?: bool,
* supportsAcknowledge?: bool,
* supportsTechnicalDetailsTrigger?: bool
* },
* optionalZones?: array<int, string>
* } $options
* @return array<string, mixed>
*/
public static function surface(OperationRun $run, array $acknowledgements = [], array $options = []): array
{
$report = self::report($run);
$summary = is_array($report['summary'] ?? null) ? $report['summary'] : [];
$counts = is_array($summary['counts'] ?? null) ? $summary['counts'] : [];
$groupedChecks = self::groupedChecks($report, $acknowledgements);
$changeIndicator = $options['changeIndicator'] ?? null;
$hostKind = is_string($options['hostKind'] ?? null) && $options['hostKind'] !== ''
? (string) $options['hostKind']
: 'operation_run_detail';
$nextStepPlacement = ($options['nextStepPlacement'] ?? 'shared_zone') === 'host_action_zone'
? 'host_action_zone'
: 'shared_zone';
$hostActions = collect($options['hostActions'] ?? [])
->filter(static fn (mixed $action): bool => is_array($action))
->map(static function (array $action): array {
$kind = is_string($action['kind'] ?? null) ? trim((string) $action['kind']) : 'navigation';
$label = is_string($action['label'] ?? null) ? trim((string) $action['label']) : 'Action';
return [
'kind' => $kind !== '' ? $kind : 'navigation',
'label' => $label !== '' ? $label : 'Action',
'ownedByHost' => (bool) ($action['ownedByHost'] ?? true),
];
})
->values()
->all();
$hostVariation = [
'ownsNoRunState' => (bool) (($options['hostVariation']['ownsNoRunState'] ?? false)),
'ownsActiveState' => (bool) (($options['hostVariation']['ownsActiveState'] ?? false)),
'supportsAssist' => (bool) (($options['hostVariation']['supportsAssist'] ?? false)),
'supportsAcknowledge' => (bool) (($options['hostVariation']['supportsAcknowledge'] ?? false)),
'supportsTechnicalDetailsTrigger' => (bool) (($options['hostVariation']['supportsTechnicalDetailsTrigger'] ?? false)),
];
$optionalZones = collect($options['optionalZones'] ?? ['technical_details', 'change_indicator', 'previous_run_context'])
->filter(static fn (mixed $zone): bool => is_string($zone) && trim($zone) !== '')
->map(static fn (string $zone): string => trim($zone))
->values()
->all();
$overall = $summary['overall'] ?? null;
$overallSpec = BadgeRenderer::spec(BadgeDomain::VerificationReportOverall, $overall);
return [
'hostKind' => $hostKind,
'coreState' => $report === null ? 'unavailable' : 'completed',
'summary' => [
'overall' => $overall,
'overallLabel' => $overallSpec->label,
'counts' => [
'total' => (int) ($counts['total'] ?? 0),
'pass' => (int) ($counts['pass'] ?? 0),
'fail' => (int) ($counts['fail'] ?? 0),
'warn' => (int) ($counts['warn'] ?? 0),
'skip' => (int) ($counts['skip'] ?? 0),
'running' => (int) ($counts['running'] ?? 0),
],
'changeIndicator' => is_array($changeIndicator) ? $changeIndicator : null,
],
'issueGroups' => $groupedChecks['issueGroups'],
'passedChecks' => $groupedChecks['passedChecks'],
'diagnostics' => [
'hasTechnicalZone' => true,
'fingerprint' => is_array($report) ? self::fingerprint($report) : null,
'previousRunUrl' => is_string($options['previousRunUrl'] ?? null) && $options['previousRunUrl'] !== ''
? (string) $options['previousRunUrl']
: null,
'operationRunId' => (int) $run->getKey(),
'flow' => (string) $run->type,
'completedAt' => $run->completed_at?->toJSON(),
],
'viewZones' => [
['key' => 'issues', 'label' => 'Issues', 'defaultVisible' => true],
['key' => 'passed', 'label' => 'Passed', 'defaultVisible' => false],
],
'nextSteps' => self::nextSteps($groupedChecks['issueGroups'], $nextStepPlacement),
'hostActions' => $hostActions,
'hostVariation' => $hostVariation,
'optionalZones' => $optionalZones,
'emptyState' => $report === null
? [
'title' => 'Verification report unavailable',
'message' => 'This operation doesnt have a report yet. If it is still running, refresh in a moment. If it already completed, start verification again.',
]
: null,
];
}
/**
* @param array<string, mixed>|null $report
* @param array<string, array<string, mixed>> $acknowledgements
* @return array{issueGroups: array<int, array{label: string, checks: array<int, array<string, mixed>>, acknowledged?: bool}>, passedChecks: array<int, array<string, mixed>>}
*/
private static function groupedChecks(?array $report, array $acknowledgements): array
{
$checks = is_array($report['checks'] ?? null) ? $report['checks'] : [];
$ackByKey = [];
foreach ($acknowledgements as $checkKey => $acknowledgement) {
if (! is_string($checkKey) || $checkKey === '' || ! is_array($acknowledgement)) {
continue;
}
$ackByKey[$checkKey] = $acknowledgement;
}
$blockers = [];
$failures = [];
$warnings = [];
$acknowledgedIssues = [];
$passed = [];
foreach ($checks as $check) {
if (! is_array($check)) {
continue;
}
$key = is_string($check['key'] ?? null) ? trim((string) $check['key']) : '';
if ($key === '') {
continue;
}
$status = is_string($check['status'] ?? null) ? strtolower(trim((string) $check['status'])) : '';
$blocking = (bool) ($check['blocking'] ?? false);
$normalizedCheck = self::normalizeCheck($check, $ackByKey[$key] ?? null);
if ($normalizedCheck['acknowledgement'] !== null) {
$acknowledgedIssues[] = $normalizedCheck;
continue;
}
if ($status === 'pass') {
$passed[] = $normalizedCheck;
continue;
}
if ($status === 'fail' && $blocking) {
$blockers[] = $normalizedCheck;
continue;
}
if ($status === 'fail') {
$failures[] = $normalizedCheck;
continue;
}
if ($status === 'warn') {
$warnings[] = $normalizedCheck;
}
}
$sortChecks = static fn (array $left, array $right): int => strcmp((string) ($left['key'] ?? ''), (string) ($right['key'] ?? ''));
usort($blockers, $sortChecks);
usort($failures, $sortChecks);
usort($warnings, $sortChecks);
usort($acknowledgedIssues, $sortChecks);
usort($passed, $sortChecks);
return [
'issueGroups' => array_values(array_filter([
['label' => 'Blockers', 'checks' => $blockers],
['label' => 'Failures', 'checks' => $failures],
['label' => 'Warnings', 'checks' => $warnings],
['label' => 'Acknowledged issues', 'checks' => $acknowledgedIssues, 'acknowledged' => true],
], static fn (array $group): bool => ($group['checks'] ?? []) !== [])),
'passedChecks' => $passed,
];
}
/**
* @param array<string, mixed> $check
* @param array<string, mixed>|null $acknowledgement
* @return array<string, mixed>
*/
private static function normalizeCheck(array $check, ?array $acknowledgement): array
{
$nextSteps = collect($check['next_steps'] ?? [])
->filter(static fn (mixed $step): bool => is_array($step))
->map(static function (array $step): array {
$label = is_string($step['label'] ?? null) ? trim((string) $step['label']) : '';
$url = is_string($step['url'] ?? null) ? trim((string) $step['url']) : '';
return [
'label' => $label,
'url' => $url,
];
})
->filter(static fn (array $step): bool => $step['label'] !== '' && $step['url'] !== '')
->values()
->all();
return [
'key' => is_string($check['key'] ?? null) ? trim((string) $check['key']) : '',
'title' => is_string($check['title'] ?? null) && trim((string) $check['title']) !== ''
? trim((string) $check['title'])
: 'Check',
'message' => is_string($check['message'] ?? null) && trim((string) $check['message']) !== ''
? trim((string) $check['message'])
: null,
'status' => is_string($check['status'] ?? null) ? trim((string) $check['status']) : null,
'severity' => is_string($check['severity'] ?? null) ? trim((string) $check['severity']) : null,
'reason_code' => is_string($check['reason_code'] ?? null) ? trim((string) $check['reason_code']) : null,
'blocking' => (bool) ($check['blocking'] ?? false),
'next_steps' => $nextSteps,
'acknowledgement' => is_array($acknowledgement) ? $acknowledgement : null,
];
}
/**
* @param array<int, array{label: string, checks: array<int, array<string, mixed>>, acknowledged?: bool}> $issueGroups
* @return array<int, array{label: string, placement: string, ownedByHost: bool, actionKind: string|null}>
*/
private static function nextSteps(array $issueGroups, string $placement): array
{
$steps = [];
foreach ($issueGroups as $group) {
foreach ($group['checks'] as $check) {
foreach ($check['next_steps'] ?? [] as $step) {
if (! is_array($step)) {
continue;
}
$label = is_string($step['label'] ?? null) ? trim((string) $step['label']) : '';
if ($label === '' || array_key_exists($label, $steps)) {
continue;
}
$steps[$label] = [
'label' => $label,
'placement' => $placement,
'ownedByHost' => $placement === 'host_action_zone',
'actionKind' => $placement === 'host_action_zone' ? 'assist' : 'navigation',
];
}
}
}
return array_values($steps);
}
/**
* @param array<string, mixed>|null $report
* @return array<int, string>

View File

@ -5,6 +5,7 @@
namespace App\Filament\Widgets\Tenant;
use App\Filament\Resources\TenantResource\Pages\ViewTenant;
use App\Filament\Support\VerificationReportChangeIndicator;
use App\Filament\Support\VerificationReportViewer;
use App\Models\OperationRun;
use App\Models\Tenant;
@ -189,6 +190,12 @@ protected function getViewData(): array
$report = $run instanceof OperationRun
? VerificationReportViewer::report($run)
: null;
$changeIndicator = $run instanceof OperationRun
? VerificationReportChangeIndicator::forRun($run)
: null;
$previousRunUrl = is_array($changeIndicator) && is_numeric($changeIndicator['previous_report_id'] ?? null)
? OperationRunLinks::tenantlessView((int) $changeIndicator['previous_report_id'])
: null;
$isInProgress = $run instanceof OperationRun
&& (string) $run->status !== OperationRunStatus::Completed->value;
@ -230,6 +237,20 @@ protected function getViewData(): array
'runData' => $runData,
'runUrl' => $run instanceof OperationRun ? OperationRunLinks::tenantlessView($run) : null,
'report' => $report,
'surface' => $run instanceof OperationRun
? VerificationReportViewer::surface($run, [], [
'hostKind' => 'tenant_widget',
'changeIndicator' => $changeIndicator,
'previousRunUrl' => $previousRunUrl,
'hostVariation' => [
'ownsNoRunState' => true,
'ownsActiveState' => true,
'supportsAssist' => false,
'supportsAcknowledge' => false,
'supportsTechnicalDetailsTrigger' => false,
],
])
: [],
'redactionNotes' => VerificationReportViewer::redactionNotes($report),
'isInProgress' => $isInProgress,
'showStartAction' => ! ($run instanceof OperationRun) && $isTenantMember && $canOperate,

View File

@ -12,7 +12,6 @@
use App\Models\Finding;
use App\Models\InventoryItem;
use App\Models\OperationRun;
use App\Models\PolicyVersion;
use App\Models\Tenant;
use App\Models\User;
use App\Models\Workspace;
@ -21,19 +20,13 @@
use App\Services\Baselines\BaselineSnapshotIdentity;
use App\Services\Baselines\BaselineSnapshotTruthResolver;
use App\Services\Baselines\CurrentStateHashResolver;
use App\Services\Baselines\Evidence\BaselinePolicyVersionResolver;
use App\Services\Baselines\Evidence\ContentEvidenceProvider;
use App\Services\Baselines\Evidence\EvidenceProvenance;
use App\Services\Baselines\Evidence\MetaEvidenceProvider;
use App\Services\Baselines\Evidence\ResolvedEvidence;
use App\Services\Drift\DriftHasher;
use App\Services\Drift\Normalizers\AssignmentsNormalizer;
use App\Services\Drift\Normalizers\ScopeTagsNormalizer;
use App\Services\Drift\Normalizers\SettingsNormalizer;
use App\Services\Findings\FindingSlaPolicy;
use App\Services\Findings\FindingWorkflowService;
use App\Services\Intune\AuditLogger;
use App\Services\Intune\IntuneRoleDefinitionNormalizer;
use App\Services\OperationRunService;
use App\Services\Settings\SettingsResolver;
use App\Support\Baselines\BaselineCaptureMode;
@ -76,11 +69,6 @@ class CompareBaselineToTenantJob implements ShouldQueue
public bool $failOnTimeout = true;
/**
* @var array<int, string>
*/
private array $baselineContentHashCache = [];
public ?OperationRun $operationRun = null;
public function __construct(
@ -825,7 +813,7 @@ private function rekeyResolvedEvidenceBySubjectKey(array $currentItems, array $r
* captured_versions?: array<string, array{
* policy_type: string,
* subject_external_id: string,
* version: PolicyVersion,
* version: \App\Models\PolicyVersion,
* observed_at: string,
* observed_operation_run_id: ?int
* }>
@ -855,7 +843,7 @@ private function resolveCapturedCurrentEvidenceByExternalId(array $phaseResult):
$observedOperationRunId = $capturedVersion['observed_operation_run_id'] ?? null;
$observedOperationRunId = is_numeric($observedOperationRunId) ? (int) $observedOperationRunId : null;
if (! $version instanceof PolicyVersion || $subjectExternalId === '' || ! is_string($key) || $key === '') {
if (! $version instanceof \App\Models\PolicyVersion || $subjectExternalId === '' || ! is_string($key) || $key === '') {
continue;
}
@ -870,6 +858,7 @@ private function resolveCapturedCurrentEvidenceByExternalId(array $phaseResult):
return $resolved;
}
private function completeWithCoverageWarning(
OperationRunService $operationRunService,
AuditLogger $auditLogger,
@ -1423,750 +1412,6 @@ private function truthfulTypesFromContext(array $context, BaselineScope $effecti
return $effectiveScope->allTypes();
}
/**
* Compare baseline items vs current inventory and produce drift results.
*
* @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{
* drift: 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>}>,
* evidence_gaps: array<string, int>,
* rbac_role_definitions: array{total_compared: int, unchanged: int, modified: int, missing: int, unexpected: int}
* }
*/
private function computeDrift(
Tenant $tenant,
int $baselineProfileId,
int $baselineSnapshotId,
int $compareOperationRunId,
int $inventorySyncRunId,
array $baselineItems,
array $currentItems,
array $resolvedCurrentEvidence,
array $severityMapping,
BaselinePolicyVersionResolver $baselinePolicyVersionResolver,
DriftHasher $hasher,
SettingsNormalizer $settingsNormalizer,
AssignmentsNormalizer $assignmentsNormalizer,
ScopeTagsNormalizer $scopeTagsNormalizer,
ContentEvidenceProvider $contentEvidenceProvider,
): array {
$drift = [];
$evidenceGaps = [];
$evidenceGapSubjects = [];
$rbacRoleDefinitionSummary = $this->emptyRbacRoleDefinitionSummary();
$roleDefinitionNormalizer = app(IntuneRoleDefinitionNormalizer::class);
$baselinePlaceholderProvenance = EvidenceProvenance::build(
fidelity: EvidenceProvenance::FidelityMeta,
source: EvidenceProvenance::SourceInventory,
observedAt: null,
observedOperationRunId: null,
);
$currentMissingProvenance = EvidenceProvenance::build(
fidelity: EvidenceProvenance::FidelityMeta,
source: EvidenceProvenance::SourceInventory,
observedAt: null,
observedOperationRunId: $inventorySyncRunId,
);
foreach ($baselineItems as $key => $baselineItem) {
$currentItem = $currentItems[$key] ?? null;
$policyType = (string) ($baselineItem['policy_type'] ?? '');
$subjectKey = (string) ($baselineItem['subject_key'] ?? '');
$isRbacRoleDefinition = $policyType === 'intuneRoleDefinition';
$baselineProvenance = $this->baselineProvenanceFromMetaJsonb($baselineItem['meta_jsonb'] ?? []);
$baselinePolicyVersionId = $this->resolveBaselinePolicyVersionId(
tenant: $tenant,
baselineItem: $baselineItem,
baselineProvenance: $baselineProvenance,
baselinePolicyVersionResolver: $baselinePolicyVersionResolver,
);
$baselineComparableHash = $this->effectiveBaselineHash(
tenant: $tenant,
baselineItem: $baselineItem,
baselinePolicyVersionId: $baselinePolicyVersionId,
contentEvidenceProvider: $contentEvidenceProvider,
);
if (! is_array($currentItem)) {
if ($isRbacRoleDefinition && $baselinePolicyVersionId === null) {
$evidenceGaps['missing_role_definition_baseline_version_reference'] = ($evidenceGaps['missing_role_definition_baseline_version_reference'] ?? 0) + 1;
$evidenceGapSubjects['missing_role_definition_baseline_version_reference'][] = $key;
continue;
}
$displayName = $baselineItem['meta_jsonb']['display_name'] ?? null;
$displayName = is_string($displayName) ? (string) $displayName : null;
$evidence = $this->buildDriftEvidenceContract(
changeType: 'missing_policy',
policyType: $policyType,
subjectKey: $subjectKey,
displayName: $displayName,
baselineHash: $baselineComparableHash,
currentHash: null,
baselineProvenance: $baselineProvenance,
currentProvenance: $currentMissingProvenance,
baselinePolicyVersionId: $baselinePolicyVersionId,
currentPolicyVersionId: null,
summaryKind: 'policy_snapshot',
baselineProfileId: $baselineProfileId,
baselineSnapshotId: $baselineSnapshotId,
compareOperationRunId: $compareOperationRunId,
inventorySyncRunId: $inventorySyncRunId,
);
if ($isRbacRoleDefinition) {
$evidence['summary']['kind'] = 'rbac_role_definition';
$evidence['rbac_role_definition'] = $this->buildRoleDefinitionEvidencePayload(
tenant: $tenant,
baselinePolicyVersionId: $baselinePolicyVersionId,
currentPolicyVersionId: null,
baselineMeta: is_array($baselineItem['meta_jsonb'] ?? null) ? $baselineItem['meta_jsonb'] : [],
currentMeta: [],
diffKind: 'missing',
);
}
if ($isRbacRoleDefinition) {
$rbacRoleDefinitionSummary['missing']++;
$rbacRoleDefinitionSummary['total_compared']++;
}
$drift[] = [
'change_type' => 'missing_policy',
'severity' => $isRbacRoleDefinition
? Finding::SEVERITY_HIGH
: $this->severityForChangeType($severityMapping, 'missing_policy'),
'subject_type' => $baselineItem['subject_type'],
'subject_external_id' => $baselineItem['subject_external_id'],
'subject_key' => $subjectKey,
'policy_type' => $policyType,
'evidence_fidelity' => (string) ($evidence['fidelity'] ?? EvidenceProvenance::FidelityMeta),
'baseline_hash' => $baselineComparableHash,
'current_hash' => '',
'evidence' => $evidence,
];
continue;
}
$currentEvidence = $resolvedCurrentEvidence[$key] ?? null;
if (! $currentEvidence instanceof ResolvedEvidence) {
$evidenceGaps['missing_current'] = ($evidenceGaps['missing_current'] ?? 0) + 1;
$evidenceGapSubjects['missing_current'][] = $key;
continue;
}
$currentPolicyVersionId = $this->currentPolicyVersionIdFromEvidence($currentEvidence);
if ($baselineComparableHash !== $currentEvidence->hash) {
$displayName = $currentItem['meta_jsonb']['display_name']
?? ($baselineItem['meta_jsonb']['display_name'] ?? null);
$displayName = is_string($displayName) ? (string) $displayName : null;
$roleDefinitionDiff = null;
if ($isRbacRoleDefinition) {
if ($baselinePolicyVersionId === null) {
$evidenceGaps['missing_role_definition_baseline_version_reference'] = ($evidenceGaps['missing_role_definition_baseline_version_reference'] ?? 0) + 1;
$evidenceGapSubjects['missing_role_definition_baseline_version_reference'][] = $key;
continue;
}
if ($currentPolicyVersionId === null) {
$evidenceGaps['missing_role_definition_current_version_reference'] = ($evidenceGaps['missing_role_definition_current_version_reference'] ?? 0) + 1;
$evidenceGapSubjects['missing_role_definition_current_version_reference'][] = $key;
continue;
}
$roleDefinitionDiff = $this->resolveRoleDefinitionDiff(
tenant: $tenant,
baselinePolicyVersionId: $baselinePolicyVersionId,
currentPolicyVersionId: $currentPolicyVersionId,
normalizer: $roleDefinitionNormalizer,
);
if ($roleDefinitionDiff === null) {
$evidenceGaps['missing_role_definition_compare_surface'] = ($evidenceGaps['missing_role_definition_compare_surface'] ?? 0) + 1;
$evidenceGapSubjects['missing_role_definition_compare_surface'][] = $key;
continue;
}
}
$summaryKind = $isRbacRoleDefinition
? 'rbac_role_definition'
: $this->selectSummaryKind(
tenant: $tenant,
policyType: $policyType,
baselinePolicyVersionId: $baselinePolicyVersionId,
currentPolicyVersionId: $currentPolicyVersionId,
hasher: $hasher,
settingsNormalizer: $settingsNormalizer,
assignmentsNormalizer: $assignmentsNormalizer,
scopeTagsNormalizer: $scopeTagsNormalizer,
);
$evidence = $this->buildDriftEvidenceContract(
changeType: 'different_version',
policyType: $policyType,
subjectKey: $subjectKey,
displayName: $displayName,
baselineHash: $baselineComparableHash,
currentHash: (string) $currentEvidence->hash,
baselineProvenance: $baselineProvenance,
currentProvenance: $currentEvidence->tenantProvenance(),
baselinePolicyVersionId: $baselinePolicyVersionId,
currentPolicyVersionId: $currentPolicyVersionId,
summaryKind: $summaryKind,
baselineProfileId: $baselineProfileId,
baselineSnapshotId: $baselineSnapshotId,
compareOperationRunId: $compareOperationRunId,
inventorySyncRunId: $inventorySyncRunId,
);
if ($isRbacRoleDefinition && is_array($roleDefinitionDiff)) {
$evidence['rbac_role_definition'] = $this->buildRoleDefinitionEvidencePayload(
tenant: $tenant,
baselinePolicyVersionId: $baselinePolicyVersionId,
currentPolicyVersionId: $currentPolicyVersionId,
baselineMeta: is_array($baselineItem['meta_jsonb'] ?? null) ? $baselineItem['meta_jsonb'] : [],
currentMeta: is_array($currentEvidence->meta ?? null) ? $currentEvidence->meta : (is_array($currentItem['meta_jsonb'] ?? null) ? $currentItem['meta_jsonb'] : []),
diffKind: (string) $roleDefinitionDiff['diff_kind'],
roleDefinitionDiff: $roleDefinitionDiff,
);
$rbacRoleDefinitionSummary['modified']++;
$rbacRoleDefinitionSummary['total_compared']++;
}
$drift[] = [
'change_type' => 'different_version',
'severity' => $isRbacRoleDefinition
? $this->severityForRoleDefinitionDiff($roleDefinitionDiff)
: $this->severityForChangeType($severityMapping, 'different_version'),
'subject_type' => $baselineItem['subject_type'],
'subject_external_id' => $currentItem['subject_external_id'],
'subject_key' => $subjectKey,
'policy_type' => $policyType,
'evidence_fidelity' => (string) ($evidence['fidelity'] ?? EvidenceProvenance::FidelityMeta),
'baseline_hash' => $baselineComparableHash,
'current_hash' => $currentEvidence->hash,
'evidence' => $evidence,
];
continue;
}
if ($isRbacRoleDefinition) {
$rbacRoleDefinitionSummary['unchanged']++;
$rbacRoleDefinitionSummary['total_compared']++;
}
}
foreach ($currentItems as $key => $currentItem) {
if (! array_key_exists($key, $baselineItems)) {
$currentEvidence = $resolvedCurrentEvidence[$key] ?? null;
if (! $currentEvidence instanceof ResolvedEvidence) {
$evidenceGaps['missing_current'] = ($evidenceGaps['missing_current'] ?? 0) + 1;
$evidenceGapSubjects['missing_current'][] = $key;
continue;
}
$policyType = (string) ($currentItem['policy_type'] ?? '');
$subjectKey = (string) ($currentItem['subject_key'] ?? '');
$isRbacRoleDefinition = $policyType === 'intuneRoleDefinition';
$displayName = $currentItem['meta_jsonb']['display_name'] ?? null;
$displayName = is_string($displayName) ? (string) $displayName : null;
$currentPolicyVersionId = $this->currentPolicyVersionIdFromEvidence($currentEvidence);
if ($isRbacRoleDefinition && $currentPolicyVersionId === null) {
$evidenceGaps['missing_role_definition_current_version_reference'] = ($evidenceGaps['missing_role_definition_current_version_reference'] ?? 0) + 1;
$evidenceGapSubjects['missing_role_definition_current_version_reference'][] = $key;
continue;
}
$evidence = $this->buildDriftEvidenceContract(
changeType: 'unexpected_policy',
policyType: $policyType,
subjectKey: $subjectKey,
displayName: $displayName,
baselineHash: null,
currentHash: (string) $currentEvidence->hash,
baselineProvenance: $baselinePlaceholderProvenance,
currentProvenance: $currentEvidence->tenantProvenance(),
baselinePolicyVersionId: null,
currentPolicyVersionId: $currentPolicyVersionId,
summaryKind: 'policy_snapshot',
baselineProfileId: $baselineProfileId,
baselineSnapshotId: $baselineSnapshotId,
compareOperationRunId: $compareOperationRunId,
inventorySyncRunId: $inventorySyncRunId,
);
if ($isRbacRoleDefinition) {
$evidence['summary']['kind'] = 'rbac_role_definition';
$evidence['rbac_role_definition'] = $this->buildRoleDefinitionEvidencePayload(
tenant: $tenant,
baselinePolicyVersionId: null,
currentPolicyVersionId: $currentPolicyVersionId,
baselineMeta: [],
currentMeta: is_array($currentEvidence->meta ?? null) ? $currentEvidence->meta : (is_array($currentItem['meta_jsonb'] ?? null) ? $currentItem['meta_jsonb'] : []),
diffKind: 'unexpected',
);
}
if ($isRbacRoleDefinition) {
$rbacRoleDefinitionSummary['unexpected']++;
$rbacRoleDefinitionSummary['total_compared']++;
}
$drift[] = [
'change_type' => 'unexpected_policy',
'severity' => $isRbacRoleDefinition
? Finding::SEVERITY_MEDIUM
: $this->severityForChangeType($severityMapping, 'unexpected_policy'),
'subject_type' => 'policy',
'subject_external_id' => $currentItem['subject_external_id'],
'subject_key' => $subjectKey,
'policy_type' => $policyType,
'evidence_fidelity' => (string) ($evidence['fidelity'] ?? EvidenceProvenance::FidelityMeta),
'baseline_hash' => '',
'current_hash' => $currentEvidence->hash,
'evidence' => $evidence,
];
}
}
return [
'drift' => $drift,
'evidence_gaps' => $evidenceGaps,
'evidence_gap_subjects' => $evidenceGapSubjects,
'rbac_role_definitions' => $rbacRoleDefinitionSummary,
];
}
/**
* @param array{subject_external_id: string, baseline_hash: string} $baselineItem
*/
private function effectiveBaselineHash(
Tenant $tenant,
array $baselineItem,
?int $baselinePolicyVersionId,
ContentEvidenceProvider $contentEvidenceProvider,
): string {
$storedHash = (string) ($baselineItem['baseline_hash'] ?? '');
if ($baselinePolicyVersionId === null) {
return $storedHash;
}
if (array_key_exists($baselinePolicyVersionId, $this->baselineContentHashCache)) {
return $this->baselineContentHashCache[$baselinePolicyVersionId];
}
$baselineVersion = PolicyVersion::query()
->where('tenant_id', (int) $tenant->getKey())
->find($baselinePolicyVersionId);
if (! $baselineVersion instanceof PolicyVersion) {
return $storedHash;
}
$hash = $contentEvidenceProvider->fromPolicyVersion(
version: $baselineVersion,
subjectExternalId: (string) ($baselineItem['subject_external_id'] ?? ''),
)->hash;
$this->baselineContentHashCache[$baselinePolicyVersionId] = $hash;
return $hash;
}
private function resolveBaselinePolicyVersionId(
Tenant $tenant,
array $baselineItem,
array $baselineProvenance,
BaselinePolicyVersionResolver $baselinePolicyVersionResolver,
): ?int {
$metaJsonb = is_array($baselineItem['meta_jsonb'] ?? null) ? $baselineItem['meta_jsonb'] : [];
$versionReferenceId = data_get($metaJsonb, 'version_reference.policy_version_id');
if (is_numeric($versionReferenceId)) {
return (int) $versionReferenceId;
}
$baselineFidelity = (string) ($baselineProvenance['fidelity'] ?? EvidenceProvenance::FidelityMeta);
$baselineSource = (string) ($baselineProvenance['source'] ?? EvidenceProvenance::SourceInventory);
if ($baselineFidelity !== EvidenceProvenance::FidelityContent || $baselineSource !== EvidenceProvenance::SourcePolicyVersion) {
return null;
}
$observedAt = $baselineProvenance['observed_at'] ?? null;
$observedAt = is_string($observedAt) ? trim($observedAt) : null;
if (! is_string($observedAt) || $observedAt === '') {
return null;
}
return $baselinePolicyVersionResolver->resolve(
tenant: $tenant,
policyType: (string) ($baselineItem['policy_type'] ?? ''),
subjectKey: (string) ($baselineItem['subject_key'] ?? ''),
observedAt: $observedAt,
);
}
private function currentPolicyVersionIdFromEvidence(ResolvedEvidence $evidence): ?int
{
$policyVersionId = $evidence->meta['policy_version_id'] ?? null;
return is_numeric($policyVersionId) ? (int) $policyVersionId : null;
}
private function selectSummaryKind(
Tenant $tenant,
string $policyType,
?int $baselinePolicyVersionId,
?int $currentPolicyVersionId,
DriftHasher $hasher,
SettingsNormalizer $settingsNormalizer,
AssignmentsNormalizer $assignmentsNormalizer,
ScopeTagsNormalizer $scopeTagsNormalizer,
): string {
if ($baselinePolicyVersionId === null || $currentPolicyVersionId === null) {
return 'policy_snapshot';
}
$baselineVersion = PolicyVersion::query()
->where('tenant_id', (int) $tenant->getKey())
->find($baselinePolicyVersionId);
$currentVersion = PolicyVersion::query()
->where('tenant_id', (int) $tenant->getKey())
->find($currentPolicyVersionId);
if (! $baselineVersion instanceof PolicyVersion || ! $currentVersion instanceof PolicyVersion) {
return 'policy_snapshot';
}
$platform = is_string($baselineVersion->platform ?? null)
? (string) $baselineVersion->platform
: (is_string($currentVersion->platform ?? null) ? (string) $currentVersion->platform : null);
$baselineSnapshot = is_array($baselineVersion->snapshot) ? $baselineVersion->snapshot : [];
$currentSnapshot = is_array($currentVersion->snapshot) ? $currentVersion->snapshot : [];
$baselineNormalized = $settingsNormalizer->normalizeForDiff(
snapshot: $baselineSnapshot,
policyType: $policyType,
platform: $platform,
);
$currentNormalized = $settingsNormalizer->normalizeForDiff(
snapshot: $currentSnapshot,
policyType: $policyType,
platform: $platform,
);
$baselineSnapshotHash = $hasher->hashNormalized([
'settings' => $baselineNormalized,
'secret_fingerprints' => $this->fingerprintBucket($baselineVersion, 'snapshot'),
]);
$currentSnapshotHash = $hasher->hashNormalized([
'settings' => $currentNormalized,
'secret_fingerprints' => $this->fingerprintBucket($currentVersion, 'snapshot'),
]);
if ($baselineSnapshotHash !== $currentSnapshotHash) {
return 'policy_snapshot';
}
$baselineAssignments = is_array($baselineVersion->assignments) ? $baselineVersion->assignments : [];
$currentAssignments = is_array($currentVersion->assignments) ? $currentVersion->assignments : [];
$baselineAssignmentsHash = $hasher->hashNormalized([
'assignments' => $assignmentsNormalizer->normalizeForDiff($baselineAssignments),
'secret_fingerprints' => $this->fingerprintBucket($baselineVersion, 'assignments'),
]);
$currentAssignmentsHash = $hasher->hashNormalized([
'assignments' => $assignmentsNormalizer->normalizeForDiff($currentAssignments),
'secret_fingerprints' => $this->fingerprintBucket($currentVersion, 'assignments'),
]);
if ($baselineAssignmentsHash !== $currentAssignmentsHash) {
return 'policy_assignments';
}
$baselineScopeTagIds = $scopeTagsNormalizer->normalizeIdsForHash($baselineVersion->scope_tags);
$currentScopeTagIds = $scopeTagsNormalizer->normalizeIdsForHash($currentVersion->scope_tags);
if ($baselineScopeTagIds === null || $currentScopeTagIds === null) {
return 'policy_snapshot';
}
$baselineScopeTagsHash = $hasher->hashNormalized([
'scope_tag_ids' => $baselineScopeTagIds,
'secret_fingerprints' => $this->fingerprintBucket($baselineVersion, 'scope_tags'),
]);
$currentScopeTagsHash = $hasher->hashNormalized([
'scope_tag_ids' => $currentScopeTagIds,
'secret_fingerprints' => $this->fingerprintBucket($currentVersion, 'scope_tags'),
]);
if ($baselineScopeTagsHash !== $currentScopeTagsHash) {
return 'policy_scope_tags';
}
return 'policy_snapshot';
}
/**
* @return array<string, string>
*/
private function fingerprintBucket(PolicyVersion $version, string $bucket): array
{
$secretFingerprints = is_array($version->secret_fingerprints) ? $version->secret_fingerprints : [];
$bucketFingerprints = $secretFingerprints[$bucket] ?? [];
return is_array($bucketFingerprints) ? $bucketFingerprints : [];
}
/**
* @param array{fidelity: string, source: string, observed_at: ?string, observed_operation_run_id: ?int} $baselineProvenance
* @param array<string, mixed> $currentProvenance
* @return array<string, mixed>
*/
private function buildDriftEvidenceContract(
string $changeType,
string $policyType,
string $subjectKey,
?string $displayName,
?string $baselineHash,
?string $currentHash,
array $baselineProvenance,
array $currentProvenance,
?int $baselinePolicyVersionId,
?int $currentPolicyVersionId,
string $summaryKind,
int $baselineProfileId,
int $baselineSnapshotId,
int $compareOperationRunId,
int $inventorySyncRunId,
): array {
$fidelity = $this->fidelityFromPolicyVersionRefs($baselinePolicyVersionId, $currentPolicyVersionId);
return [
'change_type' => $changeType,
'policy_type' => $policyType,
'subject_key' => $subjectKey,
'display_name' => $displayName,
'summary' => [
'kind' => $summaryKind,
],
'baseline' => [
'policy_version_id' => $baselinePolicyVersionId,
'hash' => $baselineHash,
'provenance' => $baselineProvenance,
],
'current' => [
'policy_version_id' => $currentPolicyVersionId,
'hash' => $currentHash,
'provenance' => $currentProvenance,
],
'fidelity' => $fidelity,
'provenance' => [
'baseline_profile_id' => $baselineProfileId,
'baseline_snapshot_id' => $baselineSnapshotId,
'compare_operation_run_id' => $compareOperationRunId,
'inventory_sync_run_id' => $inventorySyncRunId,
],
];
}
/**
* @param array<string, mixed> $baselineMeta
* @param array<string, mixed> $currentMeta
* @param array{
* baseline: array<string, mixed>,
* current: array<string, mixed>,
* changed_keys: list<string>,
* metadata_keys: list<string>,
* permission_keys: list<string>,
* diff_kind: string,
* diff_fingerprint: string
* }|null $roleDefinitionDiff
* @return array{
* diff_kind: string,
* diff_fingerprint: string,
* changed_keys: list<string>,
* metadata_keys: list<string>,
* permission_keys: list<string>,
* baseline: array{normalized: array<string, mixed>, is_built_in: mixed, role_permission_count: mixed},
* current: array{normalized: array<string, mixed>, is_built_in: mixed, role_permission_count: mixed}
* }
*/
private function buildRoleDefinitionEvidencePayload(
Tenant $tenant,
?int $baselinePolicyVersionId,
?int $currentPolicyVersionId,
array $baselineMeta,
array $currentMeta,
string $diffKind,
?array $roleDefinitionDiff = null,
): array {
$baselineVersion = $this->resolveRoleDefinitionVersion($tenant, $baselinePolicyVersionId);
$currentVersion = $this->resolveRoleDefinitionVersion($tenant, $currentPolicyVersionId);
$baselineNormalized = is_array($roleDefinitionDiff['baseline'] ?? null)
? $roleDefinitionDiff['baseline']
: $this->fallbackRoleDefinitionNormalized($baselineVersion, $baselineMeta);
$currentNormalized = is_array($roleDefinitionDiff['current'] ?? null)
? $roleDefinitionDiff['current']
: $this->fallbackRoleDefinitionNormalized($currentVersion, $currentMeta);
$changedKeys = is_array($roleDefinitionDiff['changed_keys'] ?? null)
? array_values(array_filter($roleDefinitionDiff['changed_keys'], 'is_string'))
: $this->roleDefinitionChangedKeys($baselineNormalized, $currentNormalized);
$metadataKeys = is_array($roleDefinitionDiff['metadata_keys'] ?? null)
? array_values(array_filter($roleDefinitionDiff['metadata_keys'], 'is_string'))
: array_values(array_diff($changedKeys, $this->roleDefinitionPermissionKeys($changedKeys)));
$permissionKeys = is_array($roleDefinitionDiff['permission_keys'] ?? null)
? array_values(array_filter($roleDefinitionDiff['permission_keys'], 'is_string'))
: $this->roleDefinitionPermissionKeys($changedKeys);
$resolvedDiffKind = is_string($roleDefinitionDiff['diff_kind'] ?? null)
? (string) $roleDefinitionDiff['diff_kind']
: $diffKind;
$diffFingerprint = is_string($roleDefinitionDiff['diff_fingerprint'] ?? null)
? (string) $roleDefinitionDiff['diff_fingerprint']
: hash(
'sha256',
json_encode([
'diff_kind' => $resolvedDiffKind,
'changed_keys' => $changedKeys,
'baseline' => $baselineNormalized,
'current' => $currentNormalized,
], JSON_THROW_ON_ERROR | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE),
);
return [
'diff_kind' => $resolvedDiffKind,
'diff_fingerprint' => $diffFingerprint,
'changed_keys' => $changedKeys,
'metadata_keys' => $metadataKeys,
'permission_keys' => $permissionKeys,
'baseline' => [
'normalized' => $baselineNormalized,
'is_built_in' => data_get($baselineMeta, 'rbac.is_built_in', data_get($baselineMeta, 'is_built_in')),
'role_permission_count' => data_get($baselineMeta, 'rbac.role_permission_count', data_get($baselineMeta, 'role_permission_count')),
],
'current' => [
'normalized' => $currentNormalized,
'is_built_in' => data_get($currentMeta, 'rbac.is_built_in', data_get($currentMeta, 'is_built_in')),
'role_permission_count' => data_get($currentMeta, 'rbac.role_permission_count', data_get($currentMeta, 'role_permission_count')),
],
];
}
private function resolveRoleDefinitionVersion(Tenant $tenant, ?int $policyVersionId): ?PolicyVersion
{
if ($policyVersionId === null) {
return null;
}
return PolicyVersion::query()
->where('tenant_id', (int) $tenant->getKey())
->find($policyVersionId);
}
/**
* @param array<string, mixed> $meta
* @return array<string, mixed>
*/
private function fallbackRoleDefinitionNormalized(?PolicyVersion $version, array $meta): array
{
if ($version instanceof PolicyVersion) {
return app(IntuneRoleDefinitionNormalizer::class)->buildEvidenceMap(
is_array($version->snapshot) ? $version->snapshot : [],
is_string($version->platform ?? null) ? (string) $version->platform : null,
);
}
$normalized = [];
$displayName = $meta['display_name'] ?? null;
if (is_string($displayName) && trim($displayName) !== '') {
$normalized['Role definition > Display name'] = trim($displayName);
}
$isBuiltIn = data_get($meta, 'rbac.is_built_in', data_get($meta, 'is_built_in'));
if (is_bool($isBuiltIn)) {
$normalized['Role definition > Role source'] = $isBuiltIn ? 'Built-in' : 'Custom';
}
$rolePermissionCount = data_get($meta, 'rbac.role_permission_count', data_get($meta, 'role_permission_count'));
if (is_numeric($rolePermissionCount)) {
$normalized['Role definition > Permission blocks'] = (int) $rolePermissionCount;
}
return $normalized;
}
/**
* @param array<string, mixed> $baselineNormalized
* @param array<string, mixed> $currentNormalized
* @return list<string>
*/
private function roleDefinitionChangedKeys(array $baselineNormalized, array $currentNormalized): array
{
$keys = array_values(array_unique(array_merge(array_keys($baselineNormalized), array_keys($currentNormalized))));
sort($keys, SORT_STRING);
return array_values(array_filter($keys, fn (string $key): bool => ($baselineNormalized[$key] ?? null) !== ($currentNormalized[$key] ?? null)));
}
/**
* @param list<string> $keys
* @return list<string>
*/
private function roleDefinitionPermissionKeys(array $keys): array
{
return array_values(array_filter(
$keys,
fn (string $key): bool => str_starts_with($key, 'Permission block ')
));
}
private function fidelityFromPolicyVersionRefs(?int $baselinePolicyVersionId, ?int $currentPolicyVersionId): string
{
if ($baselinePolicyVersionId !== null && $currentPolicyVersionId !== null) {
return 'content';
}
if ($baselinePolicyVersionId !== null || $currentPolicyVersionId !== null) {
return 'mixed';
}
return 'meta';
}
private function normalizeSubjectKey(
string $policyType,
?string $storedSubjectKey = null,
@ -2182,50 +1427,6 @@ private function normalizeSubjectKey(
return BaselineSubjectKey::forPolicy($policyType, $displayName, $subjectExternalId) ?? '';
}
/**
* @return array{
* baseline: array<string, mixed>,
* current: array<string, mixed>,
* changed_keys: list<string>,
* metadata_keys: list<string>,
* permission_keys: list<string>,
* diff_kind: string,
* diff_fingerprint: string
* }|null
*/
private function resolveRoleDefinitionDiff(
Tenant $tenant,
int $baselinePolicyVersionId,
int $currentPolicyVersionId,
IntuneRoleDefinitionNormalizer $normalizer,
): ?array {
$baselineVersion = $this->resolveRoleDefinitionVersion($tenant, $baselinePolicyVersionId);
$currentVersion = $this->resolveRoleDefinitionVersion($tenant, $currentPolicyVersionId);
if (! $baselineVersion instanceof PolicyVersion || ! $currentVersion instanceof PolicyVersion) {
return null;
}
return $normalizer->classifyDiff(
baselineSnapshot: is_array($baselineVersion->snapshot) ? $baselineVersion->snapshot : [],
currentSnapshot: is_array($currentVersion->snapshot) ? $currentVersion->snapshot : [],
platform: is_string($currentVersion->platform ?? null)
? (string) $currentVersion->platform
: (is_string($baselineVersion->platform ?? null) ? (string) $baselineVersion->platform : null),
);
}
/**
* @param array{diff_kind?: string}|null $roleDefinitionDiff
*/
private function severityForRoleDefinitionDiff(?array $roleDefinitionDiff): string
{
return match ($roleDefinitionDiff['diff_kind'] ?? null) {
'metadata_only' => Finding::SEVERITY_LOW,
default => Finding::SEVERITY_HIGH,
};
}
/**
* @return array{total_compared: int, unchanged: int, modified: int, missing: int, unexpected: int}
*/

View File

@ -0,0 +1,264 @@
<?php
declare(strict_types=1);
namespace App\Livewire;
use App\Filament\Resources\InventoryItemResource;
use App\Models\InventoryItem;
use App\Models\Tenant;
use App\Services\Inventory\DependencyQueryService;
use App\Services\Inventory\DependencyTargets\DependencyTargetResolver;
use App\Support\Enums\RelationshipType;
use App\Support\Filament\TablePaginationProfiles;
use Filament\Facades\Filament;
use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Filters\SelectFilter;
use Filament\Tables\Table;
use Filament\Tables\TableComponent;
use Illuminate\Contracts\View\View;
use Illuminate\Pagination\LengthAwarePaginator;
use Illuminate\Support\Collection;
use Illuminate\Support\Str;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
class InventoryItemDependencyEdgesTable extends TableComponent
{
public int $inventoryItemId;
private ?InventoryItem $cachedInventoryItem = null;
public function mount(int $inventoryItemId): void
{
$this->inventoryItemId = $inventoryItemId;
$this->resolveInventoryItem();
}
public function table(Table $table): Table
{
return $table
->queryStringIdentifier('inventoryItemDependencyEdges'.Str::studly((string) $this->inventoryItemId))
->defaultSort('relationship_label')
->defaultPaginationPageOption(10)
->paginated(TablePaginationProfiles::picker())
->striped()
->deferLoading(! app()->runningUnitTests())
->records(function (
?string $sortColumn,
?string $sortDirection,
?string $search,
array $filters,
int $page,
int $recordsPerPage
): LengthAwarePaginator {
$rows = $this->dependencyRows(
direction: (string) ($filters['direction']['value'] ?? 'all'),
relationshipType: $this->normalizeRelationshipType($filters['relationship_type']['value'] ?? null),
);
$rows = $this->sortRows($rows, $sortColumn, $sortDirection);
return $this->paginateRows($rows, $page, $recordsPerPage);
})
->filters([
SelectFilter::make('direction')
->label('Direction')
->default('all')
->options([
'all' => 'All',
'inbound' => 'Inbound',
'outbound' => 'Outbound',
]),
SelectFilter::make('relationship_type')
->label('Relationship')
->options([
'all' => 'All',
...RelationshipType::options(),
])
->default('all')
->searchable(),
])
->columns([
TextColumn::make('relationship_label')
->label('Relationship')
->badge()
->sortable(),
TextColumn::make('target_label')
->label('Target')
->badge()
->url(fn (array $record): ?string => is_string($record['target_url'] ?? null) ? $record['target_url'] : null)
->tooltip(fn (array $record): ?string => is_string($record['target_tooltip'] ?? null) ? $record['target_tooltip'] : null)
->wrap(),
TextColumn::make('missing_state')
->label('Status')
->badge()
->placeholder('—')
->color(fn (?string $state): string => $state === 'Missing' ? 'danger' : 'gray')
->icon(fn (?string $state): ?string => $state === 'Missing' ? 'heroicon-m-exclamation-triangle' : null)
->description(fn (array $record): ?string => is_string($record['missing_hint'] ?? null) ? $record['missing_hint'] : null)
->wrap(),
])
->actions([])
->bulkActions([])
->emptyStateHeading('No dependencies found')
->emptyStateDescription('Change direction or relationship filters to review a different dependency scope.');
}
public function render(): View
{
return view('livewire.inventory-item-dependency-edges-table');
}
/**
* @return Collection<string, array<string, mixed>>
*/
private function dependencyRows(string $direction, ?string $relationshipType): Collection
{
$inventoryItem = $this->resolveInventoryItem();
$tenant = $this->resolveCurrentTenant();
$service = app(DependencyQueryService::class);
$resolver = app(DependencyTargetResolver::class);
$edges = collect();
if ($direction === 'inbound' || $direction === 'all') {
$edges = $edges->merge($service->getInboundEdges($inventoryItem, $relationshipType));
}
if ($direction === 'outbound' || $direction === 'all') {
$edges = $edges->merge($service->getOutboundEdges($inventoryItem, $relationshipType));
}
return $resolver->attachRenderedTargets($edges->take(100), $tenant)
->map(function (array $edge): array {
$targetId = $edge['target_id'] ?? null;
$renderedTarget = is_array($edge['rendered_target'] ?? null) ? $edge['rendered_target'] : [];
$badgeText = is_string($renderedTarget['badge_text'] ?? null) ? $renderedTarget['badge_text'] : null;
$linkUrl = is_string($renderedTarget['link_url'] ?? null) ? $renderedTarget['link_url'] : null;
$lastKnownName = is_string(data_get($edge, 'metadata.last_known_name')) ? data_get($edge, 'metadata.last_known_name') : null;
$isMissing = ($edge['target_type'] ?? null) === 'missing';
$missingHint = null;
if ($isMissing) {
$missingHint = 'Missing target';
if (filled($lastKnownName)) {
$missingHint .= ". Last known: {$lastKnownName}";
}
$rawRef = data_get($edge, 'metadata.raw_ref');
$encodedRef = $rawRef !== null ? json_encode($rawRef) : null;
if (is_string($encodedRef) && $encodedRef !== '') {
$missingHint .= '. Ref: '.Str::limit($encodedRef, 200);
}
}
$fallbackLabel = null;
if (filled($lastKnownName)) {
$fallbackLabel = $lastKnownName;
} elseif (is_string($targetId) && $targetId !== '') {
$fallbackLabel = 'ID: '.substr($targetId, 0, 6).'…';
} else {
$fallbackLabel = 'External reference';
}
$relationshipType = (string) ($edge['relationship_type'] ?? 'unknown');
return [
'id' => (string) ($edge['id'] ?? Str::uuid()->toString()),
'relationship_label' => RelationshipType::options()[$relationshipType] ?? Str::headline($relationshipType),
'target_label' => $badgeText ?? $fallbackLabel,
'target_url' => $linkUrl,
'target_tooltip' => is_string($targetId) ? $targetId : null,
'missing_state' => $isMissing ? 'Missing' : null,
'missing_hint' => $missingHint,
];
})
->mapWithKeys(fn (array $row): array => [$row['id'] => $row]);
}
/**
* @param Collection<string, array<string, mixed>> $rows
* @return Collection<string, array<string, mixed>>
*/
private function sortRows(Collection $rows, ?string $sortColumn, ?string $sortDirection): Collection
{
$sortColumn = in_array($sortColumn, ['relationship_label', 'target_label', 'missing_state'], true)
? $sortColumn
: 'relationship_label';
$descending = Str::lower((string) ($sortDirection ?? 'asc')) === 'desc';
$records = $rows->all();
uasort($records, static function (array $left, array $right) use ($sortColumn, $descending): int {
$comparison = strnatcasecmp(
(string) ($left[$sortColumn] ?? ''),
(string) ($right[$sortColumn] ?? ''),
);
if ($comparison === 0) {
$comparison = strnatcasecmp(
(string) ($left['target_label'] ?? ''),
(string) ($right['target_label'] ?? ''),
);
}
return $descending ? ($comparison * -1) : $comparison;
});
return collect($records);
}
/**
* @param Collection<string, array<string, mixed>> $rows
*/
private function paginateRows(Collection $rows, int $page, int $recordsPerPage): LengthAwarePaginator
{
return new LengthAwarePaginator(
items: $rows->forPage($page, $recordsPerPage),
total: $rows->count(),
perPage: $recordsPerPage,
currentPage: $page,
);
}
private function resolveInventoryItem(): InventoryItem
{
if ($this->cachedInventoryItem instanceof InventoryItem) {
return $this->cachedInventoryItem;
}
$inventoryItem = InventoryItem::query()->findOrFail($this->inventoryItemId);
$tenant = $this->resolveCurrentTenant();
if ((int) $inventoryItem->tenant_id !== (int) $tenant->getKey() || ! InventoryItemResource::canView($inventoryItem)) {
throw new NotFoundHttpException;
}
return $this->cachedInventoryItem = $inventoryItem;
}
private function resolveCurrentTenant(): Tenant
{
$tenant = Filament::getTenant();
if (! $tenant instanceof Tenant) {
throw new NotFoundHttpException;
}
return $tenant;
}
private function normalizeRelationshipType(mixed $value): ?string
{
if (! is_string($value) || $value === '' || $value === 'all') {
return null;
}
return RelationshipType::tryFrom($value)?->value;
}
}

View File

@ -53,6 +53,8 @@ public function build(Tenant $tenant, array $filters = []): array
$filteredPermissions = self::applyFilterState($allPermissions, $state);
$freshness = self::deriveFreshness(self::parseLastRefreshedAt($comparison['last_refreshed_at'] ?? null));
$summaryPermissions = $filteredPermissions;
return [
'tenant' => [
'id' => (int) $tenant->getKey(),
@ -60,9 +62,9 @@ public function build(Tenant $tenant, array $filters = []): array
'name' => (string) $tenant->name,
],
'overview' => [
'overall' => self::deriveOverallStatus($allPermissions, (bool) ($freshness['is_stale'] ?? true)),
'counts' => self::deriveCounts($allPermissions),
'feature_impacts' => self::deriveFeatureImpacts($allPermissions),
'overall' => self::deriveOverallStatus($summaryPermissions, (bool) ($freshness['is_stale'] ?? true)),
'counts' => self::deriveCounts($summaryPermissions),
'feature_impacts' => self::deriveFeatureImpacts($summaryPermissions),
'freshness' => $freshness,
],
'permissions' => $filteredPermissions,

View File

@ -6,7 +6,7 @@
use App\Support\Inventory\InventoryPolicyTypeMeta;
final class GovernanceSubjectTaxonomyRegistry
class GovernanceSubjectTaxonomyRegistry
{
/**
* @var array<string, list<string>>

View File

@ -1,85 +1,6 @@
@php /** @var callable $getState */ @endphp
<div class="space-y-4">
<form method="GET" class="flex items-center gap-2">
<label for="direction" class="text-sm text-gray-600">Direction</label>
<select id="direction" name="direction" class="fi-input fi-select">
<option value="all" {{ request('direction', 'all') === 'all' ? 'selected' : '' }}>All</option>
<option value="inbound" {{ request('direction') === 'inbound' ? 'selected' : '' }}>Inbound</option>
<option value="outbound" {{ request('direction') === 'outbound' ? 'selected' : '' }}>Outbound</option>
</select>
<label for="relationship_type" class="text-sm text-gray-600">Relationship</label>
<select id="relationship_type" name="relationship_type" class="fi-input fi-select">
<option value="all" {{ request('relationship_type', 'all') === 'all' ? 'selected' : '' }}>All</option>
@foreach (\App\Support\Enums\RelationshipType::options() as $value => $label)
<option value="{{ $value }}" {{ request('relationship_type') === $value ? 'selected' : '' }}>{{ $label }}</option>
@endforeach
</select>
<button type="submit" class="fi-btn">Apply</button>
</form>
@php
$raw = $getState();
$edges = $raw instanceof \Illuminate\Support\Collection ? $raw : collect($raw);
@endphp
@if ($edges->isEmpty())
<div class="text-sm text-gray-500">No dependencies found</div>
@else
<div class="divide-y">
@foreach ($edges->groupBy('relationship_type') as $type => $group)
<div class="py-2">
<div class="text-xs uppercase tracking-wide text-gray-600 mb-2">{{ str_replace('_', ' ', $type) }}</div>
<ul class="space-y-1">
@foreach ($group as $edge)
@php
$isMissing = ($edge['target_type'] ?? null) === 'missing';
$targetId = $edge['target_id'] ?? null;
$rendered = $edge['rendered_target'] ?? [];
$badgeText = is_array($rendered) ? ($rendered['badge_text'] ?? null) : null;
$linkUrl = is_array($rendered) ? ($rendered['link_url'] ?? null) : null;
$missingTitle = 'Missing target';
$lastKnownName = $edge['metadata']['last_known_name'] ?? null;
if (is_string($lastKnownName) && $lastKnownName !== '') {
$missingTitle .= ". Last known: {$lastKnownName}";
}
$rawRef = $edge['metadata']['raw_ref'] ?? null;
if ($rawRef !== null) {
$encodedRef = json_encode($rawRef);
if (is_string($encodedRef) && $encodedRef !== '') {
$missingTitle .= '. Ref: '.\Illuminate\Support\Str::limit($encodedRef, 200);
}
}
$fallbackDisplay = null;
if (is_string($lastKnownName) && $lastKnownName !== '') {
$fallbackDisplay = $lastKnownName;
} elseif (is_string($targetId) && $targetId !== '') {
$fallbackDisplay = 'ID: '.substr($targetId, 0, 6).'…';
} else {
$fallbackDisplay = 'External reference';
}
@endphp
<li class="flex items-center gap-2 text-sm">
@if (is_string($badgeText) && $badgeText !== '')
@if (is_string($linkUrl) && $linkUrl !== '')
<a class="fi-badge" href="{{ $linkUrl }}" title="{{ is_string($targetId) ? $targetId : '' }}">{{ $badgeText }}</a>
@else
<span class="fi-badge" title="{{ is_string($targetId) ? $targetId : '' }}">{{ $badgeText }}</span>
@endif
@else
<span class="fi-badge" title="{{ is_string($targetId) ? $targetId : '' }}">{{ $fallbackDisplay }}</span>
@endif
@if ($isMissing)
<span class="fi-badge fi-badge-danger" title="{{ $missingTitle }}">Missing</span>
@endif
</li>
@endforeach
</ul>
</div>
@endforeach
</div>
@endif
<livewire:inventory-item-dependency-edges-table
:inventory-item-id="(int) $getRecord()->getKey()"
:key="'inventory-item-dependency-edges-'.$getRecord()->getKey()"
/>
</div>

View File

@ -1,108 +1,34 @@
@php
$report = $report ?? null;
$report = is_array($report) ? $report : null;
$run = $run ?? null;
$run = is_array($run) ? $run : null;
$fingerprint = $fingerprint ?? null;
$fingerprint = is_string($fingerprint) && trim($fingerprint) !== '' ? trim($fingerprint) : null;
$changeIndicator = $changeIndicator ?? null;
$changeIndicator = is_array($changeIndicator) ? $changeIndicator : null;
$previousRunUrl = $previousRunUrl ?? null;
$previousRunUrl = is_string($previousRunUrl) && $previousRunUrl !== '' ? $previousRunUrl : null;
$acknowledgements = $acknowledgements ?? [];
$acknowledgements = is_array($acknowledgements) ? $acknowledgements : [];
$redactionNotes = $redactionNotes ?? [];
$redactionNotes = is_array($redactionNotes) ? array_values(array_filter($redactionNotes, 'is_string')) : [];
$summary = $report['summary'] ?? null;
$summary = is_array($summary) ? $summary : null;
$counts = is_array($summary['counts'] ?? null) ? $summary['counts'] : [];
$checks = $report['checks'] ?? null;
$checks = is_array($checks) ? $checks : [];
$ackByKey = [];
foreach ($acknowledgements as $checkKey => $ack) {
if (! is_string($checkKey) || $checkKey === '' || ! is_array($ack)) {
continue;
}
$ackByKey[$checkKey] = $ack;
}
$blockers = [];
$failures = [];
$warnings = [];
$acknowledgedIssues = [];
$passed = [];
foreach ($checks as $check) {
$check = is_array($check) ? $check : [];
$key = $check['key'] ?? null;
$key = is_string($key) ? trim($key) : '';
if ($key === '') {
continue;
}
$statusValue = $check['status'] ?? null;
$statusValue = is_string($statusValue) ? strtolower(trim($statusValue)) : '';
$blocking = $check['blocking'] ?? false;
$blocking = is_bool($blocking) ? $blocking : false;
if (array_key_exists($key, $ackByKey)) {
$acknowledgedIssues[] = $check;
continue;
}
if ($statusValue === 'pass') {
$passed[] = $check;
continue;
}
if ($statusValue === 'fail' && $blocking) {
$blockers[] = $check;
continue;
}
if ($statusValue === 'fail') {
$failures[] = $check;
continue;
}
if ($statusValue === 'warn') {
$warnings[] = $check;
}
}
$sortChecks = static function (array $a, array $b): int {
return strcmp((string) ($a['key'] ?? ''), (string) ($b['key'] ?? ''));
};
usort($blockers, $sortChecks);
usort($failures, $sortChecks);
usort($warnings, $sortChecks);
usort($acknowledgedIssues, $sortChecks);
usort($passed, $sortChecks);
$surface = is_array($surface ?? null) ? $surface : [];
$coreState = is_string($surface['coreState'] ?? null) ? (string) $surface['coreState'] : 'unavailable';
$redactionNotes = is_array($redactionNotes ?? null)
? array_values(array_filter($redactionNotes, 'is_string'))
: [];
$canAcknowledge = (bool) ($canAcknowledge ?? false);
$ackAction = $ackAction ?? null;
$showAssist = (bool) ($showAssist ?? false);
$assistActionName = is_string($assistActionName ?? null) && trim((string) $assistActionName) !== ''
? trim((string) $assistActionName)
: 'wizardVerificationRequiredPermissionsAssist';
$linkBehavior = $linkBehavior ?? app(\App\Support\Verification\VerificationLinkBehavior::class);
$emptyState = is_array($surface['emptyState'] ?? null) ? $surface['emptyState'] : null;
@endphp
<div class="space-y-4">
@if ($report === null || $summary === null)
<div class="rounded-lg border border-gray-200 bg-white p-4 text-sm text-gray-600 shadow-sm dark:border-gray-800 dark:bg-gray-900 dark:text-gray-300">
<div
data-shared-detail-family="verification-report"
data-host-kind="{{ (string) ($surface['hostKind'] ?? 'operation_run_detail') }}"
class="space-y-4"
>
@if ($coreState === 'unavailable')
<div
data-shared-zone="unavailable"
class="rounded-lg border border-gray-200 bg-white p-4 text-sm text-gray-600 shadow-sm dark:border-gray-800 dark:bg-gray-900 dark:text-gray-300"
>
<div class="font-medium text-gray-900 dark:text-white">
Verification report unavailable
{{ $emptyState['title'] ?? 'Verification report unavailable' }}
</div>
<div class="mt-1">
This operation doesnt have a report yet. If its still running, refresh in a moment. If it already completed, start verification again.
{{ $emptyState['message'] ?? 'This operation does not have a report yet.' }}
</div>
<div class="mt-2 text-xs text-gray-600 dark:text-gray-300">
<span class="font-semibold">Read-only:</span> this view uses stored data and makes no external calls.
@ -117,68 +43,10 @@
@endif
</div>
@else
@php
$overallSpec = \App\Support\Badges\BadgeRenderer::spec(
\App\Support\Badges\BadgeDomain::VerificationReportOverall,
$summary['overall'] ?? null,
);
@endphp
<div class="rounded-lg border border-gray-200 bg-white p-4 shadow-sm dark:border-gray-800 dark:bg-gray-900">
<div class="flex flex-wrap items-center gap-2">
<x-filament::badge :color="$overallSpec->color" :icon="$overallSpec->icon">
{{ $overallSpec->label }}
</x-filament::badge>
<x-filament::badge color="gray">
{{ (int) ($counts['total'] ?? 0) }} total
</x-filament::badge>
<x-filament::badge color="success">
{{ (int) ($counts['pass'] ?? 0) }} pass
</x-filament::badge>
<x-filament::badge color="danger">
{{ (int) ($counts['fail'] ?? 0) }} fail
</x-filament::badge>
<x-filament::badge color="warning">
{{ (int) ($counts['warn'] ?? 0) }} warn
</x-filament::badge>
<x-filament::badge color="gray">
{{ (int) ($counts['skip'] ?? 0) }} skip
</x-filament::badge>
<x-filament::badge color="info">
{{ (int) ($counts['running'] ?? 0) }} running
</x-filament::badge>
@if ($changeIndicator !== null)
@php
$state = $changeIndicator['state'] ?? null;
$state = is_string($state) ? $state : null;
@endphp
@if ($state === 'no_changes')
<x-filament::badge color="success">
No changes since previous verification
</x-filament::badge>
@elseif ($state === 'changed')
<x-filament::badge color="warning">
Changed since previous verification
</x-filament::badge>
@endif
@endif
</div>
<div class="mt-2 text-xs text-gray-600 dark:text-gray-300">
<span class="font-semibold">Read-only:</span> this view uses stored data and makes no external calls.
</div>
@if ($redactionNotes !== [])
<div class="mt-3 rounded-lg border border-amber-200 bg-amber-50 px-3 py-2 text-xs text-amber-900 dark:border-amber-500/30 dark:bg-amber-500/10 dark:text-amber-100">
@foreach ($redactionNotes as $note)
<div>{{ $note }}</div>
@endforeach
</div>
@endif
</div>
@include('filament.components.verification-report.summary', [
'surface' => $surface,
'redactionNotes' => $redactionNotes,
])
<div x-data="{ tab: 'issues' }" class="space-y-4">
<x-filament::tabs label="Verification report tabs">
@ -196,313 +64,28 @@
>
Passed
</x-filament::tabs.item>
<x-filament::tabs.item
:active="false"
alpine-active="tab === 'technical'"
x-on:click="tab = 'technical'"
>
Technical details
</x-filament::tabs.item>
</x-filament::tabs>
<div x-show="tab === 'issues'">
@if ($blockers === [] && $failures === [] && $warnings === [] && $acknowledgedIssues === [])
<div class="text-sm text-gray-700 dark:text-gray-200">
No issues found in this report.
</div>
@else
<div class="space-y-3">
@php
$issueGroups = [
['label' => 'Blockers', 'checks' => $blockers],
['label' => 'Failures', 'checks' => $failures],
['label' => 'Warnings', 'checks' => $warnings],
];
@endphp
@foreach ($issueGroups as $group)
@php
$label = $group['label'];
$groupChecks = $group['checks'];
@endphp
@if ($groupChecks !== [])
<div class="space-y-2">
<div class="text-sm font-semibold text-gray-900 dark:text-white">
{{ $label }}
</div>
<div class="space-y-2">
@foreach ($groupChecks as $check)
@php
$check = is_array($check) ? $check : [];
$title = $check['title'] ?? 'Check';
$title = is_string($title) && trim($title) !== '' ? trim($title) : 'Check';
$message = $check['message'] ?? null;
$message = is_string($message) && trim($message) !== '' ? trim($message) : null;
$statusSpec = \App\Support\Badges\BadgeRenderer::spec(
\App\Support\Badges\BadgeDomain::VerificationCheckStatus,
$check['status'] ?? null,
);
$severitySpec = \App\Support\Badges\BadgeRenderer::spec(
\App\Support\Badges\BadgeDomain::VerificationCheckSeverity,
$check['severity'] ?? null,
);
$nextSteps = $check['next_steps'] ?? [];
$nextSteps = is_array($nextSteps) ? array_slice($nextSteps, 0, 2) : [];
$blocking = $check['blocking'] ?? false;
$blocking = is_bool($blocking) ? $blocking : false;
@endphp
<div class="rounded-lg border border-gray-200 bg-white p-4 shadow-sm dark:border-gray-800 dark:bg-gray-900">
<div class="flex flex-wrap items-start justify-between gap-3">
<div class="space-y-1">
<div class="text-sm font-medium text-gray-900 dark:text-white">
{{ $title }}
</div>
@if ($message)
<div class="text-sm text-gray-600 dark:text-gray-300">
{{ $message }}
</div>
@endif
</div>
<div class="flex shrink-0 flex-wrap items-center justify-end gap-2">
@if ($blocking)
<x-filament::badge color="danger" size="sm">
Blocker
</x-filament::badge>
@endif
<x-filament::badge :color="$severitySpec->color" :icon="$severitySpec->icon" size="sm">
{{ $severitySpec->label }}
</x-filament::badge>
<x-filament::badge :color="$statusSpec->color" :icon="$statusSpec->icon" size="sm">
{{ $statusSpec->label }}
</x-filament::badge>
</div>
</div>
@if ($nextSteps !== [])
<div class="mt-4">
<div class="text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">
Next steps
</div>
<ul class="mt-2 space-y-1 text-sm">
@foreach ($nextSteps as $step)
@php
$step = is_array($step) ? $step : [];
$label = $step['label'] ?? null;
$url = $step['url'] ?? null;
$isExternal = is_string($url) && (str_starts_with($url, 'http://') || str_starts_with($url, 'https://'));
@endphp
@if (is_string($label) && $label !== '' && is_string($url) && $url !== '')
<li>
<a
href="{{ $url }}"
class="text-primary-600 hover:underline dark:text-primary-400"
@if ($isExternal)
target="_blank" rel="noreferrer"
@endif
>
{{ $label }}
</a>
</li>
@endif
@endforeach
</ul>
</div>
@endif
</div>
@endforeach
</div>
</div>
@endif
@endforeach
@if ($acknowledgedIssues !== [])
<details class="rounded-lg border border-gray-200 bg-white p-4 shadow-sm dark:border-gray-800 dark:bg-gray-900">
<summary class="cursor-pointer text-sm font-semibold text-gray-900 dark:text-white">
Acknowledged issues
</summary>
<div class="mt-4 space-y-2">
@foreach ($acknowledgedIssues as $check)
@php
$check = is_array($check) ? $check : [];
$checkKey = is_string($check['key'] ?? null) ? trim((string) $check['key']) : '';
$title = $check['title'] ?? 'Check';
$title = is_string($title) && trim($title) !== '' ? trim($title) : 'Check';
$message = $check['message'] ?? null;
$message = is_string($message) && trim($message) !== '' ? trim($message) : null;
$statusSpec = \App\Support\Badges\BadgeRenderer::spec(
\App\Support\Badges\BadgeDomain::VerificationCheckStatus,
$check['status'] ?? null,
);
$severitySpec = \App\Support\Badges\BadgeRenderer::spec(
\App\Support\Badges\BadgeDomain::VerificationCheckSeverity,
$check['severity'] ?? null,
);
$ack = $checkKey !== '' ? ($ackByKey[$checkKey] ?? null) : null;
$ack = is_array($ack) ? $ack : null;
$ackReason = $ack['ack_reason'] ?? null;
$ackReason = is_string($ackReason) && trim($ackReason) !== '' ? trim($ackReason) : null;
$ackAt = $ack['acknowledged_at'] ?? null;
$ackAt = is_string($ackAt) && trim($ackAt) !== '' ? trim($ackAt) : null;
$ackBy = $ack['acknowledged_by'] ?? null;
$ackBy = is_array($ackBy) ? $ackBy : null;
$ackByName = $ackBy['name'] ?? null;
$ackByName = is_string($ackByName) && trim($ackByName) !== '' ? trim($ackByName) : null;
$expiresAt = $ack['expires_at'] ?? null;
$expiresAt = is_string($expiresAt) && trim($expiresAt) !== '' ? trim($expiresAt) : null;
@endphp
<div class="rounded-lg border border-gray-100 bg-gray-50 p-4 dark:border-gray-800 dark:bg-gray-950">
<div class="flex flex-wrap items-start justify-between gap-3">
<div class="space-y-1">
<div class="text-sm font-medium text-gray-900 dark:text-white">
{{ $title }}
</div>
@if ($message)
<div class="text-sm text-gray-600 dark:text-gray-300">
{{ $message }}
</div>
@endif
</div>
<div class="flex shrink-0 flex-wrap items-center justify-end gap-2">
<x-filament::badge :color="$severitySpec->color" :icon="$severitySpec->icon" size="sm">
{{ $severitySpec->label }}
</x-filament::badge>
<x-filament::badge :color="$statusSpec->color" :icon="$statusSpec->icon" size="sm">
{{ $statusSpec->label }}
</x-filament::badge>
</div>
</div>
@if ($ackReason || $ackAt || $ackByName || $expiresAt)
<div class="mt-3 space-y-1 text-sm text-gray-700 dark:text-gray-200">
@if ($ackReason)
<div>
<span class="font-semibold">Reason:</span> {{ $ackReason }}
</div>
@endif
@if ($ackByName || $ackAt)
<div>
<span class="font-semibold">Acknowledged:</span>
@if ($ackByName)
{{ $ackByName }}
@endif
@if ($ackAt)
<span class="text-gray-500 dark:text-gray-400">({{ $ackAt }})</span>
@endif
</div>
@endif
@if ($expiresAt)
<div>
<span class="font-semibold">Expires:</span>
<span class="text-gray-500 dark:text-gray-400">{{ $expiresAt }}</span>
</div>
@endif
</div>
@endif
</div>
@endforeach
</div>
</details>
@endif
</div>
@endif
@include('filament.components.verification-report.issues', [
'surface' => $surface,
'canAcknowledge' => $canAcknowledge,
'ackAction' => $ackAction,
'showAssist' => $showAssist,
'assistActionName' => $assistActionName,
'linkBehavior' => $linkBehavior,
])
</div>
<div x-show="tab === 'passed'" style="display: none;">
@if ($passed === [])
<div class="text-sm text-gray-600 dark:text-gray-300">
No passing checks recorded.
</div>
@else
<div class="space-y-2">
@foreach ($passed as $check)
@php
$check = is_array($check) ? $check : [];
$title = $check['title'] ?? 'Check';
$title = is_string($title) && trim($title) !== '' ? trim($title) : 'Check';
$statusSpec = \App\Support\Badges\BadgeRenderer::spec(
\App\Support\Badges\BadgeDomain::VerificationCheckStatus,
$check['status'] ?? null,
);
@endphp
<div class="flex items-center justify-between gap-3 rounded-lg border border-gray-200 bg-white p-3 text-sm shadow-sm dark:border-gray-800 dark:bg-gray-900">
<div class="font-medium text-gray-900 dark:text-white">
{{ $title }}
</div>
<x-filament::badge :color="$statusSpec->color" :icon="$statusSpec->icon" size="sm">
{{ $statusSpec->label }}
</x-filament::badge>
</div>
@endforeach
</div>
@endif
</div>
<div x-show="tab === 'technical'" style="display: none;">
<div class="space-y-4 text-sm text-gray-700 dark:text-gray-200">
<div class="space-y-1">
<div class="text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">
Identifiers
</div>
<div class="flex flex-col gap-1">
@if ($run !== null)
<div>
<span class="text-gray-500 dark:text-gray-400">Operation ID:</span>
<span class="font-mono">{{ (int) ($run['id'] ?? 0) }}</span>
</div>
<div>
<span class="text-gray-500 dark:text-gray-400">Flow:</span>
<span class="font-mono">{{ (string) ($run['type'] ?? '') }}</span>
</div>
@endif
@if ($fingerprint)
<div>
<span class="text-gray-500 dark:text-gray-400">Fingerprint:</span>
<span class="font-mono text-xs break-all">{{ $fingerprint }}</span>
</div>
@endif
</div>
</div>
@if ($previousRunUrl !== null)
<div>
<a
href="{{ $previousRunUrl }}"
class="font-medium text-primary-600 hover:underline dark:text-primary-400"
>
Open previous operation
</a>
</div>
@endif
</div>
@include('filament.components.verification-report.passed', [
'surface' => $surface,
])
</div>
</div>
@include('filament.components.verification-report.diagnostics', [
'surface' => $surface,
])
@endif
</div>

View File

@ -0,0 +1,45 @@
@php
$diagnostics = is_array($surface['diagnostics'] ?? null) ? $surface['diagnostics'] : [];
@endphp
<div
data-shared-zone="diagnostics"
class="rounded-lg border border-gray-200 bg-white p-4 shadow-sm dark:border-gray-800 dark:bg-gray-900"
>
<div class="text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">
Diagnostics
</div>
<div class="mt-3 space-y-1 text-sm text-gray-700 dark:text-gray-200">
<div>
<span class="text-gray-500 dark:text-gray-400">Operation ID:</span>
<span class="font-mono">{{ (int) ($diagnostics['operationRunId'] ?? 0) }}</span>
</div>
<div>
<span class="text-gray-500 dark:text-gray-400">Flow:</span>
<span class="font-mono">{{ (string) ($diagnostics['flow'] ?? '') }}</span>
</div>
@if (filled($diagnostics['completedAt'] ?? null))
<div>
<span class="text-gray-500 dark:text-gray-400">Completed:</span>
<span>{{ $diagnostics['completedAt'] }}</span>
</div>
@endif
@if (filled($diagnostics['fingerprint'] ?? null))
<div>
<span class="text-gray-500 dark:text-gray-400">Fingerprint:</span>
<span class="font-mono text-xs break-all">{{ $diagnostics['fingerprint'] }}</span>
</div>
@endif
@if (filled($diagnostics['previousRunUrl'] ?? null))
<div>
<a
href="{{ $diagnostics['previousRunUrl'] }}"
class="font-medium text-primary-600 hover:underline dark:text-primary-400"
>
Open previous operation
</a>
</div>
@endif
</div>
</div>

View File

@ -0,0 +1,226 @@
@php
$issueGroups = collect($surface['issueGroups'] ?? [])
->filter(static fn (mixed $group): bool => is_array($group))
->values();
$canAcknowledge = (bool) ($canAcknowledge ?? false);
$ackAction = $ackAction ?? null;
$showAssist = (bool) ($showAssist ?? false);
$assistActionName = is_string($assistActionName ?? null) && trim((string) $assistActionName) !== ''
? trim((string) $assistActionName)
: 'wizardVerificationRequiredPermissionsAssist';
$linkBehavior = $linkBehavior ?? app(\App\Support\Verification\VerificationLinkBehavior::class);
@endphp
<div data-shared-zone="issues">
@if ($issueGroups->isEmpty())
<div class="text-sm text-gray-700 dark:text-gray-200">
No issues found in this report.
</div>
@else
<div class="space-y-3">
@foreach ($issueGroups as $group)
@php
$label = is_string($group['label'] ?? null) ? (string) $group['label'] : 'Issues';
$checks = collect($group['checks'] ?? [])->filter(static fn (mixed $check): bool => is_array($check))->values();
$acknowledged = (bool) ($group['acknowledged'] ?? false);
@endphp
@if ($acknowledged)
<details class="rounded-lg border border-gray-200 bg-white p-4 shadow-sm dark:border-gray-800 dark:bg-gray-900">
<summary class="cursor-pointer text-sm font-semibold text-gray-900 dark:text-white">
{{ $label }}
</summary>
<div class="mt-4 space-y-2">
@foreach ($checks as $check)
@php
$title = is_string($check['title'] ?? null) && trim((string) $check['title']) !== '' ? trim((string) $check['title']) : 'Check';
$message = is_string($check['message'] ?? null) && trim((string) $check['message']) !== '' ? trim((string) $check['message']) : null;
$statusSpec = \App\Support\Badges\BadgeRenderer::spec(
\App\Support\Badges\BadgeDomain::VerificationCheckStatus,
$check['status'] ?? null,
);
$severitySpec = \App\Support\Badges\BadgeRenderer::spec(
\App\Support\Badges\BadgeDomain::VerificationCheckSeverity,
$check['severity'] ?? null,
);
$acknowledgement = is_array($check['acknowledgement'] ?? null) ? $check['acknowledgement'] : null;
@endphp
<div class="rounded-lg border border-gray-100 bg-gray-50 p-4 dark:border-gray-800 dark:bg-gray-950">
<div class="flex flex-wrap items-start justify-between gap-3">
<div class="space-y-1">
<div class="text-sm font-medium text-gray-900 dark:text-white">
{{ $title }}
</div>
@if ($message)
<div class="text-sm text-gray-600 dark:text-gray-300">
{{ $message }}
</div>
@endif
</div>
<div class="flex shrink-0 flex-wrap items-center justify-end gap-2">
<x-filament::badge :color="$severitySpec->color" :icon="$severitySpec->icon" size="sm">
{{ $severitySpec->label }}
</x-filament::badge>
<x-filament::badge :color="$statusSpec->color" :icon="$statusSpec->icon" size="sm">
{{ $statusSpec->label }}
</x-filament::badge>
</div>
</div>
@if ($acknowledgement)
<div class="mt-3 space-y-1 text-sm text-gray-700 dark:text-gray-200">
@if (filled($acknowledgement['ack_reason'] ?? null))
<div>
<span class="font-semibold">Reason:</span> {{ $acknowledgement['ack_reason'] }}
</div>
@endif
@if (filled($acknowledgement['acknowledged_by']['name'] ?? null) || filled($acknowledgement['acknowledged_at'] ?? null))
<div>
<span class="font-semibold">Acknowledged:</span>
@if (filled($acknowledgement['acknowledged_by']['name'] ?? null))
{{ $acknowledgement['acknowledged_by']['name'] }}
@endif
@if (filled($acknowledgement['acknowledged_at'] ?? null))
<span class="text-gray-500 dark:text-gray-400">({{ $acknowledgement['acknowledged_at'] }})</span>
@endif
</div>
@endif
@if (filled($acknowledgement['expires_at'] ?? null))
<div>
<span class="font-semibold">Expires:</span>
<span class="text-gray-500 dark:text-gray-400">{{ $acknowledgement['expires_at'] }}</span>
</div>
@endif
</div>
@endif
</div>
@endforeach
</div>
</details>
@else
<div class="space-y-2">
<div class="text-sm font-semibold text-gray-900 dark:text-white">
{{ $label }}
</div>
<div class="space-y-2">
@foreach ($checks as $check)
@php
$checkKey = is_string($check['key'] ?? null) ? trim((string) $check['key']) : '';
$title = is_string($check['title'] ?? null) && trim((string) $check['title']) !== '' ? trim((string) $check['title']) : 'Check';
$message = is_string($check['message'] ?? null) && trim((string) $check['message']) !== '' ? trim((string) $check['message']) : null;
$statusSpec = \App\Support\Badges\BadgeRenderer::spec(
\App\Support\Badges\BadgeDomain::VerificationCheckStatus,
$check['status'] ?? null,
);
$severitySpec = \App\Support\Badges\BadgeRenderer::spec(
\App\Support\Badges\BadgeDomain::VerificationCheckSeverity,
$check['severity'] ?? null,
);
$nextSteps = collect($check['next_steps'] ?? [])->filter(static fn (mixed $step): bool => is_array($step))->take(2)->values();
$blocking = (bool) ($check['blocking'] ?? false);
$routeNextStepsThroughAssist = $linkBehavior->shouldRouteThroughAssist($check, $showAssist);
@endphp
<div class="rounded-lg border border-gray-200 bg-white p-4 shadow-sm dark:border-gray-800 dark:bg-gray-900">
<div class="flex flex-wrap items-start justify-between gap-3">
<div class="space-y-1">
<div class="text-sm font-medium text-gray-900 dark:text-white">
{{ $title }}
</div>
@if ($message)
<div class="text-sm text-gray-600 dark:text-gray-300">
{{ $message }}
</div>
@endif
</div>
<div class="flex shrink-0 flex-wrap items-center justify-end gap-2">
@if ($blocking)
<x-filament::badge color="danger" size="sm">
Blocker
</x-filament::badge>
@endif
<x-filament::badge :color="$severitySpec->color" :icon="$severitySpec->icon" size="sm">
{{ $severitySpec->label }}
</x-filament::badge>
<x-filament::badge :color="$statusSpec->color" :icon="$statusSpec->icon" size="sm">
{{ $statusSpec->label }}
</x-filament::badge>
@if ($ackAction !== null && $canAcknowledge && $checkKey !== '')
{{ ($ackAction)(['check_key' => $checkKey]) }}
@endif
</div>
</div>
@if ($nextSteps->isNotEmpty())
<div class="mt-4">
<div class="text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">
Next steps
</div>
<ul class="mt-2 space-y-1 text-sm">
@foreach ($nextSteps as $step)
@php
$label = is_string($step['label'] ?? null) ? trim((string) $step['label']) : '';
$url = is_string($step['url'] ?? null) ? trim((string) $step['url']) : '';
$testId = $label !== '' ? 'verification-next-step-'.\Illuminate\Support\Str::slug($label) : null;
$behavior = $routeNextStepsThroughAssist
? null
: $linkBehavior->describe($label, $url);
@endphp
@if ($label !== '' && $url !== '')
<li>
@if ($routeNextStepsThroughAssist)
<button
type="button"
class="inline-flex items-center gap-2 text-primary-600 hover:underline dark:text-primary-400"
wire:click="mountAction('{{ $assistActionName }}')"
@if ($testId)
data-testid="{{ $testId }}"
@endif
>
<span>{{ $label }}</span>
<span class="text-xs text-gray-500 dark:text-gray-400">
Open in assist
</span>
</button>
@else
<a
href="{{ $url }}"
class="inline-flex items-center gap-2 text-primary-600 hover:underline dark:text-primary-400"
@if ($testId)
data-testid="{{ $testId }}"
@endif
@if ((bool) ($behavior['opens_in_new_tab'] ?? false))
target="_blank" rel="noopener noreferrer"
@endif
>
<span>{{ $label }}</span>
@if ((bool) ($behavior['show_new_tab_hint'] ?? false))
<span class="text-xs text-gray-500 dark:text-gray-400">
Opens in new tab
</span>
@endif
</a>
@endif
</li>
@endif
@endforeach
</ul>
</div>
@endif
</div>
@endforeach
</div>
</div>
@endif
@endforeach
</div>
@endif
</div>

View File

@ -0,0 +1,34 @@
@php
$passedChecks = collect($surface['passedChecks'] ?? [])
->filter(static fn (mixed $check): bool => is_array($check))
->values();
@endphp
<div data-shared-zone="passed">
@if ($passedChecks->isEmpty())
<div class="text-sm text-gray-600 dark:text-gray-300">
No passing checks recorded.
</div>
@else
<div class="space-y-2">
@foreach ($passedChecks as $check)
@php
$title = is_string($check['title'] ?? null) && trim((string) $check['title']) !== '' ? trim((string) $check['title']) : 'Check';
$statusSpec = \App\Support\Badges\BadgeRenderer::spec(
\App\Support\Badges\BadgeDomain::VerificationCheckStatus,
$check['status'] ?? null,
);
@endphp
<div class="flex items-center justify-between gap-3 rounded-lg border border-gray-200 bg-white p-3 text-sm shadow-sm dark:border-gray-800 dark:bg-gray-900">
<div class="font-medium text-gray-900 dark:text-white">
{{ $title }}
</div>
<x-filament::badge :color="$statusSpec->color" :icon="$statusSpec->icon" size="sm">
{{ $statusSpec->label }}
</x-filament::badge>
</div>
@endforeach
</div>
@endif
</div>

View File

@ -0,0 +1,64 @@
@php
$summary = is_array($surface['summary'] ?? null) ? $surface['summary'] : [];
$counts = is_array($summary['counts'] ?? null) ? $summary['counts'] : [];
$overallSpec = \App\Support\Badges\BadgeRenderer::spec(
\App\Support\Badges\BadgeDomain::VerificationReportOverall,
$summary['overall'] ?? null,
);
$changeIndicator = is_array($summary['changeIndicator'] ?? null) ? $summary['changeIndicator'] : null;
$redactionNotes = is_array($redactionNotes ?? null)
? array_values(array_filter($redactionNotes, 'is_string'))
: [];
@endphp
<div
data-shared-zone="summary"
class="rounded-lg border border-gray-200 bg-white p-4 shadow-sm dark:border-gray-800 dark:bg-gray-900"
>
<div class="flex flex-wrap items-center gap-2">
<x-filament::badge :color="$overallSpec->color" :icon="$overallSpec->icon">
{{ $overallSpec->label }}
</x-filament::badge>
<x-filament::badge color="gray">
{{ (int) ($counts['total'] ?? 0) }} total
</x-filament::badge>
<x-filament::badge color="success">
{{ (int) ($counts['pass'] ?? 0) }} pass
</x-filament::badge>
<x-filament::badge color="danger">
{{ (int) ($counts['fail'] ?? 0) }} fail
</x-filament::badge>
<x-filament::badge color="warning">
{{ (int) ($counts['warn'] ?? 0) }} warn
</x-filament::badge>
<x-filament::badge color="gray">
{{ (int) ($counts['skip'] ?? 0) }} skip
</x-filament::badge>
<x-filament::badge color="info">
{{ (int) ($counts['running'] ?? 0) }} running
</x-filament::badge>
@if (($changeIndicator['state'] ?? null) === 'no_changes')
<x-filament::badge color="success">
No changes since previous verification
</x-filament::badge>
@elseif (($changeIndicator['state'] ?? null) === 'changed')
<x-filament::badge color="warning">
Changed since previous verification
</x-filament::badge>
@endif
</div>
<div class="mt-2 text-xs text-gray-600 dark:text-gray-300">
<span class="font-semibold">Read-only:</span> this view uses stored data and makes no external calls.
</div>
@if ($redactionNotes !== [])
<div class="mt-3 rounded-lg border border-amber-200 bg-amber-50 px-3 py-2 text-xs text-amber-900 dark:border-amber-500/30 dark:bg-amber-500/10 dark:text-amber-100">
@foreach ($redactionNotes as $note)
<div>{{ $note }}</div>
@endforeach
</div>
@endif
</div>

View File

@ -1,70 +1,26 @@
@php
$fieldWrapperView = $getFieldWrapperView();
$run = $run ?? null;
$run = is_array($run) ? $run : null;
$runUrl = $runUrl ?? null;
$runUrl = is_string($runUrl) && $runUrl !== '' ? $runUrl : null;
$report = $report ?? null;
$report = is_array($report) ? $report : null;
$fingerprint = $fingerprint ?? null;
$fingerprint = is_string($fingerprint) && trim($fingerprint) !== '' ? trim($fingerprint) : null;
$changeIndicator = $changeIndicator ?? null;
$changeIndicator = is_array($changeIndicator) ? $changeIndicator : null;
$previousRunUrl = $previousRunUrl ?? null;
$previousRunUrl = is_string($previousRunUrl) && $previousRunUrl !== '' ? $previousRunUrl : null;
$advancedRunUrl = $advancedRunUrl ?? null;
$advancedRunUrl = is_string($advancedRunUrl) && $advancedRunUrl !== '' ? $advancedRunUrl : null;
$canAcknowledge = (bool) ($canAcknowledge ?? false);
$acknowledgements = $acknowledgements ?? [];
$acknowledgements = is_array($acknowledgements) ? $acknowledgements : [];
$assistVisibility = $assistVisibility ?? [];
$assistVisibility = is_array($assistVisibility) ? $assistVisibility : [];
$assistActionName = $assistActionName ?? 'wizardVerificationRequiredPermissionsAssist';
$assistActionName = is_string($assistActionName) && trim($assistActionName) !== ''
? trim($assistActionName)
$run = is_array($run ?? null) ? $run : null;
$runUrl = is_string($runUrl ?? null) && trim((string) $runUrl) !== '' ? trim((string) $runUrl) : null;
$surface = is_array($surface ?? null) ? $surface : [];
$redactionNotes = is_array($redactionNotes ?? null)
? array_values(array_filter($redactionNotes, 'is_string'))
: [];
$assistVisibility = is_array($assistVisibility ?? null) ? $assistVisibility : [];
$assistActionName = is_string($assistActionName ?? null) && trim((string) $assistActionName) !== ''
? trim((string) $assistActionName)
: 'wizardVerificationRequiredPermissionsAssist';
$technicalDetailsActionName = $technicalDetailsActionName ?? 'wizardVerificationTechnicalDetails';
$technicalDetailsActionName = is_string($technicalDetailsActionName) && trim($technicalDetailsActionName) !== ''
? trim($technicalDetailsActionName)
$technicalDetailsActionName = is_string($technicalDetailsActionName ?? null) && trim((string) $technicalDetailsActionName) !== ''
? trim((string) $technicalDetailsActionName)
: 'wizardVerificationTechnicalDetails';
$showAssist = (bool) ($assistVisibility['is_visible'] ?? false);
$assistReason = $assistVisibility['reason'] ?? 'hidden_irrelevant';
$assistReason = is_string($assistReason) ? $assistReason : 'hidden_irrelevant';
$assistReason = is_string($assistVisibility['reason'] ?? null) ? (string) $assistVisibility['reason'] : 'hidden_irrelevant';
$assistDescription = match ($assistReason) {
'permission_blocked' => 'Stored permission diagnostics show blockers. Review them without leaving onboarding.',
'permission_attention' => 'Stored permission diagnostics need attention before you rerun verification.',
default => 'Review required permissions without leaving onboarding.',
};
$status = $run['status'] ?? null;
$status = is_string($status) ? $status : null;
$outcome = $run['outcome'] ?? null;
$outcome = is_string($outcome) ? $outcome : null;
$targetScope = $run['target_scope'] ?? [];
$targetScope = is_array($targetScope) ? $targetScope : [];
$failures = $run['failures'] ?? [];
$failures = is_array($failures) ? $failures : [];
$completedAt = $run['completed_at'] ?? null;
$completedAt = is_string($completedAt) && $completedAt !== '' ? $completedAt : null;
$completedAt = is_string($run['completed_at'] ?? null) && trim((string) $run['completed_at']) !== '' ? (string) $run['completed_at'] : null;
$completedAtLabel = null;
if ($completedAt !== null) {
@ -75,81 +31,15 @@
}
}
$summary = $report['summary'] ?? null;
$summary = is_array($summary) ? $summary : null;
$status = is_string($run['status'] ?? null) ? (string) $run['status'] : null;
$runState = is_string($runState ?? null) ? (string) $runState : null;
$counts = is_array($summary['counts'] ?? null) ? $summary['counts'] : [];
$checks = $report['checks'] ?? null;
$checks = is_array($checks) ? $checks : [];
$ackByKey = [];
foreach ($acknowledgements as $checkKey => $ack) {
if (! is_string($checkKey) || $checkKey === '' || ! is_array($ack)) {
continue;
}
$ackByKey[$checkKey] = $ack;
if (! in_array($runState, ['no_run', 'active', 'completed'], true)) {
$runState = $run === null
? 'no_run'
: ($status === 'completed' ? 'completed' : 'active');
}
$blockers = [];
$failures = [];
$warnings = [];
$acknowledgedIssues = [];
$passed = [];
foreach ($checks as $check) {
$check = is_array($check) ? $check : [];
$key = $check['key'] ?? null;
$key = is_string($key) ? trim($key) : '';
if ($key === '') {
continue;
}
$statusValue = $check['status'] ?? null;
$statusValue = is_string($statusValue) ? strtolower(trim($statusValue)) : '';
$blocking = $check['blocking'] ?? false;
$blocking = is_bool($blocking) ? $blocking : false;
if (array_key_exists($key, $ackByKey)) {
$acknowledgedIssues[] = $check;
continue;
}
if ($statusValue === 'pass') {
$passed[] = $check;
continue;
}
if ($statusValue === 'fail' && $blocking) {
$blockers[] = $check;
continue;
}
if ($statusValue === 'fail') {
$failures[] = $check;
continue;
}
if ($statusValue === 'warn') {
$warnings[] = $check;
}
}
$sortChecks = static function (array $a, array $b): int {
return strcmp((string) ($a['key'] ?? ''), (string) ($b['key'] ?? ''));
};
usort($blockers, $sortChecks);
usort($failures, $sortChecks);
usort($warnings, $sortChecks);
usort($acknowledgedIssues, $sortChecks);
usort($passed, $sortChecks);
$ackAction = null;
if (isset($this) && method_exists($this, 'acknowledgeVerificationCheckAction')) {
@ -157,15 +47,6 @@
}
$linkBehavior = app(\App\Support\Verification\VerificationLinkBehavior::class);
$runState = $runState ?? null;
$runState = is_string($runState) ? $runState : null;
if (! in_array($runState, ['no_run', 'active', 'completed'], true)) {
$runState = $run === null
? 'no_run'
: ($status === 'completed' ? 'completed' : 'active');
}
@endphp
<x-dynamic-component :component="$fieldWrapperView" :field="$field">
@ -211,65 +92,6 @@
</div>
@else
<div class="space-y-4">
<div class="rounded-lg border border-gray-200 bg-white p-4 shadow-sm dark:border-gray-800 dark:bg-gray-900">
@php
$overallSpec = $summary === null
? null
: \App\Support\Badges\BadgeRenderer::spec(
\App\Support\Badges\BadgeDomain::VerificationReportOverall,
$summary['overall'] ?? null,
);
@endphp
<div class="flex flex-wrap items-center gap-2">
@if ($overallSpec)
<x-filament::badge :color="$overallSpec->color" :icon="$overallSpec->icon">
{{ $overallSpec->label }}
</x-filament::badge>
@endif
<x-filament::badge color="gray">
{{ (int) ($counts['total'] ?? 0) }} total
</x-filament::badge>
<x-filament::badge color="success">
{{ (int) ($counts['pass'] ?? 0) }} pass
</x-filament::badge>
<x-filament::badge color="danger">
{{ (int) ($counts['fail'] ?? 0) }} fail
</x-filament::badge>
<x-filament::badge color="warning">
{{ (int) ($counts['warn'] ?? 0) }} warn
</x-filament::badge>
<x-filament::badge color="gray">
{{ (int) ($counts['skip'] ?? 0) }} skip
</x-filament::badge>
<x-filament::badge color="info">
{{ (int) ($counts['running'] ?? 0) }} running
</x-filament::badge>
@if ($changeIndicator !== null)
@php
$state = $changeIndicator['state'] ?? null;
$state = is_string($state) ? $state : null;
@endphp
@if ($state === 'no_changes')
<x-filament::badge color="success">
No changes since previous verification
</x-filament::badge>
@elseif ($state === 'changed')
<x-filament::badge color="warning">
Changed since previous verification
</x-filament::badge>
@endif
@endif
</div>
<div class="mt-2 text-xs text-gray-600 dark:text-gray-300">
<span class="font-semibold">Read-only:</span> this view uses stored data and makes no external calls.
</div>
</div>
<div class="flex flex-wrap items-center gap-2">
@if ($runUrl)
<x-filament::button
@ -315,338 +137,15 @@
</div>
@endif
@if ($report === null || $summary === null)
<div class="rounded-lg border border-gray-200 bg-white p-4 text-sm text-gray-600 shadow-sm dark:border-gray-800 dark:bg-gray-900 dark:text-gray-300">
<div class="font-medium text-gray-900 dark:text-white">
Verification report unavailable
</div>
<div class="mt-1">
This operation doesnt have a report yet. If it already completed, start verification again.
</div>
</div>
@else
<div
x-data="{ tab: 'issues' }"
class="space-y-4"
>
<x-filament::tabs label="Verification report tabs">
<x-filament::tabs.item
:active="true"
alpine-active="tab === 'issues'"
x-on:click="tab = 'issues'"
>
Issues
</x-filament::tabs.item>
<x-filament::tabs.item
:active="false"
alpine-active="tab === 'passed'"
x-on:click="tab = 'passed'"
>
Passed
</x-filament::tabs.item>
</x-filament::tabs>
<div x-show="tab === 'issues'">
@if ($blockers === [] && $failures === [] && $warnings === [] && $acknowledgedIssues === [])
<div class="text-sm text-gray-700 dark:text-gray-200">
No issues found in this report.
</div>
@else
<div class="space-y-3">
@php
$issueGroups = [
['label' => 'Blockers', 'checks' => $blockers],
['label' => 'Failures', 'checks' => $failures],
['label' => 'Warnings', 'checks' => $warnings],
];
@endphp
@foreach ($issueGroups as $group)
@php
$label = $group['label'];
$groupChecks = $group['checks'];
@endphp
@if ($groupChecks !== [])
<div class="space-y-2">
<div class="text-sm font-semibold text-gray-900 dark:text-white">
{{ $label }}
</div>
<div class="space-y-2">
@foreach ($groupChecks as $check)
@php
$check = is_array($check) ? $check : [];
$checkKey = is_string($check['key'] ?? null) ? trim((string) $check['key']) : '';
$title = $check['title'] ?? 'Check';
$title = is_string($title) && trim($title) !== '' ? trim($title) : 'Check';
$message = $check['message'] ?? null;
$message = is_string($message) && trim($message) !== '' ? trim($message) : null;
$statusSpec = \App\Support\Badges\BadgeRenderer::spec(
\App\Support\Badges\BadgeDomain::VerificationCheckStatus,
$check['status'] ?? null,
);
$severitySpec = \App\Support\Badges\BadgeRenderer::spec(
\App\Support\Badges\BadgeDomain::VerificationCheckSeverity,
$check['severity'] ?? null,
);
$nextSteps = $check['next_steps'] ?? [];
$nextSteps = is_array($nextSteps) ? array_slice($nextSteps, 0, 2) : [];
$blocking = $check['blocking'] ?? false;
$blocking = is_bool($blocking) ? $blocking : false;
$routeNextStepsThroughAssist = $linkBehavior->shouldRouteThroughAssist($check, $showAssist);
@endphp
<div class="rounded-lg border border-gray-200 bg-white p-4 shadow-sm dark:border-gray-800 dark:bg-gray-900">
<div class="flex flex-wrap items-start justify-between gap-3">
<div class="space-y-1">
<div class="text-sm font-medium text-gray-900 dark:text-white">
{{ $title }}
</div>
@if ($message)
<div class="text-sm text-gray-600 dark:text-gray-300">
{{ $message }}
</div>
@endif
</div>
<div class="flex shrink-0 flex-wrap items-center justify-end gap-2">
@if ($blocking)
<x-filament::badge color="danger" size="sm">
Blocker
</x-filament::badge>
@endif
<x-filament::badge :color="$severitySpec->color" :icon="$severitySpec->icon" size="sm">
{{ $severitySpec->label }}
</x-filament::badge>
<x-filament::badge :color="$statusSpec->color" :icon="$statusSpec->icon" size="sm">
{{ $statusSpec->label }}
</x-filament::badge>
@if ($ackAction !== null && $canAcknowledge && $checkKey !== '')
{{ ($ackAction)(['check_key' => $checkKey]) }}
@endif
</div>
</div>
@if ($nextSteps !== [])
<div class="mt-4">
<div class="text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">
Next steps
</div>
<ul class="mt-2 space-y-1 text-sm">
@foreach ($nextSteps as $step)
@php
$step = is_array($step) ? $step : [];
$label = $step['label'] ?? null;
$url = $step['url'] ?? null;
$testId = is_string($label) && $label !== ''
? 'verification-next-step-'.\Illuminate\Support\Str::slug($label)
: null;
$behavior = $routeNextStepsThroughAssist
? null
: $linkBehavior->describe(
is_string($label) ? $label : null,
is_string($url) ? $url : null,
);
$opensInNewTab = (bool) ($behavior['opens_in_new_tab'] ?? false);
$showNewTabHint = (bool) ($behavior['show_new_tab_hint'] ?? false);
@endphp
@if (is_string($label) && $label !== '' && is_string($url) && $url !== '')
<li>
@if ($routeNextStepsThroughAssist)
<button
type="button"
class="inline-flex items-center gap-2 text-primary-600 hover:underline dark:text-primary-400"
wire:click="mountAction('{{ $assistActionName }}')"
@if ($testId)
data-testid="{{ $testId }}"
@endif
>
<span>{{ $label }}</span>
<span class="text-xs text-gray-500 dark:text-gray-400">
Open in assist
</span>
</button>
@else
<a
href="{{ $url }}"
class="inline-flex items-center gap-2 text-primary-600 hover:underline dark:text-primary-400"
@if ($testId)
data-testid="{{ $testId }}"
@endif
@if ($opensInNewTab)
target="_blank" rel="noopener noreferrer"
@endif
>
<span>{{ $label }}</span>
@if ($showNewTabHint)
<span class="text-xs text-gray-500 dark:text-gray-400">
Opens in new tab
</span>
@endif
</a>
@endif
</li>
@endif
@endforeach
</ul>
</div>
@endif
</div>
@endforeach
</div>
</div>
@endif
@endforeach
@if ($acknowledgedIssues !== [])
<details class="rounded-lg border border-gray-200 bg-white p-4 shadow-sm dark:border-gray-800 dark:bg-gray-900">
<summary class="cursor-pointer text-sm font-semibold text-gray-900 dark:text-white">
Acknowledged issues
</summary>
<div class="mt-4 space-y-2">
@foreach ($acknowledgedIssues as $check)
@php
$check = is_array($check) ? $check : [];
$checkKey = is_string($check['key'] ?? null) ? trim((string) $check['key']) : '';
$title = $check['title'] ?? 'Check';
$title = is_string($title) && trim($title) !== '' ? trim($title) : 'Check';
$message = $check['message'] ?? null;
$message = is_string($message) && trim($message) !== '' ? trim($message) : null;
$statusSpec = \App\Support\Badges\BadgeRenderer::spec(
\App\Support\Badges\BadgeDomain::VerificationCheckStatus,
$check['status'] ?? null,
);
$severitySpec = \App\Support\Badges\BadgeRenderer::spec(
\App\Support\Badges\BadgeDomain::VerificationCheckSeverity,
$check['severity'] ?? null,
);
$ack = $checkKey !== '' ? ($ackByKey[$checkKey] ?? null) : null;
$ack = is_array($ack) ? $ack : null;
$ackReason = $ack['ack_reason'] ?? null;
$ackReason = is_string($ackReason) && trim($ackReason) !== '' ? trim($ackReason) : null;
$ackAt = $ack['acknowledged_at'] ?? null;
$ackAt = is_string($ackAt) && trim($ackAt) !== '' ? trim($ackAt) : null;
$ackBy = $ack['acknowledged_by'] ?? null;
$ackBy = is_array($ackBy) ? $ackBy : null;
$ackByName = $ackBy['name'] ?? null;
$ackByName = is_string($ackByName) && trim($ackByName) !== '' ? trim($ackByName) : null;
$expiresAt = $ack['expires_at'] ?? null;
$expiresAt = is_string($expiresAt) && trim($expiresAt) !== '' ? trim($expiresAt) : null;
@endphp
<div class="rounded-lg border border-gray-100 bg-gray-50 p-4 dark:border-gray-800 dark:bg-gray-950">
<div class="flex flex-wrap items-start justify-between gap-3">
<div class="space-y-1">
<div class="text-sm font-medium text-gray-900 dark:text-white">
{{ $title }}
</div>
@if ($message)
<div class="text-sm text-gray-600 dark:text-gray-300">
{{ $message }}
</div>
@endif
</div>
<div class="flex shrink-0 flex-wrap items-center justify-end gap-2">
<x-filament::badge :color="$severitySpec->color" :icon="$severitySpec->icon" size="sm">
{{ $severitySpec->label }}
</x-filament::badge>
<x-filament::badge :color="$statusSpec->color" :icon="$statusSpec->icon" size="sm">
{{ $statusSpec->label }}
</x-filament::badge>
</div>
</div>
@if ($ackReason || $ackAt || $ackByName || $expiresAt)
<div class="mt-3 space-y-1 text-sm text-gray-700 dark:text-gray-200">
@if ($ackReason)
<div>
<span class="font-semibold">Reason:</span> {{ $ackReason }}
</div>
@endif
@if ($ackByName || $ackAt)
<div>
<span class="font-semibold">Acknowledged:</span>
@if ($ackByName)
{{ $ackByName }}
@endif
@if ($ackAt)
<span class="text-gray-500 dark:text-gray-400">({{ $ackAt }})</span>
@endif
</div>
@endif
@if ($expiresAt)
<div>
<span class="font-semibold">Expires:</span>
<span class="text-gray-500 dark:text-gray-400">{{ $expiresAt }}</span>
</div>
@endif
</div>
@endif
</div>
@endforeach
</div>
</details>
@endif
</div>
@endif
</div>
<div x-show="tab === 'passed'" style="display: none;">
@if ($passed === [])
<div class="text-sm text-gray-600 dark:text-gray-300">
No passing checks recorded.
</div>
@else
<div class="space-y-2">
@foreach ($passed as $check)
@php
$check = is_array($check) ? $check : [];
$title = $check['title'] ?? 'Check';
$title = is_string($title) && trim($title) !== '' ? trim($title) : 'Check';
$statusSpec = \App\Support\Badges\BadgeRenderer::spec(
\App\Support\Badges\BadgeDomain::VerificationCheckStatus,
$check['status'] ?? null,
);
@endphp
<div class="flex items-center justify-between gap-3 rounded-lg border border-gray-200 bg-white p-3 text-sm shadow-sm dark:border-gray-800 dark:bg-gray-900">
<div class="font-medium text-gray-900 dark:text-white">
{{ $title }}
</div>
<x-filament::badge :color="$statusSpec->color" :icon="$statusSpec->icon" size="sm">
{{ $statusSpec->label }}
</x-filament::badge>
</div>
@endforeach
</div>
@endif
</div>
</div>
@endif
@include('filament.components.verification-report-viewer', [
'surface' => $surface,
'redactionNotes' => $redactionNotes,
'canAcknowledge' => (bool) ($canAcknowledge ?? false),
'ackAction' => $ackAction,
'showAssist' => $showAssist,
'assistActionName' => $assistActionName,
'linkBehavior' => $linkBehavior,
])
</div>
@endif
</x-filament::section>

View File

@ -1,787 +1,2 @@
@php
$diff = $getState() ?? ['summary' => [], 'added' => [], 'removed' => [], 'changed' => []];
$summary = $diff['summary'] ?? [];
$policyType = $diff['policy_type'] ?? null;
$groupByBlock = static function (array $items): array {
$groups = [];
foreach ($items as $path => $value) {
if (! is_string($path) || $path === '') {
continue;
}
$parts = explode(' > ', $path, 2);
if (count($parts) === 2) {
[$group, $label] = $parts;
} else {
$group = 'Other';
$label = $path;
}
$groups[$group][$label] = $value;
}
ksort($groups);
return $groups;
};
$stringify = static function (mixed $value): string {
if ($value === null) {
return '—';
}
if (is_bool($value)) {
return $value ? 'Enabled' : 'Disabled';
}
if (is_scalar($value)) {
return (string) $value;
}
return json_encode($value, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE) ?: '';
};
$isExpandable = static function (mixed $value): bool {
if (is_array($value)) {
return true;
}
return is_string($value) && strlen($value) > 160;
};
$isScriptKey = static function (mixed $name): bool {
return in_array((string) $name, ['scriptContent', 'detectionScriptContent', 'remediationScriptContent'], true);
};
$canHighlightScripts = static function (?string $policyType): bool {
return (bool) config('tenantpilot.display.show_script_content', false)
&& in_array($policyType, ['deviceManagementScript', 'deviceShellScript', 'deviceHealthScript', 'deviceComplianceScript'], true);
};
$selectGrammar = static function (?string $policyType, string $code): string {
if ($policyType === 'deviceShellScript') {
$firstLine = strtok($code, "\n") ?: '';
$shebang = trim($firstLine);
if (str_starts_with($shebang, '#!')) {
if (str_contains($shebang, 'zsh')) {
return 'zsh';
}
if (str_contains($shebang, 'bash')) {
return 'bash';
}
return 'sh';
}
return 'sh';
}
return 'powershell';
};
$highlight = static function (?string $policyType, string $code, string $fallbackClass = '') use ($selectGrammar): ?string {
if (! class_exists(\Torchlight\Engine\Engine::class)) {
return null;
}
try {
return (new \Torchlight\Engine\Engine())->codeToHtml(
code: $code,
grammar: $selectGrammar($policyType, $code),
theme: [
'light' => 'github-light',
'dark' => 'github-dark',
],
withGutter: false,
withWrapper: true,
);
} catch (\Throwable $e) {
return null;
}
};
$highlightInline = static function (?string $policyType, string $code) use ($selectGrammar): ?string {
if (! class_exists(\Torchlight\Engine\Engine::class)) {
return null;
}
if ($code === '') {
return '';
}
try {
$html = (new \Torchlight\Engine\Engine())->codeToHtml(
code: $code,
grammar: $selectGrammar($policyType, $code),
theme: [
'light' => 'github-light',
'dark' => 'github-dark',
],
withGutter: false,
withWrapper: false,
);
$html = (string) preg_replace('/<!--\s*Syntax highlighted by[^>]*-->/', '', $html);
if (! preg_match('/<code\b[^>]*>.*?<\\/code>/s', $html, $matches)) {
return null;
}
return trim((string) ($matches[0] ?? ''));
} catch (\Throwable $e) {
return null;
}
};
$splitLines = static function (string $text): array {
$text = str_replace(["\r\n", "\r"], "\n", $text);
return $text === '' ? [] : explode("\n", $text);
};
$myersLineDiff = static function (array $a, array $b): array {
$n = count($a);
$m = count($b);
$max = $n + $m;
$v = [1 => 0];
$trace = [];
for ($d = 0; $d <= $max; $d++) {
$trace[$d] = $v;
for ($k = -$d; $k <= $d; $k += 2) {
$kPlus = $v[$k + 1] ?? 0;
$kMinus = $v[$k - 1] ?? 0;
if ($k === -$d || ($k !== $d && $kMinus < $kPlus)) {
$x = $kPlus;
} else {
$x = $kMinus + 1;
}
$y = $x - $k;
while ($x < $n && $y < $m && $a[$x] === $b[$y]) {
$x++;
$y++;
}
$v[$k] = $x;
if ($x >= $n && $y >= $m) {
break 2;
}
}
}
$ops = [];
$x = $n;
$y = $m;
for ($d = count($trace) - 1; $d >= 0; $d--) {
$v = $trace[$d];
$k = $x - $y;
$kPlus = $v[$k + 1] ?? 0;
$kMinus = $v[$k - 1] ?? 0;
if ($k === -$d || ($k !== $d && $kMinus < $kPlus)) {
$prevK = $k + 1;
} else {
$prevK = $k - 1;
}
$prevX = $v[$prevK] ?? 0;
$prevY = $prevX - $prevK;
while ($x > $prevX && $y > $prevY) {
$ops[] = ['type' => 'equal', 'line' => $a[$x - 1]];
$x--;
$y--;
}
if ($d === 0) {
break;
}
if ($x === $prevX) {
$ops[] = ['type' => 'insert', 'line' => $b[$y - 1] ?? ''];
$y--;
} else {
$ops[] = ['type' => 'delete', 'line' => $a[$x - 1] ?? ''];
$x--;
}
}
return array_reverse($ops);
};
$scriptLineDiff = static function (string $fromText, string $toText) use ($splitLines, $myersLineDiff): array {
return $myersLineDiff($splitLines($fromText), $splitLines($toText));
};
@endphp
<div class="space-y-4">
<x-filament::section
heading="Normalized diff"
:description="$summary['message'] ?? sprintf('%d added, %d removed, %d changed', $summary['added'] ?? 0, $summary['removed'] ?? 0, $summary['changed'] ?? 0)"
>
<div class="flex flex-wrap gap-2">
<x-filament::badge color="success">
{{ (int) ($summary['added'] ?? 0) }} added
</x-filament::badge>
<x-filament::badge color="danger">
{{ (int) ($summary['removed'] ?? 0) }} removed
</x-filament::badge>
<x-filament::badge color="warning">
{{ (int) ($summary['changed'] ?? 0) }} changed
</x-filament::badge>
</div>
</x-filament::section>
@foreach (['changed' => ['label' => 'Changed', 'collapsed' => false], 'added' => ['label' => 'Added', 'collapsed' => true], 'removed' => ['label' => 'Removed', 'collapsed' => true]] as $key => $meta)
@php
$items = $diff[$key] ?? [];
$groups = $groupByBlock(is_array($items) ? $items : []);
@endphp
@if ($groups !== [])
<x-filament::section
:heading="$meta['label']"
collapsible
:collapsed="$meta['collapsed']"
>
<div class="space-y-6">
@foreach ($groups as $group => $groupItems)
<div>
<div class="flex items-center justify-between">
<div class="text-sm font-medium text-gray-900 dark:text-white">
{{ $group }}
</div>
<x-filament::badge size="sm" color="gray">
{{ count($groupItems) }}
</x-filament::badge>
</div>
<div class="mt-2 divide-y divide-gray-200 rounded-lg border border-gray-200 dark:divide-white/10 dark:border-white/10">
@foreach ($groupItems as $name => $value)
<div class="px-4 py-3">
@if ($key === 'changed' && is_array($value) && array_key_exists('from', $value) && array_key_exists('to', $value))
@php
$from = $value['from'];
$to = $value['to'];
$fromText = $stringify($from);
$toText = $stringify($to);
$isScriptContent = $canHighlightScripts($policyType) && $isScriptKey($name);
$ops = $isScriptContent ? $scriptLineDiff((string) $fromText, (string) $toText) : [];
$useTorchlight = $isScriptContent && class_exists(\Torchlight\Engine\Engine::class);
$rows = [];
if ($isScriptContent) {
$count = count($ops);
for ($i = 0; $i < $count; $i++) {
$op = $ops[$i];
$next = $ops[$i + 1] ?? null;
$type = $op['type'] ?? null;
$line = (string) ($op['line'] ?? '');
if ($type === 'equal') {
$rows[] = [
'left' => ['type' => 'equal', 'line' => $line],
'right' => ['type' => 'equal', 'line' => $line],
];
continue;
}
if ($type === 'delete' && is_array($next) && ($next['type'] ?? null) === 'insert') {
$rows[] = [
'left' => ['type' => 'delete', 'line' => $line],
'right' => ['type' => 'insert', 'line' => (string) ($next['line'] ?? '')],
];
$i++;
continue;
}
if ($type === 'delete') {
$rows[] = [
'left' => ['type' => 'delete', 'line' => $line],
'right' => ['type' => 'blank', 'line' => ''],
];
continue;
}
if ($type === 'insert') {
$rows[] = [
'left' => ['type' => 'blank', 'line' => ''],
'right' => ['type' => 'insert', 'line' => $line],
];
continue;
}
}
}
@endphp
<div class="grid grid-cols-1 gap-2 sm:grid-cols-3">
<div class="text-sm font-medium text-gray-900 dark:text-white">
{{ (string) $name }}
</div>
@if ($isScriptContent)
<div class="text-sm text-gray-600 dark:text-gray-300 sm:col-span-2">
<span class="text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">Script</span>
<details class="mt-1" x-data="{ fullscreenOpen: false }">
<summary class="cursor-pointer text-sm text-gray-700 dark:text-gray-200">
View
</summary>
<div x-data="{ tab: 'diff' }" class="mt-2 space-y-3">
<div class="flex flex-wrap items-center gap-2">
<x-filament::button size="xs" color="gray" type="button" x-on:click="tab = 'diff'" x-bind:class="tab === 'diff' ? 'ring-1 ring-gray-300 dark:ring-white/20' : ''">
Diff
</x-filament::button>
<x-filament::button size="xs" color="gray" type="button" x-on:click="tab = 'before'" x-bind:class="tab === 'before' ? 'ring-1 ring-gray-300 dark:ring-white/20' : ''">
Before
</x-filament::button>
<x-filament::button size="xs" color="gray" type="button" x-on:click="tab = 'after'" x-bind:class="tab === 'after' ? 'ring-1 ring-gray-300 dark:ring-white/20' : ''">
After
</x-filament::button>
<x-filament::button size="xs" color="gray" type="button" x-on:click="fullscreenOpen = true">
Fullscreen
</x-filament::button>
</div>
<div x-show="tab === 'diff'" x-cloak>
<div class="grid grid-cols-1 gap-3 sm:grid-cols-2">
<div>
<div class="text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">Old</div>
<pre class="mt-1 max-h-96 overflow-auto font-mono text-xs text-gray-800 dark:text-gray-200 whitespace-pre">@php
foreach ($rows as $row) {
$left = $row['left'];
$leftType = $left['type'];
$leftLine = (string) ($left['line'] ?? '');
$leftHighlighted = $useTorchlight ? $highlightInline($policyType, $leftLine) : null;
$leftRendered = (is_string($leftHighlighted) && $leftHighlighted !== '') ? $leftHighlighted : e($leftLine);
if ($leftType === 'equal') {
if ($useTorchlight) {
@endphp
@once
@include('filament.partials.torchlight-dark-overrides')
<style>
.tp-script-diff-line code.torchlight {
background-color: transparent !important;
}
</style>
@endonce
@php
}
echo '<span class="tp-script-diff-line">'.$leftRendered."</span>\n";
continue;
}
if ($leftType === 'delete') {
if ($useTorchlight) {
@endphp
@once
@include('filament.partials.torchlight-dark-overrides')
<style>
.tp-script-diff-line code.torchlight {
background-color: transparent !important;
}
</style>
@endonce
@php
}
echo '<span class="block tp-script-diff-line bg-danger-50 text-danger-700 dark:bg-danger-950/40 dark:text-danger-200">- '.$leftRendered."</span>\n";
continue;
}
echo "\n";
}
@endphp</pre>
</div>
<div>
<div class="text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">New</div>
<pre class="mt-1 max-h-96 overflow-auto font-mono text-xs text-gray-800 dark:text-gray-200 whitespace-pre">@php
foreach ($rows as $row) {
$right = $row['right'];
$rightType = $right['type'];
$rightLine = (string) ($right['line'] ?? '');
$rightHighlighted = $useTorchlight ? $highlightInline($policyType, $rightLine) : null;
$rightRendered = (is_string($rightHighlighted) && $rightHighlighted !== '') ? $rightHighlighted : e($rightLine);
if ($rightType === 'equal') {
if ($useTorchlight) {
@endphp
@once
@include('filament.partials.torchlight-dark-overrides')
<style>
.tp-script-diff-line code.torchlight {
background-color: transparent !important;
}
</style>
@endonce
@php
}
echo '<span class="tp-script-diff-line">'.$rightRendered."</span>\n";
continue;
}
if ($rightType === 'insert') {
if ($useTorchlight) {
@endphp
@once
@include('filament.partials.torchlight-dark-overrides')
<style>
.tp-script-diff-line code.torchlight {
background-color: transparent !important;
}
</style>
@endonce
@php
}
echo '<span class="block tp-script-diff-line bg-success-50 text-success-700 dark:bg-success-950/40 dark:text-success-200">+ '.$rightRendered."</span>\n";
continue;
}
echo "\n";
}
@endphp</pre>
</div>
</div>
</div>
<div x-show="tab === 'before'" x-cloak>
<div class="text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">Before</div>
@php
$highlightedBefore = $useTorchlight ? $highlight($policyType, (string) $fromText) : null;
@endphp
@if (is_string($highlightedBefore) && $highlightedBefore !== '')
@once
@include('filament.partials.torchlight-dark-overrides')
@endonce
<div class="mt-1 max-h-96 overflow-auto">{!! $highlightedBefore !!}</div>
@else
<pre class="mt-1 max-h-96 overflow-auto font-mono text-xs text-gray-800 dark:text-gray-200 whitespace-pre">{{ (string) $fromText }}</pre>
@endif
</div>
<div x-show="tab === 'after'" x-cloak>
<div class="text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">After</div>
@php
$highlightedAfter = $useTorchlight ? $highlight($policyType, (string) $toText) : null;
@endphp
@if (is_string($highlightedAfter) && $highlightedAfter !== '')
@once
@include('filament.partials.torchlight-dark-overrides')
@endonce
<div class="mt-1 max-h-96 overflow-auto">{!! $highlightedAfter !!}</div>
@else
<pre class="mt-1 max-h-96 overflow-auto font-mono text-xs text-gray-800 dark:text-gray-200 whitespace-pre">{{ (string) $toText }}</pre>
@endif
</div>
</div>
<div
x-show="fullscreenOpen"
x-cloak
x-on:keydown.escape.window="fullscreenOpen = false"
class="fixed inset-0 z-50"
>
<div class="absolute inset-0 bg-gray-950/50"></div>
<div class="relative flex h-full w-full flex-col bg-white dark:bg-gray-900">
<div class="flex items-center justify-between gap-3 border-b border-gray-200 px-4 py-3 dark:border-white/10">
<div class="text-sm font-medium text-gray-900 dark:text-white">Script diff</div>
<div class="flex items-center gap-2">
<x-filament::button size="sm" color="gray" type="button" x-on:click="fullscreenOpen = false">
Close
</x-filament::button>
</div>
</div>
<div class="flex-1 overflow-hidden p-4">
<div
x-data="{
tab: 'diff',
syncing: false,
syncHorizontal: true,
sync(from, to) {
if (this.syncing) return;
this.syncing = true;
to.scrollTop = from.scrollTop;
const bothHorizontal = this.syncHorizontal
&& from.scrollWidth > from.clientWidth
&& to.scrollWidth > to.clientWidth;
if (bothHorizontal) {
to.scrollLeft = from.scrollLeft;
}
requestAnimationFrame(() => { this.syncing = false; });
},
}"
x-init="$nextTick(() => {
const left = $refs.left;
const right = $refs.right;
if (!left || !right) return;
left.addEventListener('scroll', () => sync(left, right), { passive: true });
right.addEventListener('scroll', () => sync(right, left), { passive: true });
})"
class="h-full space-y-3"
>
<div class="flex flex-wrap items-center gap-2">
<x-filament::button size="sm" color="gray" type="button" x-on:click="tab = 'diff'" x-bind:class="tab === 'diff' ? 'ring-1 ring-gray-300 dark:ring-white/20' : ''">
Diff
</x-filament::button>
<x-filament::button size="sm" color="gray" type="button" x-on:click="tab = 'before'" x-bind:class="tab === 'before' ? 'ring-1 ring-gray-300 dark:ring-white/20' : ''">
Before
</x-filament::button>
<x-filament::button size="sm" color="gray" type="button" x-on:click="tab = 'after'" x-bind:class="tab === 'after' ? 'ring-1 ring-gray-300 dark:ring-white/20' : ''">
After
</x-filament::button>
</div>
<div x-show="tab === 'diff'" x-cloak class="h-[calc(100%-3rem)]">
<div class="grid h-full grid-cols-1 gap-4 lg:grid-cols-2">
<div class="flex h-full flex-col">
<div class="text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">Old</div>
<pre x-ref="left" class="mt-2 flex-1 overflow-auto font-mono text-xs text-gray-800 dark:text-gray-200 whitespace-pre">@php
foreach ($rows as $row) {
$left = $row['left'];
$leftType = $left['type'];
$leftLine = (string) ($left['line'] ?? '');
$leftHighlighted = $useTorchlight ? $highlightInline($policyType, $leftLine) : null;
$leftRendered = (is_string($leftHighlighted) && $leftHighlighted !== '') ? $leftHighlighted : e($leftLine);
if ($leftType === 'equal') {
if ($useTorchlight) {
@endphp
@once
@include('filament.partials.torchlight-dark-overrides')
<style>
.tp-script-diff-line code.torchlight {
background-color: transparent !important;
}
</style>
@endonce
@php
}
echo '<span class="tp-script-diff-line">'.$leftRendered."</span>\n";
continue;
}
if ($leftType === 'delete') {
if ($useTorchlight) {
@endphp
@once
@include('filament.partials.torchlight-dark-overrides')
<style>
.tp-script-diff-line code.torchlight {
background-color: transparent !important;
}
</style>
@endonce
@php
}
echo '<span class="block tp-script-diff-line bg-danger-50 text-danger-700 dark:bg-danger-950/40 dark:text-danger-200">- '.$leftRendered."</span>\n";
continue;
}
echo "\n";
}
@endphp</pre>
</div>
<div class="flex h-full flex-col">
<div class="text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">New</div>
<pre x-ref="right" class="mt-2 flex-1 overflow-auto font-mono text-xs text-gray-800 dark:text-gray-200 whitespace-pre">@php
foreach ($rows as $row) {
$right = $row['right'];
$rightType = $right['type'];
$rightLine = (string) ($right['line'] ?? '');
$rightHighlighted = $useTorchlight ? $highlightInline($policyType, $rightLine) : null;
$rightRendered = (is_string($rightHighlighted) && $rightHighlighted !== '') ? $rightHighlighted : e($rightLine);
if ($rightType === 'equal') {
if ($useTorchlight) {
@endphp
@once
@include('filament.partials.torchlight-dark-overrides')
<style>
.tp-script-diff-line code.torchlight {
background-color: transparent !important;
}
</style>
@endonce
@php
}
echo '<span class="tp-script-diff-line">'.$rightRendered."</span>\n";
continue;
}
if ($rightType === 'insert') {
if ($useTorchlight) {
@endphp
@once
@include('filament.partials.torchlight-dark-overrides')
<style>
.tp-script-diff-line code.torchlight {
background-color: transparent !important;
}
</style>
@endonce
@php
}
echo '<span class="block tp-script-diff-line bg-success-50 text-success-700 dark:bg-success-950/40 dark:text-success-200">+ '.$rightRendered."</span>\n";
continue;
}
echo "\n";
}
@endphp</pre>
</div>
</div>
</div>
<div x-show="tab === 'before'" x-cloak class="h-[calc(100%-3rem)]">
<div class="text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">Before</div>
@php
$highlightedBeforeFullscreen = $useTorchlight ? $highlight($policyType, (string) $fromText) : null;
@endphp
@if (is_string($highlightedBeforeFullscreen) && $highlightedBeforeFullscreen !== '')
@once
@include('filament.partials.torchlight-dark-overrides')
@endonce
<div class="mt-2 h-full overflow-auto">{!! $highlightedBeforeFullscreen !!}</div>
@else
<pre class="mt-2 h-full overflow-auto font-mono text-xs text-gray-800 dark:text-gray-200 whitespace-pre">{{ (string) $fromText }}</pre>
@endif
</div>
<div x-show="tab === 'after'" x-cloak class="h-[calc(100%-3rem)]">
<div class="text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">After</div>
@php
$highlightedAfterFullscreen = $useTorchlight ? $highlight($policyType, (string) $toText) : null;
@endphp
@if (is_string($highlightedAfterFullscreen) && $highlightedAfterFullscreen !== '')
@once
@include('filament.partials.torchlight-dark-overrides')
@endonce
<div class="mt-2 h-full overflow-auto">{!! $highlightedAfterFullscreen !!}</div>
@else
<pre class="mt-2 h-full overflow-auto font-mono text-xs text-gray-800 dark:text-gray-200 whitespace-pre">{{ (string) $toText }}</pre>
@endif
</div>
</div>
</div>
</div>
</div>
</details>
</div>
@else
<div class="text-sm text-gray-600 dark:text-gray-300">
<span class="text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">From</span>
@if ($isExpandable($from))
<details class="mt-1">
<summary class="cursor-pointer text-sm text-gray-700 dark:text-gray-200">
View
</summary>
<pre class="mt-2 overflow-x-auto text-xs text-gray-800 dark:text-gray-200">{{ $fromText }}</pre>
</details>
@else
<div class="mt-1">{{ $fromText }}</div>
@endif
</div>
<div class="text-sm text-gray-600 dark:text-gray-300">
<span class="text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">To</span>
@if ($isExpandable($to))
<details class="mt-1">
<summary class="cursor-pointer text-sm text-gray-700 dark:text-gray-200">
View
</summary>
<pre class="mt-2 overflow-x-auto text-xs text-gray-800 dark:text-gray-200">{{ $toText }}</pre>
</details>
@else
<div class="mt-1">{{ $toText }}</div>
@endif
</div>
@endif
</div>
@else
@php
$text = $stringify($value);
@endphp
<div class="flex flex-col gap-2 sm:flex-row sm:items-start sm:justify-between">
<div class="text-sm font-medium text-gray-900 dark:text-white">
{{ (string) $name }}
</div>
<div class="text-sm text-gray-700 dark:text-gray-200 sm:max-w-[70%]">
@if ($isExpandable($value))
<details>
<summary class="cursor-pointer text-sm text-gray-700 dark:text-gray-200">
View
</summary>
@php
$isScriptContent = $canHighlightScripts($policyType) && $isScriptKey($name);
$highlighted = $isScriptContent ? $highlight($policyType, (string) $text) : null;
@endphp
@if (is_string($highlighted) && $highlighted !== '')
@once
@include('filament.partials.torchlight-dark-overrides')
@endonce
<div class="mt-2 overflow-x-auto">{!! $highlighted !!}</div>
@else
<pre class="mt-2 overflow-x-auto text-xs text-gray-800 dark:text-gray-200">{{ $text }}</pre>
@endif
</details>
@else
<div class="break-words">{{ $text }}</div>
@endif
</div>
</div>
@endif
</div>
@endforeach
</div>
</div>
@endforeach
</div>
</x-filament::section>
@endif
@endforeach
</div>
{{-- NormalizedDiffSurface normalized-diff wrapper --}}
@include('filament.infolists.entries.normalized-diff.wrapper', ['state' => $getState() ?? []])

View File

@ -0,0 +1,18 @@
@php
$emptyState = is_array($emptyState ?? null) ? $emptyState : [];
$availabilityState = is_string($availabilityState ?? null) ? (string) $availabilityState : 'available';
$toneClasses = match ($availabilityState) {
'unavailable' => 'border-danger-200 bg-danger-50 text-danger-900 dark:border-danger-500/30 dark:bg-danger-500/10 dark:text-danger-100',
'partial' => 'border-amber-200 bg-amber-50 text-amber-900 dark:border-amber-500/30 dark:bg-amber-500/10 dark:text-amber-100',
default => 'border-gray-200 bg-white text-gray-700 dark:border-white/10 dark:bg-gray-900 dark:text-gray-200',
};
@endphp
<div data-shared-zone="empty" class="rounded-lg border px-4 py-3 text-sm {{ $toneClasses }}">
<div class="font-medium">
{{ $emptyState['title'] ?? 'No normalized changes' }}
</div>
<div class="mt-1">
{{ $emptyState['message'] ?? 'No normalized changes were found.' }}
</div>
</div>

View File

@ -0,0 +1,772 @@
@php
$diff = is_array($surface['raw'] ?? null) ? $surface['raw'] : ['changed' => [], 'added' => [], 'removed' => []];
$groupMeta = collect($surface['groups'] ?? [])
->filter(static fn (mixed $group): bool => is_array($group))
->values();
$policyType = is_string($surface['scriptRendering']['policyType'] ?? null) ? (string) $surface['scriptRendering']['policyType'] : null;
$groupByBlock = static function (array $items): array {
$groups = [];
foreach ($items as $path => $value) {
if (! is_string($path) || $path === '') {
continue;
}
$parts = explode(' > ', $path, 2);
if (count($parts) === 2) {
[$group, $label] = $parts;
} else {
$group = 'Other';
$label = $path;
}
$groups[$group][$label] = $value;
}
ksort($groups);
return $groups;
};
$stringify = static function (mixed $value): string {
if ($value === null) {
return '—';
}
if (is_bool($value)) {
return $value ? 'Enabled' : 'Disabled';
}
if (is_scalar($value)) {
return (string) $value;
}
return json_encode($value, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE) ?: '';
};
$isExpandable = static function (mixed $value): bool {
if (is_array($value)) {
return true;
}
return is_string($value) && strlen($value) > 160;
};
$isScriptKey = static function (mixed $name): bool {
return in_array((string) $name, ['scriptContent', 'detectionScriptContent', 'remediationScriptContent'], true);
};
$canHighlightScripts = static function (?string $policyType): bool {
return (bool) config('tenantpilot.display.show_script_content', false)
&& in_array($policyType, ['deviceManagementScript', 'deviceShellScript', 'deviceHealthScript', 'deviceComplianceScript'], true);
};
$selectGrammar = static function (?string $policyType, string $code): string {
if ($policyType === 'deviceShellScript') {
$firstLine = strtok($code, "\n") ?: '';
$shebang = trim($firstLine);
if (str_starts_with($shebang, '#!')) {
if (str_contains($shebang, 'zsh')) {
return 'zsh';
}
if (str_contains($shebang, 'bash')) {
return 'bash';
}
return 'sh';
}
return 'sh';
}
return 'powershell';
};
$highlight = static function (?string $policyType, string $code) use ($selectGrammar): ?string {
if (! class_exists(\Torchlight\Engine\Engine::class)) {
return null;
}
try {
return (new \Torchlight\Engine\Engine())->codeToHtml(
code: $code,
grammar: $selectGrammar($policyType, $code),
theme: [
'light' => 'github-light',
'dark' => 'github-dark',
],
withGutter: false,
withWrapper: true,
);
} catch (\Throwable $e) {
return null;
}
};
$highlightInline = static function (?string $policyType, string $code) use ($selectGrammar): ?string {
if (! class_exists(\Torchlight\Engine\Engine::class)) {
return null;
}
if ($code === '') {
return '';
}
try {
$html = (new \Torchlight\Engine\Engine())->codeToHtml(
code: $code,
grammar: $selectGrammar($policyType, $code),
theme: [
'light' => 'github-light',
'dark' => 'github-dark',
],
withGutter: false,
withWrapper: false,
);
$html = (string) preg_replace('/<!--\s*Syntax highlighted by[^>]*-->/', '', $html);
if (! preg_match('/<code\b[^>]*>.*?<\\/code>/s', $html, $matches)) {
return null;
}
return trim((string) ($matches[0] ?? ''));
} catch (\Throwable $e) {
return null;
}
};
$splitLines = static function (string $text): array {
$text = str_replace(["\r\n", "\r"], "\n", $text);
return $text === '' ? [] : explode("\n", $text);
};
$myersLineDiff = static function (array $a, array $b): array {
$n = count($a);
$m = count($b);
$max = $n + $m;
$v = [1 => 0];
$trace = [];
for ($d = 0; $d <= $max; $d++) {
$trace[$d] = $v;
for ($k = -$d; $k <= $d; $k += 2) {
$kPlus = $v[$k + 1] ?? 0;
$kMinus = $v[$k - 1] ?? 0;
if ($k === -$d || ($k !== $d && $kMinus < $kPlus)) {
$x = $kPlus;
} else {
$x = $kMinus + 1;
}
$y = $x - $k;
while ($x < $n && $y < $m && $a[$x] === $b[$y]) {
$x++;
$y++;
}
$v[$k] = $x;
if ($x >= $n && $y >= $m) {
break 2;
}
}
}
$ops = [];
$x = $n;
$y = $m;
for ($d = count($trace) - 1; $d >= 0; $d--) {
$v = $trace[$d];
$k = $x - $y;
$kPlus = $v[$k + 1] ?? 0;
$kMinus = $v[$k - 1] ?? 0;
if ($k === -$d || ($k !== $d && $kMinus < $kPlus)) {
$prevK = $k + 1;
} else {
$prevK = $k - 1;
}
$prevX = $v[$prevK] ?? 0;
$prevY = $prevX - $prevK;
while ($x > $prevX && $y > $prevY) {
$ops[] = ['type' => 'equal', 'line' => $a[$x - 1]];
$x--;
$y--;
}
if ($d === 0) {
break;
}
if ($x === $prevX) {
$ops[] = ['type' => 'insert', 'line' => $b[$y - 1] ?? ''];
$y--;
} else {
$ops[] = ['type' => 'delete', 'line' => $a[$x - 1] ?? ''];
$x--;
}
}
return array_reverse($ops);
};
$scriptLineDiff = static function (string $fromText, string $toText) use ($splitLines, $myersLineDiff): array {
return $myersLineDiff($splitLines($fromText), $splitLines($toText));
};
@endphp
<div data-shared-zone="groups" class="space-y-4">
@foreach ($groupMeta as $group)
@php
$key = is_string($group['key'] ?? null) ? (string) $group['key'] : 'changed';
$label = is_string($group['label'] ?? null) ? (string) $group['label'] : ucfirst($key);
$collapsed = (bool) ($group['collapsed'] ?? false);
$items = is_array($diff[$key] ?? null) ? $diff[$key] : [];
$groups = $groupByBlock($items);
@endphp
@if ($groups !== [])
<x-filament::section
:heading="$label"
collapsible
:collapsed="$collapsed"
>
<div class="space-y-6">
@foreach ($groups as $groupLabel => $groupItems)
<div>
<div class="flex items-center justify-between">
<div class="text-sm font-medium text-gray-900 dark:text-white">
{{ $groupLabel }}
</div>
<x-filament::badge size="sm" color="gray">
{{ count($groupItems) }}
</x-filament::badge>
</div>
<div class="mt-2 divide-y divide-gray-200 rounded-lg border border-gray-200 dark:divide-white/10 dark:border-white/10">
@foreach ($groupItems as $name => $value)
<div class="px-4 py-3">
@if ($key === 'changed' && is_array($value) && array_key_exists('from', $value) && array_key_exists('to', $value))
@php
$from = $value['from'];
$to = $value['to'];
$fromText = $stringify($from);
$toText = $stringify($to);
$isScriptContent = $canHighlightScripts($policyType) && $isScriptKey($name);
$ops = $isScriptContent ? $scriptLineDiff((string) $fromText, (string) $toText) : [];
$useTorchlight = $isScriptContent && class_exists(\Torchlight\Engine\Engine::class);
$rows = [];
if ($isScriptContent) {
$count = count($ops);
for ($i = 0; $i < $count; $i++) {
$op = $ops[$i];
$next = $ops[$i + 1] ?? null;
$type = $op['type'] ?? null;
$line = (string) ($op['line'] ?? '');
if ($type === 'equal') {
$rows[] = [
'left' => ['type' => 'equal', 'line' => $line],
'right' => ['type' => 'equal', 'line' => $line],
];
continue;
}
if ($type === 'delete' && is_array($next) && ($next['type'] ?? null) === 'insert') {
$rows[] = [
'left' => ['type' => 'delete', 'line' => $line],
'right' => ['type' => 'insert', 'line' => (string) ($next['line'] ?? '')],
];
$i++;
continue;
}
if ($type === 'delete') {
$rows[] = [
'left' => ['type' => 'delete', 'line' => $line],
'right' => ['type' => 'blank', 'line' => ''],
];
continue;
}
if ($type === 'insert') {
$rows[] = [
'left' => ['type' => 'blank', 'line' => ''],
'right' => ['type' => 'insert', 'line' => $line],
];
}
}
}
@endphp
<div class="grid grid-cols-1 gap-2 sm:grid-cols-3">
<div class="text-sm font-medium text-gray-900 dark:text-white">
{{ (string) $name }}
</div>
@if ($isScriptContent)
<div class="text-sm text-gray-600 dark:text-gray-300 sm:col-span-2">
<span class="text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">Script</span>
<details class="mt-1" x-data="{ fullscreenOpen: false }">
<summary class="cursor-pointer text-sm text-gray-700 dark:text-gray-200">
View
</summary>
<div x-data="{ tab: 'diff' }" class="mt-2 space-y-3">
<div class="flex flex-wrap items-center gap-2">
<x-filament::button size="xs" color="gray" type="button" x-on:click="tab = 'diff'" x-bind:class="tab === 'diff' ? 'ring-1 ring-gray-300 dark:ring-white/20' : ''">
Diff
</x-filament::button>
<x-filament::button size="xs" color="gray" type="button" x-on:click="tab = 'before'" x-bind:class="tab === 'before' ? 'ring-1 ring-gray-300 dark:ring-white/20' : ''">
Before
</x-filament::button>
<x-filament::button size="xs" color="gray" type="button" x-on:click="tab = 'after'" x-bind:class="tab === 'after' ? 'ring-1 ring-gray-300 dark:ring-white/20' : ''">
After
</x-filament::button>
<x-filament::button size="xs" color="gray" type="button" x-on:click="fullscreenOpen = true">
Fullscreen
</x-filament::button>
</div>
<div x-show="tab === 'diff'" x-cloak>
<div class="grid grid-cols-1 gap-3 sm:grid-cols-2">
<div>
<div class="text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">Old</div>
<pre class="mt-1 max-h-96 overflow-auto whitespace-pre font-mono text-xs text-gray-800 dark:text-gray-200">@php
foreach ($rows as $row) {
$left = $row['left'];
$leftType = $left['type'];
$leftLine = (string) ($left['line'] ?? '');
$leftHighlighted = $useTorchlight ? $highlightInline($policyType, $leftLine) : null;
$leftRendered = (is_string($leftHighlighted) && $leftHighlighted !== '') ? $leftHighlighted : e($leftLine);
if ($leftType === 'equal') {
if ($useTorchlight) {
@endphp
@once
@include('filament.partials.torchlight-dark-overrides')
<style>
.tp-script-diff-line code.torchlight {
background-color: transparent !important;
}
</style>
@endonce
@php
}
echo '<span class="tp-script-diff-line">'.$leftRendered."</span>\n";
continue;
}
if ($leftType === 'delete') {
if ($useTorchlight) {
@endphp
@once
@include('filament.partials.torchlight-dark-overrides')
<style>
.tp-script-diff-line code.torchlight {
background-color: transparent !important;
}
</style>
@endonce
@php
}
echo '<span class="block tp-script-diff-line bg-danger-50 text-danger-700 dark:bg-danger-950/40 dark:text-danger-200">- '.$leftRendered."</span>\n";
continue;
}
echo "\n";
}
@endphp</pre>
</div>
<div>
<div class="text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">New</div>
<pre class="mt-1 max-h-96 overflow-auto whitespace-pre font-mono text-xs text-gray-800 dark:text-gray-200">@php
foreach ($rows as $row) {
$right = $row['right'];
$rightType = $right['type'];
$rightLine = (string) ($right['line'] ?? '');
$rightHighlighted = $useTorchlight ? $highlightInline($policyType, $rightLine) : null;
$rightRendered = (is_string($rightHighlighted) && $rightHighlighted !== '') ? $rightHighlighted : e($rightLine);
if ($rightType === 'equal') {
if ($useTorchlight) {
@endphp
@once
@include('filament.partials.torchlight-dark-overrides')
<style>
.tp-script-diff-line code.torchlight {
background-color: transparent !important;
}
</style>
@endonce
@php
}
echo '<span class="tp-script-diff-line">'.$rightRendered."</span>\n";
continue;
}
if ($rightType === 'insert') {
if ($useTorchlight) {
@endphp
@once
@include('filament.partials.torchlight-dark-overrides')
<style>
.tp-script-diff-line code.torchlight {
background-color: transparent !important;
}
</style>
@endonce
@php
}
echo '<span class="block tp-script-diff-line bg-success-50 text-success-700 dark:bg-success-950/40 dark:text-success-200">+ '.$rightRendered."</span>\n";
continue;
}
echo "\n";
}
@endphp</pre>
</div>
</div>
</div>
<div x-show="tab === 'before'" x-cloak>
<div class="text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">Before</div>
@php
$highlightedBefore = $useTorchlight ? $highlight($policyType, (string) $fromText) : null;
@endphp
@if (is_string($highlightedBefore) && $highlightedBefore !== '')
@once
@include('filament.partials.torchlight-dark-overrides')
@endonce
<div class="mt-1 max-h-96 overflow-auto">{!! $highlightedBefore !!}</div>
@else
<pre class="mt-1 max-h-96 overflow-auto whitespace-pre font-mono text-xs text-gray-800 dark:text-gray-200">{{ (string) $fromText }}</pre>
@endif
</div>
<div x-show="tab === 'after'" x-cloak>
<div class="text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">After</div>
@php
$highlightedAfter = $useTorchlight ? $highlight($policyType, (string) $toText) : null;
@endphp
@if (is_string($highlightedAfter) && $highlightedAfter !== '')
@once
@include('filament.partials.torchlight-dark-overrides')
@endonce
<div class="mt-1 max-h-96 overflow-auto">{!! $highlightedAfter !!}</div>
@else
<pre class="mt-1 max-h-96 overflow-auto whitespace-pre font-mono text-xs text-gray-800 dark:text-gray-200">{{ (string) $toText }}</pre>
@endif
</div>
</div>
<div
x-show="fullscreenOpen"
x-cloak
x-on:keydown.escape.window="fullscreenOpen = false"
class="fixed inset-0 z-50"
>
<div class="absolute inset-0 bg-gray-950/50"></div>
<div class="relative flex h-full w-full flex-col bg-white dark:bg-gray-900">
<div class="flex items-center justify-between gap-3 border-b border-gray-200 px-4 py-3 dark:border-white/10">
<div class="text-sm font-medium text-gray-900 dark:text-white">Script diff</div>
<div class="flex items-center gap-2">
<x-filament::button size="sm" color="gray" type="button" x-on:click="fullscreenOpen = false">
Close
</x-filament::button>
</div>
</div>
<div class="flex-1 overflow-hidden p-4">
<div
x-data="{
tab: 'diff',
syncing: false,
syncHorizontal: true,
sync(from, to) {
if (this.syncing) return;
this.syncing = true;
to.scrollTop = from.scrollTop;
const bothHorizontal = this.syncHorizontal
&& from.scrollWidth > from.clientWidth
&& to.scrollWidth > to.clientWidth;
if (bothHorizontal) {
to.scrollLeft = from.scrollLeft;
}
requestAnimationFrame(() => { this.syncing = false; });
},
}"
x-init="$nextTick(() => {
const left = $refs.left;
const right = $refs.right;
if (! left || ! right) {
return;
}
left.addEventListener('scroll', () => sync(left, right), { passive: true });
right.addEventListener('scroll', () => sync(right, left), { passive: true });
})"
class="h-full space-y-3"
>
<div class="flex flex-wrap items-center gap-2">
<x-filament::button size="sm" color="gray" type="button" x-on:click="tab = 'diff'" x-bind:class="tab === 'diff' ? 'ring-1 ring-gray-300 dark:ring-white/20' : ''">
Diff
</x-filament::button>
<x-filament::button size="sm" color="gray" type="button" x-on:click="tab = 'before'" x-bind:class="tab === 'before' ? 'ring-1 ring-gray-300 dark:ring-white/20' : ''">
Before
</x-filament::button>
<x-filament::button size="sm" color="gray" type="button" x-on:click="tab = 'after'" x-bind:class="tab === 'after' ? 'ring-1 ring-gray-300 dark:ring-white/20' : ''">
After
</x-filament::button>
</div>
<div x-show="tab === 'diff'" x-cloak class="h-[calc(100%-3rem)]">
<div class="grid h-full grid-cols-1 gap-4 lg:grid-cols-2">
<div class="flex h-full flex-col">
<div class="text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">Old</div>
<pre x-ref="left" class="mt-2 flex-1 overflow-auto whitespace-pre font-mono text-xs text-gray-800 dark:text-gray-200">@php
foreach ($rows as $row) {
$left = $row['left'];
$leftType = $left['type'];
$leftLine = (string) ($left['line'] ?? '');
$leftHighlighted = $useTorchlight ? $highlightInline($policyType, $leftLine) : null;
$leftRendered = (is_string($leftHighlighted) && $leftHighlighted !== '') ? $leftHighlighted : e($leftLine);
if ($leftType === 'equal') {
if ($useTorchlight) {
@endphp
@once
@include('filament.partials.torchlight-dark-overrides')
<style>
.tp-script-diff-line code.torchlight {
background-color: transparent !important;
}
</style>
@endonce
@php
}
echo '<span class="tp-script-diff-line">'.$leftRendered."</span>\n";
continue;
}
if ($leftType === 'delete') {
if ($useTorchlight) {
@endphp
@once
@include('filament.partials.torchlight-dark-overrides')
<style>
.tp-script-diff-line code.torchlight {
background-color: transparent !important;
}
</style>
@endonce
@php
}
echo '<span class="block tp-script-diff-line bg-danger-50 text-danger-700 dark:bg-danger-950/40 dark:text-danger-200">- '.$leftRendered."</span>\n";
continue;
}
echo "\n";
}
@endphp</pre>
</div>
<div class="flex h-full flex-col">
<div class="text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">New</div>
<pre x-ref="right" class="mt-2 flex-1 overflow-auto whitespace-pre font-mono text-xs text-gray-800 dark:text-gray-200">@php
foreach ($rows as $row) {
$right = $row['right'];
$rightType = $right['type'];
$rightLine = (string) ($right['line'] ?? '');
$rightHighlighted = $useTorchlight ? $highlightInline($policyType, $rightLine) : null;
$rightRendered = (is_string($rightHighlighted) && $rightHighlighted !== '') ? $rightHighlighted : e($rightLine);
if ($rightType === 'equal') {
if ($useTorchlight) {
@endphp
@once
@include('filament.partials.torchlight-dark-overrides')
<style>
.tp-script-diff-line code.torchlight {
background-color: transparent !important;
}
</style>
@endonce
@php
}
echo '<span class="tp-script-diff-line">'.$rightRendered."</span>\n";
continue;
}
if ($rightType === 'insert') {
if ($useTorchlight) {
@endphp
@once
@include('filament.partials.torchlight-dark-overrides')
<style>
.tp-script-diff-line code.torchlight {
background-color: transparent !important;
}
</style>
@endonce
@php
}
echo '<span class="block tp-script-diff-line bg-success-50 text-success-700 dark:bg-success-950/40 dark:text-success-200">+ '.$rightRendered."</span>\n";
continue;
}
echo "\n";
}
@endphp</pre>
</div>
</div>
</div>
<div x-show="tab === 'before'" x-cloak class="h-[calc(100%-3rem)]">
<div class="text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">Before</div>
@php
$highlightedBeforeFullscreen = $useTorchlight ? $highlight($policyType, (string) $fromText) : null;
@endphp
@if (is_string($highlightedBeforeFullscreen) && $highlightedBeforeFullscreen !== '')
@once
@include('filament.partials.torchlight-dark-overrides')
@endonce
<div class="mt-2 h-full overflow-auto">{!! $highlightedBeforeFullscreen !!}</div>
@else
<pre class="mt-2 h-full overflow-auto whitespace-pre font-mono text-xs text-gray-800 dark:text-gray-200">{{ (string) $fromText }}</pre>
@endif
</div>
<div x-show="tab === 'after'" x-cloak class="h-[calc(100%-3rem)]">
<div class="text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">After</div>
@php
$highlightedAfterFullscreen = $useTorchlight ? $highlight($policyType, (string) $toText) : null;
@endphp
@if (is_string($highlightedAfterFullscreen) && $highlightedAfterFullscreen !== '')
@once
@include('filament.partials.torchlight-dark-overrides')
@endonce
<div class="mt-2 h-full overflow-auto">{!! $highlightedAfterFullscreen !!}</div>
@else
<pre class="mt-2 h-full overflow-auto whitespace-pre font-mono text-xs text-gray-800 dark:text-gray-200">{{ (string) $toText }}</pre>
@endif
</div>
</div>
</div>
</div>
</div>
</details>
</div>
@else
<div class="text-sm text-gray-600 dark:text-gray-300">
<span class="text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">From</span>
@if ($isExpandable($from))
<details class="mt-1">
<summary class="cursor-pointer text-sm text-gray-700 dark:text-gray-200">
View
</summary>
<pre class="mt-2 overflow-x-auto text-xs text-gray-800 dark:text-gray-200">{{ $fromText }}</pre>
</details>
@else
<div class="mt-1">{{ $fromText }}</div>
@endif
</div>
<div class="text-sm text-gray-600 dark:text-gray-300">
<span class="text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">To</span>
@if ($isExpandable($to))
<details class="mt-1">
<summary class="cursor-pointer text-sm text-gray-700 dark:text-gray-200">
View
</summary>
<pre class="mt-2 overflow-x-auto text-xs text-gray-800 dark:text-gray-200">{{ $toText }}</pre>
</details>
@else
<div class="mt-1">{{ $toText }}</div>
@endif
</div>
@endif
</div>
@else
@php
$text = $stringify($value);
$isScriptContent = $canHighlightScripts($policyType) && $isScriptKey($name);
$highlighted = $isScriptContent ? $highlight($policyType, (string) $text) : null;
@endphp
<div class="flex flex-col gap-2 sm:flex-row sm:items-start sm:justify-between">
<div class="text-sm font-medium text-gray-900 dark:text-white">
{{ (string) $name }}
</div>
<div class="text-sm text-gray-700 dark:text-gray-200 sm:max-w-[70%]">
@if ($isExpandable($value))
<details>
<summary class="cursor-pointer text-sm text-gray-700 dark:text-gray-200">
View
</summary>
@if (is_string($highlighted) && $highlighted !== '')
@once
@include('filament.partials.torchlight-dark-overrides')
@endonce
<div class="mt-2 overflow-x-auto">{!! $highlighted !!}</div>
@else
<pre class="mt-2 overflow-x-auto text-xs text-gray-800 dark:text-gray-200">{{ $text }}</pre>
@endif
</details>
@else
<div class="break-words">{{ $text }}</div>
@endif
</div>
</div>
@endif
</div>
@endforeach
</div>
</div>
@endforeach
</div>
</x-filament::section>
@endif
@endforeach
</div>

View File

@ -0,0 +1,30 @@
@php
$summary = is_array($surface['summary'] ?? null) ? $surface['summary'] : [];
$availabilityState = is_string($surface['availabilityState'] ?? null) ? (string) $surface['availabilityState'] : 'available';
@endphp
<div data-shared-zone="summary">
<x-filament::section heading="Normalized diff">
<div class="flex flex-wrap gap-2">
@if ($availabilityState === 'unavailable')
<x-filament::badge color="danger">
Unavailable
</x-filament::badge>
@elseif ($availabilityState === 'partial')
<x-filament::badge color="warning">
Partial
</x-filament::badge>
@endif
<x-filament::badge color="success">
{{ (int) ($summary['added'] ?? 0) }} added
</x-filament::badge>
<x-filament::badge color="danger">
{{ (int) ($summary['removed'] ?? 0) }} removed
</x-filament::badge>
<x-filament::badge color="warning">
{{ (int) ($summary['changed'] ?? 0) }} changed
</x-filament::badge>
</div>
</x-filament::section>
</div>

View File

@ -0,0 +1,47 @@
@php
$state = is_array($state ?? null) ? $state : [];
$surface = array_key_exists('renderExpectations', $state)
? $state
: \App\Filament\Support\NormalizedDiffSurface::build($state, 'unknown');
$summary = is_array($surface['summary'] ?? null) ? $surface['summary'] : [];
$availabilityState = is_string($surface['availabilityState'] ?? null) ? (string) $surface['availabilityState'] : 'available';
$emptyState = is_array($surface['emptyState'] ?? null) ? $surface['emptyState'] : null;
$hasChanges = ((int) ($summary['added'] ?? 0) + (int) ($summary['removed'] ?? 0) + (int) ($summary['changed'] ?? 0)) > 0;
if ($emptyState === null && ! $hasChanges && $availabilityState === 'available') {
$message = is_string($summary['message'] ?? null) && trim((string) $summary['message']) !== ''
? trim((string) $summary['message'])
: 'No normalized changes were found.';
$emptyState = [
'title' => 'No normalized changes',
'message' => $message,
];
}
@endphp
<div
class="space-y-4"
data-shared-detail-family="normalized-diff"
data-shared-normalized-diff-host="{{ $surface['hostKind'] ?? 'unknown' }}"
data-shared-normalized-diff-state="{{ $availabilityState }}"
>
@include('filament.infolists.entries.normalized-diff.summary', [
'surface' => $surface,
])
@if ($emptyState !== null)
@include('filament.infolists.entries.normalized-diff.empty-state', [
'emptyState' => $emptyState,
'availabilityState' => $availabilityState,
])
@endif
@if ($hasChanges)
@include('filament.infolists.entries.normalized-diff.groups', [
'surface' => $surface,
])
@endif
</div>

View File

@ -1,233 +1,2 @@
@php
$normalized = $getState() ?? [];
$warnings = $normalized['warnings'] ?? [];
$settings = $normalized['settings'] ?? [];
$settingsTable = $normalized['settings_table'] ?? null;
$settingsTableRows = is_array($settingsTable) ? ($settingsTable['rows'] ?? []) : [];
$context = $normalized['context'] ?? 'policy';
$recordId = $normalized['record_id'] ?? null;
@endphp
<div class="space-y-3">
@if (! empty($warnings))
<div class="rounded-md border border-amber-300 bg-amber-50 p-3 text-sm text-amber-800">
<div class="font-semibold">Warnings</div>
<ul class="mt-1 list-disc space-y-1 pl-5">
@foreach ($warnings as $warning)
<li>{{ $warning }}</li>
@endforeach
</ul>
</div>
@endif
@if (empty($settings) && empty($settingsTableRows))
<p class="text-sm text-gray-600">No settings available.</p>
@endif
@if (! empty($settingsTableRows))
@php
$settingsTableTitle = is_array($settingsTable) ? ($settingsTable['title'] ?? null) : null;
$shouldShowTitle = is_string($settingsTableTitle)
&& $settingsTableTitle !== ''
&& ! ($context === 'policy' && strtolower($settingsTableTitle) === 'settings');
@endphp
<div class="space-y-2">
@if ($shouldShowTitle)
<div class="text-sm font-semibold text-gray-800">{{ $settingsTableTitle }}</div>
@endif
<livewire:settings-catalog-settings-table
:settings-rows="$settingsTableRows"
:context="$context"
:key="$recordId ? ('sc-settings-'.$context.'-'.$recordId) : ('sc-settings-'.$context)"
/>
</div>
@endif
@foreach ($settings as $block)
@php
$title = $block['title'] ?? 'Settings';
$isGeneral = is_string($title) && strtolower($title) === 'general';
@endphp
@if ($isGeneral)
<x-filament::section
:heading="$title"
collapsible
:collapsed="true"
data-block="general"
>
<x-slot name="headerEnd">
<span class="text-sm text-gray-500 dark:text-gray-400">
{{ count($block['entries'] ?? []) }} fields
</span>
</x-slot>
@if (($block['type'] ?? 'keyValue') === 'table')
@php
$columns = $block['columns'] ?? null;
$hasColumns = is_array($columns) && ! empty($columns);
$columnMeta = [
'definitionId' => ['width' => 'w-[35%]', 'style' => 'width: 35%;', 'cell' => 'font-mono text-xs break-all whitespace-normal', 'cellStyle' => 'word-break: break-all; overflow-wrap: anywhere; white-space: normal;'],
'instanceType' => ['width' => 'w-[20%]', 'style' => 'width: 20%;', 'cell' => 'font-mono text-xs break-all whitespace-normal', 'cellStyle' => 'word-break: break-all; overflow-wrap: anywhere; white-space: normal;'],
'value' => ['width' => 'w-[25%]', 'style' => 'width: 25%;', 'cell' => 'break-words whitespace-pre-wrap', 'cellStyle' => 'overflow-wrap: anywhere; white-space: pre-wrap;'],
'path' => ['width' => 'w-[20%]', 'style' => 'width: 20%;', 'cell' => 'font-mono text-xs break-all whitespace-normal', 'cellStyle' => 'word-break: break-all; overflow-wrap: anywhere; white-space: normal;'],
];
@endphp
<div class="overflow-x-auto rounded-lg border border-gray-200" style="overflow-x: auto;">
<table class="min-w-[900px] w-full table-fixed text-left text-sm" style="table-layout: fixed; width: 100%; min-width: 900px;">
<thead class="bg-gray-50 text-gray-700">
<tr>
@if ($hasColumns)
@foreach ($columns as $column)
@php
$key = $column['key'] ?? null;
$meta = is_string($key) ? ($columnMeta[$key] ?? []) : [];
@endphp
<th class="px-3 py-2 {{ $meta['width'] ?? '' }}" style="{{ $meta['style'] ?? '' }}">{{ $column['label'] ?? $column['key'] ?? '-' }}</th>
@endforeach
@else
<th class="px-3 py-2">Path</th>
<th class="px-3 py-2">Value</th>
@endif
</tr>
</thead>
<tbody class="divide-y divide-gray-100">
@foreach ($block['rows'] ?? [] as $row)
<tr>
@if ($hasColumns)
@foreach ($columns as $column)
@php
$key = $column['key'] ?? null;
$cell = is_string($key) ? ($row[$key] ?? null) : null;
$meta = is_string($key) ? ($columnMeta[$key] ?? []) : [];
@endphp
<td class="px-3 py-2 align-top text-gray-800 {{ $meta['cell'] ?? 'whitespace-pre-wrap' }}" style="{{ $meta['cellStyle'] ?? '' }}">
@if (is_array($cell))
<pre class="overflow-x-auto text-xs">{{ json_encode($cell, JSON_PRETTY_PRINT) }}</pre>
@elseif (is_bool($cell))
<span>{{ $cell ? 'true' : 'false' }}</span>
@else
<span title="{{ is_string($cell) ? $cell : '' }}">{{ $cell ?? '-' }}</span>
@endif
</td>
@endforeach
@else
<td class="px-3 py-2 align-top">
<div class="font-mono text-xs font-medium text-gray-800 break-all whitespace-normal" style="word-break: break-all; overflow-wrap: anywhere; white-space: normal;">{{ $row['path'] ?? '-' }}</div>
@if (! empty($row['label']))
<div class="text-xs text-gray-600">{{ $row['label'] }}</div>
@endif
</td>
<td class="px-3 py-2 align-top text-gray-800 break-words whitespace-pre-wrap" style="overflow-wrap: anywhere; white-space: pre-wrap;">
{{ is_array($row['value'] ?? null) ? json_encode($row['value'], JSON_PRETTY_PRINT) : ($row['value'] ?? '-') }}
</td>
@endif
</tr>
@endforeach
</tbody>
</table>
</div>
@else
<div class="divide-y divide-gray-200 dark:divide-gray-700">
@foreach ($block['entries'] ?? [] as $entry)
<div class="py-3 sm:grid sm:grid-cols-3 sm:gap-4">
<dt class="text-sm font-medium text-gray-500 dark:text-gray-400">
{{ $entry['key'] ?? '-' }}
</dt>
<dd class="mt-1 sm:mt-0 sm:col-span-2">
<span class="text-sm text-gray-900 dark:text-white whitespace-pre-wrap break-words">
{{ is_array($entry['value'] ?? null) ? json_encode($entry['value'], JSON_PRETTY_PRINT) : ($entry['value'] ?? '-') }}
</span>
</dd>
</div>
@endforeach
</div>
@endif
</x-filament::section>
@else
<div class="space-y-2 rounded-md border border-gray-200 bg-white p-3 shadow-sm">
<div class="text-sm font-semibold text-gray-800">{{ $title }}</div>
@if (($block['type'] ?? 'keyValue') === 'table')
@php
$columns = $block['columns'] ?? null;
$hasColumns = is_array($columns) && ! empty($columns);
$columnMeta = [
'definitionId' => ['width' => 'w-[35%]', 'style' => 'width: 35%;', 'cell' => 'font-mono text-xs break-all whitespace-normal', 'cellStyle' => 'word-break: break-all; overflow-wrap: anywhere; white-space: normal;'],
'instanceType' => ['width' => 'w-[20%]', 'style' => 'width: 20%;', 'cell' => 'font-mono text-xs break-all whitespace-normal', 'cellStyle' => 'word-break: break-all; overflow-wrap: anywhere; white-space: normal;'],
'value' => ['width' => 'w-[25%]', 'style' => 'width: 25%;', 'cell' => 'break-words whitespace-pre-wrap', 'cellStyle' => 'overflow-wrap: anywhere; white-space: pre-wrap;'],
'path' => ['width' => 'w-[20%]', 'style' => 'width: 20%;', 'cell' => 'font-mono text-xs break-all whitespace-normal', 'cellStyle' => 'word-break: break-all; overflow-wrap: anywhere; white-space: normal;'],
];
@endphp
<div class="overflow-x-auto rounded-lg border border-gray-200" style="overflow-x: auto;">
<table class="min-w-[900px] w-full table-fixed text-left text-sm" style="table-layout: fixed; width: 100%; min-width: 900px;">
<thead class="bg-gray-50 text-gray-700">
<tr>
@if ($hasColumns)
@foreach ($columns as $column)
@php
$key = $column['key'] ?? null;
$meta = is_string($key) ? ($columnMeta[$key] ?? []) : [];
@endphp
<th class="px-3 py-2 {{ $meta['width'] ?? '' }}" style="{{ $meta['style'] ?? '' }}">{{ $column['label'] ?? $column['key'] ?? '-' }}</th>
@endforeach
@else
<th class="px-3 py-2">Path</th>
<th class="px-3 py-2">Value</th>
@endif
</tr>
</thead>
<tbody class="divide-y divide-gray-100">
@foreach ($block['rows'] ?? [] as $row)
<tr>
@if ($hasColumns)
@foreach ($columns as $column)
@php
$key = $column['key'] ?? null;
$cell = is_string($key) ? ($row[$key] ?? null) : null;
$meta = is_string($key) ? ($columnMeta[$key] ?? []) : [];
@endphp
<td class="px-3 py-2 align-top text-gray-800 {{ $meta['cell'] ?? 'whitespace-pre-wrap' }}" style="{{ $meta['cellStyle'] ?? '' }}">
@if (is_array($cell))
<pre class="overflow-x-auto text-xs">{{ json_encode($cell, JSON_PRETTY_PRINT) }}</pre>
@elseif (is_bool($cell))
<span>{{ $cell ? 'true' : 'false' }}</span>
@else
<span title="{{ is_string($cell) ? $cell : '' }}">{{ $cell ?? '-' }}</span>
@endif
</td>
@endforeach
@else
<td class="px-3 py-2 align-top">
<div class="font-mono text-xs font-medium text-gray-800 break-all whitespace-normal" style="word-break: break-all; overflow-wrap: anywhere; white-space: normal;">{{ $row['path'] ?? '-' }}</div>
@if (! empty($row['label']))
<div class="text-xs text-gray-600">{{ $row['label'] }}</div>
@endif
</td>
<td class="px-3 py-2 align-top text-gray-800 break-words whitespace-pre-wrap" style="overflow-wrap: anywhere; white-space: pre-wrap;">
{{ is_array($row['value'] ?? null) ? json_encode($row['value'], JSON_PRETTY_PRINT) : ($row['value'] ?? '-') }}
</td>
@endif
</tr>
@endforeach
</tbody>
</table>
</div>
@else
<dl class="grid grid-cols-1 gap-3 sm:grid-cols-2">
@foreach ($block['entries'] ?? [] as $entry)
<div class="rounded border border-gray-100 bg-gray-50 p-3">
<dt class="text-xs uppercase tracking-wide text-gray-500">{{ $entry['key'] ?? '-' }}</dt>
<dd class="whitespace-pre-wrap text-sm text-gray-800">
{{ is_array($entry['value'] ?? null) ? json_encode($entry['value'], JSON_PRETTY_PRINT) : ($entry['value'] ?? '-') }}
</dd>
</div>
@endforeach
</dl>
@endif
</div>
@endif
@endforeach
</div>
{{-- NormalizedSettingsSurface normalized-settings wrapper --}}
@include('filament.infolists.entries.normalized-settings.wrapper', ['state' => $getState() ?? []])

View File

@ -0,0 +1,22 @@
@php
$settingsTable = is_array($settingsTable ?? null) ? $settingsTable : null;
$settingsTableRows = is_array($settingsTable['rows'] ?? null) ? $settingsTable['rows'] : [];
$context = is_string($context ?? null) && $context !== '' ? $context : 'policy';
$recordId = is_scalar($recordId ?? null) ? (string) $recordId : null;
$settingsTableTitle = is_string($settingsTable['title'] ?? null) ? $settingsTable['title'] : null;
$shouldShowTitle = is_string($settingsTableTitle)
&& $settingsTableTitle !== ''
&& ! ($context === 'policy' && strtolower($settingsTableTitle) === 'settings');
@endphp
<div class="space-y-2" data-shared-zone="settings-table">
@if ($shouldShowTitle)
<div class="text-sm font-semibold text-gray-800 dark:text-gray-200">{{ $settingsTableTitle }}</div>
@endif
<livewire:settings-catalog-settings-table
:settings-rows="$settingsTableRows"
:context="$context"
:key="$recordId ? ('sc-settings-'.$context.'-'.$recordId) : ('sc-settings-'.$context)"
/>
</div>

View File

@ -0,0 +1,273 @@
@php
use App\Support\Badges\BadgeCatalog;
use App\Support\Badges\BadgeDomain;
use App\Support\Badges\BadgeSpec;
use Illuminate\Support\Str;
$blocks = is_array($blocks ?? null) ? $blocks : [];
$policyType = is_string($policyType ?? null) ? $policyType : null;
$stringifyValue = function (mixed $value): string {
if (is_null($value)) {
return 'N/A';
}
if (is_bool($value)) {
return $value ? 'Enabled' : 'Disabled';
}
if (is_scalar($value)) {
return (string) $value;
}
if (is_array($value)) {
$encoded = json_encode($value, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
return is_string($encoded) ? $encoded : 'N/A';
}
if (is_object($value)) {
if (method_exists($value, '__toString')) {
return (string) $value;
}
$encoded = json_encode((array) $value, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
return is_string($encoded) ? $encoded : 'N/A';
}
return 'N/A';
};
$shouldRenderBadges = function (mixed $value): bool {
if (! is_array($value) || $value === []) {
return false;
}
if (! array_is_list($value)) {
return false;
}
foreach ($value as $item) {
if (! is_scalar($item) && ! is_null($item)) {
return false;
}
}
return true;
};
$asEnabledDisabledBadgeSpec = function (mixed $value): ?BadgeSpec {
$spec = BadgeCatalog::spec(BadgeDomain::BooleanEnabled, $value);
return $spec->label === 'Unknown' ? null : $spec;
};
@endphp
<div class="space-y-4" data-shared-zone="blocks">
@foreach ($blocks as $block)
@php
$blockType = is_array($block) ? ($block['type'] ?? null) : null;
@endphp
@if ($blockType === 'table')
<x-filament::section
:heading="$block['title'] ?? 'Settings'"
collapsible
>
<x-slot name="headerEnd">
<span class="text-sm text-gray-500 dark:text-gray-400">
{{ count($block['rows'] ?? []) }} {{ Str::plural('item', count($block['rows'] ?? [])) }}
</span>
</x-slot>
<div class="divide-y divide-gray-200 dark:divide-gray-700">
@foreach ($block['rows'] ?? [] as $row)
<div class="py-3 sm:grid sm:grid-cols-3 sm:gap-4">
<dt class="text-sm font-medium text-gray-500 dark:text-gray-400 break-words">
{{ $row['label'] ?? $row['path'] ?? 'Setting' }}
@if (! empty($row['path']) && ($row['label'] ?? null) !== ($row['path'] ?? null))
<p class="mt-0.5 break-all text-xs font-mono text-gray-400 dark:text-gray-500">
{{ (string) $row['path'] }}
</p>
@endif
@if (! empty($row['description']))
<p class="mt-0.5 text-xs text-gray-400">{{ Str::limit($row['description'], 80) }}</p>
@endif
</dt>
<dd class="mt-1 sm:col-span-2 sm:mt-0">
@php
$badgeSpec = $asEnabledDisabledBadgeSpec($row['value'] ?? null);
@endphp
@if ($badgeSpec)
<x-filament::badge :color="$badgeSpec->color" :icon="$badgeSpec->icon" size="sm">
{{ $badgeSpec->label }}
</x-filament::badge>
@elseif (is_numeric($row['value'] ?? null))
<span class="font-mono text-sm font-semibold text-gray-900 dark:text-white">
{{ $row['value'] }}
</span>
@elseif ($shouldRenderBadges($row['value'] ?? null))
<div class="flex flex-wrap gap-1.5">
@foreach (($row['value'] ?? []) as $item)
@php
$itemSpec = $asEnabledDisabledBadgeSpec($item);
@endphp
@if ($itemSpec)
<x-filament::badge :color="$itemSpec->color" :icon="$itemSpec->icon" size="sm">
{{ $itemSpec->label }}
</x-filament::badge>
@else
<x-filament::badge color="gray" size="sm">
{{ is_null($item) ? '—' : (string) $item }}
</x-filament::badge>
@endif
@endforeach
</div>
@else
<span class="break-words text-sm text-gray-900 dark:text-white">
{{ Str::limit($stringifyValue($row['value'] ?? null), 200) }}
</span>
@endif
</dd>
</div>
@endforeach
</div>
</x-filament::section>
@elseif ($blockType === 'keyValue')
<x-filament::section
:heading="$block['title'] ?? 'Settings'"
collapsible
>
<x-slot name="headerEnd">
<span class="text-sm text-gray-500 dark:text-gray-400">
{{ count($block['entries'] ?? []) }} {{ Str::plural('entry', count($block['entries'] ?? [])) }}
</span>
</x-slot>
<div class="divide-y divide-gray-200 dark:divide-gray-700">
@foreach ($block['entries'] ?? [] as $entry)
<div class="py-3 sm:grid sm:grid-cols-3 sm:gap-4">
<dt class="text-sm font-medium text-gray-500 dark:text-gray-400">
{{ $entry['key'] ?? 'Setting' }}
</dt>
<dd class="mt-1 sm:col-span-2 sm:mt-0">
@php
$rawValue = $entry['value'] ?? null;
$isScriptContent = in_array($entry['key'] ?? null, ['scriptContent', 'detectionScriptContent', 'remediationScriptContent'], true)
&& (bool) config('tenantpilot.display.show_script_content', false);
$badgeSpec = $asEnabledDisabledBadgeSpec($rawValue);
@endphp
@if ($isScriptContent)
@php
$code = is_string($rawValue) ? $rawValue : $stringifyValue($rawValue);
$firstLine = strtok($code, "\n") ?: '';
$grammar = 'powershell';
if ($policyType === 'deviceShellScript') {
$shebang = trim($firstLine);
if (str_starts_with($shebang, '#!')) {
if (str_contains($shebang, 'zsh')) {
$grammar = 'zsh';
} elseif (str_contains($shebang, 'bash')) {
$grammar = 'bash';
} else {
$grammar = 'sh';
}
} else {
$grammar = 'sh';
}
} elseif ($policyType === 'deviceManagementScript' || $policyType === 'deviceHealthScript') {
$grammar = 'powershell';
}
$highlightedHtml = null;
if (class_exists(\Torchlight\Engine\Engine::class)) {
try {
$highlightedHtml = (new \Torchlight\Engine\Engine())->codeToHtml(
code: $code,
grammar: $grammar,
theme: [
'light' => 'github-light',
'dark' => 'github-dark',
],
withGutter: false,
withWrapper: true,
);
} catch (\Throwable $e) {
$highlightedHtml = null;
}
}
@endphp
<div x-data="{ open: false }" class="space-y-2">
<div class="flex items-center gap-2">
<x-filament::button
size="xs"
color="gray"
type="button"
x-on:click="open = !open"
>
<span x-cloak x-show="!open">Show</span>
<span x-cloak x-show="open">Hide</span>
</x-filament::button>
<span class="text-xs text-gray-500 dark:text-gray-400">
{{ number_format(Str::length($code)) }} chars
</span>
</div>
<div x-cloak x-show="open">
@if (is_string($highlightedHtml) && $highlightedHtml !== '')
@once
@include('filament.partials.torchlight-dark-overrides')
@endonce
<div class="overflow-x-auto">{!! $highlightedHtml !!}</div>
@else
<pre class="whitespace-pre-wrap break-words text-xs font-mono text-gray-900 dark:text-white">{{ $code }}</pre>
@endif
</div>
</div>
@elseif ($shouldRenderBadges($rawValue))
<div class="flex flex-wrap gap-1.5">
@foreach (($rawValue ?? []) as $item)
@php
$itemSpec = $asEnabledDisabledBadgeSpec($item);
@endphp
@if ($itemSpec)
<x-filament::badge :color="$itemSpec->color" :icon="$itemSpec->icon" size="sm">
{{ $itemSpec->label }}
</x-filament::badge>
@else
<x-filament::badge color="gray" size="sm">
{{ is_null($item) ? '—' : (string) $item }}
</x-filament::badge>
@endif
@endforeach
</div>
@elseif ($badgeSpec)
<x-filament::badge :color="$badgeSpec->color" :icon="$badgeSpec->icon" size="sm">
{{ $badgeSpec->label }}
</x-filament::badge>
@else
<span class="break-words text-sm text-gray-900 dark:text-white">
{{ Str::limit($stringifyValue($rawValue), 200) }}
</span>
@endif
</dd>
</div>
@endforeach
</div>
</x-filament::section>
@endif
@endforeach
</div>

View File

@ -0,0 +1,56 @@
@php
$state = is_array($state ?? null) ? $state : [];
$surface = array_key_exists('renderExpectations', $state)
? $state
: \App\Filament\Support\NormalizedSettingsSurface::build(
$state,
($state['context'] ?? 'policy') === 'policy' ? 'policy' : 'policy_version'
);
$warnings = is_array($surface['warnings'] ?? null) ? $surface['warnings'] : [];
$settingsTable = is_array($surface['settingsTable'] ?? null) ? $surface['settingsTable'] : null;
$settingsTableRows = is_array($settingsTable['rows'] ?? null) ? $settingsTable['rows'] : [];
$blocks = is_array($surface['blocks'] ?? null) ? $surface['blocks'] : [];
$emptyState = is_array($surface['emptyState'] ?? null) ? $surface['emptyState'] : null;
@endphp
<div
class="space-y-4"
data-shared-detail-family="normalized-settings"
data-shared-normalized-settings-host="{{ $surface['hostKind'] ?? 'unknown' }}"
data-shared-normalized-settings-variant="{{ $surface['variant'] ?? 'standard_blocks' }}"
>
@if ($warnings !== [])
<div class="rounded-md border border-amber-300 bg-amber-50 p-3 text-sm text-amber-800" data-shared-zone="warnings">
<div class="font-semibold">Warnings</div>
<ul class="mt-1 list-disc space-y-1 pl-5">
@foreach ($warnings as $warning)
<li>{{ $warning }}</li>
@endforeach
</ul>
</div>
@endif
@if ($settingsTableRows !== [])
@include('filament.infolists.entries.normalized-settings.catalog-table', [
'settingsTable' => $settingsTable,
'context' => $surface['context'] ?? 'policy',
'recordId' => $surface['recordId'] ?? null,
])
@endif
@if ($blocks !== [])
@include('filament.infolists.entries.normalized-settings.standard-blocks', [
'blocks' => $blocks,
'policyType' => $surface['policyType'] ?? null,
])
@endif
@if ($settingsTableRows === [] && $blocks === [] && $emptyState !== null)
<div class="rounded-lg border border-dashed border-gray-300 bg-white px-6 py-8 text-center dark:border-white/15 dark:bg-gray-900/40" data-shared-zone="empty">
<p class="text-sm font-medium text-gray-900 dark:text-white">{{ $emptyState['title'] ?? 'No settings available.' }}</p>
<p class="mt-1 text-sm text-gray-600 dark:text-gray-300">{{ $emptyState['message'] ?? 'No normalized settings payload is available for this host.' }}</p>
</div>
@endif
</div>

View File

@ -1,355 +1,2 @@
@php
use App\Support\Badges\BadgeCatalog;
use App\Support\Badges\BadgeDomain;
use App\Support\Badges\BadgeSpec;
use Illuminate\Support\Str;
// Extract state from Filament ViewEntry
$state = $getState();
$status = $state['status'] ?? 'success';
$warnings = $state['warnings'] ?? [];
$settings = $state['settings'] ?? [];
$settingsTable = $state['settings_table'] ?? null;
$policyType = $state['policy_type'] ?? null;
$stringifyValue = function (mixed $value): string {
if (is_null($value)) {
return 'N/A';
}
if (is_bool($value)) {
return $value ? 'Enabled' : 'Disabled';
}
if (is_scalar($value)) {
return (string) $value;
}
if (is_array($value)) {
$encoded = json_encode($value, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
return is_string($encoded) ? $encoded : 'N/A';
}
if (is_object($value)) {
if (method_exists($value, '__toString')) {
return (string) $value;
}
$encoded = json_encode((array) $value, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
return is_string($encoded) ? $encoded : 'N/A';
}
return 'N/A';
};
$shouldRenderBadges = function (mixed $value): bool {
if (! is_array($value) || $value === []) {
return false;
}
if (! array_is_list($value)) {
return false;
}
foreach ($value as $item) {
if (! is_scalar($item) && ! is_null($item)) {
return false;
}
}
return true;
};
$asEnabledDisabledBadgeSpec = function (mixed $value): ?BadgeSpec {
$spec = BadgeCatalog::spec(BadgeDomain::BooleanEnabled, $value);
return $spec->label === 'Unknown' ? null : $spec;
};
@endphp
<div class="space-y-4">
{{-- Warnings --}}
@if(!empty($warnings))
<x-filament::section>
<div class="space-y-2">
@foreach($warnings as $warning)
<div class="flex items-start gap-2 text-sm text-warning-600 dark:text-warning-400">
<svg class="w-5 h-5 flex-shrink-0 mt-0.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"/>
</svg>
<span>{{ $warning }}</span>
</div>
@endforeach
</div>
</x-filament::section>
@endif
{{-- Settings Table (for Settings Catalog legacy format) --}}
@if($settingsTable && !empty($settingsTable['rows']))
<x-filament::section
:heading="$settingsTable['title'] ?? 'Settings'"
:description="$settingsTable['description'] ?? null"
>
<x-slot name="headerEnd">
<span class="text-sm text-gray-500 dark:text-gray-400">
{{ count($settingsTable['rows']) }} {{ Str::plural('setting', count($settingsTable['rows'])) }}
</span>
</x-slot>
<div class="divide-y divide-gray-200 dark:divide-gray-700">
@foreach($settingsTable['rows'] as $row)
<div class="py-3 sm:grid sm:grid-cols-3 sm:gap-4">
<dt class="text-sm font-medium text-gray-500 dark:text-gray-400">
{{ $row['definition'] ?? $row['label'] ?? $row['path'] ?? 'Setting' }}
</dt>
<dd class="mt-1 sm:mt-0 sm:col-span-2">
<span class="text-sm text-gray-900 dark:text-white">
@php
$badgeSpec = $asEnabledDisabledBadgeSpec($row['value'] ?? null);
@endphp
@if($badgeSpec)
<x-filament::badge :color="$badgeSpec->color" :icon="$badgeSpec->icon" size="sm">
{{ $badgeSpec->label }}
</x-filament::badge>
@elseif(is_numeric($row['value']))
<span class="font-mono font-semibold">{{ $row['value'] }}</span>
@else
{{ $row['value'] ?? 'N/A' }}
@endif
</span>
</dd>
</div>
@endforeach
</div>
</x-filament::section>
@endif
{{-- Settings Blocks (for OMA Settings, Key/Value pairs, etc.) --}}
@foreach($settings as $block)
@php
$blockType = is_array($block) ? ($block['type'] ?? null) : null;
@endphp
@if($blockType === 'table')
<x-filament::section
:heading="$block['title'] ?? 'Settings'"
collapsible
>
<x-slot name="headerEnd">
<span class="text-sm text-gray-500 dark:text-gray-400">
{{ count($block['rows'] ?? []) }} {{ Str::plural('item', count($block['rows'] ?? [])) }}
</span>
</x-slot>
<div class="divide-y divide-gray-200 dark:divide-gray-700">
@foreach($block['rows'] ?? [] as $row)
<div class="py-3 sm:grid sm:grid-cols-3 sm:gap-4">
<dt class="text-sm font-medium text-gray-500 dark:text-gray-400 break-words">
{{ $row['label'] ?? $row['path'] ?? 'Setting' }}
@if (! empty($row['path']) && ($row['label'] ?? null) !== ($row['path'] ?? null))
<p class="mt-0.5 text-xs font-mono text-gray-400 dark:text-gray-500 break-all">
{{ (string) $row['path'] }}
</p>
@endif
@if(!empty($row['description']))
<p class="text-xs text-gray-400 mt-0.5">{{ Str::limit($row['description'], 80) }}</p>
@endif
</dt>
<dd class="mt-1 sm:mt-0 sm:col-span-2">
@php
$badgeSpec = $asEnabledDisabledBadgeSpec($row['value'] ?? null);
@endphp
@if($badgeSpec)
<x-filament::badge :color="$badgeSpec->color" :icon="$badgeSpec->icon" size="sm">
{{ $badgeSpec->label }}
</x-filament::badge>
@elseif(is_numeric($row['value']))
<span class="font-mono text-sm font-semibold text-gray-900 dark:text-white">
{{ $row['value'] }}
</span>
@elseif($shouldRenderBadges($row['value'] ?? null))
<div class="flex flex-wrap gap-1.5">
@foreach(($row['value'] ?? []) as $item)
@php
$itemSpec = $asEnabledDisabledBadgeSpec($item);
@endphp
@if($itemSpec)
<x-filament::badge :color="$itemSpec->color" :icon="$itemSpec->icon" size="sm">
{{ $itemSpec->label }}
</x-filament::badge>
@else
<x-filament::badge color="gray" size="sm">
{{ is_null($item) ? '—' : (string) $item }}
</x-filament::badge>
@endif
@endforeach
</div>
@else
<span class="text-sm text-gray-900 dark:text-white break-words">
{{ Str::limit($stringifyValue($row['value'] ?? null), 200) }}
</span>
@endif
</dd>
</div>
@endforeach
</div>
</x-filament::section>
@elseif($blockType === 'keyValue')
<x-filament::section
:heading="$block['title'] ?? 'Settings'"
collapsible
>
<x-slot name="headerEnd">
<span class="text-sm text-gray-500 dark:text-gray-400">
{{ count($block['entries'] ?? []) }} {{ Str::plural('entry', count($block['entries'] ?? [])) }}
</span>
</x-slot>
<div class="divide-y divide-gray-200 dark:divide-gray-700">
@foreach($block['entries'] ?? [] as $entry)
<div class="py-3 sm:grid sm:grid-cols-3 sm:gap-4">
<dt class="text-sm font-medium text-gray-500 dark:text-gray-400">
{{ $entry['key'] }}
</dt>
<dd class="mt-1 sm:mt-0 sm:col-span-2">
@php
$rawValue = $entry['value'] ?? null;
$isScriptContent = in_array($entry['key'] ?? null, ['scriptContent', 'detectionScriptContent', 'remediationScriptContent'], true)
&& (bool) config('tenantpilot.display.show_script_content', false);
$badgeSpec = $asEnabledDisabledBadgeSpec($rawValue);
@endphp
@if($isScriptContent)
@php
$code = is_string($rawValue) ? $rawValue : $stringifyValue($rawValue);
$firstLine = strtok($code, "\n") ?: '';
$grammar = 'powershell';
if ($policyType === 'deviceShellScript') {
$shebang = trim($firstLine);
if (str_starts_with($shebang, '#!')) {
if (str_contains($shebang, 'zsh')) {
$grammar = 'zsh';
} elseif (str_contains($shebang, 'bash')) {
$grammar = 'bash';
} else {
$grammar = 'sh';
}
} else {
$grammar = 'sh';
}
} elseif ($policyType === 'deviceManagementScript' || $policyType === 'deviceHealthScript') {
$grammar = 'powershell';
}
$highlightedHtml = null;
if (class_exists(\Torchlight\Engine\Engine::class)) {
try {
$highlightedHtml = (new \Torchlight\Engine\Engine())->codeToHtml(
code: $code,
grammar: $grammar,
theme: [
'light' => 'github-light',
'dark' => 'github-dark',
],
withGutter: false,
withWrapper: true,
);
} catch (\Throwable $e) {
$highlightedHtml = null;
}
}
@endphp
<div x-data="{ open: false }" class="space-y-2">
<div class="flex items-center gap-2">
<x-filament::button
size="xs"
color="gray"
type="button"
x-on:click="open = !open"
>
<span x-show="!open" x-cloak>Show</span>
<span x-show="open" x-cloak>Hide</span>
</x-filament::button>
<span class="text-xs text-gray-500 dark:text-gray-400">
{{ number_format(Str::length($code)) }} chars
</span>
</div>
<div x-show="open" x-cloak>
@if (is_string($highlightedHtml) && $highlightedHtml !== '')
@once
@include('filament.partials.torchlight-dark-overrides')
@endonce
<div class="overflow-x-auto">{!! $highlightedHtml !!}</div>
@else
<pre class="text-xs font-mono text-gray-900 dark:text-white whitespace-pre-wrap break-words">{{ $code }}</pre>
@endif
</div>
</div>
@elseif($shouldRenderBadges($rawValue))
<div class="flex flex-wrap gap-1.5">
@foreach(($rawValue ?? []) as $item)
@php
$itemSpec = $asEnabledDisabledBadgeSpec($item);
@endphp
@if($itemSpec)
<x-filament::badge :color="$itemSpec->color" :icon="$itemSpec->icon" size="sm">
{{ $itemSpec->label }}
</x-filament::badge>
@else
<x-filament::badge color="gray" size="sm">
{{ is_null($item) ? '—' : (string) $item }}
</x-filament::badge>
@endif
@endforeach
</div>
@elseif($badgeSpec)
<x-filament::badge :color="$badgeSpec->color" :icon="$badgeSpec->icon" size="sm">
{{ $badgeSpec->label }}
</x-filament::badge>
@else
<span class="text-sm text-gray-900 dark:text-white break-words">
{{ Str::limit($stringifyValue($rawValue), 200) }}
</span>
@endif
</dd>
</div>
@endforeach
</div>
</x-filament::section>
@endif
@endforeach
{{-- Empty state --}}
@if(empty($settings) && (!$settingsTable || empty($settingsTable['rows'])))
<div class="text-center py-12">
<svg class="mx-auto h-12 w-12 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"/>
</svg>
<p class="mt-4 text-sm text-gray-500 dark:text-gray-400">
No settings data available
</p>
<p class="mt-1 text-xs text-gray-400 dark:text-gray-500">
This policy may not contain settings, or they are in an unsupported format
</p>
</div>
@endif
</div>
{{-- NormalizedSettingsSurface policy-settings-standard compatibility wrapper --}}
@include('filament.infolists.entries.normalized-settings.wrapper', ['state' => $getState() ?? []])

View File

@ -1,59 +1,5 @@
<x-filament-panels::page>
<div class="space-y-6">
@if ($rows === [])
<div class="rounded-xl border border-dashed border-gray-300 bg-white p-8 text-center shadow-sm">
<h2 class="text-lg font-semibold text-gray-950">No evidence snapshots in this scope</h2>
<p class="mt-2 text-sm text-gray-600">Adjust filters or create a tenant snapshot to populate the workspace overview.</p>
<div class="mt-4">
<a href="{{ route('admin.evidence.overview') }}" class="inline-flex items-center rounded-lg bg-gray-900 px-4 py-2 text-sm font-medium text-white">
Clear filters
</a>
</div>
</div>
@else
<div class="overflow-hidden rounded-xl border border-gray-200 bg-white shadow-sm">
<table class="min-w-full divide-y divide-gray-200 text-sm">
<thead class="bg-gray-50 text-left text-gray-600">
<tr>
<th class="px-4 py-3 font-medium">Tenant</th>
<th class="px-4 py-3 font-medium">Artifact truth</th>
<th class="px-4 py-3 font-medium">Freshness</th>
<th class="px-4 py-3 font-medium">Generated</th>
<th class="px-4 py-3 font-medium">Not collected yet</th>
<th class="px-4 py-3 font-medium">Refresh recommended</th>
<th class="px-4 py-3 font-medium">Next step</th>
<th class="px-4 py-3 font-medium">Action</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-100 bg-white text-gray-900">
@foreach ($rows as $row)
<tr>
<td class="px-4 py-3">{{ $row['tenant_name'] }}</td>
<td class="px-4 py-3">
<x-filament::badge :color="data_get($row, 'artifact_truth.color', 'gray')" :icon="data_get($row, 'artifact_truth.icon')" size="sm">
{{ data_get($row, 'artifact_truth.label', 'Unknown') }}
</x-filament::badge>
@if (is_string(data_get($row, 'artifact_truth.explanation')) && trim((string) data_get($row, 'artifact_truth.explanation')) !== '')
<div class="mt-1 text-xs text-gray-500">{{ data_get($row, 'artifact_truth.explanation') }}</div>
@endif
</td>
<td class="px-4 py-3">
<x-filament::badge :color="data_get($row, 'freshness.color', 'gray')" :icon="data_get($row, 'freshness.icon')" size="sm">
{{ data_get($row, 'freshness.label', 'Unknown') }}
</x-filament::badge>
</td>
<td class="px-4 py-3">{{ $row['generated_at'] ?? '—' }}</td>
<td class="px-4 py-3">{{ $row['missing_dimensions'] }}</td>
<td class="px-4 py-3">{{ $row['stale_dimensions'] }}</td>
<td class="px-4 py-3">{{ $row['next_step'] ?? 'No action needed' }}</td>
<td class="px-4 py-3">
<a href="{{ $row['view_url'] }}" class="text-primary-600 hover:text-primary-500">View tenant evidence</a>
</td>
</tr>
@endforeach
</tbody>
</table>
</div>
@endif
{{ $this->table }}
</div>
</x-filament-panels::page>

View File

@ -6,7 +6,7 @@
$tenant = $this->currentTenant();
$vm = is_array($viewModel ?? null) ? $viewModel : [];
$vm = $this->viewModel();
$overview = is_array($vm['overview'] ?? null) ? $vm['overview'] : [];
$counts = is_array($overview['counts'] ?? null) ? $overview['counts'] : [];
$featureImpacts = is_array($overview['feature_impacts'] ?? null) ? $overview['feature_impacts'] : [];
@ -14,20 +14,6 @@
$filters = is_array($vm['filters'] ?? null) ? $vm['filters'] : [];
$selectedFeatures = is_array($filters['features'] ?? null) ? $filters['features'] : [];
$selectedStatus = (string) ($filters['status'] ?? 'missing');
$selectedType = (string) ($filters['type'] ?? 'all');
$searchTerm = (string) ($filters['search'] ?? '');
$featureOptions = collect($featureImpacts)
->filter(fn (mixed $impact): bool => is_array($impact) && is_string($impact['feature'] ?? null))
->map(fn (array $impact): string => (string) $impact['feature'])
->filter()
->unique()
->sort()
->values()
->all();
$permissions = is_array($vm['permissions'] ?? null) ? $vm['permissions'] : [];
$overall = $overview['overall'] ?? null;
$overallSpec = $overall !== null ? BadgeRenderer::spec(BadgeDomain::VerificationReportOverall, $overall) : null;
@ -226,10 +212,8 @@ class="text-primary-600 hover:underline dark:text-primary-400"
$selected = in_array($featureKey, $selectedFeatures, true);
@endphp
<button
type="button"
wire:click="applyFeatureFilter(@js($featureKey))"
class="rounded-xl border p-4 text-left transition hover:bg-gray-50 dark:hover:bg-gray-900/40 {{ $selected ? 'border-primary-300 bg-primary-50 dark:border-primary-700 dark:bg-primary-950/40' : 'border-gray-200 bg-white dark:border-gray-800 dark:bg-gray-900' }}"
<div
class="rounded-xl border p-4 text-left {{ $selected ? 'border-primary-300 bg-primary-50 dark:border-primary-700 dark:bg-primary-950/40' : 'border-gray-200 bg-white dark:border-gray-800 dark:bg-gray-900' }}"
>
<div class="flex items-start justify-between gap-3">
<div class="min-w-0">
@ -245,17 +229,9 @@ class="rounded-xl border p-4 text-left transition hover:bg-gray-50 dark:hover:bg
{{ $isBlocked ? 'Blocked' : ($missingCount > 0 ? 'At risk' : 'OK') }}
</x-filament::badge>
</div>
</button>
</div>
@endforeach
</div>
@if ($selectedFeatures !== [])
<div>
<x-filament::button color="gray" size="sm" wire:click="clearFeatureFilter">
Clear feature filter
</x-filament::button>
</div>
@endif
@endif
<div
@ -475,182 +451,14 @@ class="group rounded-xl border border-gray-200 bg-white p-4 dark:border-gray-800
</div>
@else
<div class="space-y-6">
<div class="rounded-xl border border-gray-200 bg-white p-4 dark:border-gray-800 dark:bg-gray-900">
<div class="flex flex-wrap items-start justify-between gap-3">
<div>
<div class="text-sm font-semibold text-gray-950 dark:text-white">Filters</div>
<div class="mt-1 text-xs text-gray-600 dark:text-gray-300">
Search doesnt affect copy actions. Feature filters do.
</div>
</div>
<x-filament::button color="gray" size="sm" wire:click="resetFilters">
Reset
</x-filament::button>
</div>
<div class="mt-4 grid gap-3 sm:grid-cols-4">
<div class="space-y-1">
<label class="text-xs font-medium text-gray-600 dark:text-gray-300">Status</label>
<select wire:model.live="status" class="fi-input fi-select w-full">
<option value="missing">Missing</option>
<option value="present">Present</option>
<option value="all">All</option>
</select>
</div>
<div class="space-y-1">
<label class="text-xs font-medium text-gray-600 dark:text-gray-300">Type</label>
<select wire:model.live="type" class="fi-input fi-select w-full">
<option value="all">All</option>
<option value="application">Application</option>
<option value="delegated">Delegated</option>
</select>
</div>
<div class="space-y-1 sm:col-span-2">
<label class="text-xs font-medium text-gray-600 dark:text-gray-300">Search</label>
<input
type="search"
wire:model.live.debounce.500ms="search"
class="fi-input w-full"
placeholder="Search permission key or description…"
/>
</div>
@if ($featureOptions !== [])
<div class="space-y-1 sm:col-span-4">
<label class="text-xs font-medium text-gray-600 dark:text-gray-300">Features</label>
<select wire:model.live="features" class="fi-input fi-select w-full" multiple>
@foreach ($featureOptions as $feature)
<option value="{{ $feature }}">{{ $feature }}</option>
@endforeach
</select>
</div>
@endif
<div class="rounded-xl border border-gray-200 bg-white p-4 text-sm text-gray-700 dark:border-gray-800 dark:bg-gray-900 dark:text-gray-200">
<div class="font-semibold text-gray-950 dark:text-white">Native permission matrix</div>
<div class="mt-1 text-xs text-gray-600 dark:text-gray-300">
Search doesnt affect copy actions. Feature filters do.
</div>
</div>
@if ($requiredTotal === 0)
<div class="rounded-xl border border-gray-200 bg-white p-4 text-sm text-gray-700 dark:border-gray-800 dark:bg-gray-900 dark:text-gray-200">
<div class="font-semibold text-gray-950 dark:text-white">No permissions configured</div>
<div class="mt-1">
No required permissions are currently configured in <code class="font-mono text-xs">config/intune_permissions.php</code>.
</div>
</div>
@elseif ($permissions === [])
<div class="rounded-xl border border-gray-200 bg-white p-4 text-sm text-gray-700 dark:border-gray-800 dark:bg-gray-900 dark:text-gray-200">
@if ($selectedStatus === 'missing' && $missingTotal === 0 && $selectedType === 'all' && $selectedFeatures === [] && trim($searchTerm) === '')
<div class="font-semibold text-gray-950 dark:text-white">All required permissions are present</div>
<div class="mt-1">
Switch Status to “All” if you want to review the full matrix.
</div>
@else
<div class="font-semibold text-gray-950 dark:text-white">No matches</div>
<div class="mt-1">
No permissions match the current filters.
</div>
@endif
</div>
@else
@php
$featuresToRender = $featureImpacts;
if ($selectedFeatures !== []) {
$featuresToRender = collect($featureImpacts)
->filter(fn ($impact) => is_array($impact) && in_array((string) ($impact['feature'] ?? ''), $selectedFeatures, true))
->values()
->all();
}
@endphp
@foreach ($featuresToRender as $impact)
@php
$featureKey = is_array($impact) ? ($impact['feature'] ?? null) : null;
$featureKey = is_string($featureKey) ? $featureKey : null;
if ($featureKey === null) {
continue;
}
$rows = collect($permissions)
->filter(fn ($row) => is_array($row) && in_array($featureKey, (array) ($row['features'] ?? []), true))
->values()
->all();
if ($rows === []) {
continue;
}
@endphp
<div class="space-y-3">
<div class="flex items-center justify-between gap-4">
<div class="text-sm font-semibold text-gray-950 dark:text-white">
{{ $featureKey }}
</div>
</div>
<div class="overflow-hidden rounded-xl border border-gray-200 dark:border-gray-800">
<table class="min-w-full divide-y divide-gray-200 dark:divide-gray-800">
<thead class="bg-gray-50 dark:bg-gray-900">
<tr>
<th class="px-4 py-2 text-left text-xs font-semibold uppercase tracking-wide text-gray-600 dark:text-gray-300">
Permission
</th>
<th class="px-4 py-2 text-left text-xs font-semibold uppercase tracking-wide text-gray-600 dark:text-gray-300">
Type
</th>
<th class="px-4 py-2 text-left text-xs font-semibold uppercase tracking-wide text-gray-600 dark:text-gray-300">
Status
</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-200 bg-white dark:divide-gray-800 dark:bg-gray-950">
@foreach ($rows as $row)
@php
$key = is_array($row) ? (string) ($row['key'] ?? '') : '';
$type = is_array($row) ? (string) ($row['type'] ?? '') : '';
$status = is_array($row) ? (string) ($row['status'] ?? '') : '';
$description = is_array($row) ? ($row['description'] ?? null) : null;
$description = is_string($description) ? $description : null;
$statusSpec = BadgeRenderer::spec(BadgeDomain::TenantPermissionStatus, $status);
@endphp
<tr
class="align-top"
data-permission-key="{{ $key }}"
data-permission-type="{{ $type }}"
data-permission-status="{{ $status }}"
>
<td class="px-4 py-3">
<div class="text-sm font-medium text-gray-950 dark:text-white">
{{ $key }}
</div>
@if ($description)
<div class="mt-1 text-xs text-gray-600 dark:text-gray-300">
{{ $description }}
</div>
@endif
</td>
<td class="px-4 py-3">
<x-filament::badge color="gray" size="sm">
{{ $type === 'delegated' ? 'Delegated' : 'Application' }}
</x-filament::badge>
</td>
<td class="px-4 py-3">
<x-filament::badge :color="$statusSpec->color" :icon="$statusSpec->icon" size="sm">
{{ $statusSpec->label }}
</x-filament::badge>
</td>
</tr>
@endforeach
</tbody>
</table>
</div>
</div>
@endforeach
@endif
{{ $this->table }}
</div>
@endif
</div>

View File

@ -94,8 +94,7 @@
@endif
@else
@include('filament.components.verification-report-viewer', [
'run' => $runData,
'report' => $report,
'surface' => $surface ?? [],
'redactionNotes' => $redactionNotes ?? [],
])

View File

@ -0,0 +1,3 @@
<div>
{{ $this->table }}
</div>

View File

@ -138,6 +138,13 @@
$opService,
);
$compareRun->refresh();
expect(data_get($compareRun->context, 'baseline_compare.strategy.key'))->toBe('intune_policy')
->and(data_get($compareRun->context, 'baseline_compare.strategy.selection_state'))->toBe('supported')
->and(data_get($compareRun->context, 'baseline_compare.strategy.matched_scope_entries.0.domain_key'))->toBe('intune')
->and(data_get($compareRun->context, 'baseline_compare.strategy.execution_diagnostics.rbac_role_definitions.total_compared'))->toBe(0);
$finding = Finding::query()
->where('tenant_id', (int) $tenant->getKey())
->where('subject_external_id', (string) $policy->external_id)

View File

@ -130,6 +130,9 @@
$run->refresh();
expect($run->status)->toBe('completed');
expect($run->outcome)->toBe('succeeded');
expect(data_get($run->context, 'baseline_compare.strategy.key'))->toBe('intune_policy')
->and(data_get($run->context, 'baseline_compare.strategy.selection_state'))->toBe('supported')
->and(data_get($run->context, 'baseline_compare.strategy.state_counts.drift'))->toBe(3);
$context = is_array($run->context) ? $run->context : [];
$countsByChangeType = $context['findings']['counts_by_change_type'] ?? null;

View File

@ -123,6 +123,8 @@
$run->refresh();
expect(data_get($run->context, 'baseline_compare.evidence_gaps.by_reason.policy_not_found'))->toBeNull()
->and(data_get($run->context, 'baseline_compare.strategy.key'))->toBe('intune_policy')
->and(data_get($run->context, 'baseline_compare.strategy.selection_state'))->toBe('supported')
->and(data_get($run->context, 'baseline_compare.evidence_gaps.by_reason.policy_record_missing'))->toBe(1)
->and(data_get($run->context, 'baseline_compare.evidence_gaps.by_reason.foundation_not_policy_backed'))->toBe(1);

View File

@ -85,7 +85,9 @@
);
$compareRun->refresh();
expect(data_get($compareRun->context, 'baseline_compare.subjects_total'))->toBe(0);
expect(data_get($compareRun->context, 'baseline_compare.subjects_total'))->toBe(0)
->and(data_get($compareRun->context, 'baseline_compare.strategy.key'))->toBe('intune_policy')
->and(data_get($compareRun->context, 'baseline_compare.strategy.selection_state'))->toBe('supported');
expect(data_get($compareRun->context, 'baseline_compare.reason_code'))->toBe(BaselineCompareReasonCode::NoSubjectsInScope->value);
});
@ -200,7 +202,10 @@
$compareRun->refresh();
expect(data_get($compareRun->context, 'baseline_compare.subjects_total'))->toBe(1);
expect(data_get($compareRun->context, 'baseline_compare.subjects_total'))->toBe(1)
->and(data_get($compareRun->context, 'baseline_compare.strategy.key'))->toBe('intune_policy')
->and(data_get($compareRun->context, 'baseline_compare.strategy.selection_state'))->toBe('supported')
->and(data_get($compareRun->context, 'baseline_compare.strategy.state_counts.no_drift'))->toBe(1);
expect(data_get($compareRun->context, 'result.findings_total'))->toBe(0);
expect(data_get($compareRun->context, 'baseline_compare.reason_code'))->toBe(BaselineCompareReasonCode::NoDriftDetected->value);
});

View File

@ -10,6 +10,8 @@
use App\Services\Baselines\BaselineCaptureService;
use App\Services\Baselines\BaselineCompareService;
use App\Support\Baselines\BaselineCaptureMode;
use App\Support\Baselines\BaselineReasonCodes;
use App\Support\Baselines\BaselineSupportCapabilityGuard;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Bus;
@ -85,7 +87,7 @@ function appendBrokenFoundationSupportConfig(): void
Bus::assertDispatched(CompareBaselineToTenantJob::class);
});
it('persists the same truthful scope capability decisions before dispatching capture work', function (): void {
it('blocks capture work when the scope still contains unsupported types, while preserving truthful capability context', function (): void {
Bus::fake();
appendBrokenFoundationSupportConfig();
@ -102,10 +104,13 @@ function appendBrokenFoundationSupportConfig(): void
$result = app(BaselineCaptureService::class)->startCapture($profile, $tenant, $user);
expect($result['ok'])->toBeTrue();
$scope = $profile->normalizedScope()->toEffectiveScopeContext(
app(BaselineSupportCapabilityGuard::class),
'capture',
);
$run = $result['run'];
$scope = data_get($run->context, 'effective_scope');
expect($result['ok'])->toBeFalse()
->and($result['reason_code'] ?? null)->toBe(BaselineReasonCodes::CAPTURE_UNSUPPORTED_SCOPE);
expect(data_get($scope, 'truthful_types'))->toBe(['deviceConfiguration', 'roleScopeTag'])
->and(data_get($scope, 'limited_types'))->toBe(['roleScopeTag'])
@ -117,5 +122,5 @@ function appendBrokenFoundationSupportConfig(): void
->and(data_get($scope, 'capabilities.brokenFoundation.support_mode'))->toBe('invalid_support_config')
->and(data_get($scope, 'capabilities.unknownFoundation.support_mode'))->toBeNull();
Bus::assertDispatched(CaptureBaselineSnapshotJob::class);
Bus::assertNotDispatched(CaptureBaselineSnapshotJob::class);
});

View File

@ -362,18 +362,11 @@ public function compare(
}
}
final class FakeGovernanceSubjectTaxonomyRegistry
final class FakeGovernanceSubjectTaxonomyRegistry extends GovernanceSubjectTaxonomyRegistry
{
private readonly GovernanceSubjectTaxonomyRegistry $inner;
public function __construct()
{
$this->inner = new GovernanceSubjectTaxonomyRegistry;
}
public function all(): array
{
return array_values(array_merge($this->inner->all(), [
return array_values(array_merge(parent::all(), [
new GovernanceSubjectType(
domainKey: GovernanceDomainKey::Entra,
subjectClass: GovernanceSubjectClass::Control,
@ -389,66 +382,4 @@ public function all(): array
),
]));
}
public function active(): array
{
return array_values(array_filter(
$this->all(),
static fn (GovernanceSubjectType $subjectType): bool => $subjectType->active,
));
}
public function activeLegacyBucketKeys(string $legacyBucket): array
{
$subjectTypes = array_filter(
$this->active(),
static fn (GovernanceSubjectType $subjectType): bool => $subjectType->legacyBucket === $legacyBucket,
);
$keys = array_map(
static fn (GovernanceSubjectType $subjectType): string => $subjectType->subjectTypeKey,
$subjectTypes,
);
sort($keys, SORT_STRING);
return array_values(array_unique($keys));
}
public function find(string $domainKey, string $subjectTypeKey): ?GovernanceSubjectType
{
foreach ($this->all() as $subjectType) {
if ($subjectType->domainKey->value !== trim($domainKey)) {
continue;
}
if ($subjectType->subjectTypeKey !== trim($subjectTypeKey)) {
continue;
}
return $subjectType;
}
return null;
}
public function isKnownDomain(string $domainKey): bool
{
return $this->inner->isKnownDomain($domainKey);
}
public function allowsSubjectClass(string $domainKey, string $subjectClass): bool
{
return $this->inner->allowsSubjectClass($domainKey, $subjectClass);
}
public function supportsFilters(string $domainKey, string $subjectClass): bool
{
return $this->inner->supportsFilters($domainKey, $subjectClass);
}
public function groupLabel(string $domainKey, string $subjectClass): string
{
return $this->inner->groupLabel($domainKey, $subjectClass);
}
}

View File

@ -86,7 +86,7 @@
'display_name' => 'My Policy 123',
]);
$this->actingAs($user)
$response = $this->actingAs($user)
->get(FindingResource::getUrl('view', ['record' => $finding], tenant: $tenant))
->assertOk()
->assertSee('Normalized diff')
@ -96,4 +96,10 @@
->assertSee('To')
->assertSee('Old value')
->assertSee('New value');
expect($response->getContent())
->toContain('data-shared-detail-family="normalized-diff"')
->toContain('data-shared-normalized-diff-host="finding"')
->toContain('data-shared-zone="summary"')
->toContain('data-shared-zone="groups"');
});

View File

@ -37,7 +37,7 @@
],
]);
$this->actingAs($user)
$response = $this->actingAs($user)
->get(route('filament.tenant.resources.findings.view', array_merge(
filamentTenantRouteParams($tenant),
['record' => $finding],
@ -45,6 +45,11 @@
->assertOk()
->assertSee('Diff unavailable')
->assertDontSee('No normalized changes were found');
expect($response->getContent())
->toContain('data-shared-detail-family="normalized-diff"')
->toContain('data-shared-normalized-diff-host="finding"')
->toContain('data-shared-normalized-diff-state="unavailable"');
});
it('renders a diff against an empty baseline for unexpected_policy findings with a current policy version reference', function (): void {
@ -101,7 +106,7 @@
],
]);
$this->actingAs($user)
$response = $this->actingAs($user)
->get(route('filament.tenant.resources.findings.view', array_merge(
filamentTenantRouteParams($tenant),
['record' => $finding],
@ -110,6 +115,11 @@
->assertDontSee('Diff unavailable')
->assertSee('1 added')
->assertSee('Password required');
expect($response->getContent())
->toContain('data-shared-detail-family="normalized-diff"')
->toContain('data-shared-normalized-diff-host="finding"')
->toContain('data-shared-normalized-diff-state="available"');
});
it('renders a diff against an empty current side for missing_policy findings with a baseline policy version reference', function (): void {
@ -166,7 +176,7 @@
],
]);
$this->actingAs($user)
$response = $this->actingAs($user)
->get(route('filament.tenant.resources.findings.view', array_merge(
filamentTenantRouteParams($tenant),
['record' => $finding],
@ -175,4 +185,9 @@
->assertDontSee('Diff unavailable')
->assertSee('1 removed')
->assertSee('Password required');
expect($response->getContent())
->toContain('data-shared-detail-family="normalized-diff"')
->toContain('data-shared-normalized-diff-host="finding"')
->toContain('data-shared-normalized-diff-state="available"');
});

View File

@ -2,6 +2,7 @@
declare(strict_types=1);
use App\Filament\Pages\Monitoring\EvidenceOverview;
use App\Filament\Resources\EvidenceSnapshotResource;
use App\Models\EvidenceSnapshot;
use App\Models\Tenant;
@ -10,6 +11,7 @@
use App\Support\Workspaces\WorkspaceContext;
use Filament\Facades\Filament;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Livewire\Livewire;
use Tests\Feature\Concerns\BuildsGovernanceArtifactTruthFixtures;
uses(RefreshDatabase::class, BuildsGovernanceArtifactTruthFixtures::class);
@ -122,3 +124,56 @@
->assertSee(EvidenceSnapshotResource::getUrl('view', ['record' => $staleSnapshot], tenant: $staleTenant), false)
->assertSee(EvidenceSnapshotResource::getUrl('view', ['record' => $freshSnapshot], tenant: $freshTenant), false);
});
it('seeds the native entitled-tenant prefilter once and clears it through the page action', function (): void {
$tenantA = Tenant::factory()->create();
[$user, $tenantA] = createUserWithTenant(tenant: $tenantA, role: 'owner');
$tenantB = Tenant::factory()->create([
'workspace_id' => (int) $tenantA->workspace_id,
]);
createUserWithTenant(tenant: $tenantB, user: $user, role: 'owner');
$snapshotA = EvidenceSnapshot::query()->create([
'tenant_id' => (int) $tenantA->getKey(),
'workspace_id' => (int) $tenantA->workspace_id,
'status' => EvidenceSnapshotStatus::Active->value,
'completeness_state' => EvidenceCompletenessState::Complete->value,
'summary' => ['missing_dimensions' => 0, 'stale_dimensions' => 0],
'generated_at' => now(),
]);
$snapshotB = EvidenceSnapshot::query()->create([
'tenant_id' => (int) $tenantB->getKey(),
'workspace_id' => (int) $tenantB->workspace_id,
'status' => EvidenceSnapshotStatus::Active->value,
'completeness_state' => EvidenceCompletenessState::Partial->value,
'summary' => ['missing_dimensions' => 1, 'stale_dimensions' => 0],
'generated_at' => now(),
]);
$this->actingAs($user);
setAdminPanelContext();
Filament::setTenant(null, true);
session()->put(WorkspaceContext::SESSION_KEY, (int) $tenantA->workspace_id);
$component = Livewire::withQueryParams([
'tenant_id' => (string) $tenantB->getKey(),
'search' => $tenantB->name,
])->test(EvidenceOverview::class);
$component
->assertSet('tableFilters.tenant_id.value', (string) $tenantB->getKey())
->assertSet('tableSearch', $tenantB->name)
->assertCanSeeTableRecords([(string) $snapshotB->getKey()])
->assertCanNotSeeTableRecords([(string) $snapshotA->getKey()]);
$component
->callAction('clear_filters')
->assertSet('tableFilters.tenant_id.value', null)
->assertSet('tableSearch', '')
->assertCanSeeTableRecords([
(string) $snapshotA->getKey(),
(string) $snapshotB->getKey(),
]);
});

View File

@ -40,6 +40,8 @@
Livewire::actingAs($user)
->test(EvidenceOverview::class)
->assertCountTableRecords(1)
->assertCanSeeTableRecords([(string) $snapshot->getKey()])
->assertSee($tenant->name)
->assertSee('Artifact truth');

View File

@ -1,6 +1,14 @@
<?php
use App\Filament\Resources\PolicyVersionResource;
use App\Models\Policy;
use App\Models\PolicyVersion;
use App\Models\Tenant;
use App\Services\Intune\PolicyNormalizer;
use Carbon\CarbonImmutable;
use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
test('group policy configuration normalized diff keys use definition display names', function () {
$flat = app(PolicyNormalizer::class)->flattenForDiff(
@ -27,3 +35,77 @@
expect($keys)->toContain('Administrative Template settings > Windows Components\\Security Options > Block legacy auth (def-1)');
expect(implode("\n", $keys))->not->toContain('graph.microsoft.com');
});
test('group policy configuration policy-version detail renders the shared normalized diff family', function () {
$tenant = Tenant::factory()->create([
'status' => 'active',
]);
[$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'owner');
$policy = Policy::factory()->create([
'tenant_id' => (int) $tenant->getKey(),
'external_id' => 'gpo-policy-1',
'policy_type' => 'groupPolicyConfiguration',
'display_name' => 'Admin Templates Alpha',
'platform' => 'windows',
]);
PolicyVersion::factory()->create([
'tenant_id' => (int) $tenant->getKey(),
'policy_id' => (int) $policy->getKey(),
'version_number' => 1,
'policy_type' => $policy->policy_type,
'platform' => $policy->platform,
'created_by' => 'tester@example.com',
'captured_at' => CarbonImmutable::now()->subMinute(),
'snapshot' => [
'id' => 'gpo-1',
'displayName' => 'Admin Templates Alpha',
'@odata.type' => '#microsoft.graph.groupPolicyConfiguration',
'definitionValues' => [
[
'enabled' => false,
'definition@odata.bind' => 'https://graph.microsoft.com/beta/deviceManagement/groupPolicyDefinitions(\'def-1\')',
'#Definition_Id' => 'def-1',
'#Definition_displayName' => 'Block legacy auth',
'#Definition_categoryPath' => 'Windows Components\\Security Options',
],
],
],
]);
$version = PolicyVersion::factory()->create([
'tenant_id' => (int) $tenant->getKey(),
'policy_id' => (int) $policy->getKey(),
'version_number' => 2,
'policy_type' => $policy->policy_type,
'platform' => $policy->platform,
'created_by' => 'tester@example.com',
'captured_at' => CarbonImmutable::now(),
'snapshot' => [
'id' => 'gpo-1',
'displayName' => 'Admin Templates Alpha',
'@odata.type' => '#microsoft.graph.groupPolicyConfiguration',
'definitionValues' => [
[
'enabled' => true,
'definition@odata.bind' => 'https://graph.microsoft.com/beta/deviceManagement/groupPolicyDefinitions(\'def-1\')',
'#Definition_Id' => 'def-1',
'#Definition_displayName' => 'Block legacy auth',
'#Definition_categoryPath' => 'Windows Components\\Security Options',
],
],
],
]);
$response = $this->actingAs($user)
->get(PolicyVersionResource::getUrl('view', ['record' => $version], tenant: $tenant));
$response->assertSuccessful()->assertSee('Block legacy auth');
expect($response->getContent())
->toContain('data-shared-detail-family="normalized-diff"')
->toContain('data-shared-normalized-diff-host="policy_version"')
->toContain('data-shared-zone="groups"');
});

View File

@ -0,0 +1,131 @@
<?php
declare(strict_types=1);
use App\Livewire\InventoryItemDependencyEdgesTable;
use App\Models\InventoryItem;
use App\Models\InventoryLink;
use App\Models\Tenant;
use App\Models\User;
use Filament\Facades\Filament;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Str;
use Livewire\Livewire;
uses(RefreshDatabase::class);
function dependencyEdgesTableComponent(User $user, Tenant $tenant, InventoryItem $item)
{
$tenant->makeCurrent();
Filament::setTenant($tenant, true);
test()->actingAs($user);
return Livewire::actingAs($user)->test(InventoryItemDependencyEdgesTable::class, [
'inventoryItemId' => (int) $item->getKey(),
]);
}
it('renders dependency rows through native table filters and preserves missing-target hints', function (): void {
[$user, $tenant] = createUserWithTenant(role: 'owner');
$item = InventoryItem::factory()->create([
'tenant_id' => (int) $tenant->getKey(),
'external_id' => (string) Str::uuid(),
]);
$assigned = InventoryLink::factory()->create([
'tenant_id' => (int) $tenant->getKey(),
'source_type' => 'inventory_item',
'source_id' => $item->external_id,
'target_type' => 'missing',
'target_id' => null,
'relationship_type' => 'assigned_to',
'metadata' => [
'last_known_name' => 'Assigned Target',
'raw_ref' => ['example' => 'assigned'],
],
]);
$scoped = InventoryLink::factory()->create([
'tenant_id' => (int) $tenant->getKey(),
'source_type' => 'inventory_item',
'source_id' => $item->external_id,
'target_type' => 'missing',
'target_id' => null,
'relationship_type' => 'scoped_by',
'metadata' => [
'last_known_name' => 'Scoped Target',
'raw_ref' => ['example' => 'scoped'],
],
]);
$inbound = InventoryLink::factory()->create([
'tenant_id' => (int) $tenant->getKey(),
'source_type' => 'inventory_item',
'source_id' => (string) Str::uuid(),
'target_type' => 'inventory_item',
'target_id' => $item->external_id,
'relationship_type' => 'depends_on',
]);
$component = dependencyEdgesTableComponent($user, $tenant, $item)
->assertTableFilterExists('direction')
->assertTableFilterExists('relationship_type')
->assertCanSeeTableRecords([
(string) $assigned->getKey(),
(string) $scoped->getKey(),
(string) $inbound->getKey(),
])
->assertSee('Assigned Target')
->assertSee('Scoped Target')
->assertSee('Missing');
$component
->filterTable('direction', 'outbound')
->assertCanSeeTableRecords([
(string) $assigned->getKey(),
(string) $scoped->getKey(),
])
->assertCanNotSeeTableRecords([(string) $inbound->getKey()])
->removeTableFilters()
->filterTable('direction', 'inbound')
->assertCanSeeTableRecords([(string) $inbound->getKey()])
->assertCanNotSeeTableRecords([
(string) $assigned->getKey(),
(string) $scoped->getKey(),
])
->removeTableFilters()
->filterTable('relationship_type', 'scoped_by')
->assertCanSeeTableRecords([(string) $scoped->getKey()])
->assertCanNotSeeTableRecords([
(string) $assigned->getKey(),
(string) $inbound->getKey(),
])
->removeTableFilters()
->filterTable('direction', 'outbound')
->filterTable('relationship_type', 'depends_on')
->assertCountTableRecords(0)
->assertSee('No dependencies found');
});
it('returns deny-as-not-found when mounted for an item outside the current tenant scope', function (): void {
[$user, $tenant] = createUserWithTenant(role: 'owner');
$foreignItem = InventoryItem::factory()->create([
'tenant_id' => (int) Tenant::factory()->create()->getKey(),
'external_id' => (string) Str::uuid(),
]);
$tenant->makeCurrent();
Filament::setTenant($tenant, true);
$this->actingAs($user);
$component = Livewire::actingAs($user)->test(InventoryItemDependencyEdgesTable::class, [
'inventoryItemId' => (int) $foreignItem->getKey(),
]);
$component->assertSee('Not Found');
expect($component->instance())->toBeNull();
});

View File

@ -0,0 +1,172 @@
<?php
declare(strict_types=1);
use App\Filament\Resources\FindingResource;
use App\Filament\Resources\PolicyResource;
use App\Filament\Resources\PolicyVersionResource;
use App\Models\Finding;
use App\Models\InventoryItem;
use App\Models\Policy;
use App\Models\PolicyVersion;
use Carbon\CarbonImmutable;
use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
it('renders shared normalized settings and diff families on policy and policy-version detail hosts', function (): void {
$tenant = \App\Models\Tenant::factory()->create([
'name' => 'Tenant One',
'status' => 'active',
]);
[$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'owner');
$policy = Policy::factory()->create([
'tenant_id' => $tenant->getKey(),
'external_id' => 'policy-1',
'policy_type' => 'deviceConfiguration',
'display_name' => 'Policy A',
'platform' => 'windows',
]);
PolicyVersion::factory()->create([
'tenant_id' => $tenant->getKey(),
'policy_id' => $policy->getKey(),
'version_number' => 1,
'policy_type' => $policy->policy_type,
'platform' => $policy->platform,
'created_by' => 'tester@example.com',
'captured_at' => CarbonImmutable::now()->subMinute(),
'snapshot' => [
'displayName' => 'Policy A',
'settings' => [
['displayName' => 'Enable feature', 'value' => ['value' => 'off']],
],
],
]);
$version = PolicyVersion::factory()->create([
'tenant_id' => $tenant->getKey(),
'policy_id' => $policy->getKey(),
'version_number' => 2,
'policy_type' => $policy->policy_type,
'platform' => $policy->platform,
'created_by' => 'tester@example.com',
'captured_at' => CarbonImmutable::now(),
'snapshot' => [
'displayName' => 'Policy A',
'settings' => [
['displayName' => 'Enable feature', 'value' => ['value' => 'on']],
],
],
]);
$policyResponse = $this->actingAs($user)
->get(PolicyResource::getUrl('view', ['record' => $policy], tenant: $tenant));
$policyResponse->assertSuccessful()->assertSee('Enable feature');
expect($policyResponse->getContent())
->toContain('data-shared-detail-family="normalized-settings"')
->toContain('data-shared-normalized-settings-host="policy"');
$versionResponse = $this->actingAs($user)
->get(PolicyVersionResource::getUrl('view', ['record' => $version], tenant: $tenant));
$versionResponse->assertSuccessful()->assertSee('Normalized diff');
expect($versionResponse->getContent())
->toContain('data-shared-detail-family="normalized-settings"')
->toContain('data-shared-normalized-settings-host="policy_version"')
->toContain('data-shared-detail-family="normalized-diff"')
->toContain('data-shared-normalized-diff-host="policy_version"');
});
it('renders the shared normalized diff family on finding detail hosts', function (): void {
bindFailHardGraphClient();
[$user, $tenant] = createUserWithTenant(role: 'manager');
$baseline = createInventorySyncOperationRun($tenant, [
'selection_hash' => hash('sha256', 'shared-detail-contract'),
'status' => 'success',
'finished_at' => now()->subDays(2),
]);
$current = createInventorySyncOperationRun($tenant, [
'selection_hash' => $baseline->selection_hash,
'status' => 'success',
'finished_at' => now()->subDay(),
]);
$policy = Policy::factory()->for($tenant)->create([
'external_id' => 'policy-123',
'policy_type' => 'deviceConfiguration',
'platform' => 'windows10',
]);
$baselineVersion = PolicyVersion::factory()->for($tenant)->create([
'policy_id' => $policy->getKey(),
'version_number' => 1,
'policy_type' => $policy->policy_type,
'platform' => $policy->platform,
'captured_at' => $baseline->finished_at->copy()->subHour(),
'snapshot' => [
'displayName' => 'My Policy',
'customSettingFoo' => 'Old value',
],
]);
$currentVersion = PolicyVersion::factory()->for($tenant)->create([
'policy_id' => $policy->getKey(),
'version_number' => 2,
'policy_type' => $policy->policy_type,
'platform' => $policy->platform,
'captured_at' => $current->finished_at->copy()->subHour(),
'snapshot' => [
'displayName' => 'My Policy',
'customSettingFoo' => 'New value',
],
]);
$finding = Finding::factory()->for($tenant)->create([
'finding_type' => Finding::FINDING_TYPE_DRIFT,
'scope_key' => (string) $current->selection_hash,
'baseline_operation_run_id' => $baseline->getKey(),
'current_operation_run_id' => $current->getKey(),
'subject_type' => 'policy',
'subject_external_id' => $policy->external_id,
'evidence_jsonb' => [
'change_type' => 'modified',
'summary' => [
'kind' => 'policy_snapshot',
'changed_fields' => ['snapshot_hash'],
],
'baseline' => [
'policy_id' => $policy->external_id,
'policy_version_id' => $baselineVersion->getKey(),
'snapshot_hash' => 'baseline-hash',
],
'current' => [
'policy_id' => $policy->external_id,
'policy_version_id' => $currentVersion->getKey(),
'snapshot_hash' => 'current-hash',
],
],
]);
InventoryItem::factory()->for($tenant)->create([
'external_id' => $finding->subject_external_id,
'display_name' => 'My Policy 123',
]);
$response = $this->actingAs($user)
->get(FindingResource::getUrl('view', ['record' => $finding], tenant: $tenant));
$response->assertSuccessful()->assertSee('Normalized diff');
expect($response->getContent())
->toContain('data-shared-detail-family="normalized-diff"')
->toContain('data-shared-normalized-diff-host="finding"');
});

View File

@ -4,8 +4,10 @@
use App\Filament\Resources\PolicyResource\Pages\ListPolicies;
use App\Models\Policy;
use App\Models\PolicyVersion;
use App\Models\Tenant;
use App\Support\Workspaces\WorkspaceContext;
use Carbon\CarbonImmutable;
use Filament\Facades\Filament;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Livewire\Livewire;
@ -35,3 +37,68 @@
->assertCanSeeTableRecords([$policyA])
->assertCanNotSeeTableRecords([$policyB]);
});
it('renders remembered canonical tenant policy detail with shared normalized settings markers', function (): void {
$tenantA = Tenant::factory()->create();
[$user, $tenantA] = createUserWithTenant(tenant: $tenantA, role: 'owner');
$tenantB = Tenant::factory()->create(['workspace_id' => (int) $tenantA->workspace_id]);
createUserWithTenant(tenant: $tenantB, user: $user, role: 'owner');
$policyA = Policy::factory()->for($tenantA)->create(['display_name' => 'Remembered tenant policy']);
$policyB = Policy::factory()->for($tenantB)->create(['display_name' => 'Other tenant policy']);
PolicyVersion::factory()->for($tenantA)->for($policyA)->create([
'version_number' => 1,
'policy_type' => $policyA->policy_type,
'platform' => $policyA->platform,
'created_by' => 'tester@example.com',
'captured_at' => CarbonImmutable::now(),
'snapshot' => [
'@odata.type' => '#microsoft.graph.windows10CustomConfiguration',
'omaSettings' => [
[
'displayName' => 'Setting A',
'omaUri' => './Vendor/MSFT/SettingA',
'value' => 'Enabled',
],
],
],
]);
PolicyVersion::factory()->for($tenantB)->for($policyB)->create([
'version_number' => 1,
'policy_type' => $policyB->policy_type,
'platform' => $policyB->platform,
'created_by' => 'tester@example.com',
'captured_at' => CarbonImmutable::now(),
'snapshot' => [
'@odata.type' => '#microsoft.graph.windows10CustomConfiguration',
'omaSettings' => [],
],
]);
$this->actingAs($user);
Filament::setCurrentPanel('admin');
Filament::setTenant(null, true);
Filament::bootCurrentPanel();
$session = [
WorkspaceContext::SESSION_KEY => (int) $tenantA->workspace_id,
WorkspaceContext::LAST_TENANT_IDS_SESSION_KEY => [
(string) $tenantA->workspace_id => (int) $tenantA->getKey(),
],
];
$response = $this->withSession($session)
->get(\App\Filament\Resources\PolicyResource::getUrl('view', ['record' => $policyA], panel: 'admin'));
$response->assertSuccessful()->assertSee('Setting A');
expect($response->getContent())
->toContain('data-shared-detail-family="normalized-settings"')
->toContain('data-shared-normalized-settings-host="policy"');
$this->withSession($session)
->get(\App\Filament\Resources\PolicyResource::getUrl('view', ['record' => $policyB], panel: 'admin'))
->assertNotFound();
});

View File

@ -8,6 +8,7 @@
use App\Models\PolicyVersion;
use App\Models\Tenant;
use App\Support\Workspaces\WorkspaceContext;
use Carbon\CarbonImmutable;
use Filament\Facades\Filament;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Livewire\Livewire;
@ -73,3 +74,78 @@
->get(PolicyVersionResource::getUrl('view', ['record' => $versionB], panel: 'admin'))
->assertNotFound();
});
it('renders remembered canonical tenant policy-version detail with shared normalized detail markers', function (): void {
$tenantA = Tenant::factory()->create();
[$user, $tenantA] = createUserWithTenant(tenant: $tenantA, role: 'owner');
$tenantB = Tenant::factory()->create(['workspace_id' => (int) $tenantA->workspace_id]);
createUserWithTenant(tenant: $tenantB, user: $user, role: 'owner');
$policyA = Policy::factory()->for($tenantA)->create(['display_name' => 'Remembered policy']);
$policyB = Policy::factory()->for($tenantB)->create(['display_name' => 'Other policy']);
PolicyVersion::factory()->for($tenantA)->for($policyA)->create([
'version_number' => 1,
'policy_type' => $policyA->policy_type,
'platform' => $policyA->platform,
'created_by' => 'tester@example.com',
'captured_at' => CarbonImmutable::now()->subMinute(),
'snapshot' => [
'displayName' => 'Remembered policy',
'settings' => [
['displayName' => 'Enable feature', 'value' => ['value' => 'off']],
],
],
]);
$versionA = PolicyVersion::factory()->for($tenantA)->for($policyA)->create([
'version_number' => 2,
'policy_type' => $policyA->policy_type,
'platform' => $policyA->platform,
'created_by' => 'tester@example.com',
'captured_at' => CarbonImmutable::now(),
'snapshot' => [
'displayName' => 'Remembered policy',
'settings' => [
['displayName' => 'Enable feature', 'value' => ['value' => 'on']],
],
],
]);
PolicyVersion::factory()->for($tenantB)->for($policyB)->create([
'version_number' => 1,
'policy_type' => $policyB->policy_type,
'platform' => $policyB->platform,
'created_by' => 'tester@example.com',
'captured_at' => CarbonImmutable::now(),
'snapshot' => [
'displayName' => 'Other policy',
'settings' => [
['displayName' => 'Enable feature', 'value' => ['value' => 'off']],
],
],
]);
$this->actingAs($user);
Filament::setCurrentPanel('admin');
Filament::setTenant(null, true);
Filament::bootCurrentPanel();
$session = [
WorkspaceContext::SESSION_KEY => (int) $tenantA->workspace_id,
WorkspaceContext::LAST_TENANT_IDS_SESSION_KEY => [
(string) $tenantA->workspace_id => (int) $tenantA->getKey(),
],
];
$response = $this->withSession($session)
->get(PolicyVersionResource::getUrl('view', ['record' => $versionA], panel: 'admin'));
$response->assertSuccessful()->assertSee('Enable feature');
expect($response->getContent())
->toContain('data-shared-detail-family="normalized-settings"')
->toContain('data-shared-normalized-settings-host="policy_version"')
->toContain('data-shared-detail-family="normalized-diff"')
->toContain('data-shared-normalized-diff-host="policy_version"');
});

View File

@ -50,6 +50,12 @@
$response->assertSee('Normalized settings');
$response->assertSee('Enable feature');
$response->assertSee('Normalized diff');
expect($response->getContent())
->toContain('data-shared-detail-family="normalized-settings"')
->toContain('data-shared-normalized-settings-host="policy_version"')
->toContain('data-shared-detail-family="normalized-diff"')
->toContain('data-shared-normalized-diff-host="policy_version"');
});
test('policy version detail shows enrollment notification template settings', function () {
@ -139,4 +145,8 @@
$response->assertSee('Push Subject');
$response->assertSee('Push (en-us) Message');
$response->assertSee('Push Body');
expect($response->getContent())
->toContain('data-shared-detail-family="normalized-settings"')
->toContain('data-shared-normalized-settings-host="policy_version"');
});

View File

@ -1,8 +1,13 @@
<?php
use App\Filament\Resources\PolicyVersionResource;
use App\Models\Policy;
use App\Models\PolicyVersion;
use App\Models\SettingsCatalogCategory;
use App\Models\SettingsCatalogDefinition;
use App\Models\Tenant;
use App\Services\Intune\PolicyNormalizer;
use Carbon\CarbonImmutable;
use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
@ -53,3 +58,69 @@
expect($keys)->toContain('Settings > Account Management > Deletion Policy');
expect(implode("\n", $keys))->not->toContain('device_vendor_msft_accountmanagement_userprofilemanagement_deletionpolicy');
});
test('settings catalog policy version detail renders the shared normalized settings family', function () {
SettingsCatalogCategory::create([
'category_id' => 'cat-1',
'display_name' => 'Account Management',
'description' => null,
]);
SettingsCatalogDefinition::create([
'definition_id' => 'device_vendor_msft_accountmanagement_userprofilemanagement_deletionpolicy',
'display_name' => 'Deletion Policy',
'description' => null,
'help_text' => null,
'category_id' => 'cat-1',
'ux_behavior' => null,
'raw' => [],
]);
$tenant = Tenant::factory()->create([
'status' => 'active',
]);
[$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'owner');
$policy = Policy::factory()->create([
'tenant_id' => (int) $tenant->getKey(),
'external_id' => 'settings-catalog-policy-1',
'policy_type' => 'settingsCatalogPolicy',
'display_name' => 'Settings Catalog Policy',
'platform' => 'windows',
]);
$version = PolicyVersion::factory()->create([
'tenant_id' => (int) $tenant->getKey(),
'policy_id' => (int) $policy->getKey(),
'version_number' => 1,
'policy_type' => $policy->policy_type,
'platform' => $policy->platform,
'created_by' => 'tester@example.com',
'captured_at' => CarbonImmutable::now(),
'snapshot' => [
'@odata.type' => '#microsoft.graph.deviceManagementConfigurationPolicy',
'settings' => [
[
'id' => 's1',
'settingInstance' => [
'@odata.type' => '#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance',
'settingDefinitionId' => 'device_vendor_msft_accountmanagement_userprofilemanagement_deletionpolicy',
'choiceSettingValue' => [
'value' => 'enabled',
],
],
],
],
],
]);
$response = $this->actingAs($user)
->get(PolicyVersionResource::getUrl('view', ['record' => $version], tenant: $tenant));
$response->assertSuccessful()->assertSee('Deletion Policy');
expect($response->getContent())
->toContain('data-shared-detail-family="normalized-settings"')
->toContain('data-shared-normalized-settings-host="policy_version"');
});

View File

@ -0,0 +1,188 @@
<?php
declare(strict_types=1);
use App\Filament\Widgets\Tenant\TenantVerificationReport;
use App\Models\OperationRun;
use App\Models\ProviderConnection;
use App\Models\Tenant;
use App\Models\TenantOnboardingSession;
use App\Models\User;
use App\Models\Workspace;
use App\Models\WorkspaceMembership;
use App\Support\Verification\VerificationReportWriter;
use App\Support\Workspaces\WorkspaceContext;
use Filament\Facades\Filament;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Livewire\Livewire;
uses(RefreshDatabase::class);
it('renders the shared verification family on central operation detail', function (): void {
[$user, $tenant] = createUserWithTenant(role: 'owner');
$report = VerificationReportWriter::build('provider.connection.check', [
[
'key' => 'provider_connection',
'title' => 'Provider connection preflight',
'status' => 'fail',
'severity' => 'critical',
'blocking' => true,
'reason_code' => 'provider_connection_missing',
'message' => 'No provider connection configured.',
'evidence' => [],
'next_steps' => [],
],
]);
$run = OperationRun::factory()->create([
'tenant_id' => (int) $tenant->getKey(),
'workspace_id' => (int) $tenant->workspace_id,
'user_id' => (int) $user->getKey(),
'type' => 'provider.connection.check',
'status' => 'completed',
'outcome' => 'blocked',
'context' => [
'verification_report' => $report,
],
]);
Filament::setTenant(null, true);
$response = $this->actingAs($user)
->withSession([WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id])
->get(route('admin.operations.view', ['run' => (int) $run->getKey()]));
$response->assertSuccessful()->assertSee('Verification report');
expect($response->getContent())
->toContain('data-shared-detail-family="verification-report"')
->toContain('data-host-kind="operation_run_detail"')
->toContain('data-shared-zone="summary"')
->toContain('data-shared-zone="issues"')
->toContain('data-shared-zone="diagnostics"');
});
it('renders the shared verification family on onboarding verification', function (): void {
$workspace = Workspace::factory()->create();
$user = User::factory()->create();
WorkspaceMembership::factory()->create([
'workspace_id' => (int) $workspace->getKey(),
'user_id' => (int) $user->getKey(),
'role' => 'owner',
]);
$tenant = Tenant::factory()->create([
'workspace_id' => (int) $workspace->getKey(),
'status' => Tenant::STATUS_ONBOARDING,
]);
$user->tenants()->syncWithoutDetaching([
$tenant->getKey() => ['role' => 'owner'],
]);
$connection = ProviderConnection::factory()->create([
'workspace_id' => (int) $workspace->getKey(),
'tenant_id' => (int) $tenant->getKey(),
'is_default' => true,
]);
$report = VerificationReportWriter::build('provider.connection.check', [
[
'key' => 'onboarding_permissions',
'title' => 'Graph permissions',
'status' => 'fail',
'severity' => 'high',
'blocking' => false,
'reason_code' => 'permission_denied',
'message' => 'Missing required Graph permissions.',
'evidence' => [],
'next_steps' => [],
],
]);
$run = OperationRun::factory()->create([
'workspace_id' => (int) $workspace->getKey(),
'tenant_id' => (int) $tenant->getKey(),
'type' => 'provider.connection.check',
'status' => 'completed',
'outcome' => 'blocked',
'context' => [
'provider_connection_id' => (int) $connection->getKey(),
'verification_report' => $report,
],
]);
TenantOnboardingSession::query()->create([
'workspace_id' => (int) $workspace->getKey(),
'tenant_id' => (int) $tenant->getKey(),
'entra_tenant_id' => (string) $tenant->tenant_id,
'current_step' => 'verify',
'state' => [
'provider_connection_id' => (int) $connection->getKey(),
'verification_operation_run_id' => (int) $run->getKey(),
],
'started_by_user_id' => (int) $user->getKey(),
'updated_by_user_id' => (int) $user->getKey(),
]);
$response = $this->actingAs($user)
->withSession([WorkspaceContext::SESSION_KEY => (int) $workspace->getKey()])
->followingRedirects()
->get('/admin/onboarding');
$response->assertSuccessful()->assertSee('Graph permissions');
expect($response->getContent())
->toContain('data-shared-detail-family="verification-report"')
->toContain('data-host-kind="onboarding_wizard"')
->toContain('data-shared-zone="summary"')
->toContain('data-shared-zone="issues"')
->toContain('data-shared-zone="diagnostics"');
});
it('renders the shared verification family on the tenant widget host', function (): void {
[$user, $tenant] = createUserWithTenant(role: 'readonly');
Filament::setTenant($tenant, true);
$report = VerificationReportWriter::build('provider.connection.check', [
[
'key' => 'tenant_widget_report',
'title' => 'Tenant widget verification',
'status' => 'fail',
'severity' => 'high',
'blocking' => false,
'reason_code' => 'provider_permission_denied',
'message' => 'Insufficient permission — ask a tenant Owner.',
'evidence' => [],
'next_steps' => [],
],
]);
OperationRun::factory()->create([
'workspace_id' => (int) $tenant->workspace_id,
'tenant_id' => (int) $tenant->getKey(),
'type' => 'provider.connection.check',
'status' => 'completed',
'outcome' => 'blocked',
'context' => [
'target_scope' => [
'entra_tenant_id' => (string) $tenant->tenant_id,
],
'verification_report' => $report,
],
]);
$component = Livewire::actingAs($user)
->test(TenantVerificationReport::class, ['record' => $tenant])
->assertSee('Tenant widget verification');
expect($component->html())
->toContain('data-shared-detail-family="verification-report"')
->toContain('data-host-kind="tenant_widget"')
->toContain('data-shared-zone="summary"')
->toContain('data-shared-zone="issues"')
->toContain('data-shared-zone="diagnostics"');
});

View File

@ -0,0 +1,126 @@
<?php
declare(strict_types=1);
use App\Filament\Pages\TenantRequiredPermissions;
use App\Models\Tenant;
use App\Models\TenantPermission;
use App\Models\User;
use App\Support\Workspaces\WorkspaceContext;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Livewire\Livewire;
uses(RefreshDatabase::class);
function seedTenantRequiredPermissionsFixture(Tenant $tenant): void
{
config()->set('intune_permissions.permissions', [
[
'key' => 'DeviceManagementApps.Read.All',
'type' => 'application',
'description' => 'Backup application permission',
'features' => ['backup'],
],
[
'key' => 'Group.Read.All',
'type' => 'delegated',
'description' => 'Backup delegated permission',
'features' => ['backup'],
],
[
'key' => 'Reports.Read.All',
'type' => 'application',
'description' => 'Reporting permission',
'features' => ['reporting'],
],
]);
config()->set('entra_permissions.permissions', []);
TenantPermission::query()->create([
'tenant_id' => (int) $tenant->getKey(),
'permission_key' => 'Group.Read.All',
'status' => 'missing',
'details' => ['source' => 'fixture'],
'last_checked_at' => now(),
]);
TenantPermission::query()->create([
'tenant_id' => (int) $tenant->getKey(),
'permission_key' => 'Reports.Read.All',
'status' => 'granted',
'details' => ['source' => 'fixture'],
'last_checked_at' => now(),
]);
}
function tenantRequiredPermissionsComponent(User $user, Tenant $tenant, array $query = [])
{
test()->actingAs($user);
setAdminPanelContext();
session()->put(WorkspaceContext::SESSION_KEY, (int) $tenant->workspace_id);
$query = array_merge([
'tenant' => (string) $tenant->external_id,
], $query);
return Livewire::withQueryParams($query)->test(TenantRequiredPermissions::class);
}
it('uses native table filters and search while keeping summary state aligned with visible rows', function (): void {
[$user, $tenant] = createUserWithTenant(role: 'readonly');
seedTenantRequiredPermissionsFixture($tenant);
$component = tenantRequiredPermissionsComponent($user, $tenant)
->assertTableFilterExists('status')
->assertTableFilterExists('type')
->assertTableFilterExists('features')
->assertCanSeeTableRecords([
'DeviceManagementApps.Read.All',
'Group.Read.All',
])
->assertCanNotSeeTableRecords(['Reports.Read.All'])
->assertSee('Missing application permissions')
->assertSee('Guidance');
$component
->filterTable('status', 'present')
->filterTable('type', 'application')
->searchTable('Reports')
->assertCountTableRecords(1)
->assertCanSeeTableRecords(['Reports.Read.All'])
->assertCanNotSeeTableRecords([
'DeviceManagementApps.Read.All',
'Group.Read.All',
]);
$viewModel = $component->instance()->viewModel();
expect($viewModel['overview']['counts'])->toBe([
'missing_application' => 0,
'missing_delegated' => 0,
'present' => 1,
'error' => 0,
])
->and(array_column($viewModel['permissions'], 'key'))->toBe(['Reports.Read.All'])
->and($viewModel['copy']['application'])->toBe('DeviceManagementApps.Read.All');
});
it('keeps copy payloads feature-scoped and shows the native no-matches state', function (): void {
[$user, $tenant] = createUserWithTenant(role: 'readonly');
seedTenantRequiredPermissionsFixture($tenant);
$component = tenantRequiredPermissionsComponent($user, $tenant)
->set('tableFilters.features.values', ['backup'])
->assertSet('tableFilters.features.values', ['backup']);
$viewModel = $component->instance()->viewModel();
expect($viewModel['copy']['application'])->toBe('DeviceManagementApps.Read.All')
->and($viewModel['copy']['delegated'])->toBe('Group.Read.All');
$component
->searchTable('no-such-permission')
->assertCountTableRecords(0)
->assertSee('No matches')
->assertTableEmptyStateActionsExistInOrder(['clear_filters']);
});

View File

@ -108,7 +108,7 @@
bindFailHardGraphClient();
assertNoOutboundHttp(function () use ($user): void {
Livewire::actingAs($user)
$component = Livewire::actingAs($user)
->test(TenantVerificationReport::class)
->assertSee('Provider connection preflight')
->assertSee(OperationRunLinks::openLabel())
@ -116,6 +116,13 @@
->assertSee(OperationRunLinks::identifierLabel().':')
->assertSee('Read-only:')
->assertSee('Insufficient permission — ask a tenant Owner.');
expect($component->html())
->toContain('data-shared-detail-family="verification-report"')
->toContain('data-host-kind="tenant_widget"')
->toContain('data-shared-zone="summary"')
->toContain('data-shared-zone="issues"')
->toContain('data-shared-zone="diagnostics"');
});
});

View File

@ -5,6 +5,9 @@
use App\Filament\Resources\FindingResource;
use App\Filament\Resources\FindingResource\Pages\ListFindings;
use App\Models\Finding;
use App\Models\InventoryItem;
use App\Models\Policy;
use App\Models\PolicyVersion;
use App\Services\Findings\FindingWorkflowService;
use App\Support\Workspaces\WorkspaceContext;
use Filament\Facades\Filament;
@ -116,3 +119,89 @@
expect($exception->status())->toBe(404);
}
});
it('renders finding detail with shared normalized diff markers for entitled members', function (): void {
bindFailHardGraphClient();
[$user, $tenant] = createUserWithTenant(role: 'owner');
$baseline = createInventorySyncOperationRun($tenant, [
'selection_hash' => hash('sha256', 'finding-rbac-shared-diff'),
'status' => 'success',
'finished_at' => now()->subDays(2),
]);
$current = createInventorySyncOperationRun($tenant, [
'selection_hash' => $baseline->selection_hash,
'status' => 'success',
'finished_at' => now()->subDay(),
]);
$policy = Policy::factory()->for($tenant)->create([
'external_id' => 'policy-finding-rbac',
'policy_type' => 'deviceConfiguration',
'platform' => 'windows10',
]);
$baselineVersion = PolicyVersion::factory()->for($tenant)->create([
'policy_id' => $policy->getKey(),
'version_number' => 1,
'policy_type' => $policy->policy_type,
'platform' => $policy->platform,
'snapshot' => [
'displayName' => 'RBAC Policy',
'customSettingFoo' => 'Old value',
],
]);
$currentVersion = PolicyVersion::factory()->for($tenant)->create([
'policy_id' => $policy->getKey(),
'version_number' => 2,
'policy_type' => $policy->policy_type,
'platform' => $policy->platform,
'snapshot' => [
'displayName' => 'RBAC Policy',
'customSettingFoo' => 'New value',
],
]);
$finding = Finding::factory()->for($tenant)->create([
'finding_type' => Finding::FINDING_TYPE_DRIFT,
'scope_key' => (string) $current->selection_hash,
'baseline_operation_run_id' => $baseline->getKey(),
'current_operation_run_id' => $current->getKey(),
'subject_type' => 'policy',
'subject_external_id' => $policy->external_id,
'evidence_jsonb' => [
'change_type' => 'modified',
'summary' => [
'kind' => 'policy_snapshot',
'changed_fields' => ['snapshot_hash'],
],
'baseline' => [
'policy_id' => $policy->external_id,
'policy_version_id' => $baselineVersion->getKey(),
'snapshot_hash' => 'baseline-hash',
],
'current' => [
'policy_id' => $policy->external_id,
'policy_version_id' => $currentVersion->getKey(),
'snapshot_hash' => 'current-hash',
],
],
]);
InventoryItem::factory()->for($tenant)->create([
'external_id' => $finding->subject_external_id,
'display_name' => 'RBAC Policy',
]);
$response = $this->actingAs($user)
->get(FindingResource::getUrl('view', ['record' => $finding], tenant: $tenant));
$response->assertSuccessful()->assertSee('Normalized diff');
expect($response->getContent())
->toContain('data-shared-detail-family="normalized-diff"')
->toContain('data-shared-normalized-diff-host="finding"');
});

View File

@ -29,7 +29,9 @@
'app/Filament/Resources/BackupSetResource/RelationManagers/BackupItemsRelationManager.php',
'app/Filament/Resources/PolicyResource/RelationManagers/VersionsRelationManager.php',
'app/Filament/Resources/BackupScheduleResource/RelationManagers/BackupScheduleOperationRunsRelationManager.php',
'app/Filament/Pages/TenantRequiredPermissions.php',
'app/Filament/Pages/InventoryCoverage.php',
'app/Filament/Pages/Monitoring/EvidenceOverview.php',
'app/Filament/System/Pages/Directory/Tenants.php',
'app/Filament/System/Pages/Directory/Workspaces.php',
'app/Filament/System/Pages/Ops/Runs.php',
@ -39,6 +41,7 @@
'app/Filament/System/Pages/RepairWorkspaceOwners.php',
'app/Filament/Widgets/Dashboard/RecentDriftFindings.php',
'app/Filament/Widgets/Dashboard/RecentOperations.php',
'app/Livewire/InventoryItemDependencyEdgesTable.php',
'app/Livewire/BackupSetPolicyPickerTable.php',
'app/Livewire/EntraGroupCachePickerTable.php',
'app/Livewire/SettingsCatalogSettingsTable.php',
@ -81,7 +84,9 @@
'app/Filament/Resources/BackupSetResource/RelationManagers/BackupItemsRelationManager.php' => ['->emptyStateHeading('],
'app/Filament/Resources/PolicyResource/RelationManagers/VersionsRelationManager.php' => ['->emptyStateHeading('],
'app/Filament/Resources/BackupScheduleResource/RelationManagers/BackupScheduleOperationRunsRelationManager.php' => ['->emptyStateHeading('],
'app/Filament/Pages/TenantRequiredPermissions.php' => ['->emptyStateHeading('],
'app/Filament/Pages/InventoryCoverage.php' => ['->emptyStateHeading('],
'app/Filament/Pages/Monitoring/EvidenceOverview.php' => ['->emptyStateHeading('],
'app/Filament/System/Pages/Directory/Tenants.php' => ['->emptyStateHeading('],
'app/Filament/System/Pages/Directory/Workspaces.php' => ['->emptyStateHeading('],
'app/Filament/System/Pages/Ops/Runs.php' => ['->emptyStateHeading('],
@ -91,6 +96,7 @@
'app/Filament/System/Pages/RepairWorkspaceOwners.php' => ['->emptyStateHeading('],
'app/Filament/Widgets/Dashboard/RecentDriftFindings.php' => ['->emptyStateHeading('],
'app/Filament/Widgets/Dashboard/RecentOperations.php' => ['->emptyStateHeading('],
'app/Livewire/InventoryItemDependencyEdgesTable.php' => ['->emptyStateHeading('],
'app/Livewire/BackupSetPolicyPickerTable.php' => ['->emptyStateHeading('],
'app/Livewire/EntraGroupCachePickerTable.php' => ['->emptyStateHeading('],
'app/Livewire/SettingsCatalogSettingsTable.php' => ['->emptyStateHeading('],
@ -134,6 +140,8 @@
'app/Filament/Resources/EntraGroupResource.php',
'app/Filament/Resources/OperationRunResource.php',
'app/Filament/Resources/BaselineSnapshotResource.php',
'app/Filament/Pages/TenantRequiredPermissions.php',
'app/Filament/Pages/Monitoring/EvidenceOverview.php',
'app/Filament/Resources/BackupSetResource/RelationManagers/BackupItemsRelationManager.php',
];
@ -310,7 +318,9 @@
'app/Filament/Resources/BackupSetResource/RelationManagers/BackupItemsRelationManager.php',
'app/Filament/Resources/PolicyResource/RelationManagers/VersionsRelationManager.php',
'app/Filament/Resources/BackupScheduleResource/RelationManagers/BackupScheduleOperationRunsRelationManager.php',
'app/Filament/Pages/TenantRequiredPermissions.php',
'app/Filament/Pages/InventoryCoverage.php',
'app/Filament/Pages/Monitoring/EvidenceOverview.php',
'app/Filament/System/Pages/Directory/Tenants.php',
'app/Filament/System/Pages/Directory/Workspaces.php',
'app/Filament/System/Pages/Ops/Runs.php',
@ -320,6 +330,7 @@
'app/Filament/System/Pages/RepairWorkspaceOwners.php',
'app/Filament/Widgets/Dashboard/RecentDriftFindings.php',
'app/Filament/Widgets/Dashboard/RecentOperations.php',
'app/Livewire/InventoryItemDependencyEdgesTable.php',
'app/Livewire/BackupSetPolicyPickerTable.php',
'app/Livewire/EntraGroupCachePickerTable.php',
'app/Livewire/SettingsCatalogSettingsTable.php',
@ -337,6 +348,85 @@
expect($missing)->toBeEmpty('Missing pagination profile helper usage: '.implode(', ', $missing));
});
it('keeps spec 196 surfaces on native table contracts without faux controls or hand-built primary tables', function (): void {
$requiredPatterns = [
'app/Filament/Pages/TenantRequiredPermissions.php' => [
'implements HasTable',
'InteractsWithTable',
],
'app/Filament/Pages/Monitoring/EvidenceOverview.php' => [
'implements HasTable',
'InteractsWithTable',
],
'app/Livewire/InventoryItemDependencyEdgesTable.php' => [
'extends TableComponent',
],
'resources/views/filament/components/dependency-edges.blade.php' => [
'inventory-item-dependency-edges-table',
],
'resources/views/filament/pages/tenant-required-permissions.blade.php' => [
'$this->table',
'data-testid="technical-details"',
],
'resources/views/filament/pages/monitoring/evidence-overview.blade.php' => [
'$this->table',
],
];
$forbiddenPatterns = [
'resources/views/filament/components/dependency-edges.blade.php' => [
'<form method="GET"',
'request(',
],
'resources/views/filament/pages/tenant-required-permissions.blade.php' => [
'wire:model.live="status"',
'wire:model.live="type"',
'wire:model.live="features"',
'wire:model.live.debounce.500ms="search"',
'<table class="min-w-full divide-y divide-gray-200 dark:divide-gray-800">',
],
'resources/views/filament/pages/monitoring/evidence-overview.blade.php' => [
'<table class="min-w-full divide-y divide-gray-200 text-sm">',
],
];
$missing = [];
$unexpected = [];
foreach ($requiredPatterns as $relativePath => $patterns) {
$contents = file_get_contents(base_path($relativePath));
if (! is_string($contents)) {
$missing[] = $relativePath;
continue;
}
foreach ($patterns as $pattern) {
if (! str_contains($contents, $pattern)) {
$missing[] = "{$relativePath} ({$pattern})";
}
}
}
foreach ($forbiddenPatterns as $relativePath => $patterns) {
$contents = file_get_contents(base_path($relativePath));
if (! is_string($contents)) {
continue;
}
foreach ($patterns as $pattern) {
if (str_contains($contents, $pattern)) {
$unexpected[] = "{$relativePath} ({$pattern})";
}
}
}
expect($missing)->toBeEmpty('Missing native table contract patterns: '.implode(', ', $missing))
->and($unexpected)->toBeEmpty('Unexpected faux-control or hand-built table patterns remain: '.implode(', ', $unexpected));
});
it('keeps tenant-registry recovery triage columns, filters, and query hydration explicit', function (): void {
$patternByPath = [
'app/Filament/Resources/TenantResource.php' => [

View File

@ -0,0 +1,37 @@
<?php
declare(strict_types=1);
use Illuminate\Support\Facades\File;
it('keeps verification tab ownership inside the shared viewer', function (): void {
$sharedViewer = (string) file_get_contents(resource_path('views/filament/components/verification-report-viewer.blade.php'));
expect($sharedViewer)
->toContain('data-shared-detail-family="verification-report"')
->toContain('Verification report tabs');
$hostViews = [
resource_path('views/filament/forms/components/managed-tenant-onboarding-verification-report.blade.php'),
resource_path('views/filament/widgets/tenant/tenant-verification-report.blade.php'),
];
foreach ($hostViews as $path) {
expect((string) file_get_contents($path))->not->toContain('Verification report tabs');
}
});
it('keeps policy-settings-standard as a compatibility wrapper only', function (): void {
$compatibilityView = (string) file_get_contents(resource_path('views/filament/infolists/entries/policy-settings-standard.blade.php'));
expect($compatibilityView)->toContain('normalized-settings.wrapper');
$directUsages = collect(File::allFiles(resource_path('views/filament')))
->reject(static fn (\SplFileInfo $file): bool => $file->getPathname() === resource_path('views/filament/infolists/entries/policy-settings-standard.blade.php'))
->filter(static fn (\SplFileInfo $file): bool => str_contains((string) file_get_contents($file->getPathname()), 'policy-settings-standard'))
->map(static fn (\SplFileInfo $file): string => str_replace(resource_path('views/'), '', $file->getPathname()))
->values()
->all();
expect($directUsages)->toBe([]);
});

View File

@ -6,6 +6,10 @@
$compareJob = file_get_contents(base_path('app/Jobs/CompareBaselineToTenantJob.php'));
expect($compareJob)->toBeString();
expect($compareJob)->toContain('CurrentStateHashResolver');
expect($compareJob)->toContain('compareStrategyRegistry->select(');
expect($compareJob)->toContain('compareStrategyRegistry->resolve(');
expect($compareJob)->toContain('$strategy->compare(');
expect($compareJob)->not->toContain('computeDrift(');
expect($compareJob)->not->toContain('->fingerprint(');
expect($compareJob)->not->toContain('::fingerprint(');

View File

@ -7,6 +7,24 @@
'PolicyNormalizer',
'VersionDiff',
'flattenForDiff',
'computeDrift(',
'effectiveBaselineHash(',
'resolveBaselinePolicyVersionId(',
'selectSummaryKind(',
'buildDriftEvidenceContract(',
'buildRoleDefinitionEvidencePayload(',
'resolveRoleDefinitionVersion(',
'fallbackRoleDefinitionNormalized(',
'roleDefinitionChangedKeys(',
'roleDefinitionPermissionKeys(',
'resolveRoleDefinitionDiff(',
'severityForRoleDefinitionDiff(',
'BaselinePolicyVersionResolver',
'DriftHasher',
'SettingsNormalizer',
'AssignmentsNormalizer',
'ScopeTagsNormalizer',
'IntuneRoleDefinitionNormalizer',
];
$captureForbiddenTokens = [
@ -20,6 +38,9 @@
$compareJob = file_get_contents(base_path('app/Jobs/CompareBaselineToTenantJob.php'));
expect($compareJob)->toBeString();
expect($compareJob)->toContain('CurrentStateHashResolver');
expect($compareJob)->toContain('compareStrategyRegistry->select(');
expect($compareJob)->toContain('compareStrategyRegistry->resolve(');
expect($compareJob)->toContain('$strategy->compare(');
foreach ($compareForbiddenTokens as $token) {
expect($compareJob)->not->toContain($token);

View File

@ -41,7 +41,7 @@
->assertSee('Last known: Ghost Target');
});
it('direction filter limits to outbound or inbound', function () {
it('renders native dependency controls in place instead of a GET apply workflow', function () {
[$user, $tenant] = createUserWithTenant();
$this->actingAs($user);
@ -51,34 +51,48 @@
'external_id' => (string) Str::uuid(),
]);
$inboundSource = InventoryItem::factory()->create([
'tenant_id' => $tenant->getKey(),
'external_id' => (string) Str::uuid(),
'display_name' => 'Inbound Source',
]);
// Outbound only
InventoryLink::factory()->create([
'tenant_id' => $tenant->getKey(),
'source_type' => 'inventory_item',
'source_id' => $item->external_id,
'target_type' => 'foundation_object',
'target_id' => (string) Str::uuid(),
'target_type' => 'missing',
'target_id' => null,
'relationship_type' => 'assigned_to',
'metadata' => [
'last_known_name' => 'Assigned Target',
],
]);
// Inbound only
InventoryLink::factory()->create([
'tenant_id' => $tenant->getKey(),
'source_type' => 'inventory_item',
'source_id' => (string) Str::uuid(),
'source_id' => $inboundSource->external_id,
'target_type' => 'inventory_item',
'target_id' => $item->external_id,
'relationship_type' => 'depends_on',
]);
$urlOutbound = InventoryItemResource::getUrl('view', ['record' => $item], panel: 'admin').'?tenant='.(string) $tenant->external_id.'&direction=outbound';
$this->get($urlOutbound)->assertOk()->assertDontSee('No dependencies found');
$url = InventoryItemResource::getUrl('view', ['record' => $item], panel: 'admin').'?tenant='.(string) $tenant->external_id.'&direction=outbound';
$urlInbound = InventoryItemResource::getUrl('view', ['record' => $item], panel: 'admin').'?tenant='.(string) $tenant->external_id.'&direction=inbound';
$this->get($urlInbound)->assertOk()->assertDontSee('No dependencies found');
$this->get($url)
->assertOk()
->assertSee('Direction')
->assertSee('Inbound')
->assertSee('Outbound')
->assertSee('Relationship')
->assertSee('Assigned Target')
->assertDontSee('No dependencies found');
});
it('relationship filter limits edges by type', function () {
it('ignores legacy relationship query state while preserving visible target safety', function () {
[$user, $tenant] = createUserWithTenant();
$this->actingAs($user);
@ -115,7 +129,7 @@
$this->get($url)
->assertOk()
->assertSee('Scoped Target')
->assertDontSee('Assigned Target');
->assertSee('Assigned Target');
});
it('does not show edges from other tenants (tenant isolation)', function () {

View File

@ -6,6 +6,7 @@
use App\Models\Tenant;
use App\Models\User;
use App\Services\Graph\GraphClientInterface;
use App\Support\Verification\VerificationReportWriter;
use App\Support\Workspaces\WorkspaceContext;
use Filament\Facades\Filament;
@ -142,3 +143,45 @@
->get(route('admin.operations.view', ['run' => (int) $run->getKey()]))
->assertNotFound();
});
it('renders shared verification family markers on monitoring operation detail for workspace members', function (): void {
$tenant = Tenant::factory()->create();
[$user, $tenant] = createUserWithTenant($tenant, role: 'owner');
$run = OperationRun::factory()->create([
'tenant_id' => (int) $tenant->getKey(),
'workspace_id' => (int) $tenant->workspace_id,
'type' => 'provider.connection.check',
'status' => 'completed',
'outcome' => 'blocked',
'initiator_name' => 'System',
'run_identity_hash' => 'hash123',
'context' => [
'verification_report' => VerificationReportWriter::build('provider.connection.check', [
[
'key' => 'provider_connection',
'title' => 'Provider connection preflight',
'status' => 'fail',
'severity' => 'critical',
'blocking' => true,
'reason_code' => 'provider_connection_missing',
'message' => 'No provider connection configured.',
'evidence' => [],
'next_steps' => [],
],
]),
],
]);
Filament::setTenant(null, true);
$response = $this->actingAs($user)
->withSession([WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id])
->get(route('admin.operations.view', ['run' => (int) $run->getKey()]));
$response->assertSuccessful()->assertSee('Provider connection preflight');
expect($response->getContent())
->toContain('data-shared-detail-family="verification-report"')
->toContain('data-host-kind="operation_run_detail"');
});

View File

@ -3,11 +3,13 @@
declare(strict_types=1);
use App\Filament\Pages\Workspaces\ManagedTenantOnboardingWizard;
use App\Models\OperationRun;
use App\Models\ProviderConnection;
use App\Models\Tenant;
use App\Models\User;
use App\Models\Workspace;
use App\Models\WorkspaceMembership;
use App\Support\Verification\VerificationReportWriter;
use App\Support\Workspaces\WorkspaceContext;
use Livewire\Livewire;
@ -148,6 +150,81 @@
->assertForbidden();
});
it('renders shared verification family markers for an entitled requested verify draft', function (): void {
$workspace = Workspace::factory()->create();
$tenant = Tenant::factory()->create([
'workspace_id' => (int) $workspace->getKey(),
'status' => Tenant::STATUS_ONBOARDING,
]);
$user = User::factory()->create();
createUserWithTenant(
tenant: $tenant,
user: $user,
role: 'owner',
workspaceRole: 'owner',
ensureDefaultMicrosoftProviderConnection: false,
);
$connection = ProviderConnection::factory()->create([
'workspace_id' => (int) $workspace->getKey(),
'tenant_id' => (int) $tenant->getKey(),
'provider' => 'microsoft',
'entra_tenant_id' => (string) $tenant->tenant_id,
'display_name' => 'Requested verify connection',
'is_default' => true,
]);
$run = OperationRun::factory()->create([
'workspace_id' => (int) $workspace->getKey(),
'tenant_id' => (int) $tenant->getKey(),
'type' => 'provider.connection.check',
'status' => 'completed',
'outcome' => 'blocked',
'context' => [
'provider_connection_id' => (int) $connection->getKey(),
'verification_report' => VerificationReportWriter::build('provider.connection.check', [
[
'key' => 'requested_verify_draft',
'title' => 'Requested verify draft check',
'status' => 'fail',
'severity' => 'high',
'blocking' => false,
'reason_code' => 'missing_configuration',
'message' => 'Draft needs attention.',
'evidence' => [],
'next_steps' => [],
],
]),
],
]);
$draft = createOnboardingDraft([
'workspace' => $workspace,
'tenant' => $tenant,
'started_by' => $user,
'updated_by' => $user,
'current_step' => 'verify',
'state' => [
'entra_tenant_id' => (string) $tenant->tenant_id,
'tenant_name' => (string) $tenant->name,
'provider_connection_id' => (int) $connection->getKey(),
'verification_operation_run_id' => (int) $run->getKey(),
],
]);
$response = $this->actingAs($user)
->withSession([WorkspaceContext::SESSION_KEY => (int) $workspace->getKey()])
->followingRedirects()
->get(route('admin.onboarding.draft', ['onboardingDraft' => $draft->getKey()]));
$response->assertSuccessful()->assertSee('Requested verify draft check');
expect($response->getContent())
->toContain('data-shared-detail-family="verification-report"')
->toContain('data-host-kind="onboarding_wizard"');
});
it('mounts the requested draft with canonical persisted continuity state even when other drafts exist', function (): void {
$workspace = Workspace::factory()->create();
$tenant = Tenant::factory()->create([

View File

@ -319,7 +319,7 @@
'updated_by_user_id' => (int) $user->getKey(),
]);
$this->actingAs($user)
$response = $this->actingAs($user)
->followingRedirects()
->get('/admin/onboarding')
->assertSuccessful()
@ -331,6 +331,13 @@
->assertSee('Missing required Graph permissions.')
->assertSee('Graph permissions')
->assertSee($entraTenantId);
expect($response->getContent())
->toContain('data-shared-detail-family="verification-report"')
->toContain('data-host-kind="onboarding_wizard"')
->toContain('data-shared-zone="summary"')
->toContain('data-shared-zone="issues"')
->toContain('data-shared-zone="diagnostics"');
});
it('keeps one onboarding verification path per state while leaving workflow actions on the wizard step', function (): void {

View File

@ -246,7 +246,7 @@
'updated_by_user_id' => (int) $user->getKey(),
]);
$this->actingAs($user)
$response = $this->actingAs($user)
->followingRedirects()
->get('/admin/onboarding')
->assertSuccessful()
@ -262,4 +262,11 @@
->assertSee('First step')
->assertSee('Second step')
->assertDontSee('Third step');
expect($response->getContent())
->toContain('data-shared-detail-family="verification-report"')
->toContain('data-host-kind="onboarding_wizard"')
->toContain('data-shared-zone="summary"')
->toContain('data-shared-zone="issues"')
->toContain('data-shared-zone="diagnostics"');
});

View File

@ -26,5 +26,6 @@
expect($html)->toContain('Enabled')
->and($html)->toContain('Disabled')
->and($html)->toContain('fi-badge');
->and($html)->toContain('fi-badge')
->and($html)->toContain('data-shared-detail-family="normalized-settings"');
});

View File

@ -2,12 +2,14 @@
declare(strict_types=1);
use App\Filament\Pages\TenantRequiredPermissions;
use App\Models\Tenant;
use App\Models\TenantPermission;
use App\Models\User;
use App\Models\Workspace;
use App\Models\WorkspaceMembership;
use App\Support\Workspaces\WorkspaceContext;
use Livewire\Livewire;
it('keeps the route tenant authoritative when tenant-like query values are present', function (): void {
[$user, $tenant] = createUserWithTenant(role: 'readonly');
@ -54,7 +56,7 @@
$response
->assertSee($tenant->getFilamentName())
->assertSee('data-permission-key="Tenant.Read.All"', false);
->assertSee('Tenant.Read.All');
});
it('returns 404 when the current workspace no longer matches the tenant route scope', function (): void {
@ -86,3 +88,52 @@
->get('/admin/tenants/'.$tenant->external_id.'/required-permissions')
->assertNotFound();
});
it('seeds native table state from deeplink filters without letting query values redefine the route tenant', function (): void {
[$user, $tenant] = createUserWithTenant(role: 'readonly');
$otherTenant = Tenant::factory()->create([
'workspace_id' => (int) $tenant->workspace_id,
'name' => 'Ignored Query Tenant',
'external_id' => 'ignored-query-tenant',
]);
config()->set('intune_permissions.permissions', [
[
'key' => 'Tenant.Read.All',
'type' => 'application',
'description' => 'Tenant read permission',
'features' => ['backup'],
],
]);
config()->set('entra_permissions.permissions', []);
TenantPermission::query()->create([
'tenant_id' => (int) $tenant->getKey(),
'permission_key' => 'Tenant.Read.All',
'status' => 'granted',
'details' => ['source' => 'db'],
'last_checked_at' => now(),
]);
$this->actingAs($user);
setAdminPanelContext();
session()->put(WorkspaceContext::SESSION_KEY, (int) $tenant->workspace_id);
$component = Livewire::withQueryParams([
'tenant' => $tenant->external_id,
'tenant_id' => (string) $otherTenant->getKey(),
'status' => 'present',
'type' => 'application',
'features' => ['backup'],
'search' => 'Tenant',
])->test(TenantRequiredPermissions::class);
$component
->assertSet('tableFilters.status.value', 'present')
->assertSet('tableFilters.type.value', 'application')
->assertSet('tableFilters.features.values', ['backup'])
->assertSet('tableSearch', 'Tenant');
expect($component->instance()->currentTenant()?->is($tenant))->toBeTrue();
});

View File

@ -9,6 +9,7 @@
use App\Models\Workspace;
use App\Support\OperationRunOutcome;
use App\Support\OperationRunStatus;
use App\Support\Verification\VerificationReportWriter;
use App\Support\Workspaces\WorkspaceContext;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Http;
@ -39,6 +40,21 @@
'type' => 'provider.connection.check',
'status' => OperationRunStatus::Queued->value,
'outcome' => OperationRunOutcome::Pending->value,
'context' => [
'verification_report' => VerificationReportWriter::build('provider.connection.check', [
[
'key' => 'provider_connection',
'title' => 'Provider connection preflight',
'status' => 'fail',
'severity' => 'critical',
'blocking' => true,
'reason_code' => 'provider_connection_missing',
'message' => 'No provider connection configured.',
'evidence' => [],
'next_steps' => [],
],
]),
],
]);
$this->actingAs($user)

View File

@ -50,6 +50,13 @@
->assertSee('Blocked')
->assertSee('Token acquisition works');
expect($component->html())
->toContain('data-shared-detail-family="verification-report"')
->toContain('data-host-kind="operation_run_detail"')
->toContain('data-shared-zone="summary"')
->toContain('data-shared-zone="issues"')
->toContain('data-shared-zone="diagnostics"');
$component
->call('$refresh')
->assertSee('Token acquisition works');
@ -184,11 +191,18 @@
]);
assertNoOutboundHttp(function () use ($user): void {
$this->actingAs($user)
$response = $this->actingAs($user)
->followingRedirects()
->get('/admin/onboarding')
->assertSuccessful()
->assertSee('Onboarding check');
expect($response->getContent())
->toContain('data-shared-detail-family="verification-report"')
->toContain('data-host-kind="onboarding_wizard"')
->toContain('data-shared-zone="summary"')
->toContain('data-shared-zone="issues"')
->toContain('data-shared-zone="diagnostics"');
});
Bus::assertNothingDispatched();

View File

@ -377,11 +377,18 @@ x-spec-196-notes:
- route tenant stays authoritative on required-permissions
- evidence overview only exposes entitled tenant rows
- dependency rendering remains tenant-isolated and DB-only
- cleaned surfaces remain read-only and introduce no new remote runtime calls at render time
- native Filament or Livewire state remains the primary contract, with no new wrapper or presenter layer introduced only to translate pseudo-native state
- required-permissions summary counts, freshness, guidance visibility, and copy payload remain derived from the same normalized filter state as the visible rows
- evidence overview preserves a meaningful empty state, clear-filter affordance when scoped, and one workspace-safe inspect path per authorized row
- query values may seed initial state but not stay the primary contract
nonGoals:
- runtime API exposure
- new persistence
- new polling behavior
- new provider or route families
- new global or on-demand asset requirements
- shared wrapper or presenter framework
- global context shell redesign
- monitoring page-state architecture rewrite
- audit log selected-record or inspect duality cleanup

View File

@ -64,7 +64,7 @@ ## Phase 0 Research
- 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.
- No additional same-class extra hit is confirmed in planning; default implementation scope stays fixed at the three named core surfaces unless the setup audit records a candidate that passes every FR-196-015 admission check without widening architecture or adding new persistence, routes, or shared abstractions.
- Extend existing focused tests and the current Filament table guard where possible instead of introducing a new browser-only verification layer.
## Phase 1 Design
@ -169,7 +169,8 @@ ### Phase 0.5 - Establish shared test and guard scaffolding
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.
- Record the pre-implementation scope gate: unless the setup audit documents an FR-196-015 pass, scope is frozen to the three named core surfaces and no optional extra hit may begin.
- Extend shared guard coverage for new native page-table expectations, faux-control regressions, and no-new-wrapper drift.
- Add shared regression coverage for mount-only query seeding versus authoritative scope on required permissions and evidence overview.
Tests:
@ -245,6 +246,7 @@ ### Phase D - Verification, guard alignment, and explicit scope stop
- 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.
- Verify the final implementation still satisfies the non-functional constraints: DB-only rendering, no new remote calls, no new persistence, and no asset or provider drift.
- Stop immediately if implementation reaches shared micro-UI family, monitoring-state, or shell-context architecture.
Tests:
@ -293,4 +295,4 @@ ## Implementation Order Recommendation
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.
5. Run the final focused verification pack, formatting, and release close-out last, and record whether the setup audit admitted any optional same-class extra hit. No new optional extra hit may enter scope after implementation has started.

View File

@ -19,6 +19,7 @@ ### 1. Prepare shared test and guard scaffolding
Do:
- create the new focused surface-test entry points before story implementation starts
- perform the setup audit that decides whether any optional same-class extra hit passes every FR-196-015 admission check before any extra hit begins; otherwise lock scope to the three named surfaces
- 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
@ -124,7 +125,7 @@ ### 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
- whether any optional same-class extra hit was included or explicitly rejected, and if included, which FR-196-015 admission checks it satisfied
- which related themes stayed out of scope and were deferred
- which follow-up specs or artifacts were touched
@ -151,15 +152,26 @@ ## Suggested Test Pack
## 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.
1. Open an inventory item detail page and confirm current record context stays visible, dependency direction and relationship changes happen in place without a foreign apply-and-reload workflow, missing-target markers remain visible where applicable, and empty-state copy still explains the no-results case.
2. Open tenant required permissions and confirm current tenant scope and active filter state remain visible, the filter surface feels native, summary counts and freshness stay consistent with the visible rows, guidance remains available, and copy flows still use the same filtered state.
3. Open evidence overview and confirm workspace scope and any active entitled-tenant filter remain visible, the table behaves like a native Filament report, artifact truth, freshness, and next-step context remain visible by default, `Clear filters` behaves correctly, and each authorized row still has one workspace-safe inspect path.
4. Confirm no cleaned surface leaks scope, rows, counts, or drilldown targets through query manipulation.
5. Confirm no implementation expanded into monitoring-state, shell, shared micro-UI redesign work, new wrapper layers, or new persistence created only to support the native controls.
## Deployment Notes
- No migration is expected.
- No polling change 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.
- Existing `cd apps/platform && php artisan filament:assets` deployment handling remains sufficient and unchanged.
## Release Close-Out
- Cleaned surfaces: inventory item dependency edges now run through the embedded `InventoryItemDependencyEdgesTable` component; tenant required permissions now uses one page-owned native table contract; evidence overview now uses one page-owned native workspace table.
- Optional same-class extra hit decision: rejected. No additional nativity-bypass candidate passed every FR-196-015 admission check during setup, so scope remained frozen to the three named surfaces.
- Deferred themes kept out of scope: monitoring page-state architecture, global shell or context redesign, shared micro-UI or wrapper abstractions, verification viewer families, and any new persistence or asset work.
- Follow-up artifacts touched: this quickstart note, the Spec 196 task ledger, and the existing logical contract remained aligned without widening consumer scope.
- Focused Sail verification pack: passed on 2026-04-14 with 45 tests and 177 assertions across the Spec 196 feature, guard, and unit coverage set.
- Integrated-browser smoke sign-off: passed on `http://localhost` against tenant `19000000-0000-4000-8000-000000000191`, including an inventory detail fixture (`inventory-items/383`) and evidence fixture (`evidence/20`). Verified in-place dependency filters with visible active filter chips and missing-target hints, native required-permissions search plus technical-details matrix continuity, and evidence overview tenant prefilter plus `Clear filters` behavior with workspace-safe drilldown links.
- Browser-log note: the integrated-browser session still contains old historical 419 and aborted-request noise from prior sessions, but no new Spec 196 surface-specific JavaScript failure blocked the smoke flow above.

View File

@ -177,12 +177,19 @@ ### Functional Requirements
- **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-015**: Any additional cleanup hit included under this spec MUST pass all of the following admission checks before implementation starts on that hit: it removes the same confirmed nativity-bypass problem class as the three core surfaces (primary GET-form controls, request-driven page-body state, or a hand-built primary table imitating native Filament behavior); it stays read-only and preserves current scope semantics; it can be completed by modifying existing files only, or by adding at most one narrow sibling view or component file with no new route, persistence, enum, or shared abstraction; it does not enter shared-family, shell, monitoring-state, diff, verification-report, or special-visualization work; and it is explicitly recorded in release close-out. If any admission check fails, the candidate is out of scope.
- **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-017**: This cleanup MUST NOT introduce a new wrapper microframework, presenter layer, cross-page UI abstraction, or service whose main purpose is to translate bespoke pseudo-native page state into native Filament primitives. If native Filament or Livewire page state can express the behavior directly, direct state wins.
- **FR-196-018**: Each cleaned surface MUST remain operatorically at least as clear as before. This requirement is satisfied only when the cleaned surface preserves all of the following, in the same primary surface where the operator is already working: inventory dependencies still shows current record context, direction and relationship scope labels, missing-target markers, meaningful empty-state copy, and safe inspect links; tenant required permissions still shows current tenant scope, active filter state, overall counts, freshness, guidance visibility, and copy payload behavior derived from the same normalized filter state; evidence overview still shows workspace scope, active entitled-tenant filter state, artifact truth, freshness, next-step context, clear-filter affordance, and one workspace-safe inspect path for authorized rows.
- **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.
### Non-Functional Requirements
- **NFR-196-001**: Render-time behavior for inventory dependencies, tenant required permissions, and evidence overview MUST remain DB-only and MUST NOT introduce new Microsoft Graph calls, external HTTP requests, or other remote runtime dependencies.
- **NFR-196-002**: This cleanup MUST NOT add new persistence artifacts, including tables, persisted UI-state mirrors, materialized helper projections, or helper models whose only purpose is to support the new native controls.
- **NFR-196-003**: This cleanup MUST NOT add polling, provider registration changes, or new global or on-demand asset requirements. Existing `bootstrap/providers.php` registration and current `filament:assets` deployment handling remain unchanged.
- **NFR-196-004**: Implementation MUST stay inside the current Filament v5 and Livewire v4 page layer and current derived services unless a touched existing service needs a narrow adapter to keep one authoritative normalized filter state.
## 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 |
@ -208,6 +215,9 @@ ### Measurable Outcomes
- **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.
- **SC-196-007**: Inventory dependencies preserves current-record context, direction and relationship labels, missing-target markers, safe inspect links, and meaningful empty-state copy after native state adoption.
- **SC-196-008**: Tenant required permissions preserves visible active filter state, counts, freshness, guidance, and copy payload behavior that stay internally consistent for the same tenant and the same normalized filter state.
- **SC-196-009**: Evidence overview preserves visible workspace scope signals, entitled-tenant clear-filter behavior, artifact truth, freshness, next-step context, and one workspace-safe inspect path for every authorized row.
## Assumptions

View File

@ -7,7 +7,7 @@ # 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.
**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 and their DB-only, no-wrapper, and scope-safety constraints.
**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`.
@ -18,8 +18,8 @@ ## 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`
- [X] 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`, and record whether any optional extra candidate passes every FR-196-015 admission check; otherwise freeze scope to the three named surfaces
- [X] 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`
---
@ -29,11 +29,11 @@ ## Phase 2: Foundational (Blocking Prerequisites)
**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`
- [X] 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`
- [X] T004 [P] Review and, if newly applicable, extend shared native-table guard coverage for Spec 196 page-owned tables, faux-control regressions, and no-new-wrapper drift in `apps/platform/tests/Feature/Guards/FilamentTableStandardsGuardTest.php`
- [X] 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.
**Checkpoint**: The shared Spec 196 test harness and scope gate are in place, and later surface work can prove native state ownership without reopening scope or guard assumptions.
---
@ -47,13 +47,13 @@ ### 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
- [X] 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
- [X] 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
- [X] 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
- [X] 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.
@ -69,14 +69,14 @@ ### 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`
- [X] 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
- [X] 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
- [X] 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
- [X] T013 [US2] Convert `apps/platform/app/Filament/Pages/TenantRequiredPermissions.php` to `HasTable` / `InteractsWithTable` with native filters, native search, and mount-only query seeding
- [X] 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.
@ -92,13 +92,13 @@ ### 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
- [X] T015 [P] [US3] Extend `apps/platform/tests/Feature/Evidence/EvidenceOverviewPageTest.php` for native table rendering, native search behavior, visible workspace scope and active entitled-tenant filter state, artifact-truth and freshness and next-step row fields, entitled-tenant seed and clear behavior, workspace-safe row drilldown, clear-filter behavior, empty states, and deny-as-not-found enforcement
- [X] 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
- [X] T017 [US3] Convert `apps/platform/app/Filament/Pages/Monitoring/EvidenceOverview.php` to `HasTable` / `InteractsWithTable` with derived row callbacks that preserve visible workspace scope, active entitled-tenant filter state, artifact-truth, freshness, and next-step row context, native filter and search state, entitled-tenant query seeding, clear-filter behavior, and one inspect model
- [X] 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.
@ -108,11 +108,11 @@ ## 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
- [X] 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`, and confirm the pack still proves DB-only rendering, no-wrapper drift protection, and scope-safety invariants
- [X] 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/`
- [X] T021 Execute the manual smoke checklist in `specs/196-hard-filament-nativity-cleanup/quickstart.md` across the three cleaned surfaces, explicitly verifying current-context visibility, active filter or scope signals, empty-state meaning, guidance or next-step clarity, and inspect navigation, and capture any sign-off notes needed for release close-out
- [X] 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
- [X] T023 Verify the final close-out note in `specs/196-hard-filament-nativity-cleanup/quickstart.md` and the contract-modeled consumers, invariants, non-goals, and non-functional constraints in `specs/196-hard-filament-nativity-cleanup/contracts/filament-nativity-cleanup.logical.openapi.yaml` remain aligned with the implemented scope
---

View File

@ -0,0 +1,36 @@
# Specification Quality Checklist: Shared Detail Micro-UI Contract
**Purpose**: Validate specification completeness and quality before proceeding to planning
**Created**: 2026-04-15
**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.
- The spec stays bounded to two already proven shared detail families and explicitly excludes shell, monitoring-state, and broad custom-view consolidation themes.
- Repository truth from current host usage informed the in-scope family definitions without embedding file-level implementation detail into the spec body.

View File

@ -0,0 +1,299 @@
openapi: 3.1.0
info:
title: Normalized Detail Family Internal Surface Contract
version: 0.1.0
summary: Internal logical contract for shared normalized settings and normalized diff families
description: |
This contract is an internal planning artifact for Spec 197. It documents the
family-owned normalized settings and normalized diff surfaces used across
policy, policy version, and finding detail hosts. Rendered routes still
return HTML. The structured schemas below describe the internal surface
contract and approved host-variation boundaries. This does not add a public
HTTP API.
servers:
- url: /internal
x-family-consumers:
- surface: policy_settings
source: normalized_settings_surface
accessPattern: infolist_safe
guardScope:
- apps/platform/app/Filament/Resources/PolicyResource.php
- apps/platform/resources/views/filament/infolists/entries/normalized-settings.blade.php
requiredMarkers:
- NormalizedSettingsSurface::build
- data-shared-detail-family="normalized-settings"
- data-shared-normalized-settings-host="policy"
- surface: policy_version_settings
source: normalized_settings_surface
accessPattern: infolist_safe
guardScope:
- apps/platform/app/Filament/Resources/PolicyVersionResource.php
- apps/platform/resources/views/filament/infolists/entries/normalized-settings.blade.php
requiredMarkers:
- NormalizedSettingsSurface::build
- data-shared-detail-family="normalized-settings"
- data-shared-normalized-settings-host="policy_version"
x-forbiddenMarkers:
- needle: "->view('filament.infolists.entries.policy-settings-standard')"
max: 0
- surface: policy_version_diff
source: normalized_diff_surface
accessPattern: infolist_safe
guardScope:
- apps/platform/app/Filament/Resources/PolicyVersionResource.php
- apps/platform/resources/views/filament/infolists/entries/normalized-diff.blade.php
requiredMarkers:
- NormalizedDiffSurface::build
- data-shared-detail-family="normalized-diff"
- data-shared-normalized-diff-host="policy_version"
- data-shared-zone="summary"
- data-shared-zone="groups"
- surface: finding_diff
source: normalized_diff_surface
accessPattern: infolist_safe
guardScope:
- apps/platform/app/Filament/Resources/FindingResource.php
- apps/platform/resources/views/filament/infolists/entries/normalized-diff.blade.php
requiredMarkers:
- NormalizedDiffSurface::build
- data-shared-detail-family="normalized-diff"
- data-shared-normalized-diff-host="finding"
- data-shared-normalized-diff-state
- data-shared-zone="summary"
paths:
/ui-contracts/normalized-settings/resolve:
get:
summary: Resolve the shared normalized settings family contract for one host
operationId: resolveNormalizedSettingsSurface
parameters:
- name: host
in: query
required: true
schema:
$ref: '#/components/schemas/NormalizedSettingsHostKind'
responses:
'200':
description: Shared normalized settings family contract resolved for the current host context
content:
application/vnd.tenantpilot.normalized-settings-surface+json:
schema:
$ref: '#/components/schemas/NormalizedSettingsSurfaceContract'
'403':
description: Actor is in scope but lacks the capability required to view the host
'404':
description: Host or tenant scope is not visible in the current workspace or tenant context
/ui-contracts/normalized-diff/resolve:
get:
summary: Resolve the shared normalized diff family contract for one host
operationId: resolveNormalizedDiffSurface
parameters:
- name: host
in: query
required: true
schema:
$ref: '#/components/schemas/NormalizedDiffHostKind'
responses:
'200':
description: Shared normalized diff family contract resolved for the current host context
content:
application/vnd.tenantpilot.normalized-diff-surface+json:
schema:
$ref: '#/components/schemas/NormalizedDiffSurfaceContract'
'403':
description: Actor is in scope but lacks the capability required to view the host
'404':
description: Host or tenant scope is not visible in the current workspace or tenant context
components:
schemas:
NormalizedSettingsHostKind:
type: string
enum:
- policy
- policy_version
NormalizedDiffHostKind:
type: string
enum:
- policy_version
- finding
NormalizedSettingsVariant:
type: string
enum:
- settings_catalog_table
- standard_blocks
AvailabilityState:
type: string
enum:
- available
- unavailable
- partial
NormalizedSettingsSectionBehavior:
type: object
additionalProperties: false
required:
- preservesSectionOrder
- supportsExpansion
- ownsEmptyState
properties:
preservesSectionOrder:
type: boolean
supportsExpansion:
type: boolean
ownsEmptyState:
type: boolean
NormalizedSettingsRenderExpectations:
type: object
additionalProperties: false
required:
- ownsWarningsInWrapper
- ownsSubtypeDelegation
- keepsHostFramingOutsideCore
properties:
ownsWarningsInWrapper:
type: boolean
ownsSubtypeDelegation:
type: boolean
keepsHostFramingOutsideCore:
type: boolean
NormalizedDiffViewMode:
type: object
additionalProperties: false
required:
- key
- label
- default
properties:
key:
type: string
label:
type: string
default:
type: boolean
NormalizedDiffSectionBehavior:
type: object
additionalProperties: false
required:
- preservesGroupOrder
- supportsExpansion
- supportsFullscreen
properties:
preservesGroupOrder:
type: boolean
supportsExpansion:
type: boolean
supportsFullscreen:
type: boolean
NormalizedDiffRenderExpectations:
type: object
additionalProperties: false
required:
- ownsAvailabilityState
- ownsZeroDiffMessaging
- keepsHostFramingOutsideCore
properties:
ownsAvailabilityState:
type: boolean
ownsZeroDiffMessaging:
type: boolean
keepsHostFramingOutsideCore:
type: boolean
NormalizedSettingsSurfaceContract:
type: object
additionalProperties: false
required:
- hostKind
- context
- variant
- sectionBehavior
- renderExpectations
- titlePolicy
properties:
hostKind:
$ref: '#/components/schemas/NormalizedSettingsHostKind'
context:
type: string
enum:
- policy
- version
variant:
$ref: '#/components/schemas/NormalizedSettingsVariant'
warnings:
type: array
items:
type: string
settingsTable:
type:
- object
- 'null'
blocks:
type: array
items:
type: object
sectionBehavior:
$ref: '#/components/schemas/NormalizedSettingsSectionBehavior'
renderExpectations:
$ref: '#/components/schemas/NormalizedSettingsRenderExpectations'
emptyState:
type:
- object
- 'null'
titlePolicy:
type: object
additionalProperties: false
required:
- showWrapperTitle
properties:
showWrapperTitle:
type: boolean
NormalizedDiffSurfaceContract:
type: object
additionalProperties: false
required:
- hostKind
- availabilityState
- summary
- viewModes
- sectionBehavior
- renderExpectations
properties:
hostKind:
$ref: '#/components/schemas/NormalizedDiffHostKind'
availabilityState:
$ref: '#/components/schemas/AvailabilityState'
summary:
type: object
additionalProperties: false
required:
- added
- removed
- changed
properties:
added:
type: integer
removed:
type: integer
changed:
type: integer
message:
type:
- string
- 'null'
viewModes:
type: array
items:
$ref: '#/components/schemas/NormalizedDiffViewMode'
sectionBehavior:
$ref: '#/components/schemas/NormalizedDiffSectionBehavior'
renderExpectations:
$ref: '#/components/schemas/NormalizedDiffRenderExpectations'
groups:
type: array
items:
type: object
scriptRendering:
type:
- object
- 'null'
emptyState:
type:
- object
- 'null'

View File

@ -0,0 +1,304 @@
openapi: 3.1.0
info:
title: Verification Report Family Internal Surface Contract
version: 0.1.0
summary: Internal logical contract for the shared Verification Report family
description: |
This contract is an internal planning artifact for Spec 197. It documents the
family-owned verification surface used across operation detail, onboarding,
and tenant verification widget hosts. Rendered routes still return HTML.
The structured schemas below describe the internal surface contract and the
allowed host-variation boundaries. This does not add a public HTTP API.
servers:
- url: /internal
x-family-consumers:
- surface: operation_run_detail
source: verification_report_surface
accessPattern: detail_safe
guardScope:
- apps/platform/app/Filament/Resources/OperationRunResource.php
- apps/platform/resources/views/filament/components/verification-report-viewer.blade.php
requiredMarkers:
- VerificationReportViewer::surface
- data-shared-detail-family="verification-report"
- data-host-kind="operation_run_detail"
- data-shared-zone="summary"
- data-shared-zone="diagnostics"
- surface: onboarding_verification_step
source: verification_report_surface
accessPattern: form_embedded_safe
guardScope:
- apps/platform/app/Filament/Pages/Workspaces/ManagedTenantOnboardingWizard.php
- apps/platform/resources/views/filament/forms/components/managed-tenant-onboarding-verification-report.blade.php
requiredMarkers:
- VerificationReportViewer::surface
- data-shared-detail-family="verification-report"
- data-host-kind="onboarding_wizard"
- assistActionName
- technicalDetailsActionName
x-forbiddenMarkers:
- needle: "x-data=\"{ tab: 'issues' }\""
max: 0
- surface: tenant_verification_widget
source: verification_report_surface
accessPattern: widget_safe
guardScope:
- apps/platform/app/Filament/Widgets/Tenant/TenantVerificationReport.php
- apps/platform/resources/views/filament/widgets/tenant/tenant-verification-report.blade.php
requiredMarkers:
- VerificationReportViewer::surface
- data-shared-detail-family="verification-report"
- data-host-kind="tenant_widget"
- data-shared-zone="summary"
- data-shared-zone="diagnostics"
paths:
/ui-contracts/verification-report/resolve:
get:
summary: Resolve the shared Verification Report family contract for one host
operationId: resolveVerificationReportSurface
parameters:
- name: host
in: query
required: true
schema:
$ref: '#/components/schemas/VerificationHostKind'
- name: runId
in: query
required: false
schema:
type: integer
responses:
'200':
description: Shared Verification Report surface contract resolved for the current host context
content:
application/vnd.tenantpilot.verification-report-surface+json:
schema:
$ref: '#/components/schemas/VerificationReportSurfaceContract'
'403':
description: Actor is in scope but lacks the capability required for host-owned actions
'404':
description: Run or tenant scope is not visible in the current workspace or tenant context
/admin/onboarding:
get:
summary: Onboarding verification host using the shared Verification Report family
operationId: viewOnboardingVerificationHost
responses:
'200':
description: Rendered onboarding verification step with shared verification family core
content:
text/html:
schema:
type: string
/admin/t/{tenant}:
get:
summary: Tenant verification widget host using the shared Verification Report family
operationId: viewTenantVerificationWidgetHost
parameters:
- name: tenant
in: path
required: true
schema:
type: string
responses:
'200':
description: Rendered tenant detail page containing the shared verification family widget
content:
text/html:
schema:
type: string
components:
schemas:
VerificationHostKind:
type: string
enum:
- operation_run_detail
- onboarding_wizard
- tenant_widget
VerificationCoreState:
type: string
enum:
- unavailable
- completed
VerificationActionKind:
type: string
enum:
- navigation
- assist
- acknowledge
- refresh
- technical_details
VerificationHostVariation:
type: object
additionalProperties: false
required:
- ownsNoRunState
- ownsActiveState
- supportsAssist
- supportsAcknowledge
- supportsTechnicalDetailsTrigger
properties:
ownsNoRunState:
type: boolean
ownsActiveState:
type: boolean
supportsAssist:
type: boolean
supportsAcknowledge:
type: boolean
supportsTechnicalDetailsTrigger:
type: boolean
VerificationAction:
type: object
additionalProperties: false
required:
- kind
- label
- ownedByHost
properties:
kind:
$ref: '#/components/schemas/VerificationActionKind'
label:
type: string
ownedByHost:
type: boolean
VerificationViewZone:
type: object
additionalProperties: false
required:
- key
- label
- defaultVisible
properties:
key:
type: string
label:
type: string
defaultVisible:
type: boolean
optional:
type: boolean
VerificationNextStepPlacement:
type: string
enum:
- shared_zone
- host_action_zone
VerificationNextStep:
type: object
additionalProperties: false
required:
- label
- placement
- ownedByHost
properties:
label:
type: string
placement:
$ref: '#/components/schemas/VerificationNextStepPlacement'
ownedByHost:
type: boolean
actionKind:
oneOf:
- $ref: '#/components/schemas/VerificationActionKind'
- type: 'null'
VerificationOptionalZone:
type: string
enum:
- technical_details
- change_indicator
- previous_run_context
VerificationIssueGroup:
type: object
additionalProperties: false
required:
- label
- checks
properties:
label:
type: string
checks:
type: array
items:
type: object
VerificationDiagnostics:
type: object
additionalProperties: false
required:
- hasTechnicalZone
properties:
hasTechnicalZone:
type: boolean
fingerprint:
type:
- string
- 'null'
previousRunUrl:
type:
- string
- 'null'
operationRunId:
type:
- integer
- 'null'
VerificationReportSurfaceContract:
type: object
additionalProperties: false
required:
- hostKind
- coreState
- summary
- issueGroups
- passedChecks
- diagnostics
- viewZones
- nextSteps
- hostVariation
properties:
hostKind:
$ref: '#/components/schemas/VerificationHostKind'
coreState:
$ref: '#/components/schemas/VerificationCoreState'
summary:
type: object
additionalProperties: false
required:
- overallLabel
- counts
properties:
overallLabel:
type: string
counts:
type: object
additionalProperties:
type: integer
issueGroups:
type: array
items:
$ref: '#/components/schemas/VerificationIssueGroup'
passedChecks:
type: array
items:
type: object
diagnostics:
$ref: '#/components/schemas/VerificationDiagnostics'
viewZones:
type: array
items:
$ref: '#/components/schemas/VerificationViewZone'
nextSteps:
type: array
items:
$ref: '#/components/schemas/VerificationNextStep'
hostActions:
type: array
items:
$ref: '#/components/schemas/VerificationAction'
hostVariation:
$ref: '#/components/schemas/VerificationHostVariation'
optionalZones:
type: array
items:
$ref: '#/components/schemas/VerificationOptionalZone'
emptyState:
type:
- object
- 'null'

View File

@ -0,0 +1,232 @@
# Phase 1 Data Model: Shared Detail Micro-UI Contract
## Overview
This feature adds no database table, no persisted UI artifact, and no new state family. It formalizes two existing shared detail families as runtime contracts with explicit host-variation boundaries.
## Persistent Source Truths
### OperationRun
**Purpose**: Supplies stored verification status, timing, target scope, failure summary, and the raw verification report payload already used by the current verification hosts.
**Key fields**:
- `id`
- `workspace_id`
- `tenant_id`
- `type`
- `status`
- `outcome`
- `started_at`
- `completed_at`
- `context.verification_report`
- `context.target_scope`
- `failure_summary`
**Validation rules**:
- Verification family rendering remains DB-only and derives only from stored run data.
- No host may introduce a second source of verification truth outside the run payload and existing support helpers.
### Policy and PolicyVersion snapshot truth
**Purpose**: Supplies the source payloads for normalized settings and version-to-version diffs.
**Key fields**:
- `policies.id`
- `policies.tenant_id`
- `policies.policy_type`
- `policies.platform`
- `policy_versions.id`
- `policy_versions.policy_id`
- `policy_versions.snapshot`
- `policy_versions.policy_type`
- `policy_versions.platform`
**Validation rules**:
- The settings and diff families remain derived from current normalized snapshot truth.
- No new persisted “surface state” may be added to remember tabs, expanded sections, or unavailable-state decisions.
### Finding drift evidence
**Purpose**: Supplies the source references and availability reasons for diff rendering on drift findings.
**Key fields**:
- `findings.id`
- `findings.tenant_id`
- `findings.finding_type`
- `findings.evidence_jsonb.summary.kind`
- `findings.evidence_jsonb.baseline.policy_version_id`
- `findings.evidence_jsonb.current.policy_version_id`
**Validation rules**:
- Diff availability remains derived from referenced versions and current tenant context.
- The shared diff family must express unavailable or partial state from these sources instead of letting each host improvise new rules.
## Existing Runtime Source Objects
### VerificationReportViewer
**Purpose**: Existing support seam that extracts and validates the verification report, computes fingerprints, and resolves previous runs.
**Current responsibilities already present**:
- `report()`
- `fingerprint()`
- `previousRun()`
- `shouldRenderForRun()`
- `redactionNotes()`
**Relationship to this feature**:
- It becomes the narrow verification-family contract seam rather than being replaced.
- It should grow family-owned shaping helpers instead of remaining only a payload extractor.
### PolicyNormalizer, VersionDiff, and DriftFindingDiffBuilder
**Purpose**: Existing domain truth builders that already normalize settings and derive diffs.
**Relationship to this feature**:
- They remain the domain truth source.
- The new family contracts sit after these builders and before host rendering.
- The feature must not create a second normalization pipeline.
### SettingsCatalogSettingsTable
**Purpose**: Existing Livewire renderer for the settings-catalog table subtype.
**Relationship to this feature**:
- It remains the renderer for one settings subtype.
- The settings family wrapper decides when it appears and with what shared wrapper semantics.
## New Derived Runtime Contracts
### VerificationReportSurfaceContract
**Purpose**: One runtime contract that defines the verification familys shared zones and allowed host variations.
#### Fields
| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `host_kind` | enum(`operation_run_detail`,`onboarding_wizard`,`tenant_widget`) | yes | Current host family consumer |
| `core_state` | enum(`unavailable`,`completed`) | yes | Shared family core state after host-owned no-run or in-progress framing |
| `summary` | object | yes | Overall badge, counts, and change-indicator data |
| `issue_groups` | array | yes | Grouped blockers, failures, warnings, and acknowledged issues |
| `passed_checks` | array | yes | Checks rendered under the passed zone |
| `diagnostics` | object | yes | Fingerprint, run identity, previous-run link, and related technical details |
| `view_zones` | array | yes | Ordered family view or tab definitions |
| `next_steps` | array | yes | Shared next-step items derived from report checks |
| `host_actions` | array | no | Host-owned actions such as assist, acknowledge, refresh, or open operation |
| `optional_zones` | array | no | Family-recognized zones that may be absent, such as technical details |
| `empty_state` | object nullable | no | Family-owned unavailable-state explanation when no valid report payload exists |
#### Validation rules
- The contract must own one tab or view model across all completed or unavailable verification surfaces.
- Hosts may append actions and framing, but may not redefine issue grouping, passed-check grouping, or diagnostics ownership.
- Host actions must declare whether they are shared navigation, host assist, or host mutation.
### VerificationReportHostVariation
**Purpose**: Declares the bounded host-owned differences for a verification-family consumer.
#### Fields
| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `owns_no_run_state` | bool | yes | Whether the host frames its own pre-run state |
| `owns_active_state` | bool | yes | Whether the host frames its own in-progress state |
| `supports_acknowledge` | bool | yes | Whether the host may surface acknowledgement mutations |
| `supports_assist` | bool | yes | Whether the host may route next steps through assist actions |
| `supports_technical_details_trigger` | bool | yes | Whether the host exposes a dedicated technical-details trigger outside the family core |
#### Validation rules
- Host variation may extend actions and framing only.
- Host variation must never add a second structural tab system or redefine the diagnostics contract.
### NormalizedSettingsSurfaceContract
**Purpose**: One runtime contract that defines the normalized settings familys wrapper ownership, subtype selection, warnings, and empty-state behavior.
#### Fields
| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `host_kind` | enum(`policy`,`policy_version`) | yes | Host context for the settings family |
| `context` | enum(`policy`,`version`) | yes | Existing context string used for table query-string isolation and titles |
| `variant` | enum(`settings_catalog_table`,`standard_blocks`) | yes | Explicit subtype used by the shared family |
| `warnings` | array | no | Warning list displayed by the family wrapper |
| `settings_table` | object nullable | no | Table subtype payload when the settings-catalog subtype is active |
| `blocks` | array | no | Standard block payload for general, table, or key-value sections |
| `section_behavior` | object | yes | Shared section-order, expansion, and empty-state ownership rules for the settings family |
| `render_expectations` | object | yes | Explicit wrapper expectations for warnings, subtype delegation, and host-framing boundaries |
| `empty_state` | object nullable | no | Family-owned empty or unavailable message when no settings payload exists |
| `title_policy` | object | yes | Rules for whether wrapper or subtype titles are shown |
#### Validation rules
- Hosts may not choose sibling top-level settings blades once the family contract exists.
- Subtypes must be explicit family variations, not host-selected main variants.
- Warning and empty-state behavior must come from the family wrapper, not from host-specific text entries.
- Section order, expansion behavior, and wrapper expectations must be explicit so hosts do not silently redefine how the family reads.
### NormalizedDiffSurfaceContract
**Purpose**: One runtime contract that defines normalized diff summary, grouped rendering, unavailable behavior, and zero-diff messaging.
#### Fields
| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `host_kind` | enum(`policy_version`,`finding`) | yes | Host context for the diff family |
| `availability_state` | enum(`available`,`unavailable`,`partial`) | yes | Family-owned availability state |
| `summary` | object | yes | Added, removed, changed counts and summary message |
| `view_modes` | array | yes | Explicit diff-family modes or views that the host may expose without redefining the family |
| `section_behavior` | object | yes | Shared grouped-section order, expansion, and fullscreen rules |
| `render_expectations` | object | yes | Explicit ownership of availability, zero-diff messaging, and host-framing boundaries |
| `groups` | array | no | Changed, added, and removed groups with ordered row payloads |
| `script_rendering` | object | no | Script-highlighting flags and grammar hints |
| `empty_state` | object nullable | no | Family-owned zero-diff or unavailable-state explanation |
#### Validation rules
- Hosts may not prepend separate unavailable-state entries for the same normalized diff concept.
- The family must own both true unavailable and zero-diff informational states.
- Rich script rendering remains allowed, but it must live inside the family contract.
- View modes, grouped-section behavior, and render expectations must be explicit so a policy-version diff and a finding diff cannot drift into separate interaction models.
## Consumer Mapping
| Consumer | Family | Shared-family responsibility | Local host responsibility |
|---|---|---|---|
| `OperationRunResource` verification section | Verification report | Completed and unavailable verification core, tab contract, diagnostics, grouped issues | Section placement inside enterprise detail, record authorization, no new mutation behavior |
| `ManagedTenantOnboardingWizard` verify step | Verification report | Completed and unavailable verification core, grouped issues, diagnostics, shared view zones | No-run and active-state framing, assist routing, acknowledge action, verify-step workflow actions |
| `TenantVerificationReport` widget | Verification report | Completed and unavailable verification core | Widget no-run and active-state framing, start-verification CTA, tenant operability copy |
| `PolicyResource` settings tab or fallback section | Normalized settings | Warnings, empty state, subtype ownership, wrapper structure | Tab framing, route context, surrounding policy detail layout |
| `PolicyVersionResource` normalized settings tab | Normalized settings | Same settings family contract as policy detail | Tab framing and version-specific surrounding detail layout |
| `PolicyVersionResource` diff tab | Normalized diff | Summary, grouped diff rendering, zero-diff and unavailable semantics | Tab framing and raw diff sidecar JSON |
| `FindingResource` diff section | Normalized diff | Same diff family contract as policy-version diff | Surrounding drift-specific sections, version resolution, and domain-specific sibling diff viewers kept out of scope |
## Derived Lifecycle
1. A host resolves current domain truth using existing run, normalization, or diff builders.
2. The host passes that truth through the appropriate shared family contract seam.
3. The family contract shapes shared zones, states, and subtype markers.
4. The host renders the shared family core and attaches only its allowed host-owned variations.
5. Tests assert that a new host cannot redefine family structure outside those variations.
## Migration Notes
- No database migration is required.
- `policy-settings-standard.blade.php` must stop acting as a sibling top-level host choice and become an internal subtype or be absorbed.
- Existing verification DB-only and tenant widget tests remain the primary regression base for the verification family.
- Existing policy-version and drift-diff tests remain the primary regression base for normalized detail families.

View File

@ -0,0 +1,46 @@
# Closing Migration Note: Shared Detail Micro-UI Contract
This file is the release-acceptance artifact for Spec 197. Update it as implementation closes so FR-197-015 and D-197-004 are satisfied without overloading `quickstart.md`.
## Migrated Hosts
### Verification Report family
- `OperationRunResource` verification detail surface via `VerificationReportViewer::surface(...)` and shared summary, issues, passed, diagnostics, and unavailable zones
- `ManagedTenantOnboardingWizard` verification step surface via the same shared verification-family core with onboarding-only assist, acknowledge, and technical-detail variations layered around it
- `TenantVerificationReport` widget surface via the same completed-state verification-family core while preserving widget-owned no-run and in-progress framing
### Normalized Settings family
- `PolicyResource` normalized settings surface via `NormalizedSettingsSurface::build(...)`
- `PolicyVersionResource` normalized settings surface via `NormalizedSettingsSurface::build(...)`
- `policy-settings-standard.blade.php` retained only as an include-only compatibility shim into the normalized-settings family wrapper
- `SettingsCatalogSettingsTable` retained as the settings-catalog subtype renderer inside the normalized-settings family
### Normalized Diff family
- `PolicyVersionResource` normalized diff surface via `NormalizedDiffSurface::build(...)` and the shared summary, grouped-rendering, and empty-state wrapper
- `FindingResource` normalized diff surface via `NormalizedDiffSurface::build(...)` for available, unavailable, and zero-diff states
## Intentionally Allowed Remaining Variations
- Onboarding-specific assist, acknowledge, and technical-details actions remain host-scoped variations.
- No-run and in-progress framing remain host-owned where the host genuinely owns that lifecycle state.
- `SettingsCatalogSettingsTable` remains the settings-catalog subtype renderer inside the normalized-settings family.
- Drift-specific surrounding context remains host-owned in `FindingResource` as long as the normalized diff family core stays shared.
- The compatibility view `policy-settings-standard.blade.php` remains as a thin include-only shim so existing callers/tests do not become a new fork point.
## Manual Smoke Evidence
- Reviewer: GitHub Copilot via the local integrated browser using the local smoke-login flow
- Review date: 2026-04-15
- SC-197-003 result: Pass. Operation detail `Operation #6655`, the onboarding verify-step host, and the tenant verification widget all exposed the same verification-family core with recognizable summary, issues, passed, diagnostics, and read-only zones. Onboarding kept its bounded host-owned assist and technical-detail behavior, while the tenant widget kept its widget-specific framing on the tenant management detail page.
- SC-197-004 result: Pass. Policy detail `Policy #233`, policy-version detail `Policy Version #270`, drift finding `#79`, and a temporary local-only unavailable drift finding clone all rendered family-consistent normalized settings or diff behavior. The available diff surface matched the policy-version grouped diff contract, and the unavailable diff surface rendered the same family-owned unavailable-state treatment instead of a host-local message.
- Notes: Local browser smoke used `/admin/local/smoke-login` against Phoenicon in workspace `wp`. The tenant verification widget evidence came from the tenant management detail page rather than the tenant dashboard, which is the current widget host. The local dev dataset did not include a resumable verify-step onboarding draft or a natural missing-version drift finding for this tenant family, so temporary local-only fixture records were created, exercised, and deleted after verification. `./vendor/bin/sail bin pint --dirty --format agent` and the focused Sail regression pack had already passed in this feature session.
## Out-of-Scope Follow-Ups
- Local dev seed data still lacks a reusable resumable verify-step onboarding draft and a natural missing-version drift finding for this tenant family; future smoke work will need either richer seed data or the same kind of ephemeral local fixtures.
- Shell-level refactors discovered during implementation remain intentionally out of scope.
- Monitoring page-state topics discovered during implementation remain intentionally out of scope.
- Any future shared-detail framework discussion beyond the two proven families remains intentionally out of scope.

View File

@ -0,0 +1,269 @@
# Implementation Plan: Shared Detail Micro-UI Contract
**Branch**: `197-shared-detail-contract` | **Date**: 2026-04-15 | **Spec**: `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/specs/197-shared-detail-contract/spec.md`
**Input**: Feature specification from `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/specs/197-shared-detail-contract/spec.md`
**Note**: This template is filled in by the `/speckit.plan` command. See `.specify/scripts/` for helper scripts.
## Summary
Standardize two already real shared detail families without introducing a generic UI framework. The first slice extends the existing verification support seam so `OperationRunResource`, `ManagedTenantOnboardingWizard`, and `TenantVerificationReport` render the same family-owned verification surface with one tab contract, one diagnostics contract, and explicit host-owned variation slots for assist, acknowledge, and host framing. The second slice standardizes normalized settings and normalized diff around family-owned wrappers that make subtype handling, unavailable states, section structure, and view behavior explicit across `PolicyResource`, `PolicyVersionResource`, and `FindingResource`, while preserving the existing Livewire settings table and rich diff rendering where the domain still needs it. The implementation stays derived-only, keeps Filament v5 + Livewire v4 semantics, adds no new assets or persistence, and protects the result with cross-host parity tests plus a small guard against reintroducing ad hoc host forks.
## Technical Context
**Language/Version**: PHP 8.4.15
**Primary Dependencies**: Laravel 12, Filament v5, Livewire v4, Pest v4, Tailwind CSS v4, existing `VerificationReportViewer`, `VerificationReportChangeIndicator`, `PolicyNormalizer`, `VersionDiff`, `DriftFindingDiffBuilder`, and `SettingsCatalogSettingsTable`
**Storage**: PostgreSQL unchanged; no new persistence, cache store, or durable UI artifact
**Testing**: Pest 4 unit and feature tests, including Livewire component coverage for widgets/pages and focused guard tests, run through Laravel Sail
**Target Platform**: Laravel monolith web application in Sail locally and containerized Linux deployment for staging and production
**Project Type**: web application
**Performance Goals**: Keep verification viewers DB-only at render time, avoid any new outbound HTTP or queued work, preserve current diff and settings render responsiveness, and prevent repeated host-local surface logic from re-normalizing or re-shaping the same data differently
**Constraints**: No new persisted truth, no new Graph call path, no new panel/provider registration change, no hidden shell or monitoring-state refactor, no generic component framework, and no new destructive actions in the shared family core
**Scale/Scope**: Two shared families across seven concrete host surfaces: verification in operation detail, onboarding wizard, and tenant widget; normalized settings or diff in policy detail, policy-version detail, and finding detail, plus the existing standard-settings sibling view that must be absorbed or demoted into an explicit subtype
## 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 | Both families remain pure renderers of already stored `OperationRun`, policy snapshot, and finding evidence truth. No new snapshot or inventory semantics are added. |
| Read/write separation | PASS | PASS | The shared-family contracts are read-only. Existing host actions such as start verification, refresh, assist, or acknowledge remain host-owned and keep their current mutation semantics. |
| Graph contract path | N/A | N/A | No Graph calls or `config/graph_contracts.php` changes are introduced. |
| Deterministic capabilities | PASS | PASS | No new capability family is introduced. Existing host authorization and capability registries remain authoritative. |
| Workspace + tenant isolation | PASS | PASS | Every host continues to resolve data inside existing workspace and tenant scope. Tenantless operation detail remains subject to existing entitlement checks before rendering tenant-owned verification data. |
| RBAC-UX authorization semantics | PASS | PASS | No 404/403 rules change. Non-members remain not found, in-scope capability denial stays forbidden where host actions already enforce it. |
| Run observability / Ops-UX | PASS | PASS | No new `OperationRun` type, lifecycle, or notification path is added. Existing operation links remain navigation-only. |
| Data minimization | PASS | PASS | Contracts stay runtime-only and derive from existing stored payloads without new mirrors or caches. |
| Proportionality / no premature abstraction | PASS WITH JUSTIFIED ABSTRACTION | PASS WITH JUSTIFIED ABSTRACTION | One narrow contract seam per family is justified because both families already exist across multiple concrete hosts. The design explicitly rejects a cross-domain shared-detail framework. |
| Persisted truth / behavioral state | PASS | PASS | No new tables, persisted UI artifacts, or new state families are introduced. |
| UI semantics / few layers | PASS | PASS | The plan replaces duplicated host ownership with family-owned contracts. It does not add a new presenter stack or badge taxonomy. |
| Badge semantics (BADGE-001) | PASS | PASS | Existing badge domains remain authoritative. Shared-family work reuses them rather than introducing host-local mappings. |
| Filament-native UI / Action Surface Contract | PASS | PASS | Family surfaces remain embedded detail/evidence surfaces inside existing Filament pages and widgets. No new row or bulk action hierarchy is introduced. |
| Filament UX-001 | PASS | PASS | The plan changes detail-family ownership only. Existing view-style layouts remain in place and must stay operator-first. |
| Filament v5 / Livewire v4 compliance | PASS | PASS | The implementation remains within Filament v5 and Livewire v4 only. No legacy Filament or Livewire v3 APIs are introduced. |
| Provider registration location | PASS | PASS | No panel or provider registration change is required. Laravel 11+ provider registration remains in `bootstrap/providers.php`. |
| Global search hard rule | PASS | PASS | No global-search behavior changes are planned. The touched resources already render viewable detail pages, so the Filament global-search requirement remains satisfied. |
| Destructive action safety | PASS | PASS | No new destructive actions are added inside either shared family. Any host mutation remains outside the family core and keeps existing confirmation and authorization rules. |
| Asset strategy | PASS | PASS | No new JS or CSS assets are planned. No `filament:assets` deployment change is required. |
| Testing truth (TEST-TRUTH-001) | PASS | PASS | The test plan focuses on cross-host parity, DB-only rendering, unavailable-state consistency, and guard coverage against re-forking, instead of thin indirection-only tests. |
## Phase 0 Research
Research outcomes are captured in `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/specs/197-shared-detail-contract/research.md`.
Key decisions:
- Standardize the Verification Report family by extending the existing `VerificationReportViewer` seam and one family-owned Blade root instead of creating a new Livewire component or a custom entry type that only solves one host class.
- Treat onboarding-specific assist and acknowledge behavior as explicit host-owned variation slots of the verification family rather than a parallel report UI.
- Converge `normalized-settings.blade.php` and `policy-settings-standard.blade.php` under one family-owned settings wrapper with explicit subtypes, instead of continuing host-level view switching.
- Move normalized diff unavailable and partial-state behavior into the family contract so `FindingResource` and `PolicyVersionResource` stop expressing different availability semantics for the same concept.
- Keep `SettingsCatalogSettingsTable` as the existing Livewire subtype used by the settings family, since it already provides search, sort, pagination, and context-safe query-string isolation.
- Protect the family contracts with parity tests and a small fork guard, not with a generic UI meta-framework or a screenshot-heavy suite.
## Phase 1 Design
Design artifacts are created under `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/specs/197-shared-detail-contract/`:
- `data-model.md`: runtime contracts, host variation model, and source-truth boundaries for both shared families
- `contracts/verification-report-family.openapi.yaml`: internal logical contract for the verification family and its approved hosts
- `contracts/normalized-detail-family.openapi.yaml`: internal logical contract for normalized settings and normalized diff families and their approved hosts
- `quickstart.md`: focused implementation, verification, and smoke workflow
Release-acceptance artifact maintained during implementation:
- `migration-note.md`: closing migrated-host inventory, bounded allowed variations, manual smoke evidence, and out-of-scope follow-ups required for release acceptance
Design decisions:
- Extend the existing `VerificationReportViewer` support seam instead of replacing it.
- Add narrow normalized-detail support builders under existing Filament support paths rather than creating a new base framework.
- Keep subtype richness for settings catalog tables, standard key-value settings, script content rendering, and diff block rendering, but move wrapper ownership and unavailable-state ownership into one family contract.
- Keep host no-run and in-progress framing local where the host genuinely owns that state, while making the completed or unavailable family core the same surface everywhere.
## Project Structure
### Documentation (this feature)
```text
specs/197-shared-detail-contract/
├── spec.md
├── plan.md
├── research.md
├── data-model.md
├── migration-note.md
├── quickstart.md
├── contracts/
│ ├── normalized-detail-family.openapi.yaml
│ └── verification-report-family.openapi.yaml
├── checklists/
│ └── requirements.md
└── tasks.md
```
### Source Code (repository root)
```text
apps/platform/
├── app/
│ ├── Filament/
│ │ ├── Pages/
│ │ │ └── Workspaces/
│ │ │ └── ManagedTenantOnboardingWizard.php
│ │ ├── Resources/
│ │ │ ├── FindingResource.php
│ │ │ ├── OperationRunResource.php
│ │ │ ├── PolicyResource.php
│ │ │ └── PolicyVersionResource.php
│ │ ├── Support/
│ │ │ ├── VerificationReportChangeIndicator.php
│ │ │ ├── VerificationReportViewer.php
│ │ │ ├── NormalizedSettingsSurface.php
│ │ │ └── NormalizedDiffSurface.php
│ │ └── Widgets/
│ │ └── Tenant/
│ │ └── TenantVerificationReport.php
│ └── Livewire/
│ └── SettingsCatalogSettingsTable.php
├── resources/
│ └── views/
│ └── filament/
│ ├── components/
│ │ ├── verification-report-viewer.blade.php
│ │ └── verification-report/
│ ├── forms/
│ │ └── components/
│ │ └── managed-tenant-onboarding-verification-report.blade.php
│ ├── infolists/
│ │ └── entries/
│ │ ├── normalized-diff.blade.php
│ │ ├── normalized-settings.blade.php
│ │ └── policy-settings-standard.blade.php
│ └── widgets/
│ └── tenant/
│ └── tenant-verification-report.blade.php
└── tests/
├── Feature/
│ ├── Drift/
│ ├── Filament/
│ ├── Onboarding/
│ ├── Verification/
│ └── Guards/
└── Unit/
```
**Structure Decision**: Keep the existing Laravel monolith layout and current Filament resource/widget/view directories. Add only narrow support builders under `app/Filament/Support` and family-owned partials under existing `resources/views/filament/...` paths. Do not add a new shared UI framework directory or new base package.
## Implementation Strategy
### Phase A — Cut the Verification Report Family Contract
**Goal**: Make one family-owned verification surface the semantic owner of completed and unavailable report rendering across the covered hosts.
| Step | File | Change |
|------|------|--------|
| A.1 | `apps/platform/app/Filament/Support/VerificationReportViewer.php` | Extend the existing seam so it can build the shared verification surface contract, not only extract sanitized report payloads. |
| A.2 | `apps/platform/resources/views/filament/components/verification-report-viewer.blade.php` and new family-owned partials under `apps/platform/resources/views/filament/components/verification-report/` | Move summary, issues, passed, diagnostics, and optional action-zone ownership into one shared Blade root with one tab or view contract. |
| A.3 | `apps/platform/resources/views/filament/forms/components/managed-tenant-onboarding-verification-report.blade.php` | Remove local verification-family tab ownership and local structural duplication; render the same family core with explicit onboarding-only variation slots for assist, acknowledge, and technical details. |
| A.4 | `apps/platform/app/Filament/Pages/Workspaces/ManagedTenantOnboardingWizard.php`, `apps/platform/app/Filament/Resources/OperationRunResource.php`, `apps/platform/app/Filament/Widgets/Tenant/TenantVerificationReport.php`, and `apps/platform/resources/views/filament/widgets/tenant/tenant-verification-report.blade.php` | Pass only explicit host context and host-owned actions into the verification family contract while keeping no-run or in-progress framing local where appropriate. |
### Phase B — Standardize the Normalized Settings Family
**Goal**: Make one family-owned settings wrapper the contract owner for warnings, empty state, subtype selection, and section behavior.
| Step | File | Change |
|------|------|--------|
| B.1 | `apps/platform/app/Filament/Support/NormalizedSettingsSurface.php` | Add a narrow runtime builder that shapes normalized settings state into one explicit family contract with subtype markers and render expectations. |
| B.2 | `apps/platform/resources/views/filament/infolists/entries/normalized-settings.blade.php` and subtype partials under `apps/platform/resources/views/filament/infolists/entries/normalized-settings/` | Own warnings, empty-state behavior, wrapper headings, and subtype delegation inside one family surface instead of splitting ownership between sibling host-selected blades. |
| B.3 | `apps/platform/resources/views/filament/infolists/entries/policy-settings-standard.blade.php` | Absorb this blade into the normalized-settings family as an internal subtype partial or retire it as a direct host-facing sibling view. |
| B.4 | `apps/platform/app/Filament/Resources/PolicyResource.php` and `apps/platform/app/Filament/Resources/PolicyVersionResource.php` | Stop choosing between sibling settings views at the host level and always pass family contract state to the normalized settings surface. |
| B.5 | `apps/platform/app/Livewire/SettingsCatalogSettingsTable.php` | Keep the existing table component as the settings-catalog subtype renderer while ensuring the shared family contract owns when and how it is shown. |
### Phase C — Standardize the Normalized Diff Family
**Goal**: Make one family-owned diff wrapper the contract owner for summary, unavailable state, zero-diff messaging, and grouped detail rendering.
| Step | File | Change |
|------|------|--------|
| C.1 | `apps/platform/app/Filament/Support/NormalizedDiffSurface.php` | Add a narrow runtime builder that shapes diff state, availability state, and zero-change semantics into one explicit family contract. |
| C.2 | `apps/platform/resources/views/filament/infolists/entries/normalized-diff.blade.php` and family-owned partials under `apps/platform/resources/views/filament/infolists/entries/normalized-diff/` | Move unavailable, partial, summary, and grouped-render ownership into the shared diff family. Preserve script highlighting and grouped row rendering as explicit subzones of that family. |
| C.3 | `apps/platform/app/Filament/Resources/FindingResource.php` | Replace host-owned diff-unavailable `TextEntry` behavior with family-owned unavailable state input so drift detail uses the same contract as other normalized diff hosts. |
| C.4 | `apps/platform/app/Filament/Resources/PolicyVersionResource.php` | Route normalized diff state through the shared diff builder so policy-version diff and finding diff expose the same family semantics for available and unavailable paths. |
### Phase D — Protect the Family Boundaries
**Goal**: Make future host changes extend the family contracts instead of silently re-forking them.
| Step | File | Change |
|------|------|--------|
| D.1 | `apps/platform/tests/Feature/Guards/SharedDetailFamilyContractGuardTest.php` | Add a focused guard that blocks new ad hoc host forks, such as host-selected `policy-settings-standard` references or duplicated verification-family tab ownership outside the shared core. |
| D.2 | `apps/platform/tests/Feature/Filament/SharedVerificationReportFamilyContractTest.php` and `apps/platform/tests/Feature/Filament/NormalizedDetailFamilyContractTest.php` | Add cross-host parity tests that assert the same family zones, unavailable semantics, and allowed host-variation boundaries. |
| D.3 | `apps/platform/tests/Feature/Verification/VerificationReportViewerDbOnlyTest.php`, `apps/platform/tests/Feature/Filament/TenantVerificationReportWidgetTest.php`, `apps/platform/tests/Feature/Onboarding/OnboardingVerificationTest.php`, `apps/platform/tests/Feature/Onboarding/OnboardingVerificationV1_5UxTest.php`, `apps/platform/tests/Feature/MonitoringOperationsTest.php`, `apps/platform/tests/Feature/Spec085/DenyAsNotFoundSemanticsTest.php`, `apps/platform/tests/Feature/Onboarding/OnboardingDraftAccessTest.php`, `apps/platform/tests/Feature/Filament/PolicyVersionSettingsTest.php`, `apps/platform/tests/Feature/Filament/SettingsCatalogPolicyNormalizedDiffTest.php`, `apps/platform/tests/Feature/Filament/GroupPolicyConfigurationNormalizedDiffTest.php`, `apps/platform/tests/Feature/Drift/DriftFindingDiffUnavailableTest.php`, `apps/platform/tests/Feature/Filament/PolicyResourceAdminTenantParityTest.php`, `apps/platform/tests/Feature/Filament/PolicyVersionAdminTenantParityTest.php`, and `apps/platform/tests/Feature/Findings/FindingRbacTest.php` | Extend existing feature coverage so it asserts the new family contracts without losing current DB-only, RBAC-safe, deny-as-not-found, capability-safe, and domain-rich behavior. |
### Phase E — Close the Migration and Verify Operator Sameness
**Goal**: Finish the feature with an explicit migrated-host inventory and a manual smoke path that matches the specs acceptance model.
| Step | File | Change |
|------|------|--------|
| E.1 | `specs/197-shared-detail-contract/tasks.md` | Break implementation into dependency-ordered tasks across verification family, normalized settings family, normalized diff family, tests, and migration notes. |
| E.2 | `specs/197-shared-detail-contract/migration-note.md` | Record migrated hosts, consciously allowed remaining variations, smoke-review evidence, and out-of-scope follow-ups required by release acceptance. |
| E.3 | `specs/197-shared-detail-contract/quickstart.md` | Use the verification order and smoke path below to prove operator sameness across hosts and feed the result into the migration note. |
## Key Design Decisions
### D-001 — Verification Report remains one Blade-rooted family because its hosts are heterogeneous
The verification family already spans an infolist-style detail section, a form `ViewField`, and a widget include. A new custom infolist entry or a new Livewire component would only solve one host class and would risk over-abstracting the rest. The narrowest path is to extend the existing `VerificationReportViewer` seam and let one family-owned Blade root own structure.
### D-002 — Onboarding keeps host-owned actions but loses structural ownership
The onboarding wizard legitimately owns verify-step actions, assist routing, and acknowledge mutations. It does not need to own the reports tab system or its core diagnostics structure. The plan therefore keeps these actions as explicit host slots, not as a second verification UI.
### D-003 — `policy-settings-standard` becomes a subtype, not a peer family
The repo already exposes two sibling settings surfaces for the same conceptual family. The plan explicitly keeps settings-catalog and standard-settings richness, but moves them under one family wrapper so hosts stop selecting unrelated siblings at the top level.
### D-004 — Unavailable-state ownership belongs inside normalized diff
`FindingResource` currently owns unavailable messaging outside the diff family, while other diff hosts do not. The family contract should own availability, partial-state, and zero-diff semantics so the same content concept cannot drift at the host boundary.
### D-005 — The test strategy protects family sameness, not only helper output
The plan prefers cross-host parity tests, DB-only safety tests, and one small fork guard. It explicitly avoids a new UI meta-test framework or broad screenshot infrastructure because the product need is bounded and already well represented by existing feature tests.
## Risk Assessment
| Risk | Impact | Likelihood | Mitigation |
|------|--------|------------|------------|
| Verification onboarding loses assist or acknowledge power while removing its fork | High | Medium | Keep assist and acknowledge as host-owned variation slots and extend onboarding tests before removing duplicated structural markup. |
| Standardizing settings removes useful subtype richness | High | Medium | Keep explicit subtypes for settings-catalog tables, standard blocks, and script-heavy content; standardize the wrapper, not the domain richness away. |
| Diff unavailable semantics collapse distinct host cases | Medium | Medium | Make unavailable and partial states explicit contract inputs and cover missing-version and empty-side finding scenarios in feature tests. |
| The feature grows into a generic shared-detail framework | High | Low | Limit support additions to one existing verification seam plus one settings builder and one diff builder under current Filament support paths. No cross-domain base class or registry is introduced. |
| New hosts later reintroduce ad hoc forks | Medium | Medium | Add one focused fork guard and internal contract YAMLs that document approved consumers and forbidden host-level patterns. |
## Test Strategy
- Extend verification-family feature coverage so `OperationRunResource`, onboarding verification, and the tenant verification widget all assert the same family-owned summary, issues, passed, diagnostics, and unavailable semantics for equivalent report data.
- Keep `VerificationReportViewerDbOnlyTest` and `TenantVerificationReportWidgetTest` as truth guards that the standardized family remains DB-only and does not introduce outbound work.
- Extend onboarding verification tests to prove host-specific assist and acknowledge actions survive as bounded variations of the same family.
- Extend normalized settings and diff coverage in `PolicyVersionSettingsTest`, `SettingsCatalogPolicyNormalizedDiffTest`, `GroupPolicyConfigurationNormalizedDiffTest`, and `DriftFindingDiffUnavailableTest` so they assert family-level structure and unavailable behavior instead of only content presence.
- Add one new parity test per family and one focused guard test that blocks direct host fork patterns.
- Use Livewire component tests for widgets and pages, and do not try to mount non-Livewire resource classes directly. This follows Filament v5 testing guidance.
- Run the smallest Sail verification pack plus `pint --dirty --format agent` before implementation completion.
## Complexity Tracking
| Violation | Why Needed | Simpler Alternative Rejected Because |
|-----------|------------|-------------------------------------|
| One new normalized-settings builder and one new normalized-diff builder | The repo already has multiple concrete hosts for each family, but no existing support seam analogous to `VerificationReportViewer` for normalized detail families | Leaving host files to shape state ad hoc would preserve the exact drift this spec is meant to remove |
| One focused fork guard | The features long-term value depends on preventing new host forks after the first cleanup | Relying only on reviewer memory or prose documentation would not reliably stop drift from returning |
## Proportionality Review
- **Current operator problem**: Operators encounter the same verification or normalized detail concept in multiple hosts, but the structure, next-step zone, diagnostics placement, and unavailable behavior vary enough to slow recognition and trust.
- **Existing structure is insufficient because**: Shared Blade reuse exists, but shared contract ownership does not. Hosts still decide core structure and availability semantics themselves, which is why drift already exists.
- **Narrowest correct implementation**: Extend the existing verification support seam, add two narrow normalized-detail support builders, and move family wrapper ownership into current Blade paths. Do not add persistence, a new framework, or a cross-domain taxonomy.
- **Ownership cost created**: Three narrow support seams in current Filament support paths, a handful of family-owned partials, two internal contract YAMLs, and focused parity or guard tests.
- **Alternative intentionally rejected**: Continue local host cleanups or invent a full shared-detail framework. Local cleanup would not stop drift, and a framework would import much more permanent complexity than this feature needs.
- **Release truth**: Current-release truth. The affected families and host drift already exist in shipped operator surfaces.

View File

@ -0,0 +1,110 @@
# Quickstart: Shared Detail Micro-UI Contract
## Goal
Validate that the same verification and normalized detail concepts now render through family-owned contracts across their covered hosts, without adding persistence, Graph calls, or a generic UI framework.
## Prerequisites
1. Start Sail.
2. Ensure you have a tenant with at least one completed verification run and one onboarding session that can resume on the verify step.
3. Ensure you have policy and policy-version data that exercises both settings-catalog and standard-settings rendering.
4. Ensure you have at least one drift finding with an available normalized diff and one drift finding with missing version references.
5. Ensure the current user can open the covered hosts in tenant and workspace-safe contexts.
## Implementation Validation Order
### 1. Run verification-family regression coverage
```bash
export PATH="/bin:/usr/bin:/usr/local/bin:$PATH"
cd /Users/ahmeddarrazi/Documents/projects/TenantAtlas/apps/platform
./vendor/bin/sail artisan test --compact tests/Feature/Verification/VerificationReportViewerDbOnlyTest.php
./vendor/bin/sail artisan test --compact tests/Feature/Filament/TenantVerificationReportWidgetTest.php
./vendor/bin/sail artisan test --compact tests/Feature/Onboarding/OnboardingVerificationTest.php
./vendor/bin/sail artisan test --compact tests/Feature/Onboarding/OnboardingVerificationV1_5UxTest.php
./vendor/bin/sail artisan test --compact tests/Feature/MonitoringOperationsTest.php
./vendor/bin/sail artisan test --compact tests/Feature/Spec085/DenyAsNotFoundSemanticsTest.php
./vendor/bin/sail artisan test --compact tests/Feature/Onboarding/OnboardingDraftAccessTest.php
./vendor/bin/sail artisan test --compact --filter=SharedVerificationReportFamilyContract
```
Expected outcome:
- Operation detail, onboarding, and tenant widget all expose the same verification-family core.
- Verification rendering stays DB-only and does not dispatch outbound work.
- Onboarding-only assist and acknowledge actions remain available as bounded host variations.
- Workspace-context and tenant-context verification hosts keep deny-as-not-found and capability-safe behavior after the shared viewer refactor.
### 2. Run normalized detail-family regression coverage
```bash
export PATH="/bin:/usr/bin:/usr/local/bin:$PATH"
cd /Users/ahmeddarrazi/Documents/projects/TenantAtlas/apps/platform
./vendor/bin/sail artisan test --compact tests/Feature/Filament/PolicyVersionSettingsTest.php
./vendor/bin/sail artisan test --compact tests/Feature/Filament/SettingsCatalogPolicyNormalizedDiffTest.php
./vendor/bin/sail artisan test --compact tests/Feature/Filament/GroupPolicyConfigurationNormalizedDiffTest.php
./vendor/bin/sail artisan test --compact tests/Feature/Drift/DriftFindingDiffUnavailableTest.php
./vendor/bin/sail artisan test --compact tests/Feature/Filament/PolicyResourceAdminTenantParityTest.php
./vendor/bin/sail artisan test --compact tests/Feature/Filament/PolicyVersionAdminTenantParityTest.php
./vendor/bin/sail artisan test --compact tests/Feature/Findings/FindingRbacTest.php
./vendor/bin/sail artisan test --compact --filter=NormalizedDetailFamilyContract
```
Expected outcome:
- Policy and policy-version settings use the same family-owned wrapper with explicit subtype behavior.
- Policy-version diff and finding diff use the same normalized diff family semantics for available, unavailable, and zero-diff cases.
- No host still relies on a sibling top-level settings view or a host-only diff unavailable message for the same concept.
- Tenant-scoped policy, policy-version, and finding detail hosts preserve existing deny-as-not-found and capability semantics.
### 3. Run the fork guard and targeted parity checks
```bash
export PATH="/bin:/usr/bin:/usr/local/bin:$PATH"
cd /Users/ahmeddarrazi/Documents/projects/TenantAtlas/apps/platform
./vendor/bin/sail artisan test --compact tests/Feature/Guards/SharedDetailFamilyContractGuardTest.php
./vendor/bin/sail artisan test --compact --filter=FamilyContract
```
Expected outcome:
- Guard coverage blocks reintroduction of direct host forks.
- Parity tests prove the same family zones and the same unavailable-state semantics across hosts.
### 4. Format touched files
```bash
export PATH="/bin:/usr/bin:/usr/local/bin:$PATH"
cd /Users/ahmeddarrazi/Documents/projects/TenantAtlas/apps/platform
./vendor/bin/sail bin pint --dirty --format agent
```
Expected outcome:
- All touched PHP files conform to the repos Pint rules.
## Manual Smoke Check
1. Open the operation run detail that shows a verification report and confirm you can immediately identify summary, issues, passed, and technical details zones.
2. Open the onboarding wizard verify step for the same or equivalent verification payload and confirm the same core verification surface appears, with onboarding-only assist or acknowledge behavior layered on top instead of a different tab model.
3. Open the tenant verification widget and confirm the completed-state core matches the other verification hosts while no-run and in-progress framing stays widget-specific.
4. Open a policy-version detail view and a policy detail view that both show normalized settings and confirm warnings, empty-state behavior, and subtype structure feel like one family.
5. Open a policy-version detail view that shows normalized diff and confirm summary, grouped sections, and availability messaging match the same shared diff-family contract used elsewhere.
6. Open a drift finding with missing referenced versions and confirm the diff area uses a family-consistent unavailable state.
7. Open a drift finding with an available normalized diff and confirm the grouped diff surface feels like the same family as policy-version diff, not a new host-specific micro-app.
## Release Acceptance Recording
1. Update `specs/197-shared-detail-contract/migration-note.md` with the migrated hosts actually touched by the implementation.
2. Record the consciously allowed remaining variations that still differ by host.
3. Capture reviewer, review date, and pass or fail notes for SC-197-003 and SC-197-004 in the migration note.
4. Record any shell, monitoring-state, or other intentionally out-of-scope follow-ups in the migration note instead of expanding this feature silently.
## Non-Goals For This Slice
- No database migration.
- No new public API.
- No new Graph call path or `OperationRun` lifecycle change.
- No new Filament asset registration or `filament:assets` deployment change.
- No shell, workspace context bar, or monitoring page-state refactor.

View File

@ -0,0 +1,74 @@
# Phase 0 Research: Shared Detail Micro-UI Contract
## Research Inputs
- Repository truth from current hosts and tests for `verification-report-viewer`, onboarding verification, tenant verification widget, `normalized-settings`, `policy-settings-standard`, and `normalized-diff`
- Filament v5 notes in `docs/research/filament-v5-notes.md`
- Version-specific Filament documentation for custom fields, custom infolist entries, render hooks, and testing
## Decision 1 — Verification Report should stay Blade-rooted and support-backed
**Decision**: Standardize the Verification Report family by extending the existing `VerificationReportViewer` support seam and one shared Blade root, instead of introducing a new Livewire component or a custom Filament entry class.
**Rationale**: The family already spans three host classes: an infolist-style run detail, a form `ViewField` inside onboarding, and a widget include. Filament custom entries are reusable, but they are still infolist-specific. Filament custom fields are form-specific. A new Livewire component would impose a heavier lifecycle and more new surface area than this feature needs. The existing support seam already owns report extraction, fingerprinting, and previous-run lookup, which is the right narrow base.
**Alternatives considered**:
- Create a custom infolist entry for verification: rejected because onboarding and widget hosts would still need separate structural ownership.
- Create a dedicated Livewire verification viewer: rejected because it imports new lifecycle complexity for a mostly read-only, DB-only surface.
- Keep the onboarding fork and only restyle it: rejected because the drift is structural, not cosmetic.
## Decision 2 — Onboarding-specific assist and acknowledge behavior must remain host-owned slots
**Decision**: Treat assist, acknowledge, refresh, and technical-details triggers in onboarding as explicit host-owned variations of the verification family, not as a second verification UI contract.
**Rationale**: The onboarding wizard legitimately owns interactive workflow actions and different no-run or in-progress framing. The shared family should own summary, tabs or view zones, issues, passed checks, diagnostics, and unavailable behavior. This respects the specs rule that hosts may extend actions and placement without redefining the family core.
**Alternatives considered**:
- Move assist and acknowledge logic into the shared core: rejected because those actions are wizard-specific and would leak workflow behavior into unrelated hosts.
- Leave onboarding fully separate: rejected because it preserves the existing duplicate tab contract and duplicated grouping logic.
## Decision 3 — Normalized settings must converge under one family wrapper with explicit subtypes
**Decision**: Standardize normalized settings through one family-owned wrapper that explicitly supports at least two subtypes: settings-catalog table rendering and standard block or key-value rendering.
**Rationale**: `PolicyResource` and `PolicyVersionResource` currently choose between `normalized-settings` and `policy-settings-standard` at the host level. That means the host, not the family, owns warnings, wrapper titles, empty state, and subtype selection. The repo already proves the two subtypes are real. The narrowest fix is not to flatten them, but to put both behind one family wrapper.
**Alternatives considered**:
- Keep both views and only document when to use each: rejected because the host-level fork remains the same.
- Force every settings subtype into one totally uniform table: rejected because the domain richness and script-oriented rendering would be weakened.
## Decision 4 — Normalized diff must own unavailable and partial-state semantics
**Decision**: Move unavailable, partial, and zero-diff behavior into the normalized diff family contract instead of leaving those semantics to individual hosts.
**Rationale**: `FindingResource` currently shows a host-owned unavailable text entry before the shared diff view, while `PolicyVersionResource` simply renders the diff view. This is the clearest current evidence that the same content concept does not yet own its own availability rules. The family should own these states so equivalent cases feel consistent across hosts.
**Alternatives considered**:
- Keep host-owned unavailable messages and only align wording: rejected because ownership would still be split.
- Force all hosts to pre-normalize everything into “available only” and hide gaps: rejected because it would reduce diagnostic honesty.
## Decision 5 — The existing Livewire settings table remains the settings-catalog subtype renderer
**Decision**: Keep `SettingsCatalogSettingsTable` as the renderer for the settings-catalog subtype inside the normalized settings family.
**Rationale**: The table already provides search, sort, pagination, query-string isolation by context, and a details action. Replacing it would add risk and produce little user benefit. The problem is not that the table exists; the problem is that the host, rather than the family, currently decides when the table belongs to the surface.
**Alternatives considered**:
- Replace the table with static Blade markup: rejected because it would lose established usability and re-implement behavior already present.
- Split settings-catalog into its own separate family: rejected because the operator still experiences it as a settings detail subtype, not a separate product surface.
## Decision 6 — Testing should prove cross-host sameness and DB-only behavior, then add a small fork guard
**Decision**: Reuse and extend the current feature tests for widgets, onboarding, verification DB-only behavior, policy-version settings, and finding diffs, then add one parity test per family and one focused guard against re-forking.
**Rationale**: The repo already has strong tests around verification and normalized detail content. The missing coverage is family-level sameness across hosts and explicit protection against new host forks. A small guard gives lasting value without forcing a generic UI compliance framework.
**Alternatives considered**:
- Rely only on manual smoke checks: rejected because the spec explicitly wants drift prevention.
- Add a large screenshot or browser-regression suite: rejected because it would be expensive relative to the bounded scope and existing feature-level coverage.

View File

@ -0,0 +1,245 @@
# Feature Specification: Shared Detail Micro-UI Contract
**Feature Branch**: `197-shared-detail-contract`
**Created**: 2026-04-15
**Status**: Proposed
**Input**: User description: "Spec 197 — Shared Detail Micro-UI Contract"
## Spec Candidate Check *(mandatory — SPEC-GATE-001)*
- **Problem**: Wiederverwendete Detail-Micro-UIs für dieselben fachlichen Objekte leben heute als leicht unterschiedliche Host-Varianten, sodass Operatoren dieselbe Surface je nach Host neu lesen und neu erlernen müssen.
- **Today's failure**: Dieselbe fachliche Surface zeigt je nach Host andere Tabs, anders platzierte Next Steps, anders platzierte technische Details oder lokale Zusatzlogik. Dadurch entstehen inkonsistente Operator-Erwartungen, Drift bei Weiterentwicklungen und unnötiger Test- sowie Pflegeaufwand.
- **User-visible improvement**: Operatoren erleben dieselbe Verification-Report- oder Diff-/Settings-Surface hostübergreifend als dieselbe fachliche Surface mit denselben Kernzonen, denselben erwartbaren Interaktionsmustern und klar begrenzten Host-Unterschieden.
- **Smallest enterprise-capable version**: Nur zwei bereits real belegte Shared-Familien werden standardisiert: die Verification-Report-Familie sowie die Normalized Diff / Normalized Settings-Familie. Der Spec führt keinen generischen UI-Baukasten ein und modelliert keine weiteren Custom-Surfaces ohne nachgewiesene Familienwiederholung.
- **Explicit non-goals**: Kein Shell- oder Monitoring-Page-State-Refactor, keine Vereinheitlichung aller Custom-Detailansichten, kein generisches internes Komponenten-Framework, keine Table-/CRUD-/Badge-Konsistenzkampagne außerhalb der belegten Shared-Familien.
- **Permanent complexity imported**: Zwei explizite Shared-Family-Verträge, dokumentierte Pflicht-/Optional-/Host-Zonen, begrenzte Host-Variationsregeln, family-orientierte Regressionsabdeckung, eine kleine Abschlussdokumentation zu migrierten Hosts und erlaubten Restvarianten.
- **Why now**: Beide Familien sind bereits in mehreren Hosts sichtbar und zeigen belegte Drift. Jeder weitere Host erhöht die Wahrscheinlichkeit, dass dieselbe Surface erneut als lokale Mini-App eingebaut wird.
- **Why not local**: Lokale Bereinigungen pro Host beseitigen die Wiederholung nicht. Das Problem liegt in fehlendem gemeinsamen Vertrag für dieselbe Surface-Familie, nicht in einem einzelnen Host.
- **Approval class**: Cleanup
- **Red flags triggered**: Eine rote Flagge: Shared-Cross-Surface-Contract kann in ein zu breites UI-Framework kippen, wenn der Scope über die zwei belegten Familien hinaus wächst.
- **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**: canonical-view
- **Primary Routes**:
- Monitoring- beziehungsweise Operations-Detailflächen, die Verification Reports als eingebettete Detail-Surface zeigen
- Tenant-onboarding- und tenantbezogene Verifikationsflächen, die denselben Verification Report in Wizard-, Widget- oder Inline-Kontext rendern
- Tenantbezogene Policy-, Policy-Version- und Finding-Detailflächen, die Normalized Settings oder Normalized Diff als Detail-Surface zeigen
- **Data Ownership**:
- Tenant-owned: verifikationsbezogene Detaildaten, Policy-, Policy-Version- und Finding-Daten, die innerhalb der Shared-Familien dargestellt werden
- Workspace-context / canonical-view owned: die hostseitige Einbettung in kanonische Monitoring- oder Operations-Detailflächen
- Dieser Spec führt keine neue persistierte UI- oder Vertrags-Entität ein; die Shared-Family-Verträge bleiben rein darstellungs- und verhaltensbezogen
- **RBAC**:
- Bestehende Workspace-Mitgliedschaft und Tenant-Entitlement bleiben die Zugangsvoraussetzung für alle Hosts, die diese Shared-Familien rendern
- Bestehende View-/Inspect-Berechtigungen der Hosts bleiben maßgeblich; der Spec führt keine neue Capability und keinen neuen Autorisierungsweg ein
- Host-spezifische Aktionen innerhalb oder neben der Shared-Family bleiben weiter an die bereits vorhandenen Capability-Regeln des Hosts gebunden
For canonical-view specs, the spec MUST define:
- **Default filter behavior when tenant-context is active**: Wenn Tenant-Kontext aktiv ist, rendern Shared-Familien ausschließlich Daten aus dem aktuell aktiven Tenant-Kontext des Hosts. Ein Host darf keine Shared-Family tenantübergreifend oder tenantlos auf tenant-owned Daten erweitern.
- **Explicit entitlement checks preventing cross-tenant leakage**: Jeder Host bleibt dafür verantwortlich, nur bereits autorisierte Records oder Reports an die Shared-Family zu übergeben. Tenantlose kanonische Hosts dürfen tenant-owned Inhalte nur nach bestehender Entitlement-Prüfung sichtbar machen; Nicht-Mitglieder bleiben deny-as-not-found.
## 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 |
|---|---|---|---|---|---|---|---|
| Verification Report family | Secondary Context Surface | Operator prüft, warum ein Verify- oder Onboarding-Schritt bereit, blockiert oder nacharbeitspflichtig ist | Ergebnisstatus, Blocker oder Warnungen, zentrale Next Steps, klarer Host-Kontext | Technische Details, Run-Identität, tiefere Diagnose, Host-spezifische Assist-Einstiege | Nicht primär, weil die eigentliche Entscheidung im Host-Workflow liegt; diese Surface liefert die entscheidungsrelevante Begründung | Unterstützt Verify-, Onboarding- und Tenant-Follow-up-Workflows, ohne selbst eine separate Prozessfläche zu werden | Gleiche Grundstruktur in mehreren Hosts reduziert erneutes Uminterpretieren derselben Report-Aussage |
| Normalized Diff / Settings family | Tertiary Evidence / Diagnostics Surface | Operator prüft, was sich fachlich geändert hat oder welche Settings inhaltlich gelten | Klar gegliederte Abschnitte, aktiver View-Modus, wichtigste Diff- oder Settings-Inhalte | Zusätzliche Abschnitte, tiefere technische Darstellung, volle Detailtiefe, optionale Expand-/Fullscreen-Zonen | Nicht primär, weil die Entscheidung meist von einer übergeordneten Policy-, Version- oder Finding-Detailfläche ausgelöst wird | Unterstützt Review-, Diagnose- und Vergleichsarbeit innerhalb bestehender Detail-Workflows | Konsistente View-Zonen und Zustände reduzieren Vergleichsaufwand zwischen Policy-, Version- und Finding-Hosts |
## 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 |
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| Verification Report family | Detail / Embedded Review | Shared detail micro-UI | Read blockers, follow next step, open related operation or assist path | Host-owned embedded detail surface | forbidden | Innerhalb der gemeinsamen Action-/Assist-Zone oder hostseitig unmittelbar angrenzend, klar getrennt von Kernstatus und Diagnose | Keine destruktiven Aktionen im Shared Core; hostseitige Mutationen bleiben außerhalb des Core-Vertrags und confirmation-gated | Host-owned verification entry surfaces | Host-owned verification detail contexts in operations, onboarding, or tenant review flows | Aktiver Tenant- oder Workspace-Kontext des Hosts, Report-Status, Ergebnis-/Readiness-Signale | Verification report | Readiness, blockers or warnings, and next action intent | Embedded shared family; kein eigenständiges list-first oder standalone resource detail |
| Normalized Diff / Settings family | Detail / Evidence | Shared detail micro-UI | Inspect sections, switch view mode, expand detail, compare meaningfully | Host-owned embedded detail surface | forbidden | Innerhalb der gemeinsamen View-/Action-Zone der Surface; hostseitige Navigation bleibt außerhalb der Kern-Interaktion | Keine destruktiven Aktionen innerhalb der Shared-Family | Host-owned policy, policy version, or finding detail entries | Host-owned policy, version, and finding detail contexts | Aktiver Tenant-Kontext des Hosts, Inhaltstyp, verfügbarer Detailmodus, Empty/Unavailable-Signale | Normalized settings / Normalized diff | Die aktuell relevante fachliche Detailaussage des Settings- oder Diff-Inhalts | Embedded evidence family; kein eigenständiges CRUD- oder queue-first surface |
## 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 |
|---|---|---|---|---|---|---|---|---|---|---|
| Verification Report family | Tenant operator, workspace operator, onboarding operator | Verstehen, ob ein Verify- oder Onboarding-Schritt bereit ist, was blockiert, und was als Nächstes zu tun ist | Embedded report detail | Why is this verification ready, blocked, or cautionary, and what should I do next? | Summary oder header zone, outcome or readiness status, issues or passes overview, next-step or assist entry points | Technical details, operation identity, deeper diagnostics, host-specific helper content | readiness, outcome, severity of issues, data completeness | Shared core is read-only; host-owned acknowledge or assist mutations remain explicitly bounded | Review issues, switch view zone, follow next step, open technical detail or related run | None in shared core |
| Normalized Diff / Settings family | Tenant operator, reviewer, diagnostician | Verstehen, welche Inhalte gelten oder was sich fachlich geändert hat | Embedded evidence detail | What changed or what settings are in force, and where should I inspect next? | Shared section structure, active view or tab, visible content blocks, empty or unavailable explanation | Secondary sections, deeper technical content, larger expansions, raw or diagnostic follow-up hosted elsewhere | content availability, comparison completeness, section context | Read-only | Switch view or tab, expand relevant detail, inspect targeted sections | None |
## Proportionality Review *(mandatory when structural complexity is introduced)*
- **New source of truth?**: No
- **New persisted entity/table/artifact?**: No
- **New abstraction?**: Yes
- **New enum/state/reason family?**: No
- **New cross-domain UI framework/taxonomy?**: No
- **Current operator problem**: Dieselbe fachliche Detail-Surface wirkt in verschiedenen Hosts wie verschiedene kleine Anwendungen. Das erschwert sichere Wiedererkennung, verlangsamt Folgeschritte und erhöht die Wahrscheinlichkeit, dass Weiterentwicklungen inkonsistent erfolgen.
- **Existing structure is insufficient because**: Reines Fragment-Sharing auf View-Ebene verhindert keine Drift bei Tabs, Assist-Zonen, Diagnostics-Zonen, lokalen Zuständen oder Action-Platzierung. Ohne expliziten Familienvertrag bleibt jeder Host faktisch Mitbesitzer der Surface-Logik.
- **Narrowest correct implementation**: Definiere nur für die zwei bereits belegten Shared-Familien einen gemeinsamen Detailvertrag mit expliziten Pflicht-, Optional- und Host-Variationszonen; keine generische Plattform, kein globales UI-System, keine neue Persistenz.
- **Ownership cost**: Dauerhaft entstehen begrenzte zusätzliche Vertragsdokumentation, cross-host Regressionstests und ein klarer Review-Maßstab für künftige Host-Einbindungen. Im Gegenzug sinken parallele View-Logik, Drift-Risiko und Teststreuung.
- **Alternative intentionally rejected**: Einzelne Hosts lokal aufzuräumen oder weitere Blade-Sharing-Fixes einzubauen wurde verworfen, weil das die gemeinsame Kernlogik nicht schützt. Ein generisches internes UI-Framework wurde ebenfalls verworfen, weil nur zwei reale Familien standardisiert werden müssen.
- **Release truth**: Current-release truth
## User Scenarios & Testing *(mandatory)*
### User Story 1 - Recognize the same verification surface everywhere (Priority: P1)
As an operator, I want verification results to use the same recognizable structure in operation detail, onboarding, and tenant verification hosts, so that I can immediately see the same core meaning, next steps, and diagnostics without relearning the surface.
**Why this priority**: The most visible current drift is inside the Verification Report family, where the same report meaning is rendered with host-specific structure and local state.
**Independent Test**: Can be fully tested by rendering each covered verification host with equivalent verification data and confirming that the same core zones, same status meaning, and same next-step intent are visible in each host while allowed host variations remain bounded.
**Acceptance Scenarios**:
1. **Given** the same verification outcome is rendered in an operations detail host and an onboarding host, **When** the operator opens each host, **Then** the same core summary, issue/pass structure, and diagnostics contract are recognizable.
2. **Given** a verification host offers host-specific assist or acknowledge behavior, **When** the operator uses that host, **Then** the host-specific behavior appears as an explicit variation of the same shared verification contract rather than as a different report UI.
3. **Given** a verification report has no technical detail payload available, **When** it is rendered in any covered host, **Then** the absence is communicated through the same contractually defined unavailable or optional zone behavior rather than host-specific omission rules.
---
### User Story 2 - Inspect normalized settings and diffs consistently (Priority: P1)
As an operator reviewing policy, version, or finding detail, I want normalized settings and normalized diff surfaces to behave like one family, so that tabs, view modes, sections, and unavailable states do not feel reinvented per host.
**Why this priority**: The diff/settings family already appears across multiple detail hosts and contains subtle host-specific drift around visibility, context handling, and section behavior.
**Independent Test**: Can be fully tested by rendering representative policy, policy-version, and finding hosts and verifying that the same family-level structure, same core view behavior, and same empty or unavailable semantics apply wherever the same content concept is shown.
**Acceptance Scenarios**:
1. **Given** a normalized diff is available in more than one covered host, **When** the operator opens each host, **Then** the same family-level view structure and section logic are recognizable.
2. **Given** a normalized settings surface renders different content subtypes, **When** those subtypes are shown in covered hosts, **Then** subtype differences remain explicit and do not appear as ad hoc host forks.
3. **Given** a host cannot show a diff or settings payload, **When** the operator reaches that area, **Then** the surface shows a family-consistent unavailable or partial state rather than a host-specific gap.
---
### User Story 3 - Add or update a host without re-forking the family (Priority: P2)
As a developer or reviewer, I want a clear shared-family contract for repeated detail surfaces, so that a new host or host change can extend the family through known variation points instead of quietly re-forking the whole micro-UI.
**Why this priority**: The product gain only lasts if later host work cannot easily recreate drift.
**Independent Test**: Can be fully tested by reviewing a changed or newly added host against the family contract and verifying that only allowed host-driven inputs, zones, and actions differ.
**Acceptance Scenarios**:
1. **Given** a developer introduces a new host for an existing shared family, **When** the host is reviewed and tested, **Then** the host declares or uses only the family's allowed variation points.
2. **Given** an existing host needs a unique action or assist entry, **When** that difference is added, **Then** the difference remains visibly host-scoped and does not redefine the family core structure.
3. **Given** a future host tries to introduce a different tab or view contract for the same family without domain justification, **When** the change is reviewed, **Then** it is rejected as out of contract.
### Edge Cases
- A covered host lacks one optional zone, such as technical details, but still must preserve the same family-level structure and optional-state behavior.
- A host needs an assist or acknowledge action that no other host needs; the action must remain an explicit host variation and not become a hidden structural fork.
- The same family appears in both tenant-context and workspace-context hosts; tenant scope must stay explicit and inaccessible data must remain not found.
- A diff or settings payload is partially available, unavailable, or subtype-specific; the surface must communicate this consistently without silently dropping the zone.
- A family contains local state, such as view selection or expansion state; that state must be owned once at the family level rather than recreated differently in each host.
## Requirements *(mandatory)*
**Constitution alignment (required):** This feature introduces no new Microsoft Graph calls, no new long-running or queued work, and no new write workflow. It standardizes repeated operator-facing detail surfaces that already exist and must remain derived from the host's current domain truth.
**Constitution alignment (PROP-001 / ABSTR-001 / PERSIST-001 / STATE-001 / BLOAT-001):** This feature introduces one narrow kind of abstraction: an explicit shared detail-family contract for two already proven families. It does not introduce new persistence, new state families, or a cross-domain UI framework. The purpose is to replace duplicate cross-host ownership, not to layer a new semantic system on top of existing truth.
**Constitution alignment (OPS-UX):** No new `OperationRun` type or execution path is introduced. Existing links to operations remain navigation only and continue to rely on the current operations contract.
**Constitution alignment (RBAC-UX):** The feature does not change authorization behavior. Existing workspace and tenant entitlement rules continue to govern every host. Non-members remain deny-as-not-found, members without capability remain forbidden where host-owned actions already enforce capability, and no shared family may bypass host authorization.
**Constitution alignment (OPS-EX-AUTH-001):** Not applicable.
**Constitution alignment (BADGE-001):** Existing centralized status, severity, readiness, and availability semantics remain the source of truth. This feature must not create host-local badge or tone vocabularies for the same shared family.
**Constitution alignment (UI-FIL-001):** Existing native detail containers, shared status primitives, and current custom viewer bodies remain the basis. The work must consolidate contract ownership and avoid new host-local replacement markup for status, assist, or action zones. Exception: the core bodies of the two shared families remain domain-specific rich viewers rather than being flattened into generic primitives.
**Constitution alignment (UI-NAMING-001):** Shared family vocabulary must stay stable across hosts. Operators should continue to see the same canonical nouns, such as verification report, technical details, next steps, normalized settings, and diff, instead of host-specific synonyms for the same concept.
**Constitution alignment (DECIDE-001):** Verification Report surfaces remain secondary context surfaces, and Normalized Diff / Settings surfaces remain tertiary evidence surfaces. Each host must still make the first decision possible without forcing the operator to decode different family structures for the same concept.
**Constitution alignment (UI-CONST-001 / UI-SURF-001 / ACTSURF-001 / UI-HARD-001 / UI-EX-001 / UI-REVIEW-001 / HDR-001):** The feature standardizes embedded detail and evidence surfaces. Each family keeps exactly one primary inspect model per host: the host opens the record, and the family provides the repeated inner surface. Pure navigation remains host-owned or within the family's secondary action zone. Destructive behavior is outside the shared core and remains governed by host rules.
**Constitution alignment (ACTSURF-001 - action hierarchy):** Shared-family actions are limited to navigation, inspection, view switching, assist entry, and other low-risk context actions. Any mutating or destructive host action remains clearly separated from the shared-family core and may not compete with the family's primary evidence or review function.
**Constitution alignment (OPSURF-001):** Default-visible content stays operator-first. Shared-family surfaces must show the core meaning, next step, or relevant content first, while deeper diagnostics remain secondary and intentionally revealed. Host context remains visible so operators understand scope.
**Constitution alignment (UI-SEM-001 / LAYER-001 / TEST-TRUTH-001):** Direct host-by-host mapping is currently insufficient because it has already produced duplicate ownership and drift. This feature must replace that duplicate ownership with one shared contract per family rather than adding extra presenter layers. Regression tests must assert the business-visible consequences of sameness across hosts.
**Constitution alignment (Filament Action Surfaces):** The Action Surface Contract remains satisfied. These are embedded read-heavy detail families, not new standalone CRUD resources. Each host continues to own its one primary inspect/open model, no redundant View action is introduced by the family work, and destructive actions stay outside the shared core. UI-FIL-001 is satisfied with the documented exception that family-specific detail viewers remain custom where the domain requires it.
**Constitution alignment (UX-001 — Layout & Information Architecture):** The feature does not add create or edit screens. Existing detail hosts must continue to use appropriate detail layouts, clear sectioning, explicit empty or unavailable states, and operator-first information ordering. Where a host already uses a view-style detail surface, the shared family must strengthen rather than weaken that clarity.
### Functional Requirements
- **FR-197-001**: The system MUST define a shared detail contract for the Verification Report family that covers every currently in-scope host where the same verification surface appears.
- **FR-197-002**: The Verification Report contract MUST identify which zones are mandatory, optional, host-extendable, and host-governed, including summary or header, outcome or readiness, view or tab zone, next-step or assist zone, technical-details or diagnostics zone, and empty or unavailable state handling.
- **FR-197-003**: Covered Verification Report hosts MUST use the same family-level core structure and MUST NOT redefine the family through incompatible local tab, view, or zone contracts.
- **FR-197-004**: Host-specific Verification Report differences MAY vary action availability, host placement, or assist entry behavior, but MUST NOT change the family's core structure, diagnostics contract, or next-step contract without explicit domain justification.
- **FR-197-005**: The Verification Report family MUST remain at least functionally equivalent to the pre-standardized state, preserving current information value, current operator usefulness, and current diagnosis depth.
- **FR-197-006**: The system MUST define a shared detail contract for the Normalized Diff / Normalized Settings family across every currently in-scope host that presents the same content concept.
- **FR-197-007**: The Normalized Diff / Settings contract MUST standardize family-level view zones, section behavior, empty or unavailable states, partial states, and any supported expand or fullscreen semantics where those behaviors are part of the family.
- **FR-197-008**: If the Normalized Diff / Settings family contains meaningful subtypes, those subtypes MUST be explicit variations of the same family contract and MUST NOT exist only as accidental host forks.
- **FR-197-009**: Covered hosts for the Normalized Diff / Settings family MAY vary host framing or surrounding context, but MUST preserve recognizable family-level interaction patterns for view switching, section reading, and detail inspection.
- **FR-197-010**: For each in-scope shared family, the required inputs, supported states, and render expectations MUST be explicit enough that a host does not need hidden local assumptions to render the family correctly.
- **FR-197-011**: If a shared family requires local UI state, that state MUST be owned once at the family level and MUST NOT be recreated differently in each host.
- **FR-197-012**: New or updated hosts using an in-scope shared family MUST extend the family only through the contract's documented variation points and MUST NOT introduce new ad hoc Blade-level main variants for the same surface.
- **FR-197-013**: Domain-specific viewers that are similar in shape but intentionally separate from the two in-scope families MAY remain outside this spec, but they MUST stay explicitly out of scope rather than becoming accidental exceptions inside the standardized families.
- **FR-197-014**: Regression coverage MUST prove that multiple hosts for each in-scope shared family use the same family-level contract and that any remaining differences are explicit, bounded host variations.
- **FR-197-015**: Release acceptance for this spec MUST include a closing inventory of migrated hosts, intentionally allowed remaining variations, and follow-up topics that were found to be shell, monitoring-state, or other out-of-scope concerns.
## 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 |
|---|---|---|---|---|---|---|---|---|---|---|
| Verification Report family | Embedded inside operations detail, onboarding verification, and tenant verification hosts | Host-owned header actions; shared family may expose low-risk report actions such as open related operation, open technical details, or follow next step | Host-owned detail inspection; not a list/table surface | None at family level; any host-specific per-item action stays explicitly host-scoped | None | Family-consistent empty, unavailable, or assist CTA where relevant | N/A | N/A | No new audit path introduced | Action Surface Contract satisfied. Shared core is read-heavy; host-owned acknowledge or assist mutations remain outside the core contract and retain existing authorization and confirmation rules. |
| Normalized Diff / Settings family | Embedded inside policy, policy version, and finding detail hosts | Host-owned detail header actions only | Host-owned detail inspection; not a list/table surface | None | None | Family-consistent unavailable or partial-state explanation; CTA only if the host already owns a follow-up path | N/A | N/A | No | Action Surface Contract satisfied. Read-only evidence family; no new mutation or destructive affordance is introduced. |
### Key Entities *(include if feature involves data)*
- **Shared detail family**: A repeated domain detail surface that appears in more than one host and should read as the same surface wherever it appears.
- **Family contract**: The explicit definition of a shared family's inputs, supported states, mandatory zones, optional zones, and allowed host variations.
- **Host variation**: A deliberately bounded host-specific difference, such as action availability, placement, or assist entry, that does not redefine the family core.
- **Verification report detail**: The domain report surface that communicates verification readiness, blockers, passes, next steps, and deeper diagnostics.
- **Normalized detail surface**: The domain evidence surface that communicates structured settings content or structured diff content through one recognizable family.
## Deliverables
- **D-197-001**: A standardized Verification Report shared-family contract covering the currently in-scope hosts.
- **D-197-002**: A standardized Normalized Diff / Normalized Settings shared-family contract covering the currently in-scope hosts.
- **D-197-003**: Documented variation rules per family that state what is shared core, what hosts may extend, and what hosts must not re-invent.
- **D-197-004**: A closing migration note listing migrated hosts, consciously allowed remaining differences, and related topics that were intentionally left out of scope.
## Success Criteria *(mandatory)*
### Measurable Outcomes
- **SC-197-001**: In regression coverage, every currently in-scope host for the Verification Report family passes the shared-family contract assertions with zero unexplained core-structure differences.
- **SC-197-002**: In regression coverage, every currently in-scope host for the Normalized Diff / Settings family passes the shared-family contract assertions with zero unexplained view-structure differences.
- **SC-197-003**: In operator smoke review, a reviewer can identify summary, next-step or assist placement, and diagnostics placement within 10 seconds on each covered Verification Report host.
- **SC-197-004**: In operator smoke review, covered diff/settings hosts are recognized as the same family for view modes, section behavior, and unavailable-state behavior in 100% of reviewed scenarios.
- **SC-197-005**: The release contains no remaining unbounded host-only main variant for either in-scope shared family.
## Assumptions
- The current repository already contains the two in-scope shared families in enough hosts to justify a family-level contract now.
- Existing host authorization, route structure, and domain truth remain correct; this spec changes shared UI contract ownership, not host entitlement logic.
- Rich domain-specific detail rendering remains necessary; only the family contract is being standardized, not the domain detail richness itself.
## Non-Goals
- Standardizing every custom detail surface in the repository
- Refactoring the global shell, workspace context bar, or monitoring page-state behavior
- Reworking Evidence Overview, baseline compare matrix, or generic table surfaces
- Eliminating every custom view body in favor of a generic component system
- Pulling unrelated domain-specific diff viewers into scope without proving that they are the same shared family
## Dependencies
- Existing operations detail hosts, tenant verification hosts, and onboarding verification hosts that already render Verification Report content
- Existing policy, policy version, and finding detail hosts that already render Normalized Settings or Normalized Diff content
- Existing host-level authorization, detail routing, and status semantics that the shared-family contracts must preserve rather than replace
## Definition of Done
Spec 197 is complete when:
- both confirmed in-scope shared families use explicit family contracts,
- the main hosts for each family no longer behave like loosely drifted mini-app variants of the same surface,
- remaining host differences are explicitly documented as bounded variations,
- regression and smoke verification prove operator sameness and preserved usefulness,
- and no shell-, monitoring-state-, or other out-of-scope topic has been silently absorbed into this work.

View File

@ -0,0 +1,229 @@
# Tasks: Shared Detail Micro-UI Contract
**Input**: Design documents from `/specs/197-shared-detail-contract/`
**Prerequisites**: plan.md, spec.md, research.md, data-model.md, contracts/, quickstart.md
**Tests**: Tests are REQUIRED for this feature. Use Pest feature coverage and Livewire-safe Filament tests via Laravel Sail.
**Operations**: No new `OperationRun`, queue, scheduler, or notification lifecycle is introduced by this feature.
**RBAC**: No new capability or authorization plane is introduced; all hosts must preserve existing deny-as-not-found and capability enforcement behavior.
**Release Artifact**: `specs/197-shared-detail-contract/migration-note.md` records migrated hosts, bounded variations, manual smoke evidence, and out-of-scope follow-ups.
**Organization**: Tasks are grouped by user story so each family can be implemented and verified independently.
## Phase 1: Setup (Shared Scaffolding)
**Purpose**: Create the new support, view, and test entry points that later phases will fill.
- [X] T001 [P] Create shared-detail support class skeletons in apps/platform/app/Filament/Support/NormalizedSettingsSurface.php and apps/platform/app/Filament/Support/NormalizedDiffSurface.php
- [X] T002 [P] Create family partial entry points in apps/platform/resources/views/filament/components/verification-report/summary.blade.php, apps/platform/resources/views/filament/components/verification-report/issues.blade.php, apps/platform/resources/views/filament/infolists/entries/normalized-settings/wrapper.blade.php, and apps/platform/resources/views/filament/infolists/entries/normalized-diff/wrapper.blade.php
- [X] T003 [P] Create focused contract-test shells in apps/platform/tests/Feature/Filament/SharedVerificationReportFamilyContractTest.php, apps/platform/tests/Feature/Filament/NormalizedDetailFamilyContractTest.php, and apps/platform/tests/Feature/Guards/SharedDetailFamilyContractGuardTest.php
**Checkpoint**: The new files and entry points exist so the implementation can proceed without inventing paths mid-stream.
---
## Phase 2: Foundational (Blocking Contract Seams)
**Purpose**: Establish the shared support builders that every story relies on.
**⚠️ CRITICAL**: No user story work should start before these support seams exist.
- [X] T004 [P] Extend apps/platform/app/Filament/Support/VerificationReportViewer.php to build an explicit verification surface contract with shared zones and host-variation metadata
- [X] T005 [P] Implement normalized settings contract shaping in apps/platform/app/Filament/Support/NormalizedSettingsSurface.php for subtype, warning, and empty-state ownership
- [X] T006 [P] Implement normalized diff contract shaping in apps/platform/app/Filament/Support/NormalizedDiffSurface.php for availability, zero-diff, partial-state, and grouped-render ownership
**Checkpoint**: Verification, normalized settings, and normalized diff each have a single contract seam that hosts can consume.
---
## Phase 3: User Story 1 - Recognize The Same Verification Surface Everywhere (Priority: P1) 🎯 MVP
**Goal**: Make operation detail, onboarding, and tenant verification render the same verification-family core while keeping only bounded host-specific actions and framing.
**Independent Test**: Render equivalent verification data through the operation detail, onboarding, and tenant widget hosts and confirm the same summary, issue/pass grouping, diagnostics contract, unavailable semantics, and authorization boundaries are recognizable everywhere.
### Tests for User Story 1
- [X] T007 [P] [US1] Add cross-host parity assertions in apps/platform/tests/Feature/Filament/SharedVerificationReportFamilyContractTest.php for operation detail, onboarding, and tenant widget verification hosts
- [X] T008 [P] [US1] Extend DB-only and widget regressions in apps/platform/tests/Feature/Verification/VerificationReportViewerDbOnlyTest.php and apps/platform/tests/Feature/Filament/TenantVerificationReportWidgetTest.php
- [X] T009 [P] [US1] Extend onboarding verification regressions in apps/platform/tests/Feature/Onboarding/OnboardingVerificationTest.php and apps/platform/tests/Feature/Onboarding/OnboardingVerificationV1_5UxTest.php
- [X] T010 [P] [US1] Extend workspace-context and onboarding entitlement regressions in apps/platform/tests/Feature/MonitoringOperationsTest.php, apps/platform/tests/Feature/Spec085/DenyAsNotFoundSemanticsTest.php, and apps/platform/tests/Feature/Onboarding/OnboardingDraftAccessTest.php so the verification-family refactor preserves 404-for-non-members and 403-for-in-scope-capability-denial behavior
### Implementation for User Story 1
- [X] T011 [US1] Refactor apps/platform/resources/views/filament/components/verification-report-viewer.blade.php and the partials under apps/platform/resources/views/filament/components/verification-report/ to own the shared summary, issues, passed, diagnostics, next-step, and unavailable zones
- [X] T012 [US1] Rebuild apps/platform/resources/views/filament/forms/components/managed-tenant-onboarding-verification-report.blade.php around the shared verification-family core while keeping assist, acknowledge, and technical-details controls host-scoped
- [X] T013 [US1] Route operation-detail and onboarding host context through the verification contract in apps/platform/app/Filament/Resources/OperationRunResource.php and apps/platform/app/Filament/Pages/Workspaces/ManagedTenantOnboardingWizard.php
- [X] T014 [US1] Route tenant-widget host context through the verification contract in apps/platform/app/Filament/Widgets/Tenant/TenantVerificationReport.php and apps/platform/resources/views/filament/widgets/tenant/tenant-verification-report.blade.php
**Checkpoint**: Verification-family hosts share one recognizable micro-UI contract and remain DB-only, host-authorized, and operator-first.
---
## Phase 4: User Story 2 - Inspect Normalized Settings And Diffs Consistently (Priority: P1)
**Goal**: Make policy, policy-version, and finding detail surfaces use one normalized settings family and one normalized diff family with explicit subtype and availability semantics.
**Independent Test**: Render representative policy, policy-version, and finding hosts and confirm that normalized settings and normalized diff use consistent family-owned wrappers, view behavior, subtype handling, unavailable-state semantics, and existing tenant-scope authorization behavior.
### Tests for User Story 2
- [X] T015 [P] [US2] Add family parity assertions in apps/platform/tests/Feature/Filament/NormalizedDetailFamilyContractTest.php for policy settings, policy-version settings, policy-version diff, and finding diff hosts
- [X] T016 [P] [US2] Extend normalized settings regressions in apps/platform/tests/Feature/Filament/PolicyVersionSettingsTest.php and apps/platform/tests/Feature/Filament/SettingsCatalogPolicyNormalizedDiffTest.php
- [X] T017 [P] [US2] Extend normalized diff availability regressions in apps/platform/tests/Feature/Filament/GroupPolicyConfigurationNormalizedDiffTest.php and apps/platform/tests/Feature/Drift/DriftFindingDiffUnavailableTest.php
- [X] T018 [P] [US2] Extend tenant-scope authorization regressions in apps/platform/tests/Feature/Filament/PolicyResourceAdminTenantParityTest.php, apps/platform/tests/Feature/Filament/PolicyVersionAdminTenantParityTest.php, and apps/platform/tests/Feature/Findings/FindingRbacTest.php so normalized-detail hosts preserve deny-as-not-found and capability-safe behavior after family wiring
### Implementation for User Story 2
- [X] T019 [US2] Refactor apps/platform/resources/views/filament/infolists/entries/normalized-settings.blade.php and subtype partials under apps/platform/resources/views/filament/infolists/entries/normalized-settings/ to own warnings, wrapper structure, subtype selection, and empty-state behavior
- [X] T020 [US2] Absorb or retire apps/platform/resources/views/filament/infolists/entries/policy-settings-standard.blade.php as a direct host-facing sibling so standard settings render only as a normalized-settings subtype
- [X] T021 [US2] Route policy and policy-version settings hosts through NormalizedSettingsSurface in apps/platform/app/Filament/Resources/PolicyResource.php and apps/platform/app/Filament/Resources/PolicyVersionResource.php
- [X] T022 [P] [US2] Adapt apps/platform/app/Livewire/SettingsCatalogSettingsTable.php so the settings-catalog table stays a subtype renderer under the normalized-settings family wrapper
- [X] T023 [US2] Refactor apps/platform/resources/views/filament/infolists/entries/normalized-diff.blade.php and the partials under apps/platform/resources/views/filament/infolists/entries/normalized-diff/ to own summary, grouped rendering, and unavailable or zero-diff states
- [X] T024 [US2] Route policy-version and finding diff hosts through NormalizedDiffSurface in apps/platform/app/Filament/Resources/PolicyVersionResource.php and apps/platform/app/Filament/Resources/FindingResource.php
**Checkpoint**: Normalized settings and diff surfaces read as one family across policy, policy-version, and finding detail hosts.
---
## Phase 5: User Story 3 - Add Or Update A Host Without Re-Forking The Family (Priority: P2)
**Goal**: Make future host work extend the documented family variation points instead of silently reintroducing host-local forks.
**Independent Test**: Run the guard suite and verify it fails on forbidden verification tab ownership or direct top-level `policy-settings-standard` host usage while the contract docs enumerate the approved consumers and allowed variations.
### Tests for User Story 3
- [X] T025 [P] [US3] Implement fork-guard coverage in apps/platform/tests/Feature/Guards/SharedDetailFamilyContractGuardTest.php for forbidden verification tab ownership and direct host-level policy-settings-standard usage
### Implementation for User Story 3
- [X] T026 [US3] Sync approved consumers, required markers, and forbidden host patterns in specs/197-shared-detail-contract/contracts/verification-report-family.openapi.yaml and specs/197-shared-detail-contract/contracts/normalized-detail-family.openapi.yaml
- [X] T027 [US3] Record the migrated host inventory, bounded variations, smoke-review evidence, and out-of-scope follow-ups in specs/197-shared-detail-contract/migration-note.md
**Checkpoint**: Reviewers and future implementers have an executable guard and a written inventory that block ad hoc family forks.
---
## Phase 6: Polish & Cross-Cutting Concerns
**Purpose**: Validate the full feature slice and record release acceptance evidence.
- [X] T028 [P] Run formatting for touched PHP files including apps/platform/app/Filament/Support/VerificationReportViewer.php, apps/platform/app/Filament/Support/NormalizedSettingsSurface.php, and apps/platform/app/Filament/Support/NormalizedDiffSurface.php with `./vendor/bin/sail bin pint --dirty --format agent`
- [X] T029 Run the focused Sail validation pack from specs/197-shared-detail-contract/quickstart.md against apps/platform/tests/Feature/Verification/VerificationReportViewerDbOnlyTest.php, apps/platform/tests/Feature/Filament/TenantVerificationReportWidgetTest.php, apps/platform/tests/Feature/Onboarding/OnboardingVerificationTest.php, apps/platform/tests/Feature/Onboarding/OnboardingVerificationV1_5UxTest.php, apps/platform/tests/Feature/MonitoringOperationsTest.php, apps/platform/tests/Feature/Spec085/DenyAsNotFoundSemanticsTest.php, apps/platform/tests/Feature/Onboarding/OnboardingDraftAccessTest.php, apps/platform/tests/Feature/Filament/PolicyVersionSettingsTest.php, apps/platform/tests/Feature/Filament/SettingsCatalogPolicyNormalizedDiffTest.php, apps/platform/tests/Feature/Filament/GroupPolicyConfigurationNormalizedDiffTest.php, apps/platform/tests/Feature/Drift/DriftFindingDiffUnavailableTest.php, apps/platform/tests/Feature/Filament/PolicyResourceAdminTenantParityTest.php, apps/platform/tests/Feature/Filament/PolicyVersionAdminTenantParityTest.php, apps/platform/tests/Feature/Findings/FindingRbacTest.php, apps/platform/tests/Feature/Filament/SharedVerificationReportFamilyContractTest.php, apps/platform/tests/Feature/Filament/NormalizedDetailFamilyContractTest.php, and apps/platform/tests/Feature/Guards/SharedDetailFamilyContractGuardTest.php
- [X] T030 Execute the Manual Smoke Check in specs/197-shared-detail-contract/quickstart.md and capture SC-197-003 and SC-197-004 evidence in specs/197-shared-detail-contract/migration-note.md
---
## Dependencies & Execution Order
### Phase Dependencies
- **Setup (Phase 1)**: No dependencies; can start immediately.
- **Foundational (Phase 2)**: Depends on Setup completion; blocks all user story implementation.
- **User Story 1 (Phase 3)**: Depends on Foundational completion.
- **User Story 2 (Phase 4)**: Depends on Foundational completion.
- **User Story 3 (Phase 5)**: Depends on User Story 1 and User Story 2 completion because the guard and inventory must reflect the final family boundaries.
- **Polish (Phase 6)**: Depends on all targeted user stories being complete.
### User Story Dependencies
- **US1**: Independent after Phase 2; no dependency on US2.
- **US2**: Independent after Phase 2; no dependency on US1.
- **US3**: Depends on US1 and US2 because it locks the final approved host patterns for both families.
### Within Each User Story
- Write or extend the listed tests before finishing implementation.
- Complete support or wrapper ownership before wiring host files to the new family contracts.
- Keep host framing and host-owned actions bounded to the variation points defined in the contracts.
- Validate each story independently before moving to the next story or polish phase.
### Parallel Opportunities
- T001, T002, and T003 can run in parallel.
- T004, T005, and T006 can run in parallel.
- After Phase 2, US1 and US2 can proceed in parallel if separate implementers avoid the shared `PolicyVersionResource.php` touchpoint.
- Within US1, T007, T008, T009, and T010 can run in parallel.
- Within US2, T015, T016, T017, and T018 can run in parallel.
- Within US3, T026 and T027 can run in parallel after T025 is in place.
---
## Parallel Example: User Story 1
```bash
# Start the verification-family test extensions together:
Task: "T007 Add cross-host parity assertions in apps/platform/tests/Feature/Filament/SharedVerificationReportFamilyContractTest.php"
Task: "T008 Extend DB-only and widget regressions in apps/platform/tests/Feature/Verification/VerificationReportViewerDbOnlyTest.php and apps/platform/tests/Feature/Filament/TenantVerificationReportWidgetTest.php"
Task: "T009 Extend onboarding verification regressions in apps/platform/tests/Feature/Onboarding/OnboardingVerificationTest.php and apps/platform/tests/Feature/Onboarding/OnboardingVerificationV1_5UxTest.php"
Task: "T010 Extend workspace-context and onboarding entitlement regressions in apps/platform/tests/Feature/MonitoringOperationsTest.php, apps/platform/tests/Feature/Spec085/DenyAsNotFoundSemanticsTest.php, and apps/platform/tests/Feature/Onboarding/OnboardingDraftAccessTest.php"
# After the shared verification wrapper is in place, these can proceed side by side:
Task: "T012 Rebuild apps/platform/resources/views/filament/forms/components/managed-tenant-onboarding-verification-report.blade.php"
Task: "T014 Route tenant-widget host context through the verification contract in apps/platform/app/Filament/Widgets/Tenant/TenantVerificationReport.php and apps/platform/resources/views/filament/widgets/tenant/tenant-verification-report.blade.php"
```
---
## Parallel Example: User Story 2
```bash
# Start the normalized-detail test extensions together:
Task: "T015 Add family parity assertions in apps/platform/tests/Feature/Filament/NormalizedDetailFamilyContractTest.php"
Task: "T016 Extend normalized settings regressions in apps/platform/tests/Feature/Filament/PolicyVersionSettingsTest.php and apps/platform/tests/Feature/Filament/SettingsCatalogPolicyNormalizedDiffTest.php"
Task: "T017 Extend normalized diff availability regressions in apps/platform/tests/Feature/Filament/GroupPolicyConfigurationNormalizedDiffTest.php and apps/platform/tests/Feature/Drift/DriftFindingDiffUnavailableTest.php"
Task: "T018 Extend tenant-scope authorization regressions in apps/platform/tests/Feature/Filament/PolicyResourceAdminTenantParityTest.php, apps/platform/tests/Feature/Filament/PolicyVersionAdminTenantParityTest.php, and apps/platform/tests/Feature/Findings/FindingRbacTest.php"
# Once the family builders exist, these can proceed in parallel without touching the same files:
Task: "T022 Adapt apps/platform/app/Livewire/SettingsCatalogSettingsTable.php"
Task: "T023 Refactor apps/platform/resources/views/filament/infolists/entries/normalized-diff.blade.php and partials under apps/platform/resources/views/filament/infolists/entries/normalized-diff/"
```
---
## Parallel Example: User Story 3
```bash
# After the guard exists, finish the enforcement artifacts together:
Task: "T026 Sync approved consumers and forbidden host patterns in specs/197-shared-detail-contract/contracts/verification-report-family.openapi.yaml and specs/197-shared-detail-contract/contracts/normalized-detail-family.openapi.yaml"
Task: "T027 Record the migrated host inventory and bounded variations in specs/197-shared-detail-contract/migration-note.md"
```
---
## Implementation Strategy
### MVP First (User Story 1 Only)
1. Complete Phase 1: Setup.
2. Complete Phase 2: Foundational.
3. Complete Phase 3: User Story 1.
4. Validate the verification family independently across operation detail, onboarding, and tenant widget hosts.
5. Demo or merge the MVP slice if the repo strategy allows partial delivery.
### Incremental Delivery
1. Finish Setup + Foundational once.
2. Deliver US1 and validate verification-family sameness.
3. Deliver US2 and validate normalized-detail sameness.
4. Deliver US3 to lock the boundaries with a guard and final inventory.
5. Finish with polish, the focused Sail pack from quickstart.md, and the manual smoke evidence recorded in migration-note.md.
### Parallel Team Strategy
1. One implementer completes Phase 1 and Phase 2.
2. After that, one implementer can take US1 while another takes US2.
3. US3 starts once both family implementations settle.
4. Finish with one shared cleanup and validation pass.
---
## Notes
- `[P]` means the task can run in parallel because it touches different files and has no dependency on incomplete work.
- `[US1]`, `[US2]`, and `[US3]` map each task back to the specs user stories.
- This feature must stay inside Filament v5 + Livewire v4; no provider registration change is required because `bootstrap/providers.php` remains unchanged.
- No globally searchable resource is added or removed by this feature; existing host resources continue to own their Edit or View surfaces.
- No destructive action is introduced inside either shared family; any existing host mutation remains host-owned and must keep existing confirmation and authorization behavior.
- No new assets are introduced, so no `filament:assets` deployment step changes are needed.

View File

@ -0,0 +1,35 @@
# Specification Quality Checklist: Compare Job Legacy Drift Path Cleanup
**Purpose**: Validate specification completeness and quality before proceeding to planning
**Created**: 2026-04-14
**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 passed on 2026-04-14 after the initial drafting pass.
- The feature is an internal cleanup, so user value is expressed through architectural honesty, review speed, and regression safety rather than a new operator-facing workflow.

View File

@ -0,0 +1,273 @@
openapi: 3.1.0
info:
title: Compare Job Legacy Drift Cleanup Internal Contract
version: 0.1.0
summary: Internal logical contract for the unchanged baseline compare start and execution path after legacy drift deletion
description: |
This contract is an internal planning artifact for Spec 205. No new HTTP
controllers or routes are introduced. The paths below identify logical
service, job, and guard boundaries that must remain true after the dead
pre-strategy drift path is removed from CompareBaselineToTenantJob.
x-logical-artifact: true
x-compare-job-cleanup-consumers:
- surface: baseline.compare.start
sourceFiles:
- apps/platform/app/Services/Baselines/BaselineCompareService.php
- apps/platform/tests/Feature/Baselines/BaselineCompareMatrixCompareAllActionTest.php
mustRemainTrue:
- compare_start_remains_enqueue_only
- deterministic_strategy_selection_recorded_in_run_context
- no_legacy_compare_fallback_at_start
- surface: baseline.compare.execution
sourceFiles:
- apps/platform/app/Jobs/CompareBaselineToTenantJob.php
- apps/platform/app/Support/Baselines/Compare/CompareStrategyRegistry.php
- apps/platform/app/Support/Baselines/Compare/IntuneCompareStrategy.php
mustConsume:
- supported_strategy_selection
- strategy_compare_result
- normalized_strategy_subject_results
- no_legacy_compute_drift_fallback
- surface: baseline.compare.findings
sourceFiles:
- apps/platform/app/Jobs/CompareBaselineToTenantJob.php
mustRemainTrue:
- finding_lifecycle_unchanged
- summary_and_gap_counts_derived_from_strategy_results
- warning_outcomes_unchanged
- reason_translation_unchanged
- operation_run_completion_semantics_unchanged
- surface: baseline.compare.guard
sourceFiles:
- apps/platform/tests/Feature/Guards/Spec116OneEngineGuardTest.php
- apps/platform/tests/Feature/Guards/Spec118NoLegacyBaselineDriftGuardTest.php
mustEnforce:
- removed_legacy_methods_stay_absent
- orchestration_file_has_one_compare_engine
- surface: baseline.compare.run-guards
sourceFiles:
- apps/platform/tests/Feature/Guards/OperationLifecycleOpsUxGuardTest.php
- apps/platform/tests/Feature/Operations/BaselineOperationRunGuardTest.php
- apps/platform/tests/Feature/OpsUx/OperationSummaryKeysSpecTest.php
- apps/platform/tests/Feature/OpsUx/SummaryCountsWhitelistTest.php
mustEnforce:
- baseline_compare_run_lifecycle_semantics_unchanged
- summary_count_keys_remain_whitelisted
- compare_run_context_updates_remain_valid
paths:
/internal/tenants/{tenant}/baseline-profiles/{profile}/compare:
post:
summary: Start baseline compare using the existing strategy-selected flow only
operationId: startBaselineCompareWithoutLegacyFallback
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/CompareLaunchRequest'
responses:
'202':
description: Compare accepted and queued with the strategy-owned execution path only
content:
application/vnd.tenantpilot.baseline-compare-run+json:
schema:
$ref: '#/components/schemas/CompareLaunchEnvelope'
'422':
description: Existing unsupported or mixed-scope preconditions prevented compare from starting
'403':
description: Actor is in scope but lacks compare-start capability
'404':
description: Tenant or baseline profile is outside actor scope
/internal/operation-runs/{run}/baseline-compare/execute:
post:
summary: Execute baseline compare through strategy selection and strategy compare only
operationId: executeBaselineCompareJobWithoutLegacyFallback
parameters:
- name: run
in: path
required: true
schema:
type: integer
responses:
'200':
description: Existing compare run completed through the strategy-owned path with no legacy drift fallback
content:
application/vnd.tenantpilot.baseline-compare-execution+json:
schema:
$ref: '#/components/schemas/CompareExecutionEnvelope'
'409':
description: Existing snapshot, coverage, or strategy preconditions blocked execution
/internal/guards/baseline-compare/no-legacy-drift:
get:
summary: Static invariant proving the orchestration file no longer retains the pre-strategy drift implementation
operationId: assertNoLegacyBaselineCompareJobPath
responses:
'200':
description: Guard passes because the removed legacy methods are absent from the compare job
content:
application/vnd.tenantpilot.compare-job-guard+json:
schema:
$ref: '#/components/schemas/LegacyDriftGuardResult'
components:
schemas:
CompareLaunchRequest:
type: object
additionalProperties: false
required:
- baseline_snapshot_id
- effective_scope
properties:
baseline_snapshot_id:
type: integer
effective_scope:
type: object
additionalProperties: true
origin:
type: string
enum:
- tenant_profile
- compare_matrix
- other_existing_surface
SupportedStrategySelection:
type: object
additionalProperties: false
required:
- selection_state
- strategy_key
- operator_reason
properties:
selection_state:
type: string
enum:
- supported
strategy_key:
type: string
example: intune_policy
operator_reason:
type: string
diagnostics:
type: object
additionalProperties: true
CompareLaunchEnvelope:
type: object
additionalProperties: false
required:
- run_id
- operation_type
- execution_mode
- selected_strategy
- legacy_drift_path_present
properties:
run_id:
type: integer
operation_type:
type: string
enum:
- baseline_compare
execution_mode:
type: string
enum:
- queued
selected_strategy:
$ref: '#/components/schemas/SupportedStrategySelection'
legacy_drift_path_present:
type: boolean
const: false
CompareExecutionEnvelope:
type: object
additionalProperties: false
required:
- run_id
- compare_source
- selected_strategy_key
- no_legacy_compute_drift
- persisted_truths
properties:
run_id:
type: integer
compare_source:
type: string
enum:
- strategy_only
selected_strategy_key:
type: string
example: intune_policy
no_legacy_compute_drift:
type: boolean
const: true
persisted_truths:
type: array
items:
type: string
example:
- operation_runs
- findings
- baseline_compare.context
outputs_preserved:
type: object
additionalProperties: false
properties:
finding_lifecycle:
type: boolean
const: true
summary_counts:
type: boolean
const: true
gap_handling:
type: boolean
const: true
warning_outcomes:
type: boolean
const: true
reason_translation:
type: boolean
const: true
run_completion:
type: boolean
const: true
LegacyDriftGuardResult:
type: object
additionalProperties: false
required:
- status
- compare_job_path
- forbidden_method_names
properties:
status:
type: string
enum:
- pass
compare_job_path:
type: string
example: apps/platform/app/Jobs/CompareBaselineToTenantJob.php
forbidden_method_names:
type: array
items:
type: string
example:
- computeDrift
- effectiveBaselineHash
- resolveBaselinePolicyVersionId
- selectSummaryKind
- buildDriftEvidenceContract
- buildRoleDefinitionEvidencePayload
- resolveRoleDefinitionVersion
- fallbackRoleDefinitionNormalized
- roleDefinitionChangedKeys
- roleDefinitionPermissionKeys
- resolveRoleDefinitionDiff
- severityForRoleDefinitionDiff
invariant:
type: string
example: compare orchestration retains one live strategy-driven execution path

View File

@ -0,0 +1,131 @@
# Data Model: Compare Job Legacy Drift Path Cleanup
## Overview
This feature introduces no new top-level persisted entity and no new runtime or product-facing contract. It removes an obsolete implementation branch from `CompareBaselineToTenantJob` and preserves the existing persisted truths and compare contracts that already drive the live strategy-based compare flow. The OpenAPI document in `contracts/` is a planning-only logical artifact that records invariants for this cleanup; it does not define a new runtime integration surface.
## Existing Persisted Truth Reused Without Change
### Workspace-owned baseline truth
- `baseline_profiles`
- `baseline_snapshots`
- `baseline_snapshot_items`
- Canonical baseline scope payload already stored in profile and run context
These remain the baseline reference truth that compare reads.
### Tenant-owned current-state and operational truth
- `inventory_items`
- `operation_runs` for `baseline_compare`
- findings written by the baseline compare lifecycle
- existing run-context JSON such as `baseline_compare`, `findings`, and `result`
These remain the long-lived operational truths written or consumed by compare.
### Existing evidence inputs reused without change
- policy-version content evidence
- inventory meta evidence
- current-state hash resolution
- coverage and gap context already recorded in the compare run
Spec 205 changes none of these inputs; it only removes a dead alternate computation path.
## Existing Internal Contracts Preserved
### Compare orchestration path
The live orchestration path remains:
1. `CompareBaselineToTenantJob::handle()`
2. `CompareStrategyRegistry::select(...)`
3. `CompareStrategyRegistry::resolve(...)`
4. `strategy->compare(...)`
5. `normalizeStrategySubjectResults(...)`
6. finding upsert, summary aggregation, gap handling, and run completion
No new branch, fallback path, or second engine is introduced.
### CompareStrategySelection
Existing selection metadata remains unchanged and continues to be written into the compare run context.
| Field | Purpose | Change in Spec 205 |
|------|---------|--------------------|
| `selection_state` | Supported vs unsupported strategy state | unchanged |
| `strategy_key` | Active compare strategy family | unchanged |
| `diagnostics` | Secondary strategy selection detail | unchanged |
### CompareOrchestrationContext
Existing strategy input context remains unchanged.
| Field | Purpose | Change in Spec 205 |
|------|---------|--------------------|
| `workspace_id` | Workspace scope for compare run | unchanged |
| `tenant_id` | Tenant scope for compare run | unchanged |
| `baseline_profile_id` | Baseline profile reference | unchanged |
| `baseline_snapshot_id` | Snapshot reference | unchanged |
| `operation_run_id` | Run identity | unchanged |
| `normalized_scope` | Canonical scope payload | unchanged |
| `coverage_context` | Coverage and unsupported-type context | unchanged |
### CompareSubjectResult and CompareFindingCandidate
Existing per-subject compare results and finding projection contracts remain unchanged.
| Contract | Purpose | Change in Spec 205 |
|----------|---------|--------------------|
| `CompareSubjectResult` | Strategy-owned per-subject compare outcome | unchanged |
| `CompareFindingCandidate` | Strategy-neutral finding mutation payload | unchanged |
### OperationRun compare context
The compare run continues to record current strategy, evidence coverage, gap counts, fidelity, reason translation, and result summaries inside the existing context structure. Spec 205 does not add, remove, or rename run-context fields.
### Finding lifecycle output
Finding severity, change type, recurrence key, evidence fidelity, timestamps, reopen behavior, and auto-close behavior remain unchanged. Spec 205 only preserves the live path that already feeds these outputs.
## Deleted Internal Cluster
Current repository inspection confirms one dead implementation cluster anchored by `computeDrift()` inside `CompareBaselineToTenantJob`, plus exclusive helpers clustered beneath it. The current candidate delete set includes:
- `computeDrift()`
- `effectiveBaselineHash()`
- `resolveBaselinePolicyVersionId()`
- `selectSummaryKind()`
- `buildDriftEvidenceContract()`
- `buildRoleDefinitionEvidencePayload()`
- `resolveRoleDefinitionVersion()`
- `fallbackRoleDefinitionNormalized()`
- `roleDefinitionChangedKeys()`
- `roleDefinitionPermissionKeys()`
- `resolveRoleDefinitionDiff()`
- `severityForRoleDefinitionDiff()`
The final delete list is confirmed by call-graph inspection during implementation. Any method still used by the live orchestration path remains out of scope.
## Relationships
- One `baseline_compare` run selects one supported strategy.
- One selected strategy processes many compare subjects.
- One `CompareSubjectResult` may yield zero or one `CompareFindingCandidate`.
- Existing finding and summary writers consume the strategy result contracts directly.
- The legacy drift cluster is not part of any required runtime relationship after Spec 203 and is therefore removed.
## Validation Rules
1. `CompareBaselineToTenantJob::handle()` must not call `computeDrift()` or any helper used exclusively by that legacy path.
2. Compare execution must continue to run through strategy selection, strategy resolution, and `strategy->compare(...)`.
3. Existing `OperationRun` status, outcome, summary-count, and context semantics must remain unchanged.
4. Existing finding lifecycle behavior must remain driven by normalized strategy subject results.
5. No new persistence, contract, or state family may be introduced as part of the cleanup.
## State Transitions
No new state transition is introduced.
Existing compare run transitions such as queued -> running -> completed or blocked remain unchanged, and finding lifecycle transitions remain governed by the current writers and services.

View File

@ -0,0 +1,198 @@
# Implementation Plan: Compare Job Legacy Drift Path Cleanup
**Branch**: `205-compare-job-cleanup` | **Date**: 2026-04-14 | **Spec**: `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/specs/205-compare-job-cleanup/spec.md`
**Input**: Feature specification from `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/specs/205-compare-job-cleanup/spec.md`
**Note**: This plan treats Spec 205 as a mechanical closure cleanup. It removes only the dead pre-strategy drift-compute path from `CompareBaselineToTenantJob`, keeps the current strategy-driven compare execution unchanged, and uses focused regression plus guard coverage to prove no behavior drift.
## Summary
Delete `computeDrift()` and its exclusive helper cluster from `CompareBaselineToTenantJob`, preserve the existing `CompareStrategyRegistry` -> `IntuneCompareStrategy` execution path, remove dead imports and misleading internal descriptions that survive only because of the retained legacy block, and verify unchanged behavior through focused compare execution, finding, and guard tests.
## Technical Context
**Language/Version**: PHP 8.4.15
**Primary Dependencies**: Laravel 12, Filament v5, Livewire v4, Pest v4, Laravel Sail, existing `BaselineCompareService`, `CompareBaselineToTenantJob`, `CompareStrategyRegistry`, `IntuneCompareStrategy`, `CurrentStateHashResolver`, and current finding lifecycle services
**Storage**: PostgreSQL via existing baseline snapshots, baseline snapshot items, inventory items, `operation_runs`, findings, and current run-context JSON; no new storage planned
**Testing**: Pest feature and guard tests run through Laravel Sail, with focused compare execution and file-content guard coverage
**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**: Preserve current compare start latency and compare job throughput, add no new remote calls or DB writes, and keep operator-facing compare and monitoring surfaces behaviorally unchanged
**Constraints**: No behavior change, no new abstraction or persistence, no operator-facing surface changes, no `OperationRun` lifecycle changes, and keep the PR mechanically small and reviewable
**Scale/Scope**: One queued compare job, one compare start service boundary, one active strategy registry, one active Intune strategy, existing finding and run writers, and a small focused regression slice
## Constitution Check
*GATE: Passed before Phase 0 research. Re-checked after Phase 1 design and still passing because the feature removes code without altering scope, auth, persistence, or UI contracts.*
| Principle | Pre-Research | Post-Design | Notes |
|-----------|--------------|-------------|-------|
| Inventory-first / snapshots-second | PASS | PASS | Compare still reads existing workspace baseline snapshots and inventory-backed current state; no new compare truth is introduced. |
| Read/write separation | PASS | PASS | Existing compare runs still write only current run, finding, and audit truth; the cleanup adds no new write path. |
| Graph contract path | PASS | PASS | No new Microsoft Graph path or contract is introduced. |
| Deterministic capabilities | PASS | PASS | Existing strategy selection and capability behavior remain unchanged because the registry and strategy classes stay intact. |
| Workspace + tenant isolation | PASS | PASS | No workspace, tenant, or route-scope behavior changes are planned. |
| RBAC-UX authorization semantics | PASS | PASS | No authorization rules, capability checks, or cross-plane behavior are changed. |
| Run observability / Ops-UX | PASS | PASS | Existing `baseline_compare` run creation, summary counts, and completion semantics remain authoritative and unchanged. |
| Data minimization | PASS | PASS | No new persisted diagnostics or helper truth is added; dead internal code is removed instead. |
| Proportionality / anti-bloat | PASS | PASS | The feature deletes an obsolete path and introduces no new structure. |
| No premature abstraction | PASS | PASS | No new factory, resolver, registry, strategy, or support layer is introduced. |
| Persisted truth / behavioral state | PASS | PASS | No new table, stored artifact, status family, or reason family is added. |
| UI semantics / few layers | PASS | PASS | No new presentation layer or surface behavior is introduced. |
| Filament v5 / Livewire v4 compliance | PASS | PASS | No Filament or Livewire API changes are part of this cleanup. |
| 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 searchable resource or search behavior is touched. |
| Destructive action safety | PASS | PASS | No destructive action is added or changed. |
| Asset strategy | PASS | PASS | No new assets are introduced and existing `filament:assets` deployment behavior remains unchanged. |
## Filament-Specific Compliance Notes
- **Livewire v4.0+ compliance**: Unchanged. The feature touches no Filament surface or Livewire component and does not introduce legacy APIs.
- **Provider registration location**: Unchanged. If any panel/provider review is needed later, Laravel 11+ still requires `bootstrap/providers.php`.
- **Global search**: No globally searchable resource is added or changed.
- **Destructive actions**: No destructive action is introduced; existing confirmation and authorization rules remain untouched.
- **Asset strategy**: No new panel or shared assets are required. Deployment handling of `cd apps/platform && php artisan filament:assets` remains unchanged.
- **Testing plan**: Focus on the required compare cleanup pack only: compare execution and findings regressions, gap and reason-code coverage, `Spec116OneEngineGuardTest`, `Spec118NoLegacyBaselineDriftGuardTest`, existing `OperationRun` and summary-count guards, and the enqueue-path matrix action regression. No new page, widget, relation manager, or action surface coverage is required.
## Phase 0 Research
Research outcomes are captured in `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/specs/205-compare-job-cleanup/research.md`.
Key decisions:
- Treat the current `CompareStrategyRegistry` -> `IntuneCompareStrategy` execution path as the only supported compare engine.
- Delete the dead `computeDrift()` cluster rather than retaining it as deprecated or archived code.
- Preserve `CompareSubjectResult`, finding upsert, summary aggregation, gap handling, and run completion semantics exactly as they currently operate through the live strategy path.
- Use one focused compare pack covering execution fidelity, finding lifecycle, gap and reason outcomes, `OperationRun` lifecycle guards, summary-count guards, and the no-legacy orchestration guard as the minimum reliable regression slice.
- Keep the contract artifact logical and internal, documenting invariants of the unchanged execution boundary instead of inventing a new external API.
## Phase 1 Design
Design artifacts are created under `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/specs/205-compare-job-cleanup/`:
- `research.md`: cleanup decisions, rationale, and rejected alternatives
- `data-model.md`: existing persisted truth and internal compare contracts preserved by the cleanup
- `contracts/compare-job-legacy-drift-cleanup.logical.openapi.yaml`: logical internal contract for the unchanged compare start and execution boundaries plus the no-legacy guard invariant
- `quickstart.md`: implementation and verification order for the cleanup
Design decisions:
- `CompareBaselineToTenantJob` remains the compare execution entry point, but only the live orchestration methods stay after cleanup.
- `CompareStrategyRegistry`, `IntuneCompareStrategy`, `CompareStrategySelection`, `CompareOrchestrationContext`, `CompareSubjectResult`, and `CompareFindingCandidate` remain reused unchanged.
- Existing persisted truth in baseline snapshots, inventory, findings, and `operation_runs` remains authoritative; no migration or compatibility layer is added.
- Guard coverage remains the explicit enforcement point preventing legacy drift computation from re-entering the orchestration file.
- Existing `OperationRun` lifecycle and summary-count guards remain part of the required verification surface because the cleanup still edits the compare executor.
- No route, UI, RBAC, or `OperationRun` design change is planned.
## Project Structure
### Documentation (this feature)
```text
specs/205-compare-job-cleanup/
├── plan.md
├── research.md
├── data-model.md
├── quickstart.md
├── spec.md
├── contracts/
│ └── compare-job-legacy-drift-cleanup.logical.openapi.yaml
└── checklists/
└── requirements.md
```
### Source Code (repository root)
```text
apps/platform/
├── app/
│ ├── Jobs/
│ │ └── CompareBaselineToTenantJob.php
│ ├── Services/
│ │ └── Baselines/
│ │ ├── BaselineCompareService.php
│ │ ├── CurrentStateHashResolver.php
│ │ └── Evidence/
│ └── Support/
│ └── Baselines/
│ └── Compare/
│ ├── CompareStrategyRegistry.php
│ ├── CompareStrategySelection.php
│ ├── CompareSubjectResult.php
│ ├── CompareFindingCandidate.php
│ └── IntuneCompareStrategy.php
└── tests/
├── Feature/
│ ├── BaselineDriftEngine/
│ │ └── FindingFidelityTest.php
│ ├── Baselines/
│ │ ├── BaselineCompareFindingsTest.php
│ │ ├── BaselineCompareGapClassificationTest.php
│ │ ├── BaselineCompareWhyNoFindingsReasonCodeTest.php
│ │ └── BaselineCompareMatrixCompareAllActionTest.php
│ └── Guards/
│ ├── Spec116OneEngineGuardTest.php
│ ├── Spec118NoLegacyBaselineDriftGuardTest.php
│ └── OperationLifecycleOpsUxGuardTest.php
│ ├── Operations/
│ │ └── BaselineOperationRunGuardTest.php
│ └── OpsUx/
│ ├── OperationSummaryKeysSpecTest.php
│ └── SummaryCountsWhitelistTest.php
```
**Structure Decision**: Keep the cleanup inside the existing compare orchestration file and current compare regression surfaces. No new namespace, support layer, or package structure is introduced.
## Complexity Tracking
No constitution exception or complexity justification is required. Spec 205 removes an obsolete implementation branch and introduces no new persistence, abstraction, state family, or semantic framework.
## Proportionality Review
Not triggered. This feature introduces no new enum or status family, DTO or presenter layer, persisted artifact, interface or registry, or cross-domain taxonomy.
## Implementation Strategy
### Phase A - Confirm dead call graph
- Confirm that `handle()` no longer reaches `computeDrift()` or its candidate helper cluster.
- Confirm that the live path still runs through strategy selection, strategy resolution, `strategy->compare(...)`, normalization, finding upsert, and run completion.
- Record the final helper delete list before editing to avoid removing shared orchestration methods.
### Phase B - Delete the legacy drift cluster
- Remove `computeDrift()` and every helper method used exclusively by that legacy path.
- Remove only the imports, comments, and internal descriptions that become dead because of the delete.
- Keep shared orchestration helpers such as evidence resolution, result normalization, summary aggregation, gap merging, and finding lifecycle methods untouched.
### Phase C - Preserve the live orchestration contract
- Leave `BaselineCompareService`, `CompareStrategyRegistry`, and `IntuneCompareStrategy` behavior unchanged unless a direct compile or test failure requires a minimal follow-up.
- Preserve the existing `baseline_compare` run context shape, summary count rules, gap handling, reason translation, and finding lifecycle semantics.
- Avoid any naming sweep, contract redesign, or opportunistic cleanup outside the dead cluster.
### Phase D - Guard and regression verification
- Keep or tighten the existing no-legacy guard so the removed path cannot silently re-enter the orchestration file.
- Run focused compare execution, gap and reason-code regression, and `OperationRun` lifecycle and summary-count guard tests to prove the delete is mechanically safe.
- Format the touched PHP files with Pint after the cleanup is implemented.
## Risk Assessment
| Risk | Impact | Likelihood | Mitigation |
|------|--------|------------|------------|
| A supposedly dead helper is still used by the live orchestration path | High | Medium | Confirm call sites before deletion and keep the initial regression pack focused on execution, findings, gap and reason outcomes, and run-lifecycle guards. |
| The cleanup grows into a broader refactor while the job file is open | Medium | Medium | Constrain edits to dead methods, direct import fallout, and guard or test changes required by the delete. |
| Existing guard tests are too weak or too token-specific to prevent reintroduction | Medium | Medium | Reuse `Spec118NoLegacyBaselineDriftGuardTest` and extend it with the removed method names only if the current assertions do not cover the dead-path cluster clearly enough. |
| A regression test depends on deleted internal structure rather than behavior | Medium | Low | Update such tests to assert live compare outcomes and orchestration invariants rather than private helper presence. |
## Test Strategy
- Run `tests/Feature/BaselineDriftEngine/FindingFidelityTest.php` as the primary execution and evidence-fidelity regression slice.
- Run `tests/Feature/Baselines/BaselineCompareFindingsTest.php` to protect finding generation, recurrence, summary counts, and run completion outcomes.
- Run `tests/Feature/Baselines/BaselineCompareGapClassificationTest.php` and `tests/Feature/Baselines/BaselineCompareWhyNoFindingsReasonCodeTest.php` to protect gap handling, warning outcomes, and reason translation behavior.
- Run `tests/Feature/Guards/Spec116OneEngineGuardTest.php` to keep the one-engine orchestration invariant explicit while the dead fallback cluster is removed.
- Run `tests/Feature/Guards/OperationLifecycleOpsUxGuardTest.php`, `tests/Feature/Operations/BaselineOperationRunGuardTest.php`, `tests/Feature/OpsUx/OperationSummaryKeysSpecTest.php`, and `tests/Feature/OpsUx/SummaryCountsWhitelistTest.php` to keep `OperationRun` lifecycle and summary-count guarantees intact.
- Run `tests/Feature/Guards/Spec118NoLegacyBaselineDriftGuardTest.php` to lock the orchestration boundary against legacy drift helper re-entry.
- Run `tests/Feature/Baselines/BaselineCompareMatrixCompareAllActionTest.php` as the required enqueue-path regression slice for the focused cleanup pack.
- Run `cd apps/platform && ./vendor/bin/sail bin pint --dirty --format agent` after the code change.

View File

@ -0,0 +1,101 @@
# Quickstart: Compare Job Legacy Drift Path Cleanup
## Goal
Remove the obsolete pre-strategy drift-compute cluster from `CompareBaselineToTenantJob` while keeping the current strategy-driven compare workflow, finding lifecycle, and run semantics unchanged.
## Prerequisites
1. Work on branch `205-compare-job-cleanup`.
2. Ensure the platform containers are available:
```bash
cd apps/platform && ./vendor/bin/sail up -d
```
3. Keep Spec 203's strategy extraction artifacts available because the cleanup assumes that strategy-driven compare execution is already the live path.
## Recommended Implementation Order
### 1. Confirm the live call graph before editing
Verify the current live path and the candidate legacy cluster:
```bash
cd apps/platform && rg -n "compareStrategyRegistry->select|compareStrategyRegistry->resolve|strategy->compare" app/Jobs/CompareBaselineToTenantJob.php app/Services/Baselines/BaselineCompareService.php
cd apps/platform && rg -n "computeDrift|effectiveBaselineHash|resolveBaselinePolicyVersionId|selectSummaryKind|buildDriftEvidenceContract|buildRoleDefinitionEvidencePayload|resolveRoleDefinitionVersion|fallbackRoleDefinitionNormalized|roleDefinitionChangedKeys|roleDefinitionPermissionKeys|resolveRoleDefinitionDiff|severityForRoleDefinitionDiff" app/Jobs/CompareBaselineToTenantJob.php
```
If additional exclusive helpers are found adjacent to the dead cluster, add them to the delete list only after confirming they are not used by the live path.
### 2. Lock the current behavior with the focused regression slice
Run the minimum reliable compare pack before deleting anything:
```bash
cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/BaselineDriftEngine/FindingFidelityTest.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/BaselineCompareWhyNoFindingsReasonCodeTest.php
cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Guards/Spec116OneEngineGuardTest.php
cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Guards/OperationLifecycleOpsUxGuardTest.php
cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Operations/BaselineOperationRunGuardTest.php
cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/OpsUx/OperationSummaryKeysSpecTest.php
cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/OpsUx/SummaryCountsWhitelistTest.php
cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Guards/Spec118NoLegacyBaselineDriftGuardTest.php
```
Run the enqueue-path slice as part of the required focused pack:
```bash
cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Baselines/BaselineCompareMatrixCompareAllActionTest.php
```
### 3. Delete the legacy drift cluster only
Remove:
- `computeDrift()`
- helper methods used exclusively by that path
- imports and internal descriptions that only exist because of those methods
Do not redesign `CompareStrategyRegistry`, `IntuneCompareStrategy`, run-context shapes, or finding lifecycle behavior while the job file is open.
### 4. Tighten or preserve the no-legacy guard
If the current guard does not explicitly block the removed helper names, extend it minimally so CI fails if the legacy drift cluster reappears in `CompareBaselineToTenantJob`.
### 5. Re-run the focused regression slice
After the delete, re-run the same focused pack:
```bash
cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/BaselineDriftEngine/FindingFidelityTest.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/BaselineCompareWhyNoFindingsReasonCodeTest.php
cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Guards/Spec116OneEngineGuardTest.php
cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Guards/OperationLifecycleOpsUxGuardTest.php
cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Operations/BaselineOperationRunGuardTest.php
cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/OpsUx/OperationSummaryKeysSpecTest.php
cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/OpsUx/SummaryCountsWhitelistTest.php
cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Guards/Spec118NoLegacyBaselineDriftGuardTest.php
```
Re-run the enqueue-path slice as part of the same focused pack:
```bash
cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Baselines/BaselineCompareMatrixCompareAllActionTest.php
```
## Final Validation
1. Format touched PHP files:
```bash
cd apps/platform && ./vendor/bin/sail bin pint --dirty --format agent
```
2. Re-check that the live compare path still flows through strategy selection and `strategy->compare(...)`.
3. Confirm the compare run still completes with the same operator-visible outcome, gap and warning semantics, reason translation, and finding behavior as before.
4. Keep the PR limited to dead-path deletion, direct fallout cleanup, and the minimal regression or guard updates required by the delete.

View File

@ -0,0 +1,41 @@
# Research: Compare Job Legacy Drift Path Cleanup
## Decision 1: Treat the strategy-driven compare path as the only authoritative execution engine
- **Decision**: Use the existing `CompareStrategyRegistry` -> `IntuneCompareStrategy` path as the sole supported compare execution boundary.
- **Rationale**: Current code inspection shows `CompareBaselineToTenantJob::handle()` selecting a strategy, resolving it, and calling `strategy->compare(...)` before normalizing subject results and writing findings. No productive call path from `handle()` reaches the retained monolithic `computeDrift()` block.
- **Alternatives considered**:
- Keep the legacy block as a documented fallback. Rejected because it leaves the file structurally dishonest and suggests a second engine still exists.
- Add a feature flag between strategy and legacy execution. Rejected because there is no legitimate second execution mode left to preserve.
## Decision 2: Delete the dead drift cluster instead of archiving or deprecating it
- **Decision**: Remove `computeDrift()` and its exclusive helper cluster directly from `CompareBaselineToTenantJob`.
- **Rationale**: The retained cluster duplicates pre-strategy compare logic that has already been extracted into the active strategy implementation. Keeping it in place continues to mislead reviewers and inflates the orchestration file without operational value.
- **Alternatives considered**:
- Move the dead methods to a trait or archive class. Rejected because it preserves confusion and ownership cost without any runtime benefit.
- Leave the methods in place with a deprecation comment. Rejected because dead code still obscures the real call graph even when labeled.
## Decision 3: Preserve existing compare contracts, findings, and run semantics unchanged
- **Decision**: Keep `CompareStrategyRegistry`, `IntuneCompareStrategy`, `CompareStrategySelection`, `CompareSubjectResult`, `CompareFindingCandidate`, existing finding writers, and existing run context semantics unchanged.
- **Rationale**: Spec 205 is a closure cleanup, not a second strategy extraction spec. The safest path is deletion of dead code while leaving the live contracts and persisted truths untouched.
- **Alternatives considered**:
- Fold in additional compare refactors while editing the job. Rejected because that turns a narrow cleanup into a mixed review.
- Rename or reframe current compare contracts for symmetry. Rejected because it is unrelated to dead-path removal.
## Decision 4: Use a focused compare plus run-guard pack as the minimum regression slice
- **Decision**: Validate the cleanup with `FindingFidelityTest`, `BaselineCompareFindingsTest`, `BaselineCompareGapClassificationTest`, `BaselineCompareWhyNoFindingsReasonCodeTest`, `Spec116OneEngineGuardTest`, `OperationLifecycleOpsUxGuardTest`, `BaselineOperationRunGuardTest`, `OperationSummaryKeysSpecTest`, `SummaryCountsWhitelistTest`, `Spec118NoLegacyBaselineDriftGuardTest`, and `BaselineCompareMatrixCompareAllActionTest` as the required focused regression pack.
- **Rationale**: `FindingFidelityTest` exercises the compare execution path and evidence selection behavior, `BaselineCompareFindingsTest` protects finding lifecycle and summary outcomes, the gap and reason-code tests protect warning and reason semantics, `Spec116OneEngineGuardTest` keeps the one-engine orchestration invariant explicit, the `OperationRun` and summary-count guards protect lifecycle invariants, and the legacy guard keeps helper re-entry visible in CI. Together they provide high confidence for a mechanical delete without requiring a broad slow suite.
- **Alternatives considered**:
- Run the full baseline compare suite for every cleanup iteration. Rejected as optional rather than required for a small internal delete.
- Skip targeted tests and rely only on formatting or static inspection. Rejected as insufficient confidence.
## Decision 5: Keep the planning artifacts logical and invariant-focused
- **Decision**: Document the cleanup through a logical internal contract and a no-new-entity data model rather than inventing cleanup-specific services, APIs, or persistence.
- **Rationale**: The plan workflow still needs explicit design artifacts, but Spec 205 adds no new feature surface. The correct documentation shape is therefore an invariant record of the unchanged compare boundaries after dead-code deletion.
- **Alternatives considered**:
- Skip the contract artifact entirely because no new endpoint exists. Rejected because the planning workflow requires a contract deliverable.
- Invent a cleanup-specific service or endpoint in the design docs. Rejected because it would introduce fake architecture not warranted by the spec.

View File

@ -0,0 +1,162 @@
# Feature Specification: Compare Job Legacy Drift Path Cleanup
**Feature Branch**: `205-compare-job-cleanup`
**Created**: 2026-04-14
**Status**: Draft
**Input**: User description: "Compare Job Legacy Drift Path Cleanup"
- **Type**: Cleanup / closure hardening
- **Priority**: Medium
- **Depends on**: Spec 203 - Baseline Compare Engine Strategy Extraction
- **Related to**: Spec 202 - Governance Subject Taxonomy and Baseline Scope V2; Spec 204 - Platform Core Vocabulary Hardening
- **Recommended timing**: Immediate close-out before the next expansion-focused strand
- **Blocks**: No strategic work
- **Does not block**: Further platform work if completed as a short closure PR
## Spec Candidate Check *(mandatory - SPEC-GATE-001)*
- **Problem**: The baseline compare orchestration unit still retains an obsolete pre-strategy drift computation block that no longer reflects how compare execution actually works.
- **Today's failure**: Contributors and reviewers must spend time proving which compare path is live, while architecture audits still see false monolithic coupling that has already been structurally replaced.
- **User-visible improvement**: No operator workflow changes, but the codebase becomes more trustworthy, easier to audit, and faster to maintain because the live compare architecture is no longer obscured by dead logic.
- **Smallest enterprise-capable version**: Remove the dead legacy drift block and its exclusively related helpers, clean direct fallout such as unused dependencies and misleading internal descriptions, and confirm that the current strategy-driven compare behavior remains unchanged.
- **Explicit non-goals**: No new abstraction, no naming sweep, no evidence-contract redesign, no schema change, no UI change, no new strategy, and no opportunistic follow-up refactor.
- **Permanent complexity imported**: None beyond minimal regression coverage or comment cleanup needed to lock in the deletion.
- **Why now**: Specs 202, 203, and 204 already established the current architecture. Leaving the old drift block behind keeps the pre-expansion foundation structurally dishonest even though the behavioral migration is complete.
- **Why not local**: A narrower action than deletion would still leave the same dead-path ambiguity in place, so the architectural trust gap would remain.
- **Approval class**: Cleanup
- **Red flags triggered**: Scope-creep risk if broader naming or architecture work is mixed into the cleanup.
- **Score**: Nutzen: 2 | Dringlichkeit: 2 | Scope: 2 | Komplexitaet: 2 | Produktnaehe: 1 | Wiederverwendung: 1 | **Gesamt: 10/12**
- **Decision**: approve
## Spec Scope Fields *(mandatory)*
- **Scope**: workspace, tenant
- **Primary Routes**:
- No new or changed routes
- Existing verification anchors remain:
- `/admin/t/{tenant}/baseline-compare`
- `/admin/operations`
- `/admin/operations/{run}`
- **Data Ownership**:
- Existing tenant-owned compare runs, findings, summaries, and warnings remain authoritative.
- No new persistence, ownership boundary, or data shape is introduced.
- **RBAC**:
- Existing compare, monitoring, and tenant access rules remain unchanged.
- No new membership rule, capability, or operator-facing action is introduced.
- No destructive action behavior changes are included.
## Assumptions & Dependencies
- Spec 203 already made strategy-driven compare execution the authoritative live path for the baseline compare orchestration unit.
- The retained legacy drift block is not part of the productive call graph and can be removed without functional redesign.
- Any test or inspection logic that still depends on deleted internal helper structure is considered stale and may be narrowed to current observable behavior.
- Successful completion depends on focused regression coverage for strategy dispatch, compare execution, finding lifecycle behavior, summary computation, gap handling, warning handling, and run completion.
## User Scenarios & Testing *(mandatory)*
### User Story 1 - Read the live compare architecture without dead-path noise (Priority: P1)
As a contributor reviewing baseline compare behavior, I want the orchestration unit to show only the active compare path so that I can understand current architecture without first disproving a retained legacy path.
**Why this priority**: This is the core value of the cleanup. If the dead path remains visible, the repository continues to teach the wrong architecture.
**Independent Test**: Inspect the orchestration unit after cleanup and confirm that only the active strategy-driven path remains while regression checks still pass.
**Acceptance Scenarios**:
1. **Given** the compare orchestration unit currently contains both live orchestration and retained legacy drift remnants, **When** a contributor reviews the file after cleanup, **Then** they can trace one active compare execution path without encountering a parallel legacy implementation.
2. **Given** a reviewer follows the productive compare call graph after cleanup, **When** they inspect the orchestration flow, **Then** the repository no longer suggests that the removed pre-strategy drift logic is still active.
---
### User Story 2 - Preserve current compare behavior while removing dead code (Priority: P1)
As a product maintainer, I want the dead-code removal to leave compare behavior unchanged so that the cleanup can merge as a safe closure PR rather than another hidden refactor.
**Why this priority**: The cleanup is only valuable if it preserves the current compare lifecycle and does not force a second architecture review.
**Independent Test**: Run focused automated regression checks for the current compare flow and confirm that expected outcomes remain unchanged after the delete.
**Acceptance Scenarios**:
1. **Given** existing baseline compare regression coverage, **When** the cleanup lands, **Then** strategy selection, compare execution, finding generation, summary computation, gap handling, warning handling, recurrence behavior, and run completion remain green.
2. **Given** a compare run that already uses the current strategy infrastructure, **When** it executes after cleanup, **Then** it produces the same class of persisted results and operator-observable outcomes as before.
---
### User Story 3 - Keep the review diff mechanically narrow (Priority: P2)
As a reviewer, I want the cleanup diff to stay limited to dead-path deletion and its direct fallout so that I can approve it quickly without re-reviewing unrelated architecture decisions.
**Why this priority**: The main delivery risk is not deletion itself, but that the cleanup grows into an opportunistic mixed refactor.
**Independent Test**: Inspect the resulting PR scope and confirm that it is limited to `CompareBaselineToTenantJob`, the focused compare guard and regression files that prove the delete is safe, and only direct blocker-driven follow-up in `BaselineCompareService` or `IntuneCompareStrategy` if the implementation explicitly justifies it.
**Acceptance Scenarios**:
1. **Given** nearby follow-up ideas exist, **When** the cleanup is implemented, **Then** the final diff touches `CompareBaselineToTenantJob`, the focused compare guard and regression files, and no other production file except a direct blocker-driven follow-up in `BaselineCompareService` or `IntuneCompareStrategy`.
2. **Given** adjacent imports, comments, or docblocks still imply the removed path exists, **When** the cleanup finishes, **Then** only those directly obsolete remnants are adjusted and no unrelated rename, schema, or UI work appears in the same PR.
### Edge Cases
- A seemingly legacy helper still has an indirect productive call site or compatibility responsibility.
- A regression test or inspection helper still asserts deleted internal structure instead of current compare behavior.
- Summary, warning, or finding behavior depends on normalization that must remain preserved through the active path even after the dead block is removed.
- Internal comments or docblocks still describe a fallback or alternate drift path that no longer exists.
## Requirements *(mandatory)*
**Constitution alignment (required):** This feature does not introduce a new external integration, new write pathway, or new long-running workflow. It removes dead internal compare logic from an existing execution unit and keeps existing tenant isolation, run observability, and audit behavior unchanged. Regression coverage must prove there is no behavior change.
**Constitution alignment (PROP-001 / ABSTR-001 / PERSIST-001 / STATE-001 / BLOAT-001):** The cleanup introduces no new persistence, abstraction, state family, or semantic layer. The narrowest correct implementation is deletion of the dead path and cleanup of its direct fallout.
**Constitution alignment (OPS-UX):** Existing run creation, lifecycle ownership, summary-count rules, and three-surface feedback behavior remain unchanged. Any touched regression coverage must continue to protect the current compare run lifecycle.
**Constitution alignment (RBAC-UX):** No authorization behavior changes are part of this feature. Existing workspace and tenant access rules, including current `404` and `403` behavior, remain untouched.
**Constitution alignment (OPS-EX-AUTH-001):** Not applicable.
**Constitution alignment (BADGE-001):** Not applicable; no status or badge semantics change.
**Constitution alignment (UI-FIL-001):** Not applicable; no Filament or Blade surface changes are introduced.
**Constitution alignment (UI-NAMING-001):** Operator-facing labels remain unchanged. Only misleading internal comments or docblocks may be corrected.
**Constitution alignment (DECIDE-001):** No new or changed operator-facing decision surface is introduced.
**Constitution alignment (UI-CONST-001 / UI-SURF-001 / ACTSURF-001 / UI-HARD-001 / UI-EX-001 / UI-REVIEW-001 / HDR-001):** No surface or action changes are in scope.
**Constitution alignment (ACTSURF-001 - action hierarchy):** Not applicable; no header, row, or bulk action structure changes are introduced.
**Constitution alignment (OPSURF-001):** Not applicable; no operator-facing surface is added or materially refactored.
**Constitution alignment (UI-SEM-001 / LAYER-001 / TEST-TRUTH-001):** The feature removes redundant legacy logic instead of adding a new interpretation layer. Tests stay focused on behavior and architectural truth rather than thin indirection.
**Constitution alignment (Filament Action Surfaces):** Not applicable.
**Constitution alignment (UX-001 - Layout & Information Architecture):** Not applicable.
### Functional Requirements
- **FR-205-001 Single active compare path**: The baseline compare orchestration unit MUST retain only the active strategy-driven compare execution path.
- **FR-205-002 Legacy drift removal**: The obsolete pre-strategy drift computation block retained in the orchestration unit MUST be removed.
- **FR-205-003 Helper cleanup**: Any helper method, local utility, or internal dependency used exclusively by the removed legacy path MUST also be removed.
- **FR-205-004 Truthful dependency surface**: After cleanup, imports, comments, and docblocks in the orchestration unit MUST reflect only currently active dependencies and behavior.
- **FR-205-005 No behavioral reshaping**: The cleanup MUST NOT change strategy selection, compare execution, finding generation, summary computation, gap handling, warning handling, recurrence behavior, reason handling, or run completion behavior.
- **FR-205-006 No speculative follow-up work**: The cleanup MUST NOT introduce new abstractions, naming generalizations, schema changes, UI changes, or unrelated refactors.
- **FR-205-007 Regression proof**: Automated regression coverage MUST demonstrate that the active strategy-driven compare path still executes correctly after the cleanup.
- **FR-205-008 Call-graph safety**: Before the cleanup is considered complete, the removed legacy path and its exclusive helpers MUST have no remaining productive call sites in the surrounding production code.
### Non-Functional Requirements
- **NFR-205-001 Reviewability**: The resulting change set MUST be limited to `CompareBaselineToTenantJob`, the focused compare guard and regression files needed to prove delete safety, and only direct blocker-driven follow-up in `BaselineCompareService` or `IntuneCompareStrategy` when explicitly justified.
- **NFR-205-002 Architectural honesty**: After cleanup, an architecture review of the compare orchestration unit MUST find one authoritative compare execution path rather than a retained parallel legacy implementation.
## Success Criteria *(mandatory)*
### Measurable Outcomes
- **SC-205-001**: A contributor can inspect the compare orchestration unit and identify a single active compare execution path without needing to rule out a retained parallel legacy path.
- **SC-205-002**: Focused automated checks covering the current strategy-driven compare flow pass after the cleanup with no newly introduced failures.
- **SC-205-003**: Review of the cleanup diff shows touched files limited to `CompareBaselineToTenantJob`, the focused compare guard and regression files, and at most a direct blocker-driven follow-up in `BaselineCompareService` or `IntuneCompareStrategy`.
- **SC-205-004**: Post-cleanup architecture review no longer reports a retained pre-strategy drift computation block in the compare orchestration unit.

View File

@ -0,0 +1,194 @@
# Tasks: Compare Job Legacy Drift Path Cleanup
**Input**: Design documents from `/specs/205-compare-job-cleanup/`
**Prerequisites**: `plan.md`, `spec.md`, `research.md`, `data-model.md`, `contracts/compare-job-legacy-drift-cleanup.logical.openapi.yaml`, `quickstart.md`
**Tests**: Required. This cleanup changes runtime compare orchestration code in `CompareBaselineToTenantJob` and must keep the current strategy-driven compare path green through focused Pest regression and guard coverage.
**Operations**: Existing `baseline_compare` `OperationRun` behavior remains unchanged. No new run type, feedback surface, or monitoring path is introduced.
**RBAC**: No authorization change is in scope. Existing compare and monitoring permissions remain authoritative, and tasks must avoid introducing RBAC drift while touching the orchestration file.
**Operator Surfaces**: No operator-facing surface change is in scope. Existing tenant compare and monitoring routes remain verification anchors only.
**Filament UI Action Surfaces**: No Filament resource, page, relation manager, or action-hierarchy change is planned.
**Proportionality**: This spec removes dead code only and must not introduce new abstractions, persistence, or semantic layers.
**Organization**: Tasks are grouped by user story so the cleanup can be implemented and verified in narrow, reviewable increments. Recommended delivery order is `US1 -> US2 -> US3`, with `US1 + US2` forming the practical merge-ready slice.
## Phase 1: Setup (Shared Baseline)
**Purpose**: Capture the full required pre-cleanup regression baseline and inspect the active compare boundary before editing the orchestration file.
- [X] T001 [P] Capture the required pre-cleanup regression baseline by running `apps/platform/tests/Feature/BaselineDriftEngine/FindingFidelityTest.php`, `apps/platform/tests/Feature/Baselines/BaselineCompareFindingsTest.php`, `apps/platform/tests/Feature/Baselines/BaselineCompareGapClassificationTest.php`, `apps/platform/tests/Feature/Baselines/BaselineCompareWhyNoFindingsReasonCodeTest.php`, `apps/platform/tests/Feature/Guards/Spec116OneEngineGuardTest.php`, `apps/platform/tests/Feature/Guards/Spec118NoLegacyBaselineDriftGuardTest.php`, `apps/platform/tests/Feature/Guards/OperationLifecycleOpsUxGuardTest.php`, `apps/platform/tests/Feature/Operations/BaselineOperationRunGuardTest.php`, `apps/platform/tests/Feature/OpsUx/OperationSummaryKeysSpecTest.php`, `apps/platform/tests/Feature/OpsUx/SummaryCountsWhitelistTest.php`, and `apps/platform/tests/Feature/Baselines/BaselineCompareMatrixCompareAllActionTest.php`
- [X] T002 [P] Inspect the live compare dispatch and candidate legacy helper cluster in `apps/platform/app/Jobs/CompareBaselineToTenantJob.php` and `apps/platform/app/Services/Baselines/BaselineCompareService.php`
**Checkpoint**: The team has a known-good full focused baseline and a confirmed starting map of the live compare path.
---
## Phase 2: Foundational (Blocking Call-Graph Confirmation)
**Purpose**: Confirm the dead-vs-live method boundary so the cleanup deletes only unreachable logic.
**CRITICAL**: No user story work should begin until this phase is complete.
- [X] T003 [P] Map exclusive callers for `computeDrift()` and its adjacent helper cluster in `apps/platform/app/Jobs/CompareBaselineToTenantJob.php`
- [X] T004 [P] Review `apps/platform/app/Support/Baselines/Compare/CompareStrategyRegistry.php` and `apps/platform/app/Support/Baselines/Compare/IntuneCompareStrategy.php` to confirm the live strategy contract needs no structural change for this cleanup
**Checkpoint**: The delete list is confirmed and the live strategy-owned path is explicitly out of scope for redesign.
---
## Phase 3: User Story 1 - Read the live compare architecture without dead-path noise (Priority: P1) MVP
**Goal**: Remove the retained monolithic drift-compute path so the compare job shows one real execution engine instead of a parallel historical implementation.
**Independent Test**: Inspect `apps/platform/app/Jobs/CompareBaselineToTenantJob.php` after cleanup and confirm that the live compare path still flows through strategy selection and `strategy->compare(...)`, while the legacy helper names are absent and the guard suite passes.
### Tests for User Story 1
> **NOTE**: Update these tests first and confirm they fail before implementation.
- [X] T005 [P] [US1] Extend legacy helper absence assertions in `apps/platform/tests/Feature/Guards/Spec118NoLegacyBaselineDriftGuardTest.php`
- [X] T006 [P] [US1] Reconfirm one-engine orchestration guard coverage in `apps/platform/tests/Feature/Guards/Spec116OneEngineGuardTest.php`
### Implementation for User Story 1
- [X] T007 [US1] Remove `computeDrift()` and its exclusive helper cluster from `apps/platform/app/Jobs/CompareBaselineToTenantJob.php`
- [X] T008 [US1] Remove dead imports and stale fallback comments or docblocks left by the deleted cluster in `apps/platform/app/Jobs/CompareBaselineToTenantJob.php`
- [X] T009 [US1] Re-run the guard coverage in `apps/platform/tests/Feature/Guards/Spec116OneEngineGuardTest.php` and `apps/platform/tests/Feature/Guards/Spec118NoLegacyBaselineDriftGuardTest.php` against `apps/platform/app/Jobs/CompareBaselineToTenantJob.php`
**Checkpoint**: The compare job is structurally honest again and the guard suite blocks reintroduction of the deleted legacy path.
---
## Phase 4: User Story 2 - Preserve current compare behavior while removing dead code (Priority: P1)
**Goal**: Prove that the cleanup leaves strategy selection, compare execution, finding lifecycle behavior, summary computation, gap handling, warning handling, reason translation, and run completion unchanged.
**Independent Test**: Run the required focused regression slice and confirm that `FindingFidelityTest`, `BaselineCompareFindingsTest`, `BaselineCompareGapClassificationTest`, `BaselineCompareWhyNoFindingsReasonCodeTest`, `Spec116OneEngineGuardTest`, `Spec118NoLegacyBaselineDriftGuardTest`, `OperationLifecycleOpsUxGuardTest`, `BaselineOperationRunGuardTest`, `OperationSummaryKeysSpecTest`, `SummaryCountsWhitelistTest`, and the matrix enqueue-path check all remain green after the delete.
### Tests for User Story 2
> **NOTE**: Update these tests first and confirm they fail before implementation.
- [X] T010 [P] [US2] Tighten strategy-driven execution assertions in `apps/platform/tests/Feature/BaselineDriftEngine/FindingFidelityTest.php`
- [X] T011 [P] [US2] Tighten finding lifecycle, recurrence, and summary outcome assertions in `apps/platform/tests/Feature/Baselines/BaselineCompareFindingsTest.php`
- [X] T012 [P] [US2] Tighten gap classification, warning-outcome, and reason-code assertions in `apps/platform/tests/Feature/Baselines/BaselineCompareGapClassificationTest.php` and `apps/platform/tests/Feature/Baselines/BaselineCompareWhyNoFindingsReasonCodeTest.php`
- [X] T013 [P] [US2] Reconfirm `OperationRun` lifecycle and summary-count guard coverage in `apps/platform/tests/Feature/Guards/OperationLifecycleOpsUxGuardTest.php`, `apps/platform/tests/Feature/Operations/BaselineOperationRunGuardTest.php`, `apps/platform/tests/Feature/OpsUx/OperationSummaryKeysSpecTest.php`, and `apps/platform/tests/Feature/OpsUx/SummaryCountsWhitelistTest.php`
### Implementation for User Story 2
- [X] T014 [US2] Keep live compare behavior unchanged while reconciling any cleanup fallout in `apps/platform/app/Jobs/CompareBaselineToTenantJob.php`; do not modify `apps/platform/app/Support/Baselines/Compare/CompareStrategyRegistry.php` or `apps/platform/app/Support/Baselines/Compare/IntuneCompareStrategy.php` unless a blocker proves the smallest direct fix is required
- [X] T015 [US2] Run the required focused regression slice in `apps/platform/tests/Feature/BaselineDriftEngine/FindingFidelityTest.php`, `apps/platform/tests/Feature/Baselines/BaselineCompareFindingsTest.php`, `apps/platform/tests/Feature/Baselines/BaselineCompareGapClassificationTest.php`, `apps/platform/tests/Feature/Baselines/BaselineCompareWhyNoFindingsReasonCodeTest.php`, `apps/platform/tests/Feature/Guards/Spec116OneEngineGuardTest.php`, `apps/platform/tests/Feature/Guards/Spec118NoLegacyBaselineDriftGuardTest.php`, `apps/platform/tests/Feature/Guards/OperationLifecycleOpsUxGuardTest.php`, `apps/platform/tests/Feature/Operations/BaselineOperationRunGuardTest.php`, `apps/platform/tests/Feature/OpsUx/OperationSummaryKeysSpecTest.php`, `apps/platform/tests/Feature/OpsUx/SummaryCountsWhitelistTest.php`, and `apps/platform/tests/Feature/Baselines/BaselineCompareMatrixCompareAllActionTest.php`
**Checkpoint**: The cleanup is behaviorally safe and the current compare lifecycle still works through the strategy-owned path.
---
## Phase 5: User Story 3 - Keep the review diff mechanically narrow (Priority: P2)
**Goal**: Keep the cleanup PR reviewable by limiting the touched area to dead-path deletion, direct fallout cleanup, and minimal regression updates.
**Independent Test**: Inspect the final changed-file set and confirm that it is limited to `apps/platform/app/Jobs/CompareBaselineToTenantJob.php`, the focused compare guard and regression files, and only direct blocker-driven follow-up in `apps/platform/app/Services/Baselines/BaselineCompareService.php` or `apps/platform/app/Support/Baselines/Compare/IntuneCompareStrategy.php` if justified.
### Implementation for User Story 3
- [X] T016 [P] [US3] Audit the touched-file set and strip opportunistic edits outside `apps/platform/app/Jobs/CompareBaselineToTenantJob.php`, `apps/platform/tests/Feature/Guards/Spec116OneEngineGuardTest.php`, `apps/platform/tests/Feature/Guards/Spec118NoLegacyBaselineDriftGuardTest.php`, `apps/platform/tests/Feature/BaselineDriftEngine/FindingFidelityTest.php`, `apps/platform/tests/Feature/Baselines/BaselineCompareFindingsTest.php`, `apps/platform/tests/Feature/Baselines/BaselineCompareGapClassificationTest.php`, `apps/platform/tests/Feature/Baselines/BaselineCompareWhyNoFindingsReasonCodeTest.php`, `apps/platform/tests/Feature/Guards/OperationLifecycleOpsUxGuardTest.php`, `apps/platform/tests/Feature/Operations/BaselineOperationRunGuardTest.php`, `apps/platform/tests/Feature/OpsUx/OperationSummaryKeysSpecTest.php`, `apps/platform/tests/Feature/OpsUx/SummaryCountsWhitelistTest.php`, and `apps/platform/tests/Feature/Baselines/BaselineCompareMatrixCompareAllActionTest.php`
- [X] T017 [P] [US3] Review `apps/platform/app/Services/Baselines/BaselineCompareService.php` and `apps/platform/app/Support/Baselines/Compare/IntuneCompareStrategy.php` to confirm they remain unchanged or carry only the smallest blocker-driven follow-up required by the cleanup
- [X] T018 [US3] Verify the final diff stays limited to `apps/platform/app/Jobs/CompareBaselineToTenantJob.php`, the focused compare guard and regression files above including `apps/platform/tests/Feature/Baselines/BaselineCompareMatrixCompareAllActionTest.php`, and only direct blocker-driven follow-up in `apps/platform/app/Services/Baselines/BaselineCompareService.php` or `apps/platform/app/Support/Baselines/Compare/IntuneCompareStrategy.php`
**Checkpoint**: The PR stays small, reviewable, and aligned with Spec 205 rather than drifting into a broader compare refactor.
---
## Phase 6: Polish & Cross-Cutting Concerns
**Purpose**: Apply formatting and rerun the final focused Sail pack before handing the cleanup over for review.
- [X] T019 Run `cd apps/platform && ./vendor/bin/sail bin pint --dirty --format agent` for touched PHP files centered on `apps/platform/app/Jobs/CompareBaselineToTenantJob.php`
- [X] T020 Run the final focused Sail pack from `specs/205-compare-job-cleanup/quickstart.md` covering `apps/platform/tests/Feature/BaselineDriftEngine/FindingFidelityTest.php`, `apps/platform/tests/Feature/Baselines/BaselineCompareFindingsTest.php`, `apps/platform/tests/Feature/Baselines/BaselineCompareGapClassificationTest.php`, `apps/platform/tests/Feature/Baselines/BaselineCompareWhyNoFindingsReasonCodeTest.php`, `apps/platform/tests/Feature/Guards/OperationLifecycleOpsUxGuardTest.php`, `apps/platform/tests/Feature/Operations/BaselineOperationRunGuardTest.php`, `apps/platform/tests/Feature/OpsUx/OperationSummaryKeysSpecTest.php`, `apps/platform/tests/Feature/OpsUx/SummaryCountsWhitelistTest.php`, `apps/platform/tests/Feature/Guards/Spec116OneEngineGuardTest.php`, `apps/platform/tests/Feature/Guards/Spec118NoLegacyBaselineDriftGuardTest.php`, and `apps/platform/tests/Feature/Baselines/BaselineCompareMatrixCompareAllActionTest.php`
---
## Dependencies & Execution Order
### Phase Dependencies
- **Setup (Phase 1)**: No dependencies; can start immediately.
- **Foundational (Phase 2)**: Depends on Setup completion; blocks all user story work.
- **User Story 1 (Phase 3)**: Depends on Foundational completion.
- **User Story 2 (Phase 4)**: Depends on User Story 1 because behavior preservation is verified after the delete lands.
- **User Story 3 (Phase 5)**: Depends on User Story 1 and User Story 2 because scope review is only meaningful once the delete, gap and reason coverage, and run-guard updates exist.
- **Polish (Phase 6)**: Depends on all user stories being complete.
### User Story Dependencies
- **US1**: No dependency beyond Foundational.
- **US2**: Depends on US1 because the focused regression slice validates the actual cleanup result.
- **US3**: Depends on US1 and US2 because it is the final scope-control pass over the implemented cleanup.
### Within Each User Story
- Update or tighten the story's tests first and confirm they fail before implementation.
- Keep compare start orchestration in `apps/platform/app/Services/Baselines/BaselineCompareService.php` and live strategy behavior in `apps/platform/app/Support/Baselines/Compare/IntuneCompareStrategy.php` out of scope unless a blocker demands the smallest possible fix.
- Finish each story's focused verification before moving to the next story.
### Parallel Opportunities
- `T001` and `T002` can run in parallel.
- `T003` and `T004` can run in parallel.
- Within US1, `T005` and `T006` can run in parallel.
- Within US2, `T010`, `T011`, `T012`, and `T013` can run in parallel.
- Within US3, `T016` and `T017` can run in parallel.
---
## Parallel Example: User Story 1
```bash
# Parallel guard updates for US1
T005 Extend legacy helper absence assertions in Spec118NoLegacyBaselineDriftGuardTest.php
T006 Reconfirm one-engine orchestration guard coverage in Spec116OneEngineGuardTest.php
```
## Parallel Example: User Story 2
```bash
# Parallel regression tightening for US2
T010 Tighten strategy-driven execution assertions in FindingFidelityTest.php
T011 Tighten finding lifecycle and summary outcome assertions in BaselineCompareFindingsTest.php
T012 Tighten gap classification and reason-code assertions in BaselineCompareGapClassificationTest.php and BaselineCompareWhyNoFindingsReasonCodeTest.php
T013 Reconfirm OperationRun lifecycle and summary-count guard coverage in OperationLifecycleOpsUxGuardTest.php, BaselineOperationRunGuardTest.php, OperationSummaryKeysSpecTest.php, and SummaryCountsWhitelistTest.php
```
## Parallel Example: User Story 3
```bash
# Parallel scope review for US3
T016 Audit the touched-file set and strip opportunistic edits outside the focused cleanup files
T017 Review BaselineCompareService.php and IntuneCompareStrategy.php for blocker-only follow-up changes
```
---
## Implementation Strategy
### MVP First
1. Complete Setup and Foundational work.
2. Deliver US1 to remove the dead path and restore a single truthful compare engine in the orchestration file.
3. Immediately follow with US2 so the cleanup is merge-safe, not just structurally cleaner.
### Incremental Delivery
1. Finish US1 and confirm the guard suite blocks the deleted helper cluster.
2. Finish US2 and prove the live strategy-driven compare behavior remains unchanged.
3. Finish US3 to keep the cleanup PR mechanically narrow.
4. Finish with formatting and the final focused Sail pack from Phase 6.
### Parallel Team Strategy
1. One contributor handles Setup and Foundational call-graph confirmation.
2. After Foundation is green:
T005 and T006 can be prepared in parallel for US1.
T010, T011, T012, and T013 can be prepared in parallel for US2.
T016 and T017 can be prepared in parallel for US3 once the cleanup diff exists.
3. Merge back for the final diff review, formatting, and focused Sail verification.