feat: add structured baseline snapshot rendering (#158)

## Summary
- replace the baseline snapshot detail page with a structured summary-first rendering flow
- add a presenter plus renderer registry with RBAC, compliance, and fallback renderers
- add grouped policy-type browsing, fidelity and gap badges, and workspace authorization coverage
- add Feature 130 spec, plan, contract, research, quickstart, and completed task artifacts

## Testing
- focused Pest coverage was added for structured rendering, fallback behavior, degraded states, authorization, presenter logic, renderer resolution, and badge mapping
- I did not rerun the full validation suite in this final PR step

## Notes
- base branch: `dev`
- feature branch: `130-structured-snapshot-rendering`

Co-authored-by: Ahmed Darrazi <ahmed.darrazi@live.de>
Reviewed-on: #158
This commit is contained in:
ahmido 2026-03-10 08:28:06 +00:00
parent 0c709df54e
commit 3c445709af
44 changed files with 3617 additions and 123 deletions

View File

@ -58,6 +58,7 @@ ## Active Technologies
- PostgreSQL for tenant-owned inventory, backup items, versions, verification outcomes, and operation runs (127-rbac-inventory-backup) - PostgreSQL for tenant-owned inventory, backup items, versions, verification outcomes, and operation runs (127-rbac-inventory-backup)
- PostgreSQL via Laravel Sail (128-rbac-baseline-compare) - PostgreSQL via Laravel Sail (128-rbac-baseline-compare)
- PostgreSQL via Laravel Sail plus session-backed workspace and tenant contex (129-workspace-admin-home) - PostgreSQL via Laravel Sail plus session-backed workspace and tenant contex (129-workspace-admin-home)
- PostgreSQL via Laravel Sail using existing `baseline_snapshots`, `baseline_snapshot_items`, and JSONB presentation source fields (130-structured-snapshot-rendering)
- PHP 8.4.15 (feat/005-bulk-operations) - PHP 8.4.15 (feat/005-bulk-operations)
@ -77,8 +78,8 @@ ## Code Style
PHP 8.4.15: Follow standard conventions PHP 8.4.15: Follow standard conventions
## Recent Changes ## Recent Changes
- 130-structured-snapshot-rendering: Added PHP 8.4.15 / Laravel 12 + Filament v5, Livewire v4.0+, Tailwind CSS v4
- 129-workspace-admin-home: Added PHP 8.4.15 / Laravel 12 + Filament v5, Livewire v4.0+, Tailwind CSS v4 - 129-workspace-admin-home: Added PHP 8.4.15 / Laravel 12 + Filament v5, Livewire v4.0+, Tailwind CSS v4
- 128-rbac-baseline-compare: Added PHP 8.4 + Laravel 12, Filament v5, Livewire v4 - 128-rbac-baseline-compare: Added PHP 8.4 + Laravel 12, Filament v5, Livewire v4
- 127-rbac-inventory-backup: Added PHP 8.4 (Laravel 12) + Filament v5, Livewire v4, Laravel Sail, Microsoft Graph provider stack
<!-- MANUAL ADDITIONS START --> <!-- MANUAL ADDITIONS START -->
<!-- MANUAL ADDITIONS END --> <!-- MANUAL ADDITIONS END -->

View File

@ -17,13 +17,8 @@
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceSlot; use App\Support\Ui\ActionSurface\Enums\ActionSurfaceSlot;
use App\Support\Workspaces\WorkspaceContext; use App\Support\Workspaces\WorkspaceContext;
use BackedEnum; use BackedEnum;
use Filament\Actions\ViewAction;
use Filament\Facades\Filament; use Filament\Facades\Filament;
use Filament\Infolists\Components\RepeatableEntry;
use Filament\Infolists\Components\TextEntry;
use Filament\Infolists\Components\ViewEntry;
use Filament\Resources\Resource; use Filament\Resources\Resource;
use Filament\Schemas\Components\Section;
use Filament\Schemas\Schema; use Filament\Schemas\Schema;
use Filament\Tables\Columns\TextColumn; use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Filters\SelectFilter; use Filament\Tables\Filters\SelectFilter;
@ -98,15 +93,26 @@ public static function canDelete(Model $record): bool
public static function canView(Model $record): bool public static function canView(Model $record): bool
{ {
return self::canViewAny(); if (! $record instanceof BaselineSnapshot) {
return false;
}
$workspace = self::resolveWorkspace();
if (! $workspace instanceof Workspace) {
return false;
}
return self::canViewAny()
&& (int) $record->workspace_id === (int) $workspace->getKey();
} }
public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration
{ {
return ActionSurfaceDeclaration::forResource(ActionSurfaceProfile::CrudListAndView) return ActionSurfaceDeclaration::forResource(ActionSurfaceProfile::CrudListAndView)
->exempt(ActionSurfaceSlot::ListHeader, 'Snapshots are created by capture runs; no list-header actions.') ->exempt(ActionSurfaceSlot::ListHeader, 'Snapshots are created by capture runs; no list-header actions.')
->satisfy(ActionSurfaceSlot::InspectAffordance, ActionSurfaceInspectAffordance::ViewAction->value) ->satisfy(ActionSurfaceSlot::InspectAffordance, ActionSurfaceInspectAffordance::ClickableRow->value)
->exempt(ActionSurfaceSlot::ListRowMoreMenu, 'Snapshots are immutable; no row actions besides view.') ->exempt(ActionSurfaceSlot::ListRowMoreMenu, 'Snapshots are immutable; rows navigate directly to the detail page.')
->exempt(ActionSurfaceSlot::ListBulkMoreGroup, 'Snapshots are immutable; no bulk actions.') ->exempt(ActionSurfaceSlot::ListBulkMoreGroup, 'Snapshots are immutable; no bulk actions.')
->exempt(ActionSurfaceSlot::ListEmptyState, 'Empty-state CTA is intentionally omitted; snapshots appear after baseline captures.') ->exempt(ActionSurfaceSlot::ListEmptyState, 'Empty-state CTA is intentionally omitted; snapshots appear after baseline captures.')
->exempt(ActionSurfaceSlot::DetailHeader, 'View page is informational and currently has no header actions.'); ->exempt(ActionSurfaceSlot::DetailHeader, 'View page is informational and currently has no header actions.');
@ -165,6 +171,9 @@ public static function table(Table $table): Table
->getStateUsing(static fn (BaselineSnapshot $record): string => self::stateLabel($record)) ->getStateUsing(static fn (BaselineSnapshot $record): string => self::stateLabel($record))
->color(static fn (BaselineSnapshot $record): string => self::hasGaps($record) ? 'warning' : 'success'), ->color(static fn (BaselineSnapshot $record): string => self::hasGaps($record) ? 'warning' : 'success'),
]) ])
->recordUrl(static fn (BaselineSnapshot $record): ?string => static::canView($record)
? static::getUrl('view', ['record' => $record])
: null)
->filters([ ->filters([
SelectFilter::make('baseline_profile_id') SelectFilter::make('baseline_profile_id')
->label('Baseline') ->label('Baseline')
@ -176,9 +185,7 @@ public static function table(Table $table): Table
->query(fn (Builder $query, array $data): Builder => static::applySnapshotStateFilter($query, $data['value'] ?? null)), ->query(fn (Builder $query, array $data): Builder => static::applySnapshotStateFilter($query, $data['value'] ?? null)),
FilterPresets::dateRange('captured_at', 'Captured', 'captured_at'), FilterPresets::dateRange('captured_at', 'Captured', 'captured_at'),
]) ])
->actions([ ->actions([])
ViewAction::make()->label('View'),
])
->bulkActions([]) ->bulkActions([])
->emptyStateHeading('No baseline snapshots') ->emptyStateHeading('No baseline snapshots')
->emptyStateDescription('Capture a baseline snapshot to review evidence fidelity and compare tenants over time.') ->emptyStateDescription('Capture a baseline snapshot to review evidence fidelity and compare tenants over time.')
@ -187,73 +194,7 @@ public static function table(Table $table): Table
public static function infolist(Schema $schema): Schema public static function infolist(Schema $schema): Schema
{ {
return $schema return $schema;
->schema([
Section::make('Snapshot')
->schema([
TextEntry::make('id')
->label('Snapshot')
->formatStateUsing(static fn (?int $state): string => $state ? '#'.$state : '—'),
TextEntry::make('baselineProfile.name')
->label('Baseline'),
TextEntry::make('captured_at')
->label('Captured')
->dateTime(),
TextEntry::make('snapshot_state')
->label('State')
->badge()
->getStateUsing(static fn (BaselineSnapshot $record): string => self::stateLabel($record))
->color(static fn (BaselineSnapshot $record): string => self::hasGaps($record) ? 'warning' : 'success'),
TextEntry::make('fidelity_summary')
->label('Fidelity')
->getStateUsing(static fn (BaselineSnapshot $record): string => self::fidelitySummary($record)),
TextEntry::make('evidence_gaps')
->label('Evidence gaps')
->getStateUsing(static fn (BaselineSnapshot $record): int => self::gapsCount($record)),
TextEntry::make('snapshot_identity_hash')
->label('Identity hash')
->copyable()
->columnSpanFull(),
])
->columns(2)
->columnSpanFull(),
Section::make('Summary')
->schema([
ViewEntry::make('summary_jsonb')
->label('')
->view('filament.infolists.entries.snapshot-json')
->state(static fn (BaselineSnapshot $record): array => is_array($record->summary_jsonb) ? $record->summary_jsonb : [])
->columnSpanFull(),
])
->columnSpanFull(),
Section::make('Intune RBAC Role Definition References')
->schema([
RepeatableEntry::make('rbac_role_definition_references')
->label('')
->state(static fn (BaselineSnapshot $record): array => self::rbacRoleDefinitionReferences($record))
->schema([
TextEntry::make('display_name')
->label('Role definition'),
TextEntry::make('role_source')
->label('Role source')
->badge(),
TextEntry::make('permission_blocks')
->label('Permission blocks'),
TextEntry::make('identity_strategy')
->label('Identity')
->badge(),
TextEntry::make('policy_version_reference')
->label('Baseline evidence'),
TextEntry::make('observed_at')
->label('Observed at')
->placeholder('—'),
])
->columns(2)
->columnSpanFull(),
])
->visible(static fn (BaselineSnapshot $record): bool => self::rbacRoleDefinitionReferences($record) !== [])
->columnSpanFull(),
]);
} }
public static function getPages(): array public static function getPages(): array
@ -293,7 +234,7 @@ private static function snapshotStateOptions(): array
]; ];
} }
private static function resolveWorkspace(): ?Workspace public static function resolveWorkspace(): ?Workspace
{ {
$workspaceId = app(WorkspaceContext::class)->currentWorkspaceId(request()); $workspaceId = app(WorkspaceContext::class)->currentWorkspaceId(request());
@ -347,48 +288,6 @@ private static function hasGaps(BaselineSnapshot $snapshot): bool
return self::gapsCount($snapshot) > 0; return self::gapsCount($snapshot) > 0;
} }
/**
* @return list<array{
* display_name: string,
* role_source: string,
* permission_blocks: string,
* identity_strategy: string,
* policy_version_reference: string,
* observed_at: ?string
* }>
*/
private static function rbacRoleDefinitionReferences(BaselineSnapshot $snapshot): array
{
return $snapshot->items()
->where('policy_type', 'intuneRoleDefinition')
->orderBy('id')
->get()
->map(static function (\App\Models\BaselineSnapshotItem $item): array {
$meta = is_array($item->meta_jsonb) ? $item->meta_jsonb : [];
$policyVersionId = data_get($meta, 'version_reference.policy_version_id');
$rolePermissionCount = data_get($meta, 'rbac.role_permission_count');
$identityStrategy = (string) data_get($meta, 'identity.strategy', 'display_name');
return [
'display_name' => (string) data_get($meta, 'display_name', '—'),
'role_source' => match (data_get($meta, 'rbac.is_built_in')) {
true => 'Built-in',
false => 'Custom',
default => 'Unknown',
},
'permission_blocks' => is_numeric($rolePermissionCount)
? (string) ((int) $rolePermissionCount)
: '—',
'identity_strategy' => $identityStrategy === 'external_id' ? 'Role definition ID' : 'Display name',
'policy_version_reference' => is_numeric($policyVersionId)
? 'Policy version #'.((int) $policyVersionId)
: 'Metadata only',
'observed_at' => data_get($meta, 'evidence.observed_at'),
];
})
->all();
}
private static function stateLabel(BaselineSnapshot $snapshot): string private static function stateLabel(BaselineSnapshot $snapshot): string
{ {
return self::hasGaps($snapshot) ? 'Captured with gaps' : 'Complete'; return self::hasGaps($snapshot) ? 'Captured with gaps' : 'Complete';

View File

@ -5,12 +5,37 @@
namespace App\Filament\Resources\BaselineSnapshotResource\Pages; namespace App\Filament\Resources\BaselineSnapshotResource\Pages;
use App\Filament\Resources\BaselineSnapshotResource; use App\Filament\Resources\BaselineSnapshotResource;
use App\Models\User;
use App\Models\Workspace;
use App\Services\Auth\WorkspaceCapabilityResolver;
use App\Support\Auth\Capabilities;
use Filament\Resources\Pages\ListRecords; use Filament\Resources\Pages\ListRecords;
class ListBaselineSnapshots extends ListRecords class ListBaselineSnapshots extends ListRecords
{ {
protected static string $resource = BaselineSnapshotResource::class; protected static string $resource = BaselineSnapshotResource::class;
protected function authorizeAccess(): void
{
$user = auth()->user();
$workspace = BaselineSnapshotResource::resolveWorkspace();
if (! $user instanceof User || ! $workspace instanceof Workspace) {
abort(404);
}
/** @var WorkspaceCapabilityResolver $resolver */
$resolver = app(WorkspaceCapabilityResolver::class);
if (! $resolver->isMember($user, $workspace)) {
abort(404);
}
if (! $resolver->can($user, $workspace, Capabilities::WORKSPACE_BASELINES_VIEW)) {
abort(403);
}
}
protected function getHeaderActions(): array protected function getHeaderActions(): array
{ {
return []; return [];

View File

@ -5,12 +5,149 @@
namespace App\Filament\Resources\BaselineSnapshotResource\Pages; namespace App\Filament\Resources\BaselineSnapshotResource\Pages;
use App\Filament\Resources\BaselineSnapshotResource; use App\Filament\Resources\BaselineSnapshotResource;
use App\Models\BaselineSnapshot;
use App\Models\User;
use App\Models\Workspace;
use App\Services\Auth\WorkspaceCapabilityResolver;
use App\Services\Baselines\SnapshotRendering\BaselineSnapshotPresenter;
use App\Support\Auth\Capabilities;
use App\Support\Badges\BadgeDomain;
use App\Support\Badges\BadgeRenderer;
use Filament\Infolists\Components\TextEntry;
use Filament\Infolists\Components\ViewEntry;
use Filament\Resources\Pages\ViewRecord; use Filament\Resources\Pages\ViewRecord;
use Filament\Schemas\Components\Section;
use Filament\Schemas\Schema;
class ViewBaselineSnapshot extends ViewRecord class ViewBaselineSnapshot extends ViewRecord
{ {
protected static string $resource = BaselineSnapshotResource::class; protected static string $resource = BaselineSnapshotResource::class;
/**
* @var array<string, mixed>
*/
public array $presentedSnapshot = [];
public function mount(int|string $record): void
{
parent::mount($record);
$snapshot = $this->getRecord();
if ($snapshot instanceof BaselineSnapshot) {
$this->presentedSnapshot = app(BaselineSnapshotPresenter::class)
->present($snapshot)
->toArray();
}
}
protected function authorizeAccess(): void
{
$user = auth()->user();
$snapshot = $this->getRecord();
$workspace = BaselineSnapshotResource::resolveWorkspace();
if (! $user instanceof User || ! $snapshot instanceof BaselineSnapshot || ! $workspace instanceof Workspace) {
abort(404);
}
if ((int) $snapshot->workspace_id !== (int) $workspace->getKey()) {
abort(404);
}
/** @var WorkspaceCapabilityResolver $resolver */
$resolver = app(WorkspaceCapabilityResolver::class);
if (! $resolver->isMember($user, $workspace)) {
abort(404);
}
if (! $resolver->can($user, $workspace, Capabilities::WORKSPACE_BASELINES_VIEW)) {
abort(403);
}
}
public function infolist(Schema $schema): Schema
{
return $schema
->schema([
Section::make('Snapshot')
->schema([
TextEntry::make('snapshot_id')
->label('Snapshot')
->state(function (): string {
$snapshotId = data_get($this->presentedSnapshot, 'snapshot.snapshotId');
return is_numeric($snapshotId) ? '#'.$snapshotId : '—';
}),
TextEntry::make('baseline_profile_name')
->label('Baseline')
->state(fn (): string => data_get($this->presentedSnapshot, 'snapshot.baselineProfileName', '—'))
->placeholder('—'),
TextEntry::make('captured_at')
->label('Captured')
->state(fn (): ?string => data_get($this->presentedSnapshot, 'snapshot.capturedAt'))
->dateTime()
->placeholder('—'),
TextEntry::make('state_label')
->label('State')
->badge()
->state(fn (): string => data_get($this->presentedSnapshot, 'snapshot.stateLabel', 'Complete'))
->color(fn (string $state): string => $state === 'Captured with gaps' ? 'warning' : 'success'),
TextEntry::make('overall_fidelity')
->label('Overall fidelity')
->badge()
->state(fn (): ?string => data_get($this->presentedSnapshot, 'snapshot.overallFidelity'))
->formatStateUsing(BadgeRenderer::label(BadgeDomain::BaselineSnapshotFidelity))
->color(BadgeRenderer::color(BadgeDomain::BaselineSnapshotFidelity))
->icon(BadgeRenderer::icon(BadgeDomain::BaselineSnapshotFidelity)),
TextEntry::make('fidelity_summary')
->label('Evidence mix')
->state(fn (): string => data_get($this->presentedSnapshot, 'snapshot.fidelitySummary', 'Content 0, Meta 0')),
TextEntry::make('overall_gap_count')
->label('Evidence gaps')
->state(fn (): int => (int) data_get($this->presentedSnapshot, 'snapshot.overallGapCount', 0)),
TextEntry::make('snapshot_identity_hash')
->label('Identity hash')
->state(fn (): ?string => data_get($this->presentedSnapshot, 'snapshot.snapshotIdentityHash'))
->copyable()
->placeholder('—')
->columnSpanFull(),
])
->columns(2)
->columnSpanFull(),
Section::make('Coverage summary')
->schema([
ViewEntry::make('summary_rows')
->label('')
->view('filament.infolists.entries.baseline-snapshot-summary-table')
->state(fn (): array => data_get($this->presentedSnapshot, 'summaryRows', []))
->columnSpanFull(),
])
->columnSpanFull(),
Section::make('Captured policy types')
->schema([
ViewEntry::make('groups')
->label('')
->view('filament.infolists.entries.baseline-snapshot-groups')
->state(fn (): array => data_get($this->presentedSnapshot, 'groups', []))
->columnSpanFull(),
])
->columnSpanFull(),
Section::make('Technical detail')
->schema([
ViewEntry::make('technical_detail')
->label('')
->view('filament.infolists.entries.baseline-snapshot-technical-detail')
->state(fn (): array => data_get($this->presentedSnapshot, 'technicalDetail', []))
->columnSpanFull(),
])
->collapsible()
->collapsed()
->columnSpanFull(),
]);
}
protected function getHeaderActions(): array protected function getHeaderActions(): array
{ {
return []; return [];

View File

@ -17,6 +17,10 @@
use App\Policies\EntraGroupPolicy; use App\Policies\EntraGroupPolicy;
use App\Policies\FindingPolicy; use App\Policies\FindingPolicy;
use App\Policies\OperationRunPolicy; use App\Policies\OperationRunPolicy;
use App\Services\Baselines\SnapshotRendering\Renderers\DeviceComplianceSnapshotTypeRenderer;
use App\Services\Baselines\SnapshotRendering\Renderers\FallbackSnapshotTypeRenderer;
use App\Services\Baselines\SnapshotRendering\Renderers\IntuneRoleDefinitionSnapshotTypeRenderer;
use App\Services\Baselines\SnapshotRendering\SnapshotTypeRendererRegistry;
use App\Services\Graph\GraphClientInterface; use App\Services\Graph\GraphClientInterface;
use App\Services\Graph\MicrosoftGraphClient; use App\Services\Graph\MicrosoftGraphClient;
use App\Services\Graph\NullGraphClient; use App\Services\Graph\NullGraphClient;
@ -80,6 +84,16 @@ public function register(): void
); );
}); });
$this->app->singleton(SnapshotTypeRendererRegistry::class, function ($app): SnapshotTypeRendererRegistry {
return new SnapshotTypeRendererRegistry(
renderers: [
$app->make(IntuneRoleDefinitionSnapshotTypeRenderer::class),
$app->make(DeviceComplianceSnapshotTypeRenderer::class),
],
fallbackRenderer: $app->make(FallbackSnapshotTypeRenderer::class),
);
});
$this->app->tag( $this->app->tag(
[ [
AppProtectionPolicyNormalizer::class, AppProtectionPolicyNormalizer::class,

View File

@ -0,0 +1,198 @@
<?php
declare(strict_types=1);
namespace App\Services\Baselines\SnapshotRendering;
use App\Models\BaselineSnapshot;
use App\Models\BaselineSnapshotItem;
use App\Support\Inventory\InventoryPolicyTypeMeta;
use Illuminate\Database\Eloquent\Collection as EloquentCollection;
use Illuminate\Support\Collection;
use Illuminate\Support\Str;
use Throwable;
final class BaselineSnapshotPresenter
{
public function __construct(
private readonly SnapshotTypeRendererRegistry $registry,
) {}
public function present(BaselineSnapshot $snapshot): RenderedSnapshot
{
$snapshot->loadMissing(['baselineProfile', 'items']);
$summary = is_array($snapshot->summary_jsonb) ? $snapshot->summary_jsonb : [];
$items = $snapshot->items instanceof EloquentCollection
? $snapshot->items->sortBy([
['policy_type', 'asc'],
['id', 'asc'],
])->values()
: collect();
$groups = $items
->groupBy(static fn (BaselineSnapshotItem $item): string => (string) $item->policy_type)
->map(fn (Collection $groupItems, string $policyType): RenderedSnapshotGroup => $this->presentGroup($policyType, $groupItems))
->sortBy(static fn (RenderedSnapshotGroup $group): string => mb_strtolower($group->label))
->values()
->all();
$summaryRows = array_map(
static fn (RenderedSnapshotGroup $group): array => [
'policyType' => $group->policyType,
'label' => $group->label,
'itemCount' => $group->itemCount,
'fidelity' => $group->fidelity->value,
'gapCount' => $group->gapSummary->count,
'capturedAt' => $group->capturedAt,
'coverageHint' => $group->coverageHint,
],
$groups,
);
$overallGapCount = $this->summaryGapCount($summary);
$overallFidelity = FidelityState::fromSummary($summary, $items->isNotEmpty());
return new RenderedSnapshot(
snapshotId: (int) $snapshot->getKey(),
baselineProfileName: $snapshot->baselineProfile?->name,
capturedAt: $snapshot->captured_at?->toIso8601String(),
snapshotIdentityHash: is_string($snapshot->snapshot_identity_hash) && trim($snapshot->snapshot_identity_hash) !== ''
? trim($snapshot->snapshot_identity_hash)
: null,
stateLabel: $overallGapCount > 0 ? 'Captured with gaps' : 'Complete',
fidelitySummary: $this->fidelitySummary($summary),
overallFidelity: $overallFidelity,
overallGapCount: $overallGapCount,
summaryRows: $summaryRows,
groups: $groups,
technicalDetail: [
'defaultCollapsed' => true,
'summaryPayload' => $summary,
'groupPayloads' => array_map(
static fn (RenderedSnapshotGroup $group): array => [
'label' => $group->label,
'renderingError' => $group->renderingError,
'payload' => $group->technicalPayload,
],
$groups,
),
],
hasItems: $items->isNotEmpty(),
);
}
/**
* @param Collection<int, BaselineSnapshotItem> $items
*/
private function presentGroup(string $policyType, Collection $items): RenderedSnapshotGroup
{
$renderer = $this->registry->rendererFor($policyType);
$fallbackRenderer = $this->registry->fallbackRenderer();
$renderingError = null;
$technicalPayload = $this->technicalPayload($items);
try {
$renderedItems = $items
->map(fn (BaselineSnapshotItem $item): RenderedSnapshotItem => $renderer->render($item))
->all();
} catch (Throwable) {
$renderedItems = $items
->map(fn (BaselineSnapshotItem $item): RenderedSnapshotItem => $fallbackRenderer->render($item))
->all();
$renderingError = 'Structured rendering failed for this policy type. Fallback metadata is shown instead.';
}
/** @var array<int, RenderedSnapshotItem> $renderedItems */
$groupFidelity = FidelityState::aggregate(array_map(
static fn (RenderedSnapshotItem $item): FidelityState => $item->fidelity,
$renderedItems,
));
$gapSummary = GapSummary::merge(array_map(
static fn (RenderedSnapshotItem $item): GapSummary => $item->gapSummary,
$renderedItems,
));
if ($renderingError !== null) {
$gapSummary = $gapSummary->withMessage($renderingError);
}
$capturedAt = collect($renderedItems)
->pluck('observedAt')
->filter(static fn (mixed $value): bool => is_string($value) && trim($value) !== '')
->sortDesc()
->first();
$coverageHint = $groupFidelity->coverageHint();
if ($coverageHint === null && $gapSummary->messages !== []) {
$coverageHint = $gapSummary->messages[0];
}
return new RenderedSnapshotGroup(
policyType: $policyType,
label: $this->typeLabel($policyType),
itemCount: $items->count(),
fidelity: $groupFidelity,
gapSummary: $gapSummary,
initiallyCollapsed: true,
items: $renderedItems,
renderingError: $renderingError,
coverageHint: $coverageHint,
capturedAt: is_string($capturedAt) ? $capturedAt : null,
technicalPayload: $technicalPayload,
);
}
/**
* @param Collection<int, BaselineSnapshotItem> $items
* @return array<string, mixed>
*/
private function technicalPayload(Collection $items): array
{
return [
'items' => $items
->map(static fn (BaselineSnapshotItem $item): array => [
'snapshot_item_id' => (int) $item->getKey(),
'policy_type' => (string) $item->policy_type,
'meta_jsonb' => is_array($item->meta_jsonb) ? $item->meta_jsonb : [],
])
->all(),
];
}
/**
* @param array<string, mixed> $summary
*/
private function summaryGapCount(array $summary): int
{
$gaps = is_array($summary['gaps'] ?? null) ? $summary['gaps'] : [];
$count = $gaps['count'] ?? 0;
return is_numeric($count) ? (int) $count : 0;
}
/**
* @param array<string, mixed> $summary
*/
private function fidelitySummary(array $summary): string
{
$counts = is_array($summary['fidelity_counts'] ?? null)
? $summary['fidelity_counts']
: [];
$content = is_numeric($counts['content'] ?? null) ? (int) $counts['content'] : 0;
$meta = is_numeric($counts['meta'] ?? null) ? (int) $counts['meta'] : 0;
return sprintf('Content %d, Meta %d', $content, $meta);
}
private function typeLabel(string $policyType): string
{
return InventoryPolicyTypeMeta::baselineCompareLabel($policyType)
?? InventoryPolicyTypeMeta::label($policyType)
?? Str::headline($policyType);
}
}

View File

@ -0,0 +1,113 @@
<?php
declare(strict_types=1);
namespace App\Services\Baselines\SnapshotRendering;
use Illuminate\Support\Str;
enum FidelityState: string
{
case Full = 'full';
case Partial = 'partial';
case ReferenceOnly = 'reference_only';
case Unsupported = 'unsupported';
public static function fromEvidence(mixed $value, bool $usesFallback = false): self
{
$normalized = Str::of((string) ($value ?? ''))
->trim()
->lower()
->replace(['-', ' '], '_')
->toString();
return match ($normalized) {
'content', 'full' => self::Full,
'partial' => self::Partial,
'meta', 'metadata_only', 'reference_only' => self::ReferenceOnly,
'unsupported' => self::Unsupported,
default => $usesFallback ? self::Unsupported : self::Partial,
};
}
/**
* @param array<string, mixed> $summary
*/
public static function fromSummary(array $summary, bool $hasItems): self
{
if (! $hasItems) {
return self::Unsupported;
}
$counts = is_array($summary['fidelity_counts'] ?? null)
? $summary['fidelity_counts']
: [];
$content = is_numeric($counts['content'] ?? null) ? (int) $counts['content'] : 0;
$meta = is_numeric($counts['meta'] ?? null) ? (int) $counts['meta'] : 0;
if ($content > 0 && $meta === 0) {
return self::Full;
}
if ($content > 0 && $meta > 0) {
return self::Partial;
}
if ($meta > 0) {
return self::ReferenceOnly;
}
return self::Unsupported;
}
/**
* @param array<int, self> $states
*/
public static function aggregate(array $states): self
{
if ($states === []) {
return self::Unsupported;
}
$worst = self::Full;
foreach ($states as $state) {
if ($state->rank() < $worst->rank()) {
$worst = $state;
}
}
return $worst;
}
public function label(): string
{
return match ($this) {
self::Full => 'Full',
self::Partial => 'Partial',
self::ReferenceOnly => 'Reference only',
self::Unsupported => 'Unsupported',
};
}
public function coverageHint(): ?string
{
return match ($this) {
self::Full => null,
self::Partial => 'Mixed evidence fidelity across this group.',
self::ReferenceOnly => 'Metadata-only evidence is available.',
self::Unsupported => 'Fallback metadata rendering is being used.',
};
}
private function rank(): int
{
return match ($this) {
self::Full => 4,
self::Partial => 3,
self::ReferenceOnly => 2,
self::Unsupported => 1,
};
}
}

View File

@ -0,0 +1,161 @@
<?php
declare(strict_types=1);
namespace App\Services\Baselines\SnapshotRendering;
use Illuminate\Support\Str;
final readonly class GapSummary
{
/**
* @param list<string> $messages
*/
public function __construct(
public int $count = 0,
public array $messages = [],
) {}
public static function none(): self
{
return new self;
}
/**
* @param array<string, mixed> $meta
*/
public static function fromItemMeta(array $meta, FidelityState $fidelity, bool $usesFallback = false): self
{
$messages = [];
$warnings = $meta['warnings'] ?? null;
if (is_array($warnings)) {
foreach ($warnings as $warning) {
if (is_string($warning) && trim($warning) !== '') {
$messages[] = trim($warning);
}
}
}
if ($fidelity === FidelityState::ReferenceOnly) {
$messages[] = 'Metadata-only evidence was captured for this item.';
}
if ($fidelity === FidelityState::Unsupported || $usesFallback) {
$messages[] = 'A fallback renderer is being used for this item.';
}
$messages = self::uniqueMessages($messages);
return new self(
count: count($messages),
messages: $messages,
);
}
/**
* @param array<string, int> $reasons
*/
public static function fromReasonMap(array $reasons): self
{
$messages = [];
foreach ($reasons as $reason => $count) {
if (! is_string($reason) || ! is_numeric($count) || (int) $count <= 0) {
continue;
}
$messages[] = sprintf('%s (%d)', self::humanizeReason($reason), (int) $count);
}
return new self(
count: array_sum(array_map(static fn (mixed $value): int => is_numeric($value) ? (int) $value : 0, $reasons)),
messages: self::uniqueMessages($messages),
);
}
/**
* @param array<int, self> $summaries
*/
public static function merge(array $summaries): self
{
$count = 0;
$messages = [];
foreach ($summaries as $summary) {
$count += $summary->count;
$messages = [...$messages, ...$summary->messages];
}
$messages = self::uniqueMessages($messages);
if ($count === 0) {
$count = count($messages);
}
return new self(
count: $count,
messages: $messages,
);
}
public function withMessage(string $message): self
{
$message = trim($message);
if ($message === '') {
return $this;
}
$messages = self::uniqueMessages([...$this->messages, $message]);
return new self(
count: max($this->count, count($messages)),
messages: $messages,
);
}
public function hasGaps(): bool
{
return $this->count > 0 || $this->messages !== [];
}
public function badgeState(): string
{
return $this->hasGaps() ? 'gaps_present' : 'clear';
}
/**
* @return array{count: int, has_gaps: bool, messages: list<string>, badge_state: string}
*/
public function toArray(): array
{
return [
'count' => $this->count,
'has_gaps' => $this->hasGaps(),
'messages' => $this->messages,
'badge_state' => $this->badgeState(),
];
}
/**
* @param list<string> $messages
* @return list<string>
*/
private static function uniqueMessages(array $messages): array
{
return array_values(array_unique(array_values(array_filter(
array_map(static fn (string $message): string => trim($message), $messages),
static fn (string $message): bool => $message !== '',
))));
}
private static function humanizeReason(string $reason): string
{
return Str::of($reason)
->replace(['_', '-'], ' ')
->headline()
->toString();
}
}

View File

@ -0,0 +1,36 @@
<?php
declare(strict_types=1);
namespace App\Services\Baselines\SnapshotRendering;
use InvalidArgumentException;
final readonly class RenderedAttribute
{
public function __construct(
public string $label,
public string $value,
public string $priority = 'primary',
) {
if (trim($this->label) === '') {
throw new InvalidArgumentException('RenderedAttribute label must be a non-empty string.');
}
if (! in_array($this->priority, ['primary', 'secondary'], true)) {
throw new InvalidArgumentException('RenderedAttribute priority must be either "primary" or "secondary".');
}
}
/**
* @return array{label: string, value: string, priority: string}
*/
public function toArray(): array
{
return [
'label' => $this->label,
'value' => $this->value,
'priority' => $this->priority,
];
}
}

View File

@ -0,0 +1,109 @@
<?php
declare(strict_types=1);
namespace App\Services\Baselines\SnapshotRendering;
final readonly class RenderedSnapshot
{
/**
* @param array<int, array{
* policyType: string,
* label: string,
* itemCount: int,
* fidelity: string,
* gapCount: int,
* capturedAt: ?string,
* coverageHint: ?string
* }> $summaryRows
* @param array<int, RenderedSnapshotGroup> $groups
* @param array<string, mixed> $technicalDetail
*/
public function __construct(
public int $snapshotId,
public ?string $baselineProfileName,
public ?string $capturedAt,
public ?string $snapshotIdentityHash,
public string $stateLabel,
public string $fidelitySummary,
public FidelityState $overallFidelity,
public int $overallGapCount,
public array $summaryRows,
public array $groups,
public array $technicalDetail,
public bool $hasItems,
) {}
/**
* @return array{
* page: string,
* snapshot: array{
* snapshotId: int,
* baselineProfileName: ?string,
* capturedAt: ?string,
* snapshotIdentityHash: ?string,
* stateLabel: string,
* fidelitySummary: string,
* overallFidelity: string,
* overallGapCount: int
* },
* summaryRows: list<array{
* policyType: string,
* label: string,
* itemCount: int,
* fidelity: string,
* gapCount: int,
* capturedAt: ?string,
* coverageHint: ?string
* }>,
* groups: list<array{
* policyType: string,
* label: string,
* itemCount: int,
* fidelity: string,
* gapSummary: array{count: int, has_gaps: bool, messages: list<string>, badge_state: string},
* initiallyCollapsed: bool,
* items: list<array{
* label: string,
* typeLabel: string,
* identityHint: string,
* referenceStatus: string,
* fidelity: string,
* gapSummary: array{count: int, has_gaps: bool, messages: list<string>, badge_state: string},
* observedAt: ?string,
* sourceReference: ?string,
* structuredAttributes: list<array{label: string, value: string, priority: string}>
* }>,
* renderingError: ?string,
* coverageHint: ?string,
* capturedAt: ?string,
* technicalPayload: array<string, mixed>
* }>,
* technicalDetail: array<string, mixed>,
* hasItems: bool
* }
*/
public function toArray(): array
{
return [
'page' => 'baseline-snapshot-detail',
'snapshot' => [
'snapshotId' => $this->snapshotId,
'baselineProfileName' => $this->baselineProfileName,
'capturedAt' => $this->capturedAt,
'snapshotIdentityHash' => $this->snapshotIdentityHash,
'stateLabel' => $this->stateLabel,
'fidelitySummary' => $this->fidelitySummary,
'overallFidelity' => $this->overallFidelity->value,
'overallGapCount' => $this->overallGapCount,
],
'summaryRows' => $this->summaryRows,
'groups' => array_map(
static fn (RenderedSnapshotGroup $group): array => $group->toArray(),
$this->groups,
),
'technicalDetail' => $this->technicalDetail,
'hasItems' => $this->hasItems,
];
}
}

View File

@ -0,0 +1,71 @@
<?php
declare(strict_types=1);
namespace App\Services\Baselines\SnapshotRendering;
final readonly class RenderedSnapshotGroup
{
/**
* @param array<int, RenderedSnapshotItem> $items
* @param array<string, mixed> $technicalPayload
*/
public function __construct(
public string $policyType,
public string $label,
public int $itemCount,
public FidelityState $fidelity,
public GapSummary $gapSummary,
public bool $initiallyCollapsed,
public array $items,
public ?string $renderingError = null,
public ?string $coverageHint = null,
public ?string $capturedAt = null,
public array $technicalPayload = [],
) {}
/**
* @return array{
* policyType: string,
* label: string,
* itemCount: int,
* fidelity: string,
* gapSummary: array{count: int, has_gaps: bool, messages: list<string>, badge_state: string},
* initiallyCollapsed: bool,
* items: list<array{
* label: string,
* typeLabel: string,
* identityHint: string,
* referenceStatus: string,
* fidelity: string,
* gapSummary: array{count: int, has_gaps: bool, messages: list<string>, badge_state: string},
* observedAt: ?string,
* sourceReference: ?string,
* structuredAttributes: list<array{label: string, value: string, priority: string}>
* }>,
* renderingError: ?string,
* coverageHint: ?string,
* capturedAt: ?string,
* technicalPayload: array<string, mixed>
* }
*/
public function toArray(): array
{
return [
'policyType' => $this->policyType,
'label' => $this->label,
'itemCount' => $this->itemCount,
'fidelity' => $this->fidelity->value,
'gapSummary' => $this->gapSummary->toArray(),
'initiallyCollapsed' => $this->initiallyCollapsed,
'items' => array_map(
static fn (RenderedSnapshotItem $item): array => $item->toArray(),
$this->items,
),
'renderingError' => $this->renderingError,
'coverageHint' => $this->coverageHint,
'capturedAt' => $this->capturedAt,
'technicalPayload' => $this->technicalPayload,
];
}
}

View File

@ -0,0 +1,54 @@
<?php
declare(strict_types=1);
namespace App\Services\Baselines\SnapshotRendering;
final readonly class RenderedSnapshotItem
{
/**
* @param array<int, RenderedAttribute> $structuredAttributes
*/
public function __construct(
public string $label,
public string $typeLabel,
public string $identityHint,
public string $referenceStatus,
public FidelityState $fidelity,
public GapSummary $gapSummary,
public ?string $observedAt,
public ?string $sourceReference,
public array $structuredAttributes,
) {}
/**
* @return array{
* label: string,
* typeLabel: string,
* identityHint: string,
* referenceStatus: string,
* fidelity: string,
* gapSummary: array{count: int, has_gaps: bool, messages: list<string>, badge_state: string},
* observedAt: ?string,
* sourceReference: ?string,
* structuredAttributes: list<array{label: string, value: string, priority: string}>
* }
*/
public function toArray(): array
{
return [
'label' => $this->label,
'typeLabel' => $this->typeLabel,
'identityHint' => $this->identityHint,
'referenceStatus' => $this->referenceStatus,
'fidelity' => $this->fidelity->value,
'gapSummary' => $this->gapSummary->toArray(),
'observedAt' => $this->observedAt,
'sourceReference' => $this->sourceReference,
'structuredAttributes' => array_map(
static fn (RenderedAttribute $attribute): array => $attribute->toArray(),
$this->structuredAttributes,
),
];
}
}

View File

@ -0,0 +1,60 @@
<?php
declare(strict_types=1);
namespace App\Services\Baselines\SnapshotRendering\Renderers;
use App\Models\BaselineSnapshotItem;
use App\Services\Baselines\SnapshotRendering\FidelityState;
use App\Services\Baselines\SnapshotRendering\GapSummary;
use App\Services\Baselines\SnapshotRendering\RenderedAttribute;
use App\Services\Baselines\SnapshotRendering\RenderedSnapshotItem;
final class DeviceComplianceSnapshotTypeRenderer extends FallbackSnapshotTypeRenderer
{
public function supports(string $policyType): bool
{
return $policyType === 'deviceCompliancePolicy';
}
public function render(BaselineSnapshotItem $item): RenderedSnapshotItem
{
$meta = $this->meta($item);
$fidelity = FidelityState::fromEvidence(data_get($meta, 'evidence.fidelity'));
$attributes = $this->baseAttributes($item, $meta);
$odataType = $meta['odata_type'] ?? null;
if (is_string($odataType) && trim($odataType) !== '') {
$attributes[] = new RenderedAttribute('OData type', trim($odataType), 'secondary');
}
$assignmentTargetCount = $meta['assignment_target_count'] ?? null;
if (is_numeric($assignmentTargetCount)) {
$attributes[] = new RenderedAttribute(
'Assignment targets',
(string) ((int) $assignmentTargetCount),
'secondary',
);
}
$threatLevel = $meta['device_threat_protection_required_security_level'] ?? null;
if (is_string($threatLevel) && trim($threatLevel) !== '') {
$attributes[] = new RenderedAttribute(
'Threat protection',
$this->humanizeValue($threatLevel),
'secondary',
);
}
return $this->buildItem(
item: $item,
fidelity: $fidelity,
structuredAttributes: $attributes,
gapSummary: GapSummary::fromItemMeta($meta, $fidelity),
meta: $meta,
);
}
}

View File

@ -0,0 +1,221 @@
<?php
declare(strict_types=1);
namespace App\Services\Baselines\SnapshotRendering\Renderers;
use App\Models\BaselineSnapshotItem;
use App\Services\Baselines\SnapshotRendering\FidelityState;
use App\Services\Baselines\SnapshotRendering\GapSummary;
use App\Services\Baselines\SnapshotRendering\RenderedAttribute;
use App\Services\Baselines\SnapshotRendering\RenderedSnapshotItem;
use App\Services\Baselines\SnapshotRendering\SnapshotTypeRenderer;
use App\Support\Inventory\InventoryPolicyTypeMeta;
use Illuminate\Support\Str;
class FallbackSnapshotTypeRenderer implements SnapshotTypeRenderer
{
public function supports(string $policyType): bool
{
return false;
}
public function render(BaselineSnapshotItem $item): RenderedSnapshotItem
{
$meta = $this->meta($item);
$fidelity = FidelityState::Unsupported;
return $this->buildItem(
item: $item,
fidelity: $fidelity,
structuredAttributes: $this->baseAttributes($item, $meta),
gapSummary: GapSummary::fromItemMeta($meta, $fidelity, usesFallback: true),
);
}
/**
* @param array<string, mixed> $meta
* @param array<int, RenderedAttribute> $structuredAttributes
*/
protected function buildItem(
BaselineSnapshotItem $item,
FidelityState $fidelity,
array $structuredAttributes,
GapSummary $gapSummary,
array $meta = [],
): RenderedSnapshotItem {
$meta = $meta === [] ? $this->meta($item) : $meta;
return new RenderedSnapshotItem(
label: $this->displayLabel($item, $meta),
typeLabel: $this->typeLabel((string) $item->policy_type),
identityHint: $this->identityHint($item, $meta),
referenceStatus: $this->referenceStatus($meta),
fidelity: $fidelity,
gapSummary: $gapSummary,
observedAt: $this->observedAt($meta),
sourceReference: $this->sourceReference($meta),
structuredAttributes: $structuredAttributes,
);
}
/**
* @param array<string, mixed> $meta
* @return array<int, RenderedAttribute>
*/
protected function baseAttributes(BaselineSnapshotItem $item, array $meta): array
{
$attributes = [];
$category = $meta['category'] ?? InventoryPolicyTypeMeta::category((string) $item->policy_type);
$platform = $meta['platform'] ?? null;
$source = data_get($meta, 'evidence.source');
$identityStrategy = data_get($meta, 'identity.strategy');
$policyVersionId = data_get($meta, 'version_reference.policy_version_id');
$capturePurpose = data_get($meta, 'version_reference.capture_purpose');
if (is_string($category) && trim($category) !== '') {
$attributes[] = new RenderedAttribute('Category', trim($category));
}
if (is_string($platform) && trim($platform) !== '') {
$attributes[] = new RenderedAttribute('Platform', $this->humanizeValue($platform));
}
if (is_string($source) && trim($source) !== '') {
$attributes[] = new RenderedAttribute('Evidence source', $this->referenceStatus($meta));
}
if (is_string($identityStrategy) && trim($identityStrategy) !== '') {
$attributes[] = new RenderedAttribute('Identity strategy', $this->humanizeValue($identityStrategy), 'secondary');
}
if (is_numeric($policyVersionId)) {
$attributes[] = new RenderedAttribute('Policy version', '#'.((int) $policyVersionId), 'secondary');
}
if (is_string($capturePurpose) && trim($capturePurpose) !== '') {
$attributes[] = new RenderedAttribute('Capture purpose', $this->humanizeValue($capturePurpose), 'secondary');
}
return $attributes;
}
/**
* @return array<string, mixed>
*/
protected function meta(BaselineSnapshotItem $item): array
{
return is_array($item->meta_jsonb) ? $item->meta_jsonb : [];
}
/**
* @param array<string, mixed> $meta
*/
protected function displayLabel(BaselineSnapshotItem $item, array $meta): string
{
$displayName = $meta['display_name'] ?? null;
if (is_string($displayName) && trim($displayName) !== '') {
return trim($displayName);
}
if (is_string($item->subject_key) && trim($item->subject_key) !== '') {
return Str::headline($item->subject_key);
}
return $this->typeLabel((string) $item->policy_type);
}
protected function typeLabel(string $policyType): string
{
return InventoryPolicyTypeMeta::baselineCompareLabel($policyType)
?? InventoryPolicyTypeMeta::label($policyType)
?? Str::headline($policyType);
}
/**
* @param array<string, mixed> $meta
*/
protected function identityHint(BaselineSnapshotItem $item, array $meta): string
{
$identityStrategy = data_get($meta, 'identity.strategy');
if ($identityStrategy === 'external_id' && is_string($item->subject_key) && $item->subject_key !== '') {
return 'External ID hash '.Str::limit($item->subject_key, 12, '…');
}
if (is_string($item->subject_key) && trim($item->subject_key) !== '') {
return $item->subject_key;
}
if (is_string($item->subject_external_id) && trim($item->subject_external_id) !== '') {
return 'Snapshot key '.Str::limit($item->subject_external_id, 12, '…');
}
return '—';
}
/**
* @param array<string, mixed> $meta
*/
protected function referenceStatus(array $meta): string
{
$source = Str::of((string) data_get($meta, 'evidence.source', 'captured'))
->trim()
->lower()
->replace(['-', ' '], '_')
->toString();
return match ($source) {
'policy_version' => 'Policy version',
'inventory', 'metadata_only' => 'Inventory metadata',
default => 'Captured metadata',
};
}
/**
* @param array<string, mixed> $meta
*/
protected function observedAt(array $meta): ?string
{
$observedAt = data_get($meta, 'evidence.observed_at');
return is_string($observedAt) && trim($observedAt) !== ''
? trim($observedAt)
: null;
}
/**
* @param array<string, mixed> $meta
*/
protected function sourceReference(array $meta): ?string
{
$policyVersionId = data_get($meta, 'version_reference.policy_version_id');
$capturePurpose = data_get($meta, 'version_reference.capture_purpose');
$parts = [];
if (is_numeric($policyVersionId)) {
$parts[] = 'Policy version #'.((int) $policyVersionId);
}
if (is_string($capturePurpose) && trim($capturePurpose) !== '') {
$parts[] = $this->humanizeValue($capturePurpose);
}
if ($parts === []) {
return null;
}
return implode(' · ', $parts);
}
protected function humanizeValue(mixed $value): string
{
return Str::of((string) $value)
->replace(['_', '-'], ' ')
->headline()
->toString();
}
}

View File

@ -0,0 +1,56 @@
<?php
declare(strict_types=1);
namespace App\Services\Baselines\SnapshotRendering\Renderers;
use App\Models\BaselineSnapshotItem;
use App\Services\Baselines\SnapshotRendering\FidelityState;
use App\Services\Baselines\SnapshotRendering\GapSummary;
use App\Services\Baselines\SnapshotRendering\RenderedAttribute;
use App\Services\Baselines\SnapshotRendering\RenderedSnapshotItem;
final class IntuneRoleDefinitionSnapshotTypeRenderer extends FallbackSnapshotTypeRenderer
{
public function supports(string $policyType): bool
{
return $policyType === 'intuneRoleDefinition';
}
public function render(BaselineSnapshotItem $item): RenderedSnapshotItem
{
$meta = $this->meta($item);
$fidelity = FidelityState::fromEvidence(data_get($meta, 'evidence.fidelity'));
$attributes = $this->baseAttributes($item, $meta);
$attributes[] = new RenderedAttribute(
'Role source',
match (data_get($meta, 'rbac.is_built_in')) {
true => 'Built-in',
false => 'Custom',
default => 'Unknown',
},
);
$permissionBlocks = data_get($meta, 'rbac.role_permission_count');
if (is_numeric($permissionBlocks)) {
$attributes[] = new RenderedAttribute('Permission blocks', (string) ((int) $permissionBlocks));
}
$attributes[] = new RenderedAttribute(
'Identity',
data_get($meta, 'identity.strategy') === 'external_id'
? 'Role definition ID'
: 'Display name',
);
return $this->buildItem(
item: $item,
fidelity: $fidelity,
structuredAttributes: $attributes,
gapSummary: GapSummary::fromItemMeta($meta, $fidelity),
meta: $meta,
);
}
}

View File

@ -0,0 +1,14 @@
<?php
declare(strict_types=1);
namespace App\Services\Baselines\SnapshotRendering;
use App\Models\BaselineSnapshotItem;
interface SnapshotTypeRenderer
{
public function supports(string $policyType): bool;
public function render(BaselineSnapshotItem $item): RenderedSnapshotItem;
}

View File

@ -0,0 +1,37 @@
<?php
declare(strict_types=1);
namespace App\Services\Baselines\SnapshotRendering;
final readonly class SnapshotTypeRendererRegistry
{
/**
* @param array<int, SnapshotTypeRenderer> $renderers
*/
public function __construct(
private array $renderers,
private SnapshotTypeRenderer $fallbackRenderer,
) {}
public function rendererFor(string $policyType): SnapshotTypeRenderer
{
foreach ($this->renderers as $renderer) {
if ($renderer->supports($policyType)) {
return $renderer;
}
}
return $this->fallbackRenderer;
}
public function fallbackRenderer(): SnapshotTypeRenderer
{
return $this->fallbackRenderer;
}
public function usesFallback(string $policyType): bool
{
return $this->rendererFor($policyType) === $this->fallbackRenderer;
}
}

View File

@ -12,6 +12,8 @@ final class BadgeCatalog
* @var array<string, class-string<BadgeMapper>> * @var array<string, class-string<BadgeMapper>>
*/ */
private const DOMAIN_MAPPERS = [ private const DOMAIN_MAPPERS = [
BadgeDomain::BaselineSnapshotFidelity->value => Domains\BaselineSnapshotFidelityBadge::class,
BadgeDomain::BaselineSnapshotGapStatus->value => Domains\BaselineSnapshotGapStatusBadge::class,
BadgeDomain::OperationRunStatus->value => Domains\OperationRunStatusBadge::class, BadgeDomain::OperationRunStatus->value => Domains\OperationRunStatusBadge::class,
BadgeDomain::OperationRunOutcome->value => Domains\OperationRunOutcomeBadge::class, BadgeDomain::OperationRunOutcome->value => Domains\OperationRunOutcomeBadge::class,
BadgeDomain::BackupSetStatus->value => Domains\BackupSetStatusBadge::class, BadgeDomain::BackupSetStatus->value => Domains\BackupSetStatusBadge::class,

View File

@ -4,6 +4,8 @@
enum BadgeDomain: string enum BadgeDomain: string
{ {
case BaselineSnapshotFidelity = 'baseline_snapshot_fidelity';
case BaselineSnapshotGapStatus = 'baseline_snapshot_gap_status';
case OperationRunStatus = 'operation_run_status'; case OperationRunStatus = 'operation_run_status';
case OperationRunOutcome = 'operation_run_outcome'; case OperationRunOutcome = 'operation_run_outcome';
case BackupSetStatus = 'backup_set_status'; case BackupSetStatus = 'backup_set_status';

View File

@ -0,0 +1,26 @@
<?php
declare(strict_types=1);
namespace App\Support\Badges\Domains;
use App\Services\Baselines\SnapshotRendering\FidelityState;
use App\Support\Badges\BadgeCatalog;
use App\Support\Badges\BadgeMapper;
use App\Support\Badges\BadgeSpec;
final class BaselineSnapshotFidelityBadge implements BadgeMapper
{
public function spec(mixed $value): BadgeSpec
{
$state = BadgeCatalog::normalizeState($value);
return match ($state) {
FidelityState::Full->value => new BadgeSpec('Full', 'success', 'heroicon-m-check-circle'),
FidelityState::Partial->value => new BadgeSpec('Partial', 'warning', 'heroicon-m-exclamation-triangle'),
FidelityState::ReferenceOnly->value => new BadgeSpec('Reference only', 'info', 'heroicon-m-document-text'),
FidelityState::Unsupported->value => new BadgeSpec('Unsupported', 'gray', 'heroicon-m-question-mark-circle'),
default => BadgeSpec::unknown(),
};
}
}

View File

@ -0,0 +1,23 @@
<?php
declare(strict_types=1);
namespace App\Support\Badges\Domains;
use App\Support\Badges\BadgeCatalog;
use App\Support\Badges\BadgeMapper;
use App\Support\Badges\BadgeSpec;
final class BaselineSnapshotGapStatusBadge implements BadgeMapper
{
public function spec(mixed $value): BadgeSpec
{
$state = BadgeCatalog::normalizeState($value);
return match ($state) {
'clear' => new BadgeSpec('No gaps', 'success', 'heroicon-m-check-circle'),
'gaps_present' => new BadgeSpec('Gaps present', 'warning', 'heroicon-m-exclamation-triangle'),
default => BadgeSpec::unknown(),
};
}
}

View File

@ -0,0 +1,145 @@
@php
use App\Support\Badges\BadgeDomain;
use App\Support\Badges\BadgeRenderer;
use Illuminate\Support\Carbon;
$groups = $getState() ?? [];
$formatTimestamp = static function (?string $value): string {
if (! is_string($value) || trim($value) === '') {
return '—';
}
try {
return Carbon::parse($value)->toDayDateTimeString();
} catch (\Throwable) {
return $value;
}
};
@endphp
<div class="space-y-4">
@if ($groups === [])
<div class="rounded-lg border border-dashed border-gray-300 px-4 py-6 text-sm text-gray-600 dark:border-gray-700 dark:text-gray-300">
No snapshot items were captured for this baseline snapshot.
</div>
@else
@foreach ($groups as $group)
@php
$fidelitySpec = BadgeRenderer::spec(BadgeDomain::BaselineSnapshotFidelity, $group['fidelity'] ?? null);
$gapSpec = BadgeRenderer::spec(BadgeDomain::BaselineSnapshotGapStatus, data_get($group, 'gapSummary.badge_state'));
$messages = data_get($group, 'gapSummary.messages', []);
@endphp
<x-filament::section
:heading="$group['label'] ?? ($group['policyType'] ?? 'Policy type')"
:description="$group['coverageHint'] ?? null"
collapsible
:collapsed="(bool) ($group['initiallyCollapsed'] ?? true)"
>
<x-slot name="headerEnd">
<div class="flex flex-wrap items-center gap-2">
<span class="text-xs text-gray-500 dark:text-gray-400">
{{ (int) ($group['itemCount'] ?? 0) }} {{ \Illuminate\Support\Str::plural('item', (int) ($group['itemCount'] ?? 0)) }}
</span>
<x-filament::badge :color="$fidelitySpec->color" :icon="$fidelitySpec->icon" size="sm">
{{ $fidelitySpec->label }}
</x-filament::badge>
<x-filament::badge :color="$gapSpec->color" :icon="$gapSpec->icon" size="sm">
{{ $gapSpec->label }}
</x-filament::badge>
</div>
</x-slot>
<div class="space-y-4">
@if (! empty($group['renderingError']))
<div class="rounded-lg border border-amber-200 bg-amber-50 px-4 py-3 text-sm text-amber-900 dark:border-amber-800 dark:bg-amber-950/30 dark:text-amber-200">
{{ $group['renderingError'] }}
</div>
@endif
@if (is_array($messages) && $messages !== [])
<div class="rounded-lg border border-gray-200 bg-gray-50 px-4 py-3 text-sm text-gray-700 dark:border-gray-700 dark:bg-gray-900/40 dark:text-gray-200">
<div class="font-medium">Gap details</div>
<ul class="mt-2 list-disc space-y-1 pl-5">
@foreach ($messages as $message)
<li>{{ $message }}</li>
@endforeach
</ul>
</div>
@endif
<div class="space-y-3">
@foreach (($group['items'] ?? []) as $item)
@php
$itemFidelitySpec = BadgeRenderer::spec(BadgeDomain::BaselineSnapshotFidelity, $item['fidelity'] ?? null);
$itemGapSpec = BadgeRenderer::spec(BadgeDomain::BaselineSnapshotGapStatus, data_get($item, 'gapSummary.badge_state'));
@endphp
<div class="rounded-xl border border-gray-200 bg-white p-4 shadow-sm dark:border-gray-700 dark:bg-gray-900">
<div class="flex flex-col gap-3 md:flex-row md:items-start md:justify-between">
<div class="space-y-1">
<div class="text-sm font-semibold text-gray-900 dark:text-white">
{{ $item['label'] ?? 'Snapshot item' }}
</div>
<div class="text-xs text-gray-500 dark:text-gray-400">
{{ $item['typeLabel'] ?? 'Policy type' }}
</div>
</div>
<div class="flex flex-wrap items-center gap-2">
<span class="rounded-full bg-gray-100 px-2.5 py-1 text-xs font-medium text-gray-700 dark:bg-gray-800 dark:text-gray-200">
{{ $item['referenceStatus'] ?? 'Captured metadata' }}
</span>
<x-filament::badge :color="$itemFidelitySpec->color" :icon="$itemFidelitySpec->icon" size="sm">
{{ $itemFidelitySpec->label }}
</x-filament::badge>
<x-filament::badge :color="$itemGapSpec->color" :icon="$itemGapSpec->icon" size="sm">
{{ $itemGapSpec->label }}
</x-filament::badge>
</div>
</div>
<div class="mt-3 grid gap-2 text-sm text-gray-700 dark:text-gray-200 md:grid-cols-3">
<div>
<div class="text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">Identity</div>
<div class="mt-1 break-all">{{ $item['identityHint'] ?? '—' }}</div>
</div>
<div>
<div class="text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">Observed</div>
<div class="mt-1">{{ $formatTimestamp($item['observedAt'] ?? null) }}</div>
</div>
<div>
<div class="text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">Source reference</div>
<div class="mt-1">{{ $item['sourceReference'] ?? '—' }}</div>
</div>
</div>
@if (is_array(data_get($item, 'gapSummary.messages')) && data_get($item, 'gapSummary.messages') !== [])
<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-800 dark:bg-amber-950/30 dark:text-amber-200">
{{ implode(' ', data_get($item, 'gapSummary.messages', [])) }}
</div>
@endif
@if (($item['structuredAttributes'] ?? []) !== [])
<dl class="mt-4 grid gap-3 md:grid-cols-2">
@foreach (($item['structuredAttributes'] ?? []) as $attribute)
<div class="rounded-lg border border-gray-100 bg-gray-50 px-3 py-2 dark:border-gray-800 dark:bg-gray-950/40">
<dt class="text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">
{{ $attribute['label'] ?? 'Attribute' }}
</dt>
<dd class="mt-1 text-sm text-gray-800 dark:text-gray-100">
{{ $attribute['value'] ?? '—' }}
</dd>
</div>
@endforeach
</dl>
@endif
</div>
@endforeach
</div>
</div>
</x-filament::section>
@endforeach
@endif
</div>

View File

@ -0,0 +1,81 @@
@php
use App\Support\Badges\BadgeDomain;
use App\Support\Badges\BadgeRenderer;
use Illuminate\Support\Carbon;
$rows = $getState() ?? [];
$formatTimestamp = static function (?string $value): string {
if (! is_string($value) || trim($value) === '') {
return '—';
}
try {
return Carbon::parse($value)->toDayDateTimeString();
} catch (\Throwable) {
return $value;
}
};
@endphp
<div class="space-y-3">
@if ($rows === [])
<div class="rounded-lg border border-dashed border-gray-300 px-4 py-6 text-sm text-gray-600 dark:border-gray-700 dark:text-gray-300">
No captured policy types are available in this snapshot.
</div>
@else
<div class="overflow-x-auto rounded-lg border border-gray-200 dark:border-gray-700">
<table class="min-w-full divide-y divide-gray-200 text-left text-sm dark:divide-gray-700">
<thead class="bg-gray-50 dark:bg-gray-900/50">
<tr class="text-xs uppercase tracking-wide text-gray-500 dark:text-gray-400">
<th class="px-4 py-3">Policy type</th>
<th class="px-4 py-3">Items</th>
<th class="px-4 py-3">Fidelity</th>
<th class="px-4 py-3">Gaps</th>
<th class="px-4 py-3">Latest evidence</th>
<th class="px-4 py-3">Coverage hint</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-100 dark:divide-gray-800">
@foreach ($rows as $row)
@php
$fidelitySpec = BadgeRenderer::spec(BadgeDomain::BaselineSnapshotFidelity, $row['fidelity'] ?? null);
$gapState = (($row['gapCount'] ?? 0) > 0) ? 'gaps_present' : 'clear';
$gapSpec = BadgeRenderer::spec(BadgeDomain::BaselineSnapshotGapStatus, $gapState);
@endphp
<tr class="align-top">
<td class="px-4 py-3 font-medium text-gray-900 dark:text-white">
{{ $row['label'] ?? ($row['policyType'] ?? 'Policy type') }}
</td>
<td class="px-4 py-3 text-gray-700 dark:text-gray-200">
{{ (int) ($row['itemCount'] ?? 0) }}
</td>
<td class="px-4 py-3">
<x-filament::badge :color="$fidelitySpec->color" :icon="$fidelitySpec->icon" size="sm">
{{ $fidelitySpec->label }}
</x-filament::badge>
</td>
<td class="px-4 py-3">
<div class="flex items-center gap-2">
<x-filament::badge :color="$gapSpec->color" :icon="$gapSpec->icon" size="sm">
{{ $gapSpec->label }}
</x-filament::badge>
<span class="text-xs text-gray-500 dark:text-gray-400">
{{ (int) ($row['gapCount'] ?? 0) }}
</span>
</div>
</td>
<td class="px-4 py-3 text-gray-700 dark:text-gray-200">
{{ $formatTimestamp($row['capturedAt'] ?? null) }}
</td>
<td class="px-4 py-3 text-gray-600 dark:text-gray-300">
{{ $row['coverageHint'] ?? '—' }}
</td>
</tr>
@endforeach
</tbody>
</table>
</div>
@endif
</div>

View File

@ -0,0 +1,36 @@
@php
$technical = $getState() ?? [];
$summaryPayload = is_array($technical['summaryPayload'] ?? null) ? $technical['summaryPayload'] : [];
$groupPayloads = is_array($technical['groupPayloads'] ?? null) ? $technical['groupPayloads'] : [];
@endphp
<div class="space-y-4">
<div class="text-sm text-gray-600 dark:text-gray-300">
Technical payloads are secondary on purpose. Use them for debugging capture fidelity and renderer fallbacks.
</div>
<x-filament::section heading="Snapshot summary payload" collapsible :collapsed="true">
@include('filament.partials.json-viewer', ['value' => $summaryPayload])
</x-filament::section>
@if ($groupPayloads !== [])
<div class="space-y-3">
@foreach ($groupPayloads as $group)
@php
$label = $group['label'] ?? 'Policy type';
$payload = is_array($group['payload'] ?? null) ? $group['payload'] : [];
@endphp
<x-filament::section :heading="$label" collapsible :collapsed="true">
@if (! empty($group['renderingError']))
<div class="mb-3 rounded-lg border border-amber-200 bg-amber-50 px-3 py-2 text-sm text-amber-900 dark:border-amber-800 dark:bg-amber-950/30 dark:text-amber-200">
{{ $group['renderingError'] }}
</div>
@endif
@include('filament.partials.json-viewer', ['value' => $payload])
</x-filament::section>
@endforeach
</div>
@endif
</div>

View File

@ -0,0 +1,36 @@
# Specification Quality Checklist: Structured Snapshot Rendering & Type-Agnostic Item Browser
**Purpose**: Validate specification completeness and quality before proceeding to planning
**Created**: 2026-03-09
**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 on 2026-03-09 against the drafted spec.
- No clarification questions are required before moving to planning.
- The spec preserves existing workspace-scoped authorization and immutable snapshot constraints while keeping the page redesign bounded to presentation behavior.

View File

@ -0,0 +1,272 @@
openapi: 3.1.0
info:
title: Baseline Snapshot Structured Rendering Contract
version: 1.0.0
description: >-
Behavioral contract for the workspace-scoped Baseline Snapshot detail surface
after Spec 130. These are HTML/admin-panel endpoints, but the response shape
is modeled here so summary-first rendering, grouped-browser behavior, and
authorization semantics stay explicit.
servers:
- url: https://tenantpilot.test
paths:
/admin/baseline-snapshots:
get:
summary: Open the workspace-scoped baseline snapshot list
operationId: listBaselineSnapshots
tags:
- Baseline Snapshot Rendering
responses:
'200':
description: Snapshot list rendered for an authenticated actor entitled to the active workspace scope
'302':
description: Redirect to workspace chooser because no workspace is selected
headers:
Location:
schema:
type: string
enum:
- /admin/choose-workspace
'403':
description: Actor is in the active workspace scope but lacks the baseline-view capability required for snapshot list inspection
'404':
description: Actor is not entitled to the admin workspace plane or active workspace scope
/admin/baseline-snapshots/{snapshot}:
get:
summary: Open the structured baseline snapshot detail page
operationId: openBaselineSnapshotDetail
tags:
- Baseline Snapshot Rendering
parameters:
- name: snapshot
in: path
required: true
schema:
type: integer
description: Workspace-owned baseline snapshot identifier
responses:
'200':
description: Structured snapshot detail rendered with metadata, summary, groups, and secondary technical detail
content:
text/html:
schema:
$ref: '#/components/schemas/BaselineSnapshotDetailPage'
'302':
description: Redirect to workspace chooser because no workspace is selected
headers:
Location:
schema:
type: string
enum:
- /admin/choose-workspace
'403':
description: Actor is in the active workspace scope but lacks the baseline-view capability required for protected detail rendering
'404':
description: Actor is not entitled to the workspace scope or the snapshot is outside the active workspace boundary
components:
schemas:
BaselineSnapshotDetailPage:
type: object
required:
- page
- snapshot
- summaryRows
- groups
properties:
page:
type: string
const: baseline-snapshot-detail
snapshot:
$ref: '#/components/schemas/SnapshotMeta'
summaryRows:
type: array
items:
$ref: '#/components/schemas/SnapshotSummaryRow'
groups:
type: array
items:
$ref: '#/components/schemas/SnapshotGroup'
technicalDetail:
description: Optional secondary disclosure shown only when technical payload inspection is retained and authorized.
$ref: '#/components/schemas/TechnicalDetailDisclosure'
SnapshotMeta:
type: object
required:
- snapshotId
- capturedAt
- overallFidelity
- overallGapCount
properties:
snapshotId:
type: integer
baselineProfileName:
type:
- string
- 'null'
capturedAt:
type:
- string
- 'null'
format: date-time
snapshotIdentityHash:
type:
- string
- 'null'
overallFidelity:
$ref: '#/components/schemas/FidelityState'
overallGapCount:
type: integer
SnapshotSummaryRow:
type: object
required:
- policyType
- label
- itemCount
- fidelity
- gapCount
properties:
policyType:
type: string
label:
type: string
itemCount:
type: integer
fidelity:
$ref: '#/components/schemas/FidelityState'
gapCount:
type: integer
capturedAt:
type:
- string
- 'null'
format: date-time
coverageHint:
type:
- string
- 'null'
SnapshotGroup:
type: object
required:
- policyType
- label
- itemCount
- fidelity
- gapSummary
- initiallyCollapsed
- items
properties:
policyType:
type: string
label:
type: string
itemCount:
type: integer
fidelity:
$ref: '#/components/schemas/FidelityState'
gapSummary:
$ref: '#/components/schemas/GapSummary'
initiallyCollapsed:
type: boolean
const: true
renderingError:
type:
- string
- 'null'
items:
type: array
items:
$ref: '#/components/schemas/SnapshotItem'
SnapshotItem:
type: object
required:
- label
- typeLabel
- identityHint
- referenceStatus
- fidelity
- gapSummary
- structuredAttributes
properties:
label:
type: string
typeLabel:
type: string
identityHint:
type: string
referenceStatus:
type: string
fidelity:
$ref: '#/components/schemas/FidelityState'
gapSummary:
$ref: '#/components/schemas/GapSummary'
observedAt:
type:
- string
- 'null'
format: date-time
sourceReference:
type:
- string
- 'null'
structuredAttributes:
type: array
items:
$ref: '#/components/schemas/StructuredAttribute'
StructuredAttribute:
type: object
required:
- label
- value
- priority
properties:
label:
type: string
value:
oneOf:
- type: string
- type: number
- type: integer
- type: boolean
priority:
type: string
enum:
- primary
- secondary
GapSummary:
type: object
required:
- count
- hasGaps
- messages
properties:
count:
type: integer
hasGaps:
type: boolean
messages:
type: array
items:
type: string
TechnicalDetailDisclosure:
type: object
required:
- label
- defaultCollapsed
properties:
label:
type: string
const: Technical detail
defaultCollapsed:
type: boolean
const: true
summaryPayloadAvailable:
type: boolean
groupPayloadsAvailable:
type: boolean
FidelityState:
type: string
enum:
- full
- partial
- reference_only
- unsupported

View File

@ -0,0 +1,247 @@
# Data Model: Structured Snapshot Rendering & Type-Agnostic Item Browser
**Feature**: 130-structured-snapshot-rendering | **Date**: 2026-03-09
## Overview
This feature introduces no database schema changes. It adds a normalized read model over existing workspace-owned baseline snapshot records so the snapshot detail page can render every captured policy type through a shared summary and grouped-browser abstraction.
The design relies on:
1. existing persistent snapshot records,
2. existing persistent snapshot-item records,
3. a new normalized page presentation model,
4. renderer-specific enrichment layered on top of a shared fallback contract.
## Existing Persistent Entities
### BaselineSnapshot
| Attribute | Type | Notes |
|-----------|------|-------|
| `id` | int | Snapshot identity shown on the page |
| `workspace_id` | int | Workspace isolation boundary |
| `baseline_profile_id` | int | Source baseline profile reference |
| `summary_jsonb` | array/jsonb | Snapshot-level counts, fidelity, policy-type totals, and gap summaries |
| `snapshot_identity_hash` | string | Stable snapshot fingerprint shown as technical metadata |
| `captured_at` | datetime | Snapshot capture timestamp |
**Relationships**:
- belongs to `Workspace`
- belongs to `BaselineProfile`
- has many `BaselineSnapshotItem`
**Usage rules**:
- The snapshot is immutable and read-only in this feature.
- Snapshot detail can render only within the active workspace scope.
- `summary_jsonb` remains the pages aggregation source but not the primary raw UI payload.
### BaselineSnapshotItem
| Attribute | Type | Notes |
|-----------|------|-------|
| `id` | int | Item identity for rendering and ordering |
| `baseline_snapshot_id` | int | Parent snapshot reference |
| `policy_type` | string | Primary grouping key for the browser |
| `subject_type` | string | Stable subject class hint, typically policy |
| `subject_key` | string | Stable identity hint for operator inspection |
| `subject_external_id` | string | Workspace-safe external identity hint |
| `baseline_hash` | string | Captured evidence hash |
| `meta_jsonb` | array/jsonb | Best-available display, evidence, version-reference, and optional type-specific metadata |
**Usage rules**:
- Items are grouped by `policy_type` for summary and browser rendering.
- Items must remain visible even when type-specific enrichment is unavailable.
- `meta_jsonb` is the canonical source for fallback rendering.
### BaselineProfile
| Attribute | Type | Notes |
|-----------|------|-------|
| `id` | int | Reference-only profile link |
| `workspace_id` | int | Workspace ownership boundary |
| `name` | string | Profile label shown in snapshot metadata |
**Usage rules**:
- Used only as metadata context in this feature.
- No baseline-profile lifecycle changes are introduced.
## Existing Snapshot Metadata Shape
### Snapshot summary payload
`summary_jsonb` is expected to provide enough information for snapshot-level aggregation:
| Key | Type | Purpose |
|-----|------|---------|
| `total_items` | int | Total item count for snapshot-level overview |
| `policy_type_counts` | map<string,int> | Count by policy type for summary rows |
| `fidelity_counts` | object | Aggregate fidelity counts already tracked at snapshot level |
| `gaps` | object | Aggregate gap count and by-reason summaries |
### Snapshot item metadata payload
`meta_jsonb` is expected to provide enough information for the minimum rendering contract:
| Key | Type | Purpose |
|-----|------|---------|
| `display_name` | string nullable | Best available item label |
| `category` | string nullable | Optional contextual attribute |
| `platform` | string nullable | Optional contextual attribute |
| `evidence.fidelity` | string | Item-level fidelity source |
| `evidence.source` | string | Capture or reference provenance |
| `evidence.observed_at` | date-time nullable | Best available observed timestamp |
| `identity.strategy` | string nullable | Identity interpretation hint |
| `version_reference.policy_version_id` | int nullable | Source reference hint |
| `version_reference.capture_purpose` | string nullable | Optional provenance hint |
| `rbac.*` | type-specific object | Optional RBAC enrichment fields |
## New Computed Read Models
### RenderedSnapshot
Page-level presentation model built from one snapshot and its items.
| Field | Type | Description |
|------|------|-------------|
| `snapshot` | SnapshotMeta | Top-of-page metadata summary |
| `summaryRows` | list<RenderedSnapshotSummaryRow> | One row per policy type |
| `groups` | list<RenderedSnapshotGroup> | Grouped item browser content |
| `technicalDetail` | RenderedTechnicalDetail | Secondary disclosure payload |
| `hasItems` | bool | Empty-state signal |
### SnapshotMeta
| Field | Type | Description |
|------|------|-------------|
| `snapshotId` | int | Visible snapshot identifier |
| `baselineProfileName` | string nullable | Visible baseline label |
| `capturedAt` | date-time nullable | Capture timestamp |
| `snapshotIdentityHash` | string nullable | Technical identity hint |
| `overallFidelity` | FidelityState | Overall fidelity status for the page |
| `overallGapCount` | int | Total known gaps |
### RenderedSnapshotSummaryRow
| Field | Type | Description |
|------|------|-------------|
| `policyType` | string | Canonical type key |
| `label` | string | Human-readable type label |
| `itemCount` | int | Number of items in this group |
| `fidelity` | FidelityState | Group-level fidelity summary |
| `gapCount` | int | Group-level gap count |
| `capturedAt` | date-time nullable | Most relevant timing summary for the group |
| `coverageHint` | string nullable | Optional summary hint |
### RenderedSnapshotGroup
| Field | Type | Description |
|------|------|-------------|
| `policyType` | string | Canonical type key |
| `label` | string | Group heading |
| `itemCount` | int | Count shown in group header |
| `fidelity` | FidelityState | Group-level fidelity badge state |
| `gapSummary` | GapSummary | Group-level degraded-state explanation |
| `initiallyCollapsed` | bool | Default UI state |
| `items` | list<RenderedSnapshotItem> | Visible item rows |
| `renderingError` | string nullable | Per-group error message when renderer fails |
### RenderedSnapshotItem
| Field | Type | Description |
|------|------|-------------|
| `label` | string | Best available human-readable title |
| `typeLabel` | string | Human-readable policy type label |
| `identityHint` | string | Stable inspection identity |
| `referenceStatus` | string | Capture or reference status label |
| `fidelity` | FidelityState | Item-level fidelity state |
| `gapSummary` | GapSummary | Item-level degraded-state summary |
| `observedAt` | date-time nullable | Best available timestamp |
| `sourceReference` | string nullable | PolicyVersion or equivalent reference hint |
| `structuredAttributes` | list<RenderedAttribute> | Shared and type-specific key-value attributes |
### RenderedAttribute
| Field | Type | Description |
|------|------|-------------|
| `label` | string | Operator-readable field label |
| `value` | scalar or string | Display value |
| `priority` | string enum | `primary` or `secondary` |
### FidelityState
| Value | Meaning |
|-------|---------|
| `full` | Structured rendering is complete and content-backed |
| `partial` | Structured rendering exists but some expected detail is missing |
| `reference_only` | Rendering is based on metadata or reference-only evidence |
| `unsupported` | Only the minimum fallback path is available |
### GapSummary
| Field | Type | Description |
|------|------|-------------|
| `count` | int | Number of known gaps |
| `hasGaps` | bool | Shortcut for UI conditions |
| `messages` | list<string> | Human-readable explanations or translated reason labels |
### RenderedTechnicalDetail
| Field | Type | Description |
|------|------|-------------|
| `defaultCollapsed` | bool | Always true for initial render |
| `summaryPayload` | array | Raw summary payload for debugging |
| `groupPayloads` | map<string,mixed> | Optional renderer debug payloads where safe |
## Relationships and Aggregation Rules
### Snapshot to group aggregation
```text
BaselineSnapshot
-> many BaselineSnapshotItem
-> grouped by policy_type
-> one RenderedSnapshotSummaryRow per group
-> one RenderedSnapshotGroup per group
```
### Group derivation rules
```text
policy_type group
-> item count = number of items in the group
-> fidelity = worst effective fidelity across item set or unsupported if fallback only
-> gap count = derived from item gaps plus any renderer-level degraded-state signals
-> captured/observed timing = most relevant available timing summary
```
### Renderer resolution rules
```text
Known supported type with specific renderer
-> render shared item contract + type-specific enrichments
Known type with no specific renderer
-> fallback renderer
Unknown type
-> fallback renderer
Specific renderer throws or returns invalid data
-> group-level rendering error + fallback group shell
```
## Validation Rules
| Rule | Result |
|------|--------|
| Every captured policy type yields a summary row | Required |
| Every captured policy type yields a grouped browser section | Required |
| Every item yields at least the minimum rendering contract | Required |
| Unknown types never disappear from the page | Required |
| Technical payload is secondary and collapsed by default | Required |
| One renderer failure cannot break the rest of the snapshot page | Required |
## Schema Impact
No schema migration is expected for this feature.

View File

@ -0,0 +1,266 @@
# Implementation Plan: Structured Snapshot Rendering & Type-Agnostic Item Browser
**Branch**: `130-structured-snapshot-rendering` | **Date**: 2026-03-09 | **Spec**: [spec.md](./spec.md)
**Input**: Feature specification from `/specs/130-structured-snapshot-rendering/spec.md`
**Note**: This template is filled in by the `/speckit.plan` command. See `.specify/scripts/` for helper scripts.
## Summary
Replace the current Baseline Snapshot detail experience, which mixes a raw summary JSON block with an RBAC-only repeatable section, with a normalized presenter-driven view that renders every captured policy type through a shared summary-first inspection model. Keep the feature workspace-scoped and read-only, introduce a `BaselineSnapshotPresenter` plus a type-renderer registry and fallback renderer, preserve richer RBAC detail as an enrichment layer rather than a privileged exception, add initial structured compliance rendering for `deviceCompliancePolicy`, and deliver the UI through sectioned Filament infolist entries backed by focused feature and unit tests.
## Technical Context
**Language/Version**: PHP 8.4.15 / Laravel 12
**Primary Dependencies**: Filament v5, Livewire v4.0+, Tailwind CSS v4
**Storage**: PostgreSQL via Laravel Sail using existing `baseline_snapshots`, `baseline_snapshot_items`, and JSONB presentation source fields
**Testing**: Pest v4 feature and unit tests on PHPUnit 12
**Target Platform**: Laravel Sail web application with workspace-scoped admin panel at `/admin`
**Project Type**: Laravel monolith / Filament web application
**Performance Goals**: Snapshot detail remains DB-only at render time, snapshot items are eager-loaded once per page render, groups are collapsed by default for large snapshots, and technical payload disclosure remains secondary and bounded
**Constraints**: No schema changes; no Microsoft Graph calls; preserve immutable snapshot semantics; preserve workspace-scoped authorization and 404/403 semantics on both list and detail routes; keep raw JSON non-primary; isolate per-group rendering failures; use centralized badge semantics for fidelity and gap states; keep technical detail optional and scope-safe
**Scale/Scope**: One existing Filament resource detail surface, one new presenter and renderer registry layer, fallback plus initial RBAC and device-compliance enrichments, a small set of Blade infolist entry components, and focused regression tests
## Constitution Check
*GATE: Must pass before Phase 0 research. Re-check after Phase 1 design.*
- Inventory-first: PASS — this feature consumes existing immutable snapshot artifacts for inspection and does not alter inventory versus snapshot ownership or semantics.
- Read/write separation: PASS — the redesign is read-only and introduces no mutation flow, restore action, or audit-triggering write path.
- Graph contract path: PASS — no Microsoft Graph call is added.
- Deterministic capabilities: PASS — authorization continues to rely on the existing workspace capability registry and resolver with no raw capability strings.
- RBAC-UX planes and isolation: PASS — the feature stays in the `/admin` workspace plane, keeps `/system` untouched, and preserves deny-as-not-found for non-members plus protected in-scope access semantics.
- Workspace isolation: PASS — snapshot detail remains bound to the active workspace context and existing workspace membership checks.
- RBAC-UX destructive confirmation: PASS / N/A — no destructive or mutation action is introduced.
- RBAC-UX global search: PASS — the resource remains non-globally-searchable, so global-search semantics do not change.
- Tenant isolation: PASS — no tenant-scoped read or cross-tenant aggregation is introduced.
- Run observability: PASS / N/A — no new `OperationRun` or long-running workflow is introduced.
- Ops-UX 3-surface feedback: PASS / N/A — no operation lifecycle change is involved.
- Ops-UX lifecycle and summary counts: PASS / N/A — no `OperationRun` transitions or summary producers are added.
- Ops-UX guards and system runs: PASS / N/A — existing operations behavior is unaffected.
- Automation: PASS / N/A — no queued or scheduled behavior changes are required.
- Data minimization: PASS — the page uses existing workspace-safe snapshot metadata and technical payload disclosure stays secondary.
- Badge semantics (BADGE-001): PASS — fidelity and gap states will be added through centralized badge semantics rather than inline color mapping.
- Filament UI Action Surface Contract: PASS — the feature modifies a view-only resource detail surface, keeps the immutable-resource exemptions already declared on the resource, and upgrades the list inspection affordance from a lone `View` row action to clickable rows or a primary linked column so the modified surface matches the constitution.
- Filament UI UX-001: PASS — the design remains a sectioned infolist-style detail view with metadata, summary, grouped browser, and technical disclosure sections.
- Filament v5 / Livewire v4 compliance: PASS — the design stays inside the existing Filament v5 and Livewire v4 admin panel.
- Provider registration (`bootstrap/providers.php`): PASS — no panel provider change is introduced.
- Global search resource rule: PASS — the resource remains not globally searchable, so the Edit/View global-search rule is unaffected.
- Asset strategy: PASS — no new heavy asset bundle is required; existing deploy-time `php artisan filament:assets` behavior remains sufficient.
## Project Structure
### Documentation (this feature)
```text
specs/130-structured-snapshot-rendering/
├── plan.md
├── research.md
├── data-model.md
├── quickstart.md
├── contracts/
│ └── baseline-snapshot-rendering.openapi.yaml
├── checklists/
│ └── requirements.md
└── tasks.md
```
### Source Code (repository root)
```text
app/
├── Filament/
│ └── Resources/
│ ├── BaselineSnapshotResource.php # MODIFY — replace raw summary primary UX
│ └── BaselineSnapshotResource/
│ └── Pages/
│ └── ViewBaselineSnapshot.php # MODIFY if page-level composition or eager-loading is needed
├── Services/
│ └── Baselines/
│ └── SnapshotRendering/
│ ├── BaselineSnapshotPresenter.php # NEW page-level builder
│ ├── SnapshotTypeRenderer.php # NEW renderer contract
│ ├── SnapshotTypeRendererRegistry.php # NEW registry/fallback resolution
│ ├── RenderedSnapshot.php # NEW normalized summary DTO
│ ├── RenderedSnapshotGroup.php # NEW group DTO
│ ├── RenderedSnapshotItem.php # NEW item DTO
│ ├── FidelityState.php # NEW fidelity enum/value object
│ ├── GapSummary.php # NEW gap summary value object
│ └── Renderers/
│ ├── FallbackSnapshotTypeRenderer.php # NEW minimum-contract renderer
│ ├── IntuneRoleDefinitionSnapshotTypeRenderer.php # NEW extracted RBAC renderer
│ └── DeviceComplianceSnapshotTypeRenderer.php # NEW initial compliance renderer
└── Support/
└── Badges/
├── BadgeDomain.php # MODIFY if new fidelity/gap domains are introduced
└── Domains/ # NEW badge mapper(s) if fidelity/gap become centralized domains
resources/
└── views/
└── filament/
└── infolists/
└── entries/
├── baseline-snapshot-summary-table.blade.php # NEW summary-first rendering block
├── baseline-snapshot-groups.blade.php # NEW grouped browser with collapsed sections
├── baseline-snapshot-technical-detail.blade.php # NEW secondary technical disclosure
└── snapshot-json.blade.php # MODIFY or reduce prominence if retained via partial reuse
tests/
├── Feature/
│ └── Filament/
│ ├── BaselineSnapshotFidelityVisibilityTest.php # MODIFY existing assertions for structured UI
│ ├── BaselineSnapshotStructuredRenderingTest.php # NEW mixed-type summary and grouped browser coverage
│ ├── BaselineSnapshotFallbackRenderingTest.php # NEW unknown-type fallback coverage
│ ├── BaselineSnapshotAuthorizationTest.php # NEW or MODIFY positive/negative workspace access coverage
│ └── BaselineSnapshotDegradedStateTest.php # NEW fidelity/gap and failure-isolation coverage
└── Unit/
└── Baselines/
└── SnapshotRendering/
├── BaselineSnapshotPresenterTest.php # NEW presenter/group aggregation coverage
├── SnapshotTypeRendererRegistryTest.php # NEW registry resolution coverage
├── FallbackSnapshotTypeRendererTest.php # NEW minimum-contract coverage
├── IntuneRoleDefinitionSnapshotTypeRendererTest.php # NEW RBAC contract compliance coverage
└── DeviceComplianceSnapshotTypeRendererTest.php # NEW compliance rendering coverage
```
**Structure Decision**: Keep the feature inside the existing Laravel/Filament monolith. The implementation centers on the existing `BaselineSnapshotResource` view surface, a new snapshot-rendering service layer under `app/Services/Baselines`, small Blade infolist entry components for summary and grouped rendering, and focused Pest feature and unit tests.
## Complexity Tracking
> No Constitution Check violations. No justifications needed.
| Violation | Why Needed | Simpler Alternative Rejected Because |
|-----------|------------|-------------------------------------|
| — | — | — |
## Phase 0 — Research (DONE)
Output:
- `specs/130-structured-snapshot-rendering/research.md`
Key findings captured:
- Current snapshot detail uses a raw JSON summary block plus an RBAC-only repeatable section, so the shared abstraction does not exist yet.
- `BaselineSnapshot.summary_jsonb` already provides snapshot-level counts by policy type, fidelity counts, and gap counts suitable for a summary-first page.
- `BaselineSnapshotItem.meta_jsonb` already provides a deterministic minimum metadata shape that supports a fallback renderer for all policy types.
- Existing repo patterns favor registry or normalizer strategies, centralized badge semantics, and custom `ViewEntry` Blade components inside sectioned Filament infolists.
## Phase 1 — Design & Contracts (DONE)
Outputs:
- `specs/130-structured-snapshot-rendering/data-model.md`
- `specs/130-structured-snapshot-rendering/contracts/baseline-snapshot-rendering.openapi.yaml`
- `specs/130-structured-snapshot-rendering/quickstart.md`
Design highlights:
- Add a page-level `BaselineSnapshotPresenter` that loads one snapshot and returns normalized summary, group, and item DTOs for the UI.
- Resolve per-type enrichment through a `SnapshotTypeRendererRegistry` with a shared fallback renderer so unsupported or new types still render.
- Keep the detail page inside a sectioned Filament infolist, using custom `ViewEntry` Blade components for the summary table, grouped browser, and optional technical detail disclosure.
- Centralize fidelity and gap badge semantics rather than embedding ad hoc color decisions in the resource.
## Phase 1 — Agent Context Update (DONE)
Run:
- `.specify/scripts/bash/update-agent-context.sh copilot`
## Phase 2 — Implementation Outline (tasks created in `/speckit.tasks`)
### Step 1 — Introduce normalized snapshot presentation primitives
Goal: implement FR-130-03 through FR-130-09, FR-130-15 through FR-130-18, and FR-130-22.
Changes:
- Create normalized DTOs or value objects for page summary, policy-type groups, rendered items, fidelity state, and gap summaries.
- Implement a page-level presenter that consumes `BaselineSnapshot` plus eager-loaded `items` and emits a deterministic presentation model.
- Add a renderer interface and registry with fallback resolution so every `policy_type` receives a renderer.
Tests:
- Add unit coverage for group aggregation, ordering, and fallback resolution.
- Add unit coverage proving unknown policy types still produce valid summary and item output.
### Step 2 — Refactor RBAC rendering into the shared renderer contract
Goal: implement FR-130-10 through FR-130-11 and preserve backward-compatible RBAC richness.
Changes:
- Extract the current `intuneRoleDefinition` detail mapping from `BaselineSnapshotResource` into a dedicated renderer class.
- Preserve RBAC-specific enrichment fields such as built-in versus custom, permission block counts, identity strategy, and policy version reference.
- Ensure RBAC output also satisfies the shared item contract and group shell.
Tests:
- Add unit coverage proving the RBAC renderer produces both common fields and enrichment fields.
- Add feature coverage proving RBAC remains richly inspectable without becoming the only rendered type.
### Step 3 — Add initial compliance and fallback rendering
Goal: implement FR-130-08 through FR-130-14 and FR-130-19.
Changes:
- Add an initial `deviceCompliancePolicy` renderer that emits minimum structured detail plus any safe first-pass enrichment such as platform and major compliance hints when present.
- Implement the shared fallback renderer so all other policy types render via best available metadata.
- Derive explicit fidelity and gap states at group and item level using snapshot and item metadata rather than hidden omission.
Tests:
- Add unit coverage for compliance rendering and fallback minimum-contract behavior.
- Add feature coverage for unsupported or newly added types, partial fidelity, reference-only items, and empty or degraded groups.
### Step 4 — Rebuild the Baseline Snapshot detail layout
Goal: implement FR-130-01 through FR-130-07, FR-130-20, and FR-130-21.
Changes:
- Replace the current raw-summary-first infolist section with a metadata section, a structured summary section, a grouped item-browser section, and a secondary technical detail section.
- Render the summary through a custom `ViewEntry` or equivalent Blade component that shows one row per policy type with count, fidelity, gaps, and timing.
- Render grouped items through collapsible sections that stay collapsed by default, include group header badges, and isolate failures per group.
- Keep raw JSON or technical payload inspection available only through a labeled secondary disclosure.
Tests:
- Add feature coverage proving summary-first hierarchy, collapsed-by-default groups where testable, and secondary technical payload disclosure.
- Add feature assertions proving raw JSON is no longer the primary visible content path.
### Step 5 — Centralize fidelity and gap badge semantics
Goal: implement FR-130-13 through FR-130-14 and align with BADGE-001.
Changes:
- Introduce centralized badge semantics for fidelity and gap states, either through new `BadgeDomain` entries and mappers or an existing compatible shared domain helper.
- Replace any ad hoc state-to-color logic on the detail page with centralized badge specs.
Tests:
- Add unit coverage for fidelity and gap badge mappings.
- Add feature assertions that expected labels or badges appear for full, partial, reference-only, unsupported, and gap-present cases.
### Step 6 — Preserve authorization and regression safety
Goal: satisfy the specs workspace-scope and regression guarantees.
Changes:
- Ensure the resource detail surface continues to enforce workspace membership and baseline-view capability semantics.
- Keep the resource non-globally-searchable and avoid introducing new scope leakage through related references or technical payloads.
- Update existing snapshot visibility tests to the new UI structure and add regression coverage for mixed-type rendering.
Tests:
- Add or update positive authorization coverage for an entitled workspace member.
- Add or update negative authorization coverage for non-members or under-entitled users.
- Add regression tests proving non-RBAC types do not disappear and one renderer failure does not break the page.
### Step 7 — Final verification and polish
Goal: finish the feature within the repos Laravel, Filament, and testing standards.
Changes:
- Review section hierarchy, empty-state copy, collapsed defaults, and technical detail labeling against UX-001.
- Confirm the detail surface remains view-only and consistent with the resources immutable-action exemptions.
Tests:
- Run focused Sail-based Pest coverage for unit and feature paths touched by this redesign.
- Run Pint on dirty files through Sail during implementation.
## Constitution Check (Post-Design)
Re-check result: PASS.
- Livewire v4.0+ compliance: preserved because the design remains inside the existing Filament v5 and Livewire v4 admin panel.
- Provider registration location: unchanged; the current panel provider remains registered in `bootstrap/providers.php`.
- Globally searchable resources: unchanged; `BaselineSnapshotResource` remains non-globally-searchable.
- Destructive actions: unchanged; the redesign introduces no destructive or mutation action, so no new `->requiresConfirmation()` surface is needed.
- Asset strategy: unchanged; no new heavy or shared asset bundle is introduced, and current deploy-time `php artisan filament:assets` behavior remains sufficient.
- Action-surface inspect affordance: the modified list surface will use clickable rows or a primary linked column instead of a lone `View` row action.
- Testing plan: add or update focused Pest feature and unit coverage for presenter aggregation, registry resolution, fallback rendering, compliance and RBAC contract compliance, summary-first layout, fidelity and gap badges, workspace authorization semantics, and group failure isolation.

View File

@ -0,0 +1,62 @@
# Quickstart: Structured Snapshot Rendering & Type-Agnostic Item Browser
**Feature**: 130-structured-snapshot-rendering | **Date**: 2026-03-09
## Scope
This feature upgrades the workspace-scoped baseline snapshot detail view by:
- replacing raw JSON as the primary snapshot detail UX,
- introducing a normalized summary-first presentation model,
- rendering all captured policy types through a grouped item browser,
- keeping groups collapsed by default,
- preserving richer RBAC detail as an enrichment layer,
- adding initial structured rendering for `deviceCompliancePolicy`,
- keeping raw technical payload secondary and collapsed.
## Implementation order
1. Create the normalized snapshot rendering primitives under `app/Services/Baselines/SnapshotRendering`.
2. Implement the page-level presenter and type-renderer registry with fallback resolution.
3. Extract current `intuneRoleDefinition` mapping into a dedicated renderer.
4. Add initial `deviceCompliancePolicy` structured rendering.
5. Introduce centralized fidelity and gap badge semantics.
6. Rebuild the `BaselineSnapshotResource` detail infolist around metadata, summary, groups, and technical disclosure.
7. Update existing snapshot visibility tests to the new layout and add focused presenter and fallback coverage.
8. Run focused Sail-based tests.
9. Run Pint on dirty files.
## Reference files
- [app/Filament/Resources/BaselineSnapshotResource.php](../../../app/Filament/Resources/BaselineSnapshotResource.php)
- [app/Filament/Resources/BaselineSnapshotResource/Pages/ViewBaselineSnapshot.php](../../../app/Filament/Resources/BaselineSnapshotResource/Pages/ViewBaselineSnapshot.php)
- [app/Models/BaselineSnapshot.php](../../../app/Models/BaselineSnapshot.php)
- [app/Models/BaselineSnapshotItem.php](../../../app/Models/BaselineSnapshotItem.php)
- [app/Support/Badges/BadgeCatalog.php](../../../app/Support/Badges/BadgeCatalog.php)
- [app/Support/Badges/BadgeRenderer.php](../../../app/Support/Badges/BadgeRenderer.php)
- [resources/views/filament/infolists/entries/snapshot-json.blade.php](../../../resources/views/filament/infolists/entries/snapshot-json.blade.php)
- [resources/views/filament/infolists/entries/settings-catalog-grouped.blade.php](../../../resources/views/filament/infolists/entries/settings-catalog-grouped.blade.php)
- [resources/views/filament/infolists/entries/restore-results.blade.php](../../../resources/views/filament/infolists/entries/restore-results.blade.php)
- [tests/Feature/Filament/BaselineSnapshotFidelityVisibilityTest.php](../../../tests/Feature/Filament/BaselineSnapshotFidelityVisibilityTest.php)
- [tests/Feature/Guards/FilamentTableStandardsGuardTest.php](../../../tests/Feature/Guards/FilamentTableStandardsGuardTest.php)
## Suggested validation commands
```bash
vendor/bin/sail artisan test --compact tests/Feature/Filament/BaselineSnapshotFidelityVisibilityTest.php
vendor/bin/sail artisan test --compact tests/Feature/Filament/BaselineSnapshotStructuredRenderingTest.php
vendor/bin/sail artisan test --compact tests/Feature/Filament/BaselineSnapshotFallbackRenderingTest.php
vendor/bin/sail artisan test --compact tests/Feature/Filament/BaselineSnapshotAuthorizationTest.php
vendor/bin/sail artisan test --compact tests/Feature/Filament/BaselineSnapshotDegradedStateTest.php
vendor/bin/sail artisan test --compact tests/Unit/Baselines/SnapshotRendering
vendor/bin/sail bin pint --dirty --format agent
```
## Expected outcome
- Snapshot detail opens with a structured summary instead of raw JSON.
- Every captured policy type is visible in both the summary and grouped browser.
- Unsupported or newly added types render through the fallback contract instead of disappearing.
- `intuneRoleDefinition` remains rich but is no longer the only first-class inspectable type.
- `deviceCompliancePolicy` appears as a first-class structured group.
- Fidelity and gap states are explicit and centralized.

View File

@ -0,0 +1,63 @@
# Research: Structured Snapshot Rendering & Type-Agnostic Item Browser
**Feature**: 130-structured-snapshot-rendering | **Date**: 2026-03-09
## R1: Rendering architecture for mixed policy types
**Decision**: Introduce a page-level `BaselineSnapshotPresenter` backed by a `SnapshotTypeRendererRegistry`, dedicated per-type renderers, and a shared fallback renderer.
**Rationale**: The current detail page hardcodes summary parsing and `intuneRoleDefinition` item extraction directly inside `BaselineSnapshotResource`. The repo already favors registry and strategy patterns for type-specific logic, especially through the policy normalizer architecture. A presenter plus registry cleanly separates page composition, type-specific enrichment, and shared fallback behavior while satisfying the spec requirement that new policy types become visible without page-template edits.
**Alternatives considered**:
- Keep conditional rendering in `BaselineSnapshotResource`: rejected because the current problem is precisely that the page already mixes ad hoc type-specific behavior with raw payload dumping.
- Build one giant generic mapper with embedded `switch ($policyType)` branches: rejected because it scales poorly as new types are added and makes regression isolation harder.
## R2: Minimum data contract for fallback rendering
**Decision**: Base the fallback renderer on guaranteed fields already available from `BaselineSnapshot.summary_jsonb` and `BaselineSnapshotItem.meta_jsonb`, with the minimum item contract built from display name, category or platform hints, evidence provenance, identity hints, and optional source references.
**Rationale**: Current snapshot data already includes deterministic snapshot-level counts by policy type, fidelity counts, gaps, and item metadata with evidence provenance and identity hints. That is enough to render every item without new data capture work. The fallback path therefore does not need new schema or new provider calls and can remain backward-compatible with existing snapshots.
**Alternatives considered**:
- Require bespoke renderers before a policy type can appear: rejected because the spec explicitly requires unknown and unsupported types to render immediately.
- Depend on raw JSON payload shape for unsupported types: rejected because the goal is to stop using raw payloads as the primary operator experience.
## R3: UI composition pattern for the detail page
**Decision**: Keep the view as a sectioned Filament infolist and render the summary table, grouped item browser, and technical disclosure through custom `ViewEntry` Blade components.
**Rationale**: Existing repo patterns use sectioned infolists for view pages and custom Blade entries for complex grouped rendering. This fits UX-001, keeps the page view-only, and allows the grouped browser to use collapsible sections plus per-group warning or degraded-state blocks without switching to a bespoke Livewire screen.
**Alternatives considered**:
- Replace the view page with a custom Livewire page: rejected because the feature does not need a new interaction-heavy workflow and the repo already supports complex read-only rendering through infolist entries.
- Render all content as raw Blade inside the page class: rejected because it would bypass existing Filament view composition patterns and make the detail surface harder to test structurally.
## R4: Fidelity and gap semantics
**Decision**: Represent fidelity and gap states explicitly in the presentation model and route them through centralized badge semantics rather than inline `->color()` closures or one-off labels.
**Rationale**: The constitution requires status-like badges to stay centralized. The current resource already has inline state coloring for snapshot completion, but Feature 130 introduces richer fidelity and gap semantics at summary and item level. Centralizing those states through badge mappers or an equivalent shared domain keeps labels and colors consistent across summary rows, group headers, and item rows.
**Alternatives considered**:
- Leave fidelity and gaps as plain text strings only: rejected because the spec explicitly requires them to be visible and enterprise-readable, not hidden in prose.
- Add per-view ad hoc mappings: rejected because BADGE-001 forbids it and the states would drift across sections over time.
## R5: Initial type enrichments for RBAC and compliance
**Decision**: Preserve RBAC as the richest type by extracting its current mapping into a dedicated renderer, and add an initial `deviceCompliancePolicy` renderer that emits first-class structured rows plus safe platform and compliance hints when metadata supports it.
**Rationale**: The spec requires RBAC to stop being exclusive, not to lose richness. Extracting the current RBAC mapping into a renderer preserves that value while making it conform to the common item contract. Compliance is explicitly called out in the spec as a first-class type that must stop appearing only as counts or raw JSON, so it should be the first non-RBAC enriched renderer.
**Alternatives considered**:
- Ship only the fallback renderer for all types: rejected because the spec explicitly calls for RBAC richness retention and initial compliance visibility improvements.
- Build rich bespoke renderers for every policy type in this spec: rejected because it expands scope too far and the fallback contract already covers near-term extensibility.
## R6: Testing strategy
**Decision**: Cover the redesign with focused Pest feature tests for rendered page behavior and unit tests for presenter and renderer logic.
**Rationale**: The main regression risks are grouping, fallback visibility, fidelity and gap display, authorization preservation, and safe failure isolation. Those are best protected by unit tests around the presentation layer plus feature tests against the Filament resource response. Existing snapshot visibility tests can be evolved instead of replaced wholesale.
**Alternatives considered**:
- Rely only on feature tests: rejected because presenter and renderer contracts need cheap, deterministic tests separate from full-page HTML assertions.
- Rely only on unit tests: rejected because the feature is primarily a user-facing rendering change and needs end-to-end regression protection against the resource view surface.

View File

@ -0,0 +1,174 @@
# Feature Specification: Structured Snapshot Rendering & Type-Agnostic Item Browser
**Feature Branch**: `130-structured-snapshot-rendering`
**Created**: 2026-03-09
**Status**: Draft
**Input**: User description: "Spec 130 — Structured Snapshot Rendering & Type-Agnostic Item Browser"
## Spec Scope Fields *(mandatory)*
- **Scope**: workspace
- **Primary Routes**:
- Workspace admin: `/admin/baseline-snapshots`
- Workspace admin: `/admin/baseline-snapshots/{record}`
- Existing baseline-profile navigation paths that open snapshot detail remain in scope only where they lead to the same snapshot detail surface
- **Data Ownership**:
- Workspace-owned: baseline snapshots and baseline snapshot items remain the source records rendered on this page
- Workspace-owned presentation only: this feature adds no new tenant-owned records and does not change snapshot immutability
- Existing policy version references and evidence metadata remain read-only supporting context for snapshot inspection
- **RBAC**:
- Workspace membership remains the visibility boundary for snapshot pages
- Authorized workspace users with existing baseline snapshot view capability can inspect the structured page
- Non-members or users outside the active workspace scope must receive deny-as-not-found behavior
- In-scope members lacking the required capability must continue to receive forbidden behavior for protected data or affordances
## User Scenarios & Testing *(mandatory)*
### User Story 1 - Review captured state by policy type (Priority: P1)
As a workspace governance operator, I want a snapshot page that immediately summarizes what was captured by policy type so I can understand coverage and inspection priority without reading raw payloads.
**Why this priority**: The page fails its core purpose if operators cannot tell what a snapshot contains within the first screen.
**Independent Test**: Can be fully tested by opening a mixed-policy snapshot and verifying that the first meaningful content block is a structured summary with one row per captured policy type.
**Acceptance Scenarios**:
1. **Given** a snapshot that contains multiple captured policy types, **When** an authorized user opens the snapshot detail page, **Then** the page shows a summary-first table with one row per policy type before any technical payload is shown.
2. **Given** a snapshot that includes groups with different rendering quality, **When** the summary is shown, **Then** each row clearly displays item count, fidelity state, and gap state rather than implying completeness through omission.
3. **Given** a snapshot that contains only one captured policy type, **When** the page loads, **Then** the same summary structure still appears and does not switch to a one-off special-case layout.
---
### User Story 2 - Drill into any captured type consistently (Priority: P1)
As a workspace governance operator, I want every captured policy type to appear in a grouped browser with the same baseline inspection contract so I can inspect snapshots consistently even when a type has limited enrichment.
**Why this priority**: Enterprise trust breaks if only one favored type is inspectable while others are hidden behind counts or JSON.
**Independent Test**: Can be fully tested by opening a snapshot containing RBAC, compliance, and an unsupported type and verifying that all three appear as grouped sections with visible items and stable minimum metadata.
**Acceptance Scenarios**:
1. **Given** a snapshot containing supported and unsupported policy types, **When** the grouped browser renders, **Then** every type appears as an expandable group and none are suppressed because bespoke rendering is missing.
2. **Given** a group with no rich enrichment available, **When** the user expands it, **Then** the item rows still show a readable label, identity hint, status, fidelity, and source-reference context.
3. **Given** a group with rich enrichment available, **When** the user expands it, **Then** the extra detail appears inside the shared inspection shell rather than replacing the common structure.
---
### User Story 3 - Understand fidelity, gaps, and degraded states safely (Priority: P2)
As a reviewer or auditor, I want incomplete or degraded rendering to be explicit so I can judge how much confidence to place in the snapshot without having to infer missing detail from raw data dumps.
**Why this priority**: Governance pages must expose evidence quality and known gaps directly, otherwise operators will misread incomplete evidence as complete.
**Independent Test**: Can be fully tested by loading snapshots with complete, partial, reference-only, unsupported, empty, and renderer-failure conditions and verifying that the page remains usable and explains the degraded state.
**Acceptance Scenarios**:
1. **Given** a partially renderable group, **When** the page is displayed, **Then** the group shows fidelity and gap indicators that explain the degraded state without hiding the affected items.
2. **Given** a snapshot with no items, **When** the page is displayed, **Then** the page shows a clear empty state instead of an empty JSON block or broken layout.
3. **Given** a technical payload disclosure is available, **When** the page first loads, **Then** that disclosure is collapsed and visually secondary to the structured summary and grouped browser.
### Edge Cases
- A snapshot may contain no items at all and must show an explicit empty-state explanation.
- A snapshot may contain items but no type-specific enrichment, and those items must still render through the common minimum contract.
- A newly added or unknown policy type must appear in the summary and grouped browser even if no type-specific renderer exists.
- One policy type group may fail to build structured detail while the rest of the snapshot remains renderable.
- A group may include only reference-level metadata, and the UI must label that state clearly rather than implying full fidelity.
- Large snapshots must not load as a wall of expanded cards; grouped sections remain collapsed by default.
- Related source references may be missing for some items, and the UI must show that absence explicitly rather than dropping the field silently.
## Requirements *(mandatory)*
**Constitution alignment (required):** This feature is a read-only presentation-layer redesign over existing workspace-owned baseline snapshot data. It adds no new Microsoft Graph calls, no new capture or compare writes, no new queue or scheduled behavior, and no new mutable snapshot semantics. Snapshot immutability, workspace isolation, and evidence-readability remain mandatory. If technical detail disclosure remains available, it must not broaden access beyond the existing authorized surface.
**Constitution alignment (OPS-UX):** No new or changed `OperationRun` flow is introduced. Existing capture and compare operations remain out of scope. This feature must not add inline remote work or operational side effects to the snapshot detail surface.
**Constitution alignment (RBAC-UX):** This feature affects the Tenant/Admin plane workspace-admin surface under `/admin` only. Snapshot detail remains workspace-scoped and must continue to enforce server-side authorization through the existing workspace membership and capability checks. Non-members or actors outside the active workspace scope must receive 404 deny-as-not-found behavior. In-scope members who lack the required baseline-view entitlement must continue to receive 403 semantics where protected detail execution paths exist. The feature must add at least one positive authorization test for authorized snapshot inspection and one negative authorization test proving non-members or under-entitled users cannot access structured snapshot detail. No raw capability strings or role-string checks may be introduced.
**Constitution alignment (OPS-EX-AUTH-001):** Not applicable. This feature introduces no authentication handshake behavior and must not add outbound work to render-time pages.
**Constitution alignment (BADGE-001):** Fidelity and gap indicators are status-like badges and must use centralized semantics so the same fidelity state or gap condition is rendered consistently across the snapshot summary and grouped browser. Tests must cover the allowed fidelity states and degraded-state badges.
**Constitution alignment (Filament Action Surfaces):** This feature modifies an existing Filament resource detail surface and list-to-detail inspection flow. The Action Surface Contract remains satisfied through the existing immutable-resource exemptions: list inspection remains available, no new destructive actions are introduced, and the detail page remains informational. The grouped browser and technical disclosure must fit inside a sectioned detail experience rather than creating ad hoc action-heavy cards.
**Constitution alignment (UX-001 — Layout & Information Architecture):** The snapshot detail page must remain a sectioned view experience rather than a disabled edit form. The information hierarchy must read as header and metadata, structured summary, grouped item browser, then optional technical detail. The list page keeps its existing search, sort, and filter behavior. Any empty state added to the detail page must use a specific title, explanation, and a single clear next-step message where applicable.
### Functional Requirements
- **FR-130-01 Structured primary experience**: The system must replace raw JSON or raw summary payloads as the primary snapshot detail experience with a structured, human-readable layout.
- **FR-130-02 Technical detail demotion**: If raw technical payloads remain available, they must be clearly labeled as technical detail, visually secondary, and collapsed by default.
- **FR-130-03 Summary-first layout**: The first meaningful data block on the snapshot detail page must be a structured summary grouped by policy type.
- **FR-130-04 Stable summary columns**: Each summary row must show, at minimum, policy type label, item count, fidelity state, gap state, and observed or captured timing when meaningful.
- **FR-130-05 Grouped browser**: The page must provide a grouped item browser below the summary that organizes all snapshot items by policy type.
- **FR-130-06 Predictable default state**: Policy type groups in the browser must be collapsed by default and ordered consistently so large snapshots remain scannable.
- **FR-130-07 Minimum item contract**: Every rendered snapshot item must show the best available human-readable label, policy type, capture or reference status, fidelity indicator, timing metadata when available, source-reference hint when available, and a stable identity hint suitable for operator inspection.
- **FR-130-08 Universal visibility**: Every captured policy type must satisfy the minimum rendering contract even when no type-specific enrichment exists.
- **FR-130-09 Fallback rendering**: Unsupported or newly added policy types must still produce a valid summary row and visible grouped item entries using the best available metadata.
- **FR-130-10 Type-specific enrichment layering**: Richer policy-type detail may be added for selected types, but it must layer on top of the shared inspection contract rather than bypassing it.
- **FR-130-11 RBAC parity improvement**: `intuneRoleDefinition` may remain the richest type, but it must no longer be the only type with meaningful structured inspection.
- **FR-130-12 Compliance first-class visibility**: `deviceCompliancePolicy` items must appear as first-class grouped entries with structured minimum detail instead of being visible only through counts or raw payloads.
- **FR-130-13 Explicit fidelity states**: The page must visibly represent rendering fidelity states for groups and items using the approved conceptual states of full, partial, reference-only, and unsupported.
- **FR-130-14 Explicit gap states**: The page must surface missing interpretation fields, degraded rendering, or evidence gaps explicitly at group and or item level instead of silently omitting them.
- **FR-130-15 Group-level aggregation**: The presentation model must support group-level aggregation for item count, fidelity state, gap presence or count, and timing summary where meaningful.
- **FR-130-16 Item-level attributes**: The presentation model must support item-level label, type, identity hint, fidelity state, gap summary, key structured attributes, and source reference.
- **FR-130-17 Extensible renderer contract**: The snapshot detail experience must use a centralized renderer or presenter contract so new policy types can become visible without bespoke page-template changes.
- **FR-130-18 Renderer failure isolation**: A rendering failure for one policy type group must degrade that group safely without breaking the rest of the snapshot page.
- **FR-130-19 Empty-state clarity**: The page must provide clear states for snapshots with no items, snapshots with only fallback rendering, partially renderable groups, and per-group rendering failures.
- **FR-130-20 Safe related references**: Items may show minimal safe references to related records, such as policy-version labels or identifiers, but the page must not expand into a broader navigation redesign.
- **FR-130-21 Structured sectioning**: The page must use a stable section structure consisting of snapshot metadata, summary, grouped browser, and optional technical detail rather than one-off card mixtures.
- **FR-130-22 Backward compatibility**: Existing snapshots must continue to render even when only fallback structured detail is available.
- **FR-130-23 Regression safety**: Tests must prevent regressions where non-RBAC types disappear, unknown types are silently ignored, or raw JSON becomes the primary experience again.
### Non-Goals
- Baseline compare result redesign
- Findings UI redesign
- Product-wide cross-resource navigation overhaul beyond minimal safe references inside the snapshot page
- GUID context resolution across the wider product
- Audit log redesign or new audit storage
- Export or report generation changes
- Editing snapshots or changing snapshot immutability rules
- Reworking baseline capture pipeline semantics
- Building a universal renderer for every other product surface in this release
### Assumptions
- Existing baseline snapshots and snapshot items already contain enough metadata to support a useful minimum structured rendering contract for most captured types.
- Rich enrichment can ship incrementally as long as the fallback path makes every type visible and inspectable.
- The current baseline snapshot resource remains the canonical workspace-owned inspection surface for this information.
- Technical payload disclosure, if retained, is a debugging aid and not the primary operator workflow.
### Dependencies
- Existing workspace-owned baseline snapshots and baseline snapshot items
- Existing workspace authorization and capability enforcement for baseline resources
- Existing badge or label centralization patterns for status-like semantics
- Existing baseline evidence metadata, including policy-version references and timing fields when present
## UI Action Matrix *(mandatory when Filament is changed)*
| Surface | Location | Header Actions | Inspect Affordance (List/Table) | Row Actions (max 2 visible) | Bulk Actions (grouped) | Empty-State CTA(s) | View Header Actions | Create/Edit Save+Cancel | Audit log? | Notes / Exemptions |
|---|---|---|---|---|---|---|---|---|---|---|
| Baseline Snapshots resource | Workspace admin governance snapshot list and detail | None new | Snapshot inspection moves to clickable rows or a primary linked column so the list no longer relies on a lone View action | None | None | Existing list empty state remains informational with no create CTA because snapshots are immutable capture outputs | None new | N/A | No new writes | The Action Surface Contract remains satisfied through explicit immutable-resource exemptions. This feature changes detail rendering only, upgrades the list inspect affordance to match the constitution, and introduces no destructive actions. |
### Key Entities *(include if feature involves data)*
- **Snapshot Presentation Summary**: The normalized overview of one snapshot, including page-level metadata and one row per captured policy type.
- **Policy Type Group Presentation**: The grouped representation of snapshot items for a single policy type, including aggregate counts, fidelity state, gap state, and expandable item rows.
- **Snapshot Item Presentation**: The normalized per-item inspection model that exposes the minimum readable contract for any captured item.
- **Fidelity State**: The explicit rendering-quality indicator that tells operators whether a type or item is fully rendered, partially rendered, reference-only, or unsupported.
- **Gap Indicator**: The explicit signal that known interpretation, evidence, or rendering detail is missing or degraded.
## Success Criteria *(mandatory)*
### Measurable Outcomes
- **SC-130-01 Immediate comprehension**: In acceptance testing, an authorized user can identify every captured policy type present in a snapshot from the summary section without opening technical payloads.
- **SC-130-02 Universal visibility**: In mixed-type test scenarios, 100% of captured policy types appear in both the summary and the grouped browser, including unsupported or newly added types.
- **SC-130-03 Degraded-state clarity**: In degraded-rendering scenarios, 100% of affected groups display an explicit fidelity or gap signal rather than disappearing or falling back silently to counts alone.
- **SC-130-04 Scalable default state**: In large-snapshot test scenarios, the default page load shows the summary immediately and keeps grouped detail collapsed so the initial screen remains scannable.
- **SC-130-05 Failure isolation**: In test scenarios where one renderer fails, the rest of the snapshot page remains usable and the unaffected policy type groups continue to render.

View File

@ -0,0 +1,197 @@
# Tasks: Structured Snapshot Rendering & Type-Agnostic Item Browser
**Input**: Design documents from `/specs/130-structured-snapshot-rendering/`
**Prerequisites**: plan.md, spec.md, research.md, data-model.md, contracts/baseline-snapshot-rendering.openapi.yaml, quickstart.md
**Tests**: Tests are REQUIRED for this feature because it changes runtime rendering, authorization-visible behavior, and centralized badge semantics.
**Operations**: No new `OperationRun`, queued workflow, or audit-log mutation flow is introduced.
**RBAC**: The feature stays on the workspace-scoped `/admin` plane. Non-member or out-of-scope access must remain 404, and protected in-scope access must remain 403 where applicable.
**Filament UI**: The feature modifies the existing `BaselineSnapshotResource` detail surface and must preserve its immutable-resource action-surface exemptions while moving the page to a summary-first infolist layout.
**Badges**: Fidelity and gap states must use centralized badge semantics through `BadgeCatalog` and `BadgeRenderer`.
## Phase 1: Setup (Shared Infrastructure)
**Purpose**: Create the test and view scaffolding used across the feature.
- [X] T001 Create feature-test scaffolds in tests/Feature/Filament/BaselineSnapshotStructuredRenderingTest.php, tests/Feature/Filament/BaselineSnapshotFallbackRenderingTest.php, tests/Feature/Filament/BaselineSnapshotAuthorizationTest.php, and tests/Feature/Filament/BaselineSnapshotDegradedStateTest.php
- [X] T002 Create unit-test scaffolds in tests/Unit/Baselines/SnapshotRendering/BaselineSnapshotPresenterTest.php, tests/Unit/Baselines/SnapshotRendering/SnapshotTypeRendererRegistryTest.php, tests/Unit/Baselines/SnapshotRendering/FallbackSnapshotTypeRendererTest.php, tests/Unit/Baselines/SnapshotRendering/IntuneRoleDefinitionSnapshotTypeRendererTest.php, tests/Unit/Baselines/SnapshotRendering/DeviceComplianceSnapshotTypeRendererTest.php, and tests/Unit/Support/Badges/BaselineSnapshotRenderingBadgeTest.php
- [X] T003 [P] Create detail-view entry stubs in resources/views/filament/infolists/entries/baseline-snapshot-summary-table.blade.php, resources/views/filament/infolists/entries/baseline-snapshot-groups.blade.php, and resources/views/filament/infolists/entries/baseline-snapshot-technical-detail.blade.php
---
## Phase 2: Foundational (Blocking Prerequisites)
**Purpose**: Build the normalized rendering primitives and registry that every story depends on.
**⚠️ CRITICAL**: No user story work should begin until this phase is complete.
- [X] T004 [P] Create normalized rendering DTOs in app/Services/Baselines/SnapshotRendering/RenderedSnapshot.php, app/Services/Baselines/SnapshotRendering/RenderedSnapshotGroup.php, app/Services/Baselines/SnapshotRendering/RenderedSnapshotItem.php, and app/Services/Baselines/SnapshotRendering/RenderedAttribute.php
- [X] T005 [P] Create rendering support value objects in app/Services/Baselines/SnapshotRendering/FidelityState.php and app/Services/Baselines/SnapshotRendering/GapSummary.php
- [X] T006 Implement the renderer contract and registry in app/Services/Baselines/SnapshotRendering/SnapshotTypeRenderer.php, app/Services/Baselines/SnapshotRendering/SnapshotTypeRendererRegistry.php, and app/Providers/AppServiceProvider.php
- [X] T007 Implement the base page presenter in app/Services/Baselines/SnapshotRendering/BaselineSnapshotPresenter.php
**Checkpoint**: The normalized rendering layer exists and user-story work can proceed.
---
## Phase 3: User Story 1 - Review captured state by policy type (Priority: P1) 🎯 MVP
**Goal**: Make the snapshot page summary-first so operators can understand captured coverage before drilling into detail.
**Independent Test**: Open a mixed-policy snapshot and verify the first meaningful content block is a structured summary with one row per captured policy type and no raw JSON as the primary experience.
### Tests for User Story 1
- [X] T008 [P] [US1] Add summary-first page assertions in tests/Feature/Filament/BaselineSnapshotStructuredRenderingTest.php
- [X] T009 [P] [US1] Add presenter summary aggregation assertions in tests/Unit/Baselines/SnapshotRendering/BaselineSnapshotPresenterTest.php
- [X] T010 [P] [US1] Update list and detail regression coverage in tests/Feature/Filament/BaselineSnapshotFidelityVisibilityTest.php and tests/Feature/Filament/BaselineSnapshotStructuredRenderingTest.php to assert summary-first output and clickable-row or linked-column inspection instead of a lone View action
### Implementation for User Story 1
- [X] T011 [US1] Implement snapshot metadata and summary-row derivation in app/Services/Baselines/SnapshotRendering/BaselineSnapshotPresenter.php and app/Services/Baselines/SnapshotRendering/RenderedSnapshot.php
- [X] T012 [US1] Replace the raw summary-primary detail section in app/Filament/Resources/BaselineSnapshotResource.php and resources/views/filament/infolists/entries/baseline-snapshot-summary-table.blade.php
- [X] T013 [US1] Update the snapshot list inspect affordance in app/Filament/Resources/BaselineSnapshotResource.php so the modified resource uses clickable rows or a primary linked column instead of a lone View row action
- [X] T014 [US1] Update the snapshot detail page composition in app/Filament/Resources/BaselineSnapshotResource/Pages/ViewBaselineSnapshot.php and app/Filament/Resources/BaselineSnapshotResource.php so metadata renders before summary and grouped content
**Checkpoint**: The snapshot detail page is summary-first and independently useful even before richer grouped rendering lands.
---
## Phase 4: User Story 2 - Drill into any captured type consistently (Priority: P1)
**Goal**: Render every captured policy type through a grouped browser with fallback visibility and first-class RBAC and compliance detail.
**Independent Test**: Open a snapshot containing `intuneRoleDefinition`, `deviceCompliancePolicy`, and an unsupported type and verify that all three appear as grouped sections with stable minimum metadata and collapsed-by-default headers.
### Tests for User Story 2
- [X] T015 [P] [US2] Add mixed-type grouped-browser feature coverage in tests/Feature/Filament/BaselineSnapshotStructuredRenderingTest.php and tests/Feature/Filament/BaselineSnapshotFallbackRenderingTest.php
- [X] T016 [P] [US2] Add fallback and registry unit coverage in tests/Unit/Baselines/SnapshotRendering/FallbackSnapshotTypeRendererTest.php and tests/Unit/Baselines/SnapshotRendering/SnapshotTypeRendererRegistryTest.php
- [X] T017 [P] [US2] Add RBAC and compliance renderer contract tests in tests/Unit/Baselines/SnapshotRendering/IntuneRoleDefinitionSnapshotTypeRendererTest.php and tests/Unit/Baselines/SnapshotRendering/DeviceComplianceSnapshotTypeRendererTest.php
### Implementation for User Story 2
- [X] T018 [US2] Implement the shared fallback renderer in app/Services/Baselines/SnapshotRendering/Renderers/FallbackSnapshotTypeRenderer.php
- [X] T019 [US2] Extract RBAC enrichment into app/Services/Baselines/SnapshotRendering/Renderers/IntuneRoleDefinitionSnapshotTypeRenderer.php and remove inline RBAC mapping from app/Filament/Resources/BaselineSnapshotResource.php
- [X] T020 [US2] Add initial compliance rendering in app/Services/Baselines/SnapshotRendering/Renderers/DeviceComplianceSnapshotTypeRenderer.php
- [X] T021 [US2] Wire renderer resolution and grouped item output into app/Services/Baselines/SnapshotRendering/BaselineSnapshotPresenter.php and app/Services/Baselines/SnapshotRendering/SnapshotTypeRendererRegistry.php
- [X] T022 [US2] Build the collapsed grouped item browser in resources/views/filament/infolists/entries/baseline-snapshot-groups.blade.php and app/Filament/Resources/BaselineSnapshotResource.php
**Checkpoint**: Every captured policy type is visible and drill-down capable, with RBAC no longer acting as the only first-class inspected type.
---
## Phase 5: User Story 3 - Understand fidelity, gaps, and degraded states safely (Priority: P2)
**Goal**: Make incomplete rendering explicit, isolate per-group failures, and keep technical payloads secondary and authorized.
**Independent Test**: Render snapshots with partial, reference-only, unsupported, empty, and per-group failure conditions and verify the page remains usable, visibly degraded where appropriate, and secondary technical detail stays collapsed.
### Tests for User Story 3
- [X] T023 [P] [US3] Add degraded-state and technical-detail feature coverage in tests/Feature/Filament/BaselineSnapshotDegradedStateTest.php
- [X] T024 [P] [US3] Add workspace authorization and route-contract coverage in tests/Feature/Filament/BaselineSnapshotAuthorizationTest.php for list and detail 403 or 404 semantics plus optional technical-detail disclosure availability
- [X] T025 [P] [US3] Add fidelity and gap badge mapping coverage in tests/Unit/Support/Badges/BaselineSnapshotRenderingBadgeTest.php
### Implementation for User Story 3
- [X] T026 [US3] Add centralized fidelity and gap badge semantics in app/Support/Badges/BadgeDomain.php, app/Support/Badges/BadgeCatalog.php, app/Support/Badges/Domains/BaselineSnapshotFidelityBadge.php, and app/Support/Badges/Domains/BaselineSnapshotGapStatusBadge.php
- [X] T027 [US3] Implement explicit fidelity and gap derivation in app/Services/Baselines/SnapshotRendering/FidelityState.php, app/Services/Baselines/SnapshotRendering/GapSummary.php, and app/Services/Baselines/SnapshotRendering/BaselineSnapshotPresenter.php
- [X] T028 [US3] Add per-group failure isolation and degraded-state rendering in app/Services/Baselines/SnapshotRendering/BaselineSnapshotPresenter.php and resources/views/filament/infolists/entries/baseline-snapshot-groups.blade.php
- [X] T029 [US3] Add the secondary technical payload disclosure in resources/views/filament/infolists/entries/baseline-snapshot-technical-detail.blade.php and app/Filament/Resources/BaselineSnapshotResource.php
- [X] T030 [US3] Preserve workspace-scoped list and detail authorization, plus optional secondary technical-detail gating, in app/Filament/Resources/BaselineSnapshotResource.php and app/Filament/Resources/BaselineSnapshotResource/Pages/ViewBaselineSnapshot.php
**Checkpoint**: The page communicates degraded evidence quality clearly, keeps failures isolated, and preserves scope-safe access semantics.
---
## Phase 6: Polish & Cross-Cutting Concerns
**Purpose**: Final verification and cleanup across all stories.
- [X] T031 [P] Run the focused Feature 130 validation commands from specs/130-structured-snapshot-rendering/quickstart.md
- [X] T032 [P] Run formatting on changed files with vendor/bin/sail bin pint --dirty --format agent
---
## Dependencies & Execution Order
### Phase Dependencies
- **Setup (Phase 1)**: No dependencies; start immediately.
- **Foundational (Phase 2)**: Depends on Setup completion; blocks all user stories.
- **User Story 1 (Phase 3)**: Depends on Foundational completion.
- **User Story 2 (Phase 4)**: Depends on Foundational completion and is safest after User Story 1 because both modify the same snapshot detail surface.
- **User Story 3 (Phase 5)**: Depends on Foundational completion and is safest after User Story 2 because degraded-state handling wraps the grouped-browser output.
- **Polish (Phase 6)**: Depends on all selected user stories being complete.
### User Story Dependencies
- **User Story 1 (P1)**: No dependency on other stories after Foundational; this is the MVP slice.
- **User Story 2 (P1)**: Functionally independent after Foundational, but recommended after US1 to avoid page-composition conflicts on the same resource.
- **User Story 3 (P2)**: Depends on the shared rendering layer and grouped browser; recommended after US2.
### Within Each User Story
- Write or update tests first and confirm they fail before implementing behavior.
- Implement presenter and renderer logic before wiring Blade or Filament view composition.
- Land Blade view changes before final authorization and regression cleanup on the shared resource.
### Parallel Opportunities
- `T003`, `T004`, and `T005` can run in parallel after setup starts.
- `T008`, `T009`, and `T010` can run in parallel inside US1.
- `T015`, `T016`, and `T017` can run in parallel inside US2.
- `T023`, `T024`, and `T025` can run in parallel inside US3.
- `T031` and `T032` can run in parallel in the polish phase.
---
## Parallel Example: User Story 1
```bash
# Launch the summary-first test updates together:
Task: "Add summary-first page assertions in tests/Feature/Filament/BaselineSnapshotStructuredRenderingTest.php"
Task: "Add presenter summary aggregation assertions in tests/Unit/Baselines/SnapshotRendering/BaselineSnapshotPresenterTest.php"
Task: "Update list and detail regression coverage in tests/Feature/Filament/BaselineSnapshotFidelityVisibilityTest.php and tests/Feature/Filament/BaselineSnapshotStructuredRenderingTest.php to assert summary-first output and clickable-row or linked-column inspection instead of a lone View action"
```
## Parallel Example: User Story 2
```bash
# Launch the renderer-contract tests together:
Task: "Add fallback and registry unit coverage in tests/Unit/Baselines/SnapshotRendering/FallbackSnapshotTypeRendererTest.php and tests/Unit/Baselines/SnapshotRendering/SnapshotTypeRendererRegistryTest.php"
Task: "Add RBAC and compliance renderer contract tests in tests/Unit/Baselines/SnapshotRendering/IntuneRoleDefinitionSnapshotTypeRendererTest.php and tests/Unit/Baselines/SnapshotRendering/DeviceComplianceSnapshotTypeRendererTest.php"
```
## Parallel Example: User Story 3
```bash
# Launch the degraded-state and authorization test work together:
Task: "Add degraded-state and technical-detail feature coverage in tests/Feature/Filament/BaselineSnapshotDegradedStateTest.php"
Task: "Add workspace authorization and route-contract coverage in tests/Feature/Filament/BaselineSnapshotAuthorizationTest.php for list and detail 403 or 404 semantics plus optional technical-detail disclosure availability"
Task: "Add fidelity and gap badge mapping coverage in tests/Unit/Support/Badges/BaselineSnapshotRenderingBadgeTest.php"
```
---
## 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 that the snapshot detail page is summary-first and no longer raw-JSON-primary.
### Incremental Delivery
1. Deliver US1 to establish the normalized summary-first page shell.
2. Deliver US2 to make every policy type visible and drill-down capable through fallback plus initial enrichments.
3. Deliver US3 to make fidelity, gaps, degraded states, and technical disclosure production-safe.
### Team Strategy
1. One engineer can own the presenter and registry foundation while another prepares the feature and unit tests.
2. After Foundational completes, one engineer can implement RBAC and compliance renderers while another builds the grouped Blade view.
3. Finish with a focused pass on badge centralization, authorization regressions, and Sail-based validation.

View File

@ -0,0 +1,85 @@
<?php
declare(strict_types=1);
use App\Filament\Resources\BaselineSnapshotResource;
use App\Models\BaselineProfile;
use App\Models\BaselineSnapshot;
use App\Models\User;
use App\Models\Workspace;
use App\Models\WorkspaceMembership;
use App\Services\Auth\WorkspaceCapabilityResolver;
use App\Support\Workspaces\WorkspaceContext;
it('returns 404 for non-members hitting the baseline snapshot list', function (): void {
$user = User::factory()->create();
$workspace = Workspace::factory()->create();
session()->put(WorkspaceContext::SESSION_KEY, (int) $workspace->getKey());
$this->actingAs($user)
->get(BaselineSnapshotResource::getUrl(panel: 'admin'))
->assertNotFound();
});
it('returns 403 for workspace members without the baseline view capability', function (): void {
$workspace = Workspace::factory()->create();
$user = User::factory()->create();
WorkspaceMembership::factory()->create([
'workspace_id' => (int) $workspace->getKey(),
'user_id' => (int) $user->getKey(),
'role' => 'readonly',
]);
$profile = BaselineProfile::factory()->active()->create([
'workspace_id' => (int) $workspace->getKey(),
]);
$snapshot = BaselineSnapshot::factory()->create([
'workspace_id' => (int) $workspace->getKey(),
'baseline_profile_id' => (int) $profile->getKey(),
]);
$resolver = \Mockery::mock(WorkspaceCapabilityResolver::class);
$resolver->shouldReceive('isMember')->andReturnTrue();
$resolver->shouldReceive('can')->andReturnFalse();
app()->instance(WorkspaceCapabilityResolver::class, $resolver);
session()->put(WorkspaceContext::SESSION_KEY, (int) $workspace->getKey());
$this->actingAs($user)
->get(BaselineSnapshotResource::getUrl(panel: 'admin'))
->assertForbidden();
$this->actingAs($user)
->get(BaselineSnapshotResource::getUrl('view', ['record' => $snapshot], panel: 'admin'))
->assertForbidden();
});
it('returns 404 when a member requests a snapshot from another workspace', function (): void {
[$user, $tenant] = createUserWithTenant(role: 'readonly');
$currentWorkspaceId = (int) $tenant->workspace_id;
$otherWorkspace = Workspace::factory()->create();
WorkspaceMembership::factory()->create([
'workspace_id' => (int) $otherWorkspace->getKey(),
'user_id' => (int) $user->getKey(),
'role' => 'readonly',
]);
$profile = BaselineProfile::factory()->active()->create([
'workspace_id' => (int) $otherWorkspace->getKey(),
]);
$snapshot = BaselineSnapshot::factory()->create([
'workspace_id' => (int) $otherWorkspace->getKey(),
'baseline_profile_id' => (int) $profile->getKey(),
]);
session()->put(WorkspaceContext::SESSION_KEY, $currentWorkspaceId);
$this->actingAs($user)
->get(BaselineSnapshotResource::getUrl('view', ['record' => $snapshot], panel: 'admin'))
->assertNotFound();
});

View File

@ -0,0 +1,114 @@
<?php
declare(strict_types=1);
use App\Filament\Resources\BaselineSnapshotResource;
use App\Models\BaselineProfile;
use App\Models\BaselineSnapshot;
use App\Models\BaselineSnapshotItem;
use App\Services\Baselines\SnapshotRendering\RenderedSnapshotItem;
use App\Services\Baselines\SnapshotRendering\Renderers\FallbackSnapshotTypeRenderer;
use App\Services\Baselines\SnapshotRendering\SnapshotTypeRendererRegistry;
it('shows reference-only fidelity and explicit gap messaging for metadata-backed items', function (): void {
[$user, $tenant] = createUserWithTenant(role: 'readonly');
$profile = BaselineProfile::factory()->active()->create([
'workspace_id' => (int) $tenant->workspace_id,
]);
$snapshot = BaselineSnapshot::factory()->create([
'workspace_id' => (int) $tenant->workspace_id,
'baseline_profile_id' => (int) $profile->getKey(),
'summary_jsonb' => [
'total_items' => 1,
'policy_type_counts' => ['deviceCompliancePolicy' => 1],
'fidelity_counts' => ['content' => 0, 'meta' => 1],
'gaps' => ['count' => 1, 'by_reason' => ['meta_fallback' => 1]],
],
]);
BaselineSnapshotItem::factory()->create([
'baseline_snapshot_id' => (int) $snapshot->getKey(),
'policy_type' => 'deviceCompliancePolicy',
'subject_key' => 'bitlocker require',
'subject_external_id' => hash('sha256', 'deviceCompliancePolicy|bitlocker require'),
'meta_jsonb' => [
'display_name' => 'Bitlocker Require',
'platform' => 'windows',
'warnings' => ['Only inventory metadata was available.'],
'evidence' => [
'fidelity' => 'meta',
'source' => 'inventory',
'observed_at' => '2026-03-09T12:00:00+00:00',
],
],
]);
$this->actingAs($user)
->get(BaselineSnapshotResource::getUrl('view', ['record' => $snapshot], panel: 'admin'))
->assertOk()
->assertSee('Inventory metadata')
->assertSee('Metadata-only evidence was captured for this item.')
->assertSee('Only inventory metadata was available.');
});
it('isolates renderer failures per group and falls back without breaking the page', function (): void {
[$user, $tenant] = createUserWithTenant(role: 'readonly');
$profile = BaselineProfile::factory()->active()->create([
'workspace_id' => (int) $tenant->workspace_id,
]);
$snapshot = BaselineSnapshot::factory()->create([
'workspace_id' => (int) $tenant->workspace_id,
'baseline_profile_id' => (int) $profile->getKey(),
'summary_jsonb' => [
'total_items' => 1,
'policy_type_counts' => ['deviceCompliancePolicy' => 1],
'fidelity_counts' => ['content' => 1, 'meta' => 0],
'gaps' => ['count' => 0, 'by_reason' => []],
],
]);
BaselineSnapshotItem::factory()->create([
'baseline_snapshot_id' => (int) $snapshot->getKey(),
'policy_type' => 'deviceCompliancePolicy',
'subject_key' => 'bitlocker require',
'subject_external_id' => hash('sha256', 'deviceCompliancePolicy|bitlocker require'),
'meta_jsonb' => [
'display_name' => 'Bitlocker Require',
'platform' => 'windows',
'evidence' => [
'fidelity' => 'content',
'source' => 'policy_version',
'observed_at' => '2026-03-09T12:00:00+00:00',
],
],
]);
$throwingRenderer = new class extends FallbackSnapshotTypeRenderer
{
public function supports(string $policyType): bool
{
return $policyType === 'deviceCompliancePolicy';
}
public function render(BaselineSnapshotItem $item): RenderedSnapshotItem
{
throw new RuntimeException('Renderer exploded.');
}
};
app()->instance(SnapshotTypeRendererRegistry::class, new SnapshotTypeRendererRegistry(
renderers: [$throwingRenderer],
fallbackRenderer: new FallbackSnapshotTypeRenderer,
));
$this->actingAs($user)
->get(BaselineSnapshotResource::getUrl('view', ['record' => $snapshot], panel: 'admin'))
->assertOk()
->assertSee('Structured rendering failed for this policy type. Fallback metadata is shown instead.')
->assertSee('Bitlocker Require')
->assertSee('A fallback renderer is being used for this item.');
});

View File

@ -0,0 +1,52 @@
<?php
declare(strict_types=1);
use App\Filament\Resources\BaselineSnapshotResource;
use App\Models\BaselineProfile;
use App\Models\BaselineSnapshot;
use App\Models\BaselineSnapshotItem;
it('renders unsupported policy types through the fallback contract instead of hiding them', function (): void {
[$user, $tenant] = createUserWithTenant(role: 'readonly');
$profile = BaselineProfile::factory()->active()->create([
'workspace_id' => (int) $tenant->workspace_id,
]);
$snapshot = BaselineSnapshot::factory()->create([
'workspace_id' => (int) $tenant->workspace_id,
'baseline_profile_id' => (int) $profile->getKey(),
'summary_jsonb' => [
'total_items' => 1,
'policy_type_counts' => ['mysteryPolicyType' => 1],
'fidelity_counts' => ['content' => 1, 'meta' => 0],
'gaps' => ['count' => 0, 'by_reason' => []],
],
]);
BaselineSnapshotItem::factory()->create([
'baseline_snapshot_id' => (int) $snapshot->getKey(),
'policy_type' => 'mysteryPolicyType',
'subject_key' => 'mystery policy',
'subject_external_id' => hash('sha256', 'mysteryPolicyType|mystery policy'),
'meta_jsonb' => [
'display_name' => 'Mystery Policy',
'category' => 'Other',
'platform' => 'windows',
'evidence' => [
'fidelity' => 'content',
'source' => 'policy_version',
'observed_at' => '2026-03-09T12:00:00+00:00',
],
],
]);
$this->actingAs($user)
->get(BaselineSnapshotResource::getUrl('view', ['record' => $snapshot], panel: 'admin'))
->assertOk()
->assertSee('Mystery Policy')
->assertSee('Mystery Policy Type')
->assertSee('A fallback renderer is being used for this item.')
->assertSee('Technical detail');
});

View File

@ -49,6 +49,7 @@
$this->actingAs($user) $this->actingAs($user)
->get(BaselineSnapshotResource::getUrl('view', ['record' => $withGaps], panel: 'admin')) ->get(BaselineSnapshotResource::getUrl('view', ['record' => $withGaps], panel: 'admin'))
->assertOk() ->assertOk()
->assertSee('Coverage summary')
->assertSee('Captured with gaps') ->assertSee('Captured with gaps')
->assertSee('Content 3, Meta 2') ->assertSee('Content 3, Meta 2')
->assertSee('Evidence gaps') ->assertSee('Evidence gaps')
@ -57,6 +58,7 @@
$this->actingAs($user) $this->actingAs($user)
->get(BaselineSnapshotResource::getUrl('view', ['record' => $complete], panel: 'admin')) ->get(BaselineSnapshotResource::getUrl('view', ['record' => $complete], panel: 'admin'))
->assertOk() ->assertOk()
->assertSee('Coverage summary')
->assertSee('Complete') ->assertSee('Complete')
->assertSee('Content 5, Meta 0') ->assertSee('Content 5, Meta 0')
->assertSee('Evidence gaps') ->assertSee('Evidence gaps')

View File

@ -47,7 +47,7 @@
$this->actingAs($user) $this->actingAs($user)
->get(BaselineSnapshotResource::getUrl('view', ['record' => $snapshot], panel: 'admin')) ->get(BaselineSnapshotResource::getUrl('view', ['record' => $snapshot], panel: 'admin'))
->assertOk() ->assertOk()
->assertSee('Intune RBAC Role Definition References') ->assertSee('Intune RBAC Role Definition')
->assertSee('Security Reader') ->assertSee('Security Reader')
->assertSee('Custom') ->assertSee('Custom')
->assertSee('Role definition ID') ->assertSee('Role definition ID')

View File

@ -0,0 +1,106 @@
<?php
declare(strict_types=1);
use App\Filament\Resources\BaselineSnapshotResource;
use App\Models\BaselineProfile;
use App\Models\BaselineSnapshot;
use App\Models\BaselineSnapshotItem;
it('renders the baseline snapshot detail page as summary-first with grouped policy browsing', function (): void {
[$user, $tenant] = createUserWithTenant(role: 'readonly');
$profile = BaselineProfile::factory()->active()->create([
'workspace_id' => (int) $tenant->workspace_id,
'name' => 'Security Baseline',
]);
$snapshot = BaselineSnapshot::factory()->create([
'workspace_id' => (int) $tenant->workspace_id,
'baseline_profile_id' => (int) $profile->getKey(),
'summary_jsonb' => [
'total_items' => 3,
'policy_type_counts' => [
'intuneRoleDefinition' => 1,
'deviceCompliancePolicy' => 1,
'mysteryPolicyType' => 1,
],
'fidelity_counts' => ['content' => 2, 'meta' => 1],
'gaps' => ['count' => 1, 'by_reason' => ['meta_fallback' => 1]],
],
]);
BaselineSnapshotItem::factory()->create([
'baseline_snapshot_id' => (int) $snapshot->getKey(),
'policy_type' => 'intuneRoleDefinition',
'subject_key' => hash('sha256', 'intuneRoleDefinition|security-reader'),
'subject_external_id' => hash('sha256', 'intuneRoleDefinition|security-reader'),
'meta_jsonb' => [
'display_name' => 'Security Reader',
'evidence' => [
'fidelity' => 'content',
'source' => 'policy_version',
'observed_at' => '2026-03-09T12:00:00+00:00',
],
'identity' => ['strategy' => 'external_id'],
'rbac' => [
'is_built_in' => false,
'role_permission_count' => 2,
],
'version_reference' => ['policy_version_id' => 42],
],
]);
BaselineSnapshotItem::factory()->create([
'baseline_snapshot_id' => (int) $snapshot->getKey(),
'policy_type' => 'deviceCompliancePolicy',
'subject_key' => 'bitlocker require',
'subject_external_id' => hash('sha256', 'deviceCompliancePolicy|bitlocker require'),
'meta_jsonb' => [
'display_name' => 'Bitlocker Require',
'platform' => 'windows',
'assignment_target_count' => 3,
'evidence' => [
'fidelity' => 'meta',
'source' => 'inventory',
'observed_at' => '2026-03-09T11:00:00+00:00',
],
],
]);
BaselineSnapshotItem::factory()->create([
'baseline_snapshot_id' => (int) $snapshot->getKey(),
'policy_type' => 'mysteryPolicyType',
'subject_key' => 'mystery policy',
'subject_external_id' => hash('sha256', 'mysteryPolicyType|mystery policy'),
'meta_jsonb' => [
'display_name' => 'Mystery Policy',
'platform' => 'windows',
'evidence' => [
'fidelity' => 'content',
'source' => 'policy_version',
'observed_at' => '2026-03-09T10:00:00+00:00',
],
],
]);
$this->actingAs($user)
->get(BaselineSnapshotResource::getUrl('view', ['record' => $snapshot], panel: 'admin'))
->assertOk()
->assertSeeInOrder(['Coverage summary', 'Captured policy types', 'Technical detail'])
->assertDontSee('No captured policy types are available in this snapshot.')
->assertDontSee('No snapshot items were captured for this baseline snapshot.')
->assertSee('Security Reader')
->assertSee('Bitlocker Require')
->assertSee('Mystery Policy')
->assertSee('Intune RBAC Role Definition')
->assertSee('Device Compliance')
->assertSee('Mystery Policy Type')
->assertDontSee('Intune RBAC Role Definition References');
$this->actingAs($user)
->get(BaselineSnapshotResource::getUrl(panel: 'admin'))
->assertOk()
->assertSee(BaselineSnapshotResource::getUrl('view', ['record' => $snapshot], panel: 'admin'))
->assertDontSee('>View<', escape: false);
});

View File

@ -0,0 +1,114 @@
<?php
declare(strict_types=1);
use App\Models\BaselineProfile;
use App\Models\BaselineSnapshot;
use App\Models\BaselineSnapshotItem;
use App\Services\Baselines\SnapshotRendering\BaselineSnapshotPresenter;
use App\Services\Baselines\SnapshotRendering\Renderers\DeviceComplianceSnapshotTypeRenderer;
use App\Services\Baselines\SnapshotRendering\Renderers\FallbackSnapshotTypeRenderer;
use App\Services\Baselines\SnapshotRendering\Renderers\IntuneRoleDefinitionSnapshotTypeRenderer;
use App\Services\Baselines\SnapshotRendering\SnapshotTypeRendererRegistry;
use Illuminate\Database\Eloquent\Collection as EloquentCollection;
it('builds summary rows and grouped output for mixed snapshot types', function (): void {
$presenter = new BaselineSnapshotPresenter(
new SnapshotTypeRendererRegistry(
renderers: [
new IntuneRoleDefinitionSnapshotTypeRenderer,
new DeviceComplianceSnapshotTypeRenderer,
],
fallbackRenderer: new FallbackSnapshotTypeRenderer,
),
);
$snapshot = new BaselineSnapshot([
'id' => 130,
'snapshot_identity_hash' => 'snapshot-hash-130',
'captured_at' => now(),
'summary_jsonb' => [
'total_items' => 3,
'policy_type_counts' => [
'intuneRoleDefinition' => 1,
'deviceCompliancePolicy' => 1,
'mysteryPolicyType' => 1,
],
'fidelity_counts' => [
'content' => 2,
'meta' => 1,
],
'gaps' => [
'count' => 1,
'by_reason' => ['meta_fallback' => 1],
],
],
]);
$snapshot->setRelation('baselineProfile', new BaselineProfile(['name' => 'Security Baseline']));
$snapshot->setRelation('items', new EloquentCollection([
new BaselineSnapshotItem([
'id' => 1,
'policy_type' => 'intuneRoleDefinition',
'subject_key' => hash('sha256', 'intuneRoleDefinition|security-reader'),
'subject_external_id' => hash('sha256', 'intuneRoleDefinition|security-reader'),
'meta_jsonb' => [
'display_name' => 'Security Reader',
'evidence' => [
'fidelity' => 'content',
'source' => 'policy_version',
'observed_at' => '2026-03-09T12:00:00+00:00',
],
'identity' => ['strategy' => 'external_id'],
'rbac' => [
'is_built_in' => false,
'role_permission_count' => 2,
],
'version_reference' => ['policy_version_id' => 42],
],
]),
new BaselineSnapshotItem([
'id' => 2,
'policy_type' => 'deviceCompliancePolicy',
'subject_key' => 'bitlocker require',
'subject_external_id' => hash('sha256', 'deviceCompliancePolicy|bitlocker require'),
'meta_jsonb' => [
'display_name' => 'Bitlocker Require',
'platform' => 'windows',
'assignment_target_count' => 3,
'evidence' => [
'fidelity' => 'meta',
'source' => 'inventory',
'observed_at' => '2026-03-09T11:00:00+00:00',
],
],
]),
new BaselineSnapshotItem([
'id' => 3,
'policy_type' => 'mysteryPolicyType',
'subject_key' => 'mystery policy',
'subject_external_id' => hash('sha256', 'mysteryPolicyType|mystery policy'),
'meta_jsonb' => [
'display_name' => 'Mystery Policy',
'category' => 'Other',
'platform' => 'windows',
'evidence' => [
'fidelity' => 'content',
'source' => 'policy_version',
'observed_at' => '2026-03-09T10:00:00+00:00',
],
],
]),
]));
$rendered = $presenter->present($snapshot)->toArray();
expect(data_get($rendered, 'snapshot.snapshotId'))->toBe(130)
->and(data_get($rendered, 'snapshot.baselineProfileName'))->toBe('Security Baseline')
->and(data_get($rendered, 'snapshot.overallFidelity'))->toBe('partial')
->and(data_get($rendered, 'snapshot.overallGapCount'))->toBe(1)
->and($rendered['summaryRows'])->toHaveCount(3)
->and(collect($rendered['groups'])->pluck('label')->all())
->toContain('Intune RBAC Role Definition', 'Device Compliance', 'Mystery Policy Type')
->and(data_get($rendered, 'technicalDetail.defaultCollapsed'))->toBeTrue();
});

View File

@ -0,0 +1,36 @@
<?php
declare(strict_types=1);
use App\Models\BaselineSnapshotItem;
use App\Services\Baselines\SnapshotRendering\Renderers\DeviceComplianceSnapshotTypeRenderer;
it('renders structured compliance details when metadata is available', function (): void {
$renderer = new DeviceComplianceSnapshotTypeRenderer;
$item = new BaselineSnapshotItem([
'policy_type' => 'deviceCompliancePolicy',
'subject_key' => 'bitlocker require',
'subject_external_id' => hash('sha256', 'deviceCompliancePolicy|bitlocker require'),
'meta_jsonb' => [
'display_name' => 'Bitlocker Require',
'platform' => 'windows',
'odata_type' => '#microsoft.graph.windows10CompliancePolicy',
'assignment_target_count' => 3,
'evidence' => [
'fidelity' => 'meta',
'source' => 'inventory',
'observed_at' => '2026-03-09T12:00:00+00:00',
],
],
]);
$rendered = $renderer->render($item)->toArray();
$attributes = collect($rendered['structuredAttributes'])->keyBy('label');
expect($rendered['label'])->toBe('Bitlocker Require')
->and($rendered['fidelity'])->toBe('reference_only')
->and($attributes['Platform']['value'])->toBe('Windows')
->and($attributes['Assignment targets']['value'])->toBe('3')
->and($attributes['OData type']['value'])->toBe('#microsoft.graph.windows10CompliancePolicy');
});

View File

@ -0,0 +1,36 @@
<?php
declare(strict_types=1);
use App\Models\BaselineSnapshotItem;
use App\Services\Baselines\SnapshotRendering\Renderers\FallbackSnapshotTypeRenderer;
it('renders a minimum contract for unsupported snapshot types', function (): void {
$renderer = new FallbackSnapshotTypeRenderer;
$item = new BaselineSnapshotItem([
'policy_type' => 'mysteryPolicyType',
'subject_key' => 'mystery policy',
'subject_external_id' => hash('sha256', 'mysteryPolicyType|mystery policy'),
'meta_jsonb' => [
'display_name' => 'Mystery Policy',
'category' => 'Other',
'platform' => 'windows',
'evidence' => [
'fidelity' => 'content',
'source' => 'policy_version',
'observed_at' => '2026-03-09T12:00:00+00:00',
],
],
]);
$rendered = $renderer->render($item)->toArray();
expect($rendered['label'])->toBe('Mystery Policy')
->and($rendered['typeLabel'])->toBe('Mystery Policy Type')
->and($rendered['referenceStatus'])->toBe('Policy version')
->and($rendered['fidelity'])->toBe('unsupported')
->and(data_get($rendered, 'gapSummary.has_gaps'))->toBeTrue()
->and(collect($rendered['structuredAttributes'])->pluck('label')->all())
->toContain('Category', 'Platform', 'Evidence source');
});

View File

@ -0,0 +1,45 @@
<?php
declare(strict_types=1);
use App\Models\BaselineSnapshotItem;
use App\Services\Baselines\SnapshotRendering\Renderers\IntuneRoleDefinitionSnapshotTypeRenderer;
it('renders RBAC-specific enrichment on top of the shared item contract', function (): void {
$renderer = new IntuneRoleDefinitionSnapshotTypeRenderer;
$item = new BaselineSnapshotItem([
'policy_type' => 'intuneRoleDefinition',
'subject_key' => hash('sha256', 'intuneRoleDefinition|security-reader'),
'subject_external_id' => hash('sha256', 'intuneRoleDefinition|security-reader'),
'meta_jsonb' => [
'display_name' => 'Security Reader',
'platform' => 'all',
'evidence' => [
'fidelity' => 'content',
'source' => 'policy_version',
'observed_at' => '2026-03-09T12:00:00+00:00',
],
'identity' => [
'strategy' => 'external_id',
],
'version_reference' => [
'policy_version_id' => 42,
],
'rbac' => [
'is_built_in' => false,
'role_permission_count' => 2,
],
],
]);
$rendered = $renderer->render($item)->toArray();
$attributes = collect($rendered['structuredAttributes'])->keyBy('label');
expect($rendered['label'])->toBe('Security Reader')
->and($rendered['fidelity'])->toBe('full')
->and($attributes['Role source']['value'])->toBe('Custom')
->and($attributes['Permission blocks']['value'])->toBe('2')
->and($attributes['Identity']['value'])->toBe('Role definition ID')
->and($attributes['Policy version']['value'])->toBe('#42');
});

View File

@ -0,0 +1,37 @@
<?php
declare(strict_types=1);
use App\Services\Baselines\SnapshotRendering\Renderers\DeviceComplianceSnapshotTypeRenderer;
use App\Services\Baselines\SnapshotRendering\Renderers\FallbackSnapshotTypeRenderer;
use App\Services\Baselines\SnapshotRendering\Renderers\IntuneRoleDefinitionSnapshotTypeRenderer;
use App\Services\Baselines\SnapshotRendering\SnapshotTypeRendererRegistry;
it('resolves dedicated renderers before the fallback renderer', function (): void {
$fallback = new FallbackSnapshotTypeRenderer;
$registry = new SnapshotTypeRendererRegistry(
renderers: [
new IntuneRoleDefinitionSnapshotTypeRenderer,
new DeviceComplianceSnapshotTypeRenderer,
],
fallbackRenderer: $fallback,
);
expect($registry->rendererFor('intuneRoleDefinition'))->toBeInstanceOf(IntuneRoleDefinitionSnapshotTypeRenderer::class)
->and($registry->rendererFor('deviceCompliancePolicy'))->toBeInstanceOf(DeviceComplianceSnapshotTypeRenderer::class)
->and($registry->usesFallback('intuneRoleDefinition'))->toBeFalse()
->and($registry->usesFallback('deviceCompliancePolicy'))->toBeFalse();
});
it('falls back for unknown policy types', function (): void {
$fallback = new FallbackSnapshotTypeRenderer;
$registry = new SnapshotTypeRendererRegistry(
renderers: [
new IntuneRoleDefinitionSnapshotTypeRenderer,
],
fallbackRenderer: $fallback,
);
expect($registry->rendererFor('mysteryPolicyType'))->toBe($fallback)
->and($registry->usesFallback('mysteryPolicyType'))->toBeTrue();
});

View File

@ -0,0 +1,29 @@
<?php
declare(strict_types=1);
use App\Support\Badges\BadgeCatalog;
use App\Support\Badges\BadgeDomain;
it('maps baseline snapshot fidelity states through the shared badge catalog', function (): void {
$full = BadgeCatalog::spec(BadgeDomain::BaselineSnapshotFidelity, 'full');
$referenceOnly = BadgeCatalog::spec(BadgeDomain::BaselineSnapshotFidelity, 'reference_only');
$unsupported = BadgeCatalog::spec(BadgeDomain::BaselineSnapshotFidelity, 'unsupported');
expect($full->label)->toBe('Full')
->and($full->color)->toBe('success')
->and($referenceOnly->label)->toBe('Reference only')
->and($referenceOnly->color)->toBe('info')
->and($unsupported->label)->toBe('Unsupported')
->and($unsupported->color)->toBe('gray');
});
it('maps baseline snapshot gap states through the shared badge catalog', function (): void {
$clear = BadgeCatalog::spec(BadgeDomain::BaselineSnapshotGapStatus, 'clear');
$present = BadgeCatalog::spec(BadgeDomain::BaselineSnapshotGapStatus, 'gaps_present');
expect($clear->label)->toBe('No gaps')
->and($clear->color)->toBe('success')
->and($present->label)->toBe('Gaps present')
->and($present->color)->toBe('warning');
});