277-stored-reports-surface → platform-dev #333

Merged
ahmido merged 1 commits from 277-stored-reports-surface into platform-dev 2026-05-06 00:04:54 +00:00
25 changed files with 3042 additions and 27 deletions

View File

@ -0,0 +1,668 @@
<?php
declare(strict_types=1);
namespace App\Filament\Resources;
use App\Filament\Concerns\InteractsWithTenantOwnedRecords;
use App\Filament\Concerns\ResolvesPanelTenantContext;
use App\Filament\Pages\TenantDashboard;
use App\Filament\Resources\StoredReportResource\Pages;
use App\Models\StoredReport;
use App\Models\Tenant;
use App\Models\User;
use App\Support\Auth\Capabilities;
use App\Support\Badges\BadgeDomain;
use App\Support\Badges\BadgeRenderer;
use App\Support\Filament\TablePaginationProfiles;
use App\Support\Ui\ActionSurface\ActionSurfaceDeclaration;
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceInspectAffordance;
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceProfile;
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceSlot;
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceType;
use App\Support\Ui\GovernanceArtifactTruth\ArtifactTruthPresenter;
use BackedEnum;
use Carbon\CarbonInterface;
use Filament\Actions;
use Filament\Infolists\Components\TextEntry;
use Filament\Infolists\Components\ViewEntry;
use Filament\Resources\Resource;
use Filament\Schemas\Components\Section;
use Filament\Schemas\Schema;
use Filament\Tables;
use Filament\Tables\Table;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Arr;
use Illuminate\Support\Carbon;
use Illuminate\Support\Str;
use Throwable;
use UnitEnum;
class StoredReportResource extends Resource
{
use InteractsWithTenantOwnedRecords;
use ResolvesPanelTenantContext;
/**
* @var array<string, string>
*/
private const REPORT_TYPE_CAPABILITIES = [
StoredReport::REPORT_TYPE_PERMISSION_POSTURE => Capabilities::PERMISSION_POSTURE_VIEW,
StoredReport::REPORT_TYPE_ENTRA_ADMIN_ROLES => Capabilities::ENTRA_ROLES_VIEW,
];
protected static ?string $model = StoredReport::class;
protected static ?string $slug = 'stored-reports';
protected static ?string $tenantOwnershipRelationshipName = 'tenant';
protected static bool $isGloballySearchable = false;
protected static string|BackedEnum|null $navigationIcon = 'heroicon-o-document-chart-bar';
protected static string|UnitEnum|null $navigationGroup = 'Reporting';
protected static ?string $navigationLabel = 'Stored reports';
protected static ?int $navigationSort = 49;
public static function canViewAny(): bool
{
return static::visibleReportTypesForCurrentUser() !== [];
}
public static function canView(Model $record): bool
{
$tenant = static::resolveTenantContextForCurrentPanel();
$user = auth()->user();
if (! $tenant instanceof Tenant || ! $user instanceof User) {
return false;
}
if (! $user->canAccessTenant($tenant)) {
return false;
}
if (! $record instanceof StoredReport) {
return true;
}
if ((int) $record->tenant_id !== (int) $tenant->getKey() || (int) $record->workspace_id !== (int) $tenant->workspace_id) {
return false;
}
$capability = static::capabilityForReportType((string) $record->report_type);
return $capability !== null && $user->can($capability, $tenant);
}
public static function canCreate(): bool
{
return false;
}
public static function canEdit(Model $record): bool
{
return false;
}
public static function canDelete(Model $record): bool
{
return false;
}
public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration
{
return ActionSurfaceDeclaration::forResource(ActionSurfaceProfile::CrudListAndView, ActionSurfaceType::ReadOnlyRegistryReport)
->exempt(ActionSurfaceSlot::ListHeader, 'Stored reports are read-only in v1 and do not expose generation, rerun, export, or mutation actions.')
->satisfy(ActionSurfaceSlot::InspectAffordance, ActionSurfaceInspectAffordance::ClickableRow->value)
->exempt(ActionSurfaceSlot::ListRowMoreMenu, 'Clickable-row inspection is the only row action for the read-only stored-report register.')
->exempt(ActionSurfaceSlot::ListBulkMoreGroup, 'Stored reports do not support bulk actions in v1.')
->satisfy(ActionSurfaceSlot::ListEmptyState, 'Empty state links back to the tenant overview without implying report generation from this surface.')
->satisfy(ActionSurfaceSlot::DetailHeader, 'Historical detail exposes the single navigation action Open current report; current detail has no header action.');
}
public static function getEloquentQuery(): Builder
{
$query = static::getTenantOwnedEloquentQuery()
->with('tenant')
->whereIn('report_type', static::supportedReportTypes());
$tenant = static::resolveTenantContextForCurrentPanel();
if ($tenant instanceof Tenant) {
$query->where('workspace_id', (int) $tenant->workspace_id);
}
$visibleReportTypes = static::visibleReportTypesForCurrentUser();
if ($visibleReportTypes === []) {
return $query->whereRaw('1 = 0');
}
return $query->whereIn('report_type', $visibleReportTypes);
}
public static function resolveScopedRecordOrFail(int|string|null $record): Model
{
$query = parent::getEloquentQuery()
->with('tenant')
->whereIn('report_type', static::supportedReportTypes());
$tenant = static::resolveTenantContextForCurrentPanel();
if ($tenant instanceof Tenant) {
$query->where('workspace_id', (int) $tenant->workspace_id);
}
return static::resolveTenantOwnedRecordOrFail(
$record,
$query,
);
}
public static function form(Schema $schema): Schema
{
return $schema;
}
public static function infolist(Schema $schema): Schema
{
return $schema->schema([
Section::make('Outcome summary')
->schema([
ViewEntry::make('artifact_truth')
->hiddenLabel()
->view('filament.infolists.entries.governance-artifact-truth')
->state(fn (StoredReport $record): array => static::truthState($record))
->columnSpanFull(),
])
->columnSpanFull(),
Section::make('Stored report')
->schema([
TextEntry::make('display_reference')
->label('Artifact reference')
->state(fn (StoredReport $record): string => static::displayReference($record)),
TextEntry::make('report_type')
->label('Report family')
->formatStateUsing(fn (string $state): string => static::reportFamilyReportLabel($state)),
TextEntry::make('measured_at')
->label('Measured at')
->state(fn (StoredReport $record): ?CarbonInterface => static::measuredAt($record))
->dateTime()
->placeholder('—'),
TextEntry::make('lifecycle_state')
->label('Lifecycle')
->state(fn (StoredReport $record): string => static::lifecycleState($record))
->badge()
->formatStateUsing(BadgeRenderer::label(BadgeDomain::GovernanceArtifactLifecycle))
->color(BadgeRenderer::color(BadgeDomain::GovernanceArtifactLifecycle))
->icon(BadgeRenderer::icon(BadgeDomain::GovernanceArtifactLifecycle))
->iconColor(BadgeRenderer::iconColor(BadgeDomain::GovernanceArtifactLifecycle)),
TextEntry::make('retention_state')
->label('Retention')
->state(fn (): string => 'retained')
->badge()
->formatStateUsing(BadgeRenderer::label(BadgeDomain::GovernanceArtifactRetention))
->color(BadgeRenderer::color(BadgeDomain::GovernanceArtifactRetention))
->icon(BadgeRenderer::icon(BadgeDomain::GovernanceArtifactRetention))
->iconColor(BadgeRenderer::iconColor(BadgeDomain::GovernanceArtifactRetention)),
TextEntry::make('fingerprint')
->label('Integrity anchor')
->copyable()
->fontFamily('mono')
->placeholder('—')
->columnSpanFull(),
TextEntry::make('previous_fingerprint')
->label('Previous fingerprint')
->copyable()
->fontFamily('mono')
->placeholder('—')
->columnSpanFull(),
])
->columns(2)
->columnSpanFull(),
Section::make('Permission posture summary')
->schema([
TextEntry::make('payload.posture_score')->label('Posture score')->placeholder('—'),
TextEntry::make('payload.required_count')->label('Required permissions')->placeholder('0'),
TextEntry::make('payload.granted_count')->label('Granted permissions')->placeholder('0'),
TextEntry::make('missing_count')
->label('Missing permissions')
->state(fn (StoredReport $record): int => static::permissionPostureMissingCount($record)),
TextEntry::make('at_risk_permissions')
->label('Missing or at-risk permission context')
->state(fn (StoredReport $record): array => static::permissionPostureAtRiskPermissions($record))
->bulleted()
->listWithLineBreaks()
->placeholder('No missing or at-risk permissions in the stored payload.')
->columnSpanFull(),
])
->columns(2)
->visible(fn (StoredReport $record): bool => $record->report_type === StoredReport::REPORT_TYPE_PERMISSION_POSTURE)
->columnSpanFull(),
Section::make('Entra admin roles summary')
->schema([
TextEntry::make('payload.totals.roles_total')->label('Roles total')->placeholder('0'),
TextEntry::make('payload.totals.assignments_total')->label('Assignments total')->placeholder('0'),
TextEntry::make('payload.totals.high_privilege_assignments')->label('High-privilege assignments')->placeholder('0'),
TextEntry::make('highest_risk_assignment')
->label('Highest-risk assignment')
->state(fn (StoredReport $record): ?string => static::highestRiskAssignmentLabel($record))
->placeholder('No high-privilege assignments in the stored payload.')
->columnSpanFull(),
])
->columns(2)
->visible(fn (StoredReport $record): bool => $record->report_type === StoredReport::REPORT_TYPE_ENTRA_ADMIN_ROLES)
->columnSpanFull(),
Section::make('Raw payload')
->schema([
ViewEntry::make('payload')
->hiddenLabel()
->view('filament.infolists.entries.snapshot-json')
->state(fn (StoredReport $record): array => is_array($record->payload) ? $record->payload : [])
->columnSpanFull(),
])
->collapsible()
->collapsed()
->columnSpanFull(),
]);
}
public static function table(Table $table): Table
{
return $table
->defaultSort('created_at', 'desc')
->paginated(TablePaginationProfiles::resource())
->recordUrl(fn (StoredReport $record): string => static::getUrl('view', ['record' => $record]))
->columns([
Tables\Columns\TextColumn::make('id')
->label('Reference')
->formatStateUsing(fn (int|string|null $state): string => sprintf('Stored report #%s', $state ?? '—'))
->searchable(query: fn (Builder $query, string $search): Builder => static::applyReportSearch($query, $search)),
Tables\Columns\TextColumn::make('report_type')
->label('Report family')
->formatStateUsing(fn (string $state): string => static::reportFamilyReportLabel($state))
->searchable(query: fn (Builder $query, string $search): Builder => static::applyReportSearch($query, $search)),
Tables\Columns\TextColumn::make('lifecycle_state')
->label('Lifecycle')
->getStateUsing(fn (StoredReport $record): string => static::lifecycleState($record))
->badge()
->formatStateUsing(BadgeRenderer::label(BadgeDomain::GovernanceArtifactLifecycle))
->color(BadgeRenderer::color(BadgeDomain::GovernanceArtifactLifecycle))
->icon(BadgeRenderer::icon(BadgeDomain::GovernanceArtifactLifecycle))
->iconColor(BadgeRenderer::iconColor(BadgeDomain::GovernanceArtifactLifecycle)),
Tables\Columns\TextColumn::make('measured_at')
->label('Measured at')
->getStateUsing(fn (StoredReport $record): ?CarbonInterface => static::measuredAt($record))
->dateTime()
->placeholder('—'),
Tables\Columns\TextColumn::make('summary')
->label('Summary')
->getStateUsing(fn (StoredReport $record): string => static::summaryText($record))
->wrap(),
Tables\Columns\TextColumn::make('fingerprint')
->label('Integrity')
->formatStateUsing(fn (?string $state): string => filled($state) ? 'Present' : '—')
->toggleable(isToggledHiddenByDefault: true),
])
->filters([
Tables\Filters\SelectFilter::make('report_type')
->label('Report family')
->options(fn (): array => static::visibleReportFamilyOptions()),
Tables\Filters\SelectFilter::make('history')
->label('Records')
->options([
'current' => 'Current records',
'all' => 'Include history',
])
->default('current')
->query(fn (Builder $query, array $data): Builder => ($data['value'] ?? 'current') === 'all'
? $query
: static::scopeCurrentRecords($query)),
])
->actions([])
->bulkActions([])
->emptyStateHeading('No stored reports yet')
->emptyStateDescription('Stored reports appear here after their origin surfaces create retained report records.')
->emptyStateIcon('heroicon-o-document-chart-bar')
->emptyStateActions([
Actions\Action::make('open_tenant_overview')
->label('Open tenant overview')
->icon('heroicon-o-home')
->url(fn (): string => static::tenantOverviewUrl()),
]);
}
public static function getPages(): array
{
return [
'index' => Pages\ListStoredReports::route('/'),
'view' => Pages\ViewStoredReport::route('/{record}'),
];
}
/**
* @return array<string>
*/
public static function supportedReportTypes(): array
{
return array_keys(self::REPORT_TYPE_CAPABILITIES);
}
/**
* @return array<string, string>
*/
public static function reportFamilyOptions(): array
{
return [
StoredReport::REPORT_TYPE_PERMISSION_POSTURE => 'Permission posture',
StoredReport::REPORT_TYPE_ENTRA_ADMIN_ROLES => 'Entra admin roles',
];
}
/**
* @return array<string, string>
*/
public static function visibleReportFamilyOptions(): array
{
return array_intersect_key(
static::reportFamilyOptions(),
array_flip(static::visibleReportTypesForCurrentUser()),
);
}
/**
* @return array<string>
*/
public static function visibleReportTypesForCurrentUser(): array
{
$tenant = static::resolveTenantContextForCurrentPanel();
$user = auth()->user();
if (! $tenant instanceof Tenant || ! $user instanceof User || ! $user->canAccessTenant($tenant)) {
return [];
}
return array_values(array_filter(
static::supportedReportTypes(),
static fn (string $reportType): bool => ($capability = static::capabilityForReportType($reportType)) !== null
&& $user->can($capability, $tenant),
));
}
public static function capabilityForReportType(string $reportType): ?string
{
return self::REPORT_TYPE_CAPABILITIES[$reportType] ?? null;
}
public static function reportFamilyLabel(string $reportType): string
{
return static::reportFamilyOptions()[$reportType] ?? Str::headline($reportType);
}
public static function reportFamilyReportLabel(string $reportType): string
{
return match ($reportType) {
StoredReport::REPORT_TYPE_PERMISSION_POSTURE => 'Permission posture report',
StoredReport::REPORT_TYPE_ENTRA_ADMIN_ROLES => 'Entra admin roles report',
default => Str::headline($reportType),
};
}
public static function displayReference(StoredReport $report): string
{
$truth = app(ArtifactTruthPresenter::class)->forStoredReportFresh($report);
return $truth->displayReference ?? sprintf('Stored report #%d (%s)', (int) $report->getKey(), static::reportFamilyLabel((string) $report->report_type));
}
public static function lifecycleState(StoredReport $report): string
{
$truth = app(ArtifactTruthPresenter::class)->forStoredReportFresh($report);
return $truth->lifecycleState ?? 'current';
}
/**
* @return array<string, mixed>
*/
public static function truthState(StoredReport $report): array
{
return app(ArtifactTruthPresenter::class)->forStoredReportFresh($report)->toArray();
}
public static function measuredAt(StoredReport $report): ?CarbonInterface
{
$payload = is_array($report->payload) ? $report->payload : [];
$value = Arr::get($payload, 'measured_at') ?? Arr::get($payload, 'checked_at');
if ($value instanceof CarbonInterface) {
return $value;
}
if (is_string($value) && trim($value) !== '') {
try {
return Carbon::parse($value);
} catch (Throwable) {
// Fall back to persisted timestamps when payload timestamps are malformed.
}
}
return $report->created_at ?? $report->updated_at;
}
public static function summaryText(StoredReport $report): string
{
$highlights = static::summaryHighlights($report);
if ($highlights === []) {
return 'No bounded summary is available for this report family.';
}
return collect($highlights)
->map(static fn (array $highlight): string => sprintf('%s: %s', $highlight['label'], $highlight['value']))
->implode(' · ');
}
/**
* @return list<array{label: string, value: string}>
*/
public static function summaryHighlights(StoredReport $report): array
{
return match ((string) $report->report_type) {
StoredReport::REPORT_TYPE_PERMISSION_POSTURE => static::permissionPostureHighlights($report),
StoredReport::REPORT_TYPE_ENTRA_ADMIN_ROLES => static::entraAdminRolesHighlights($report),
default => [],
};
}
public static function permissionPostureMissingCount(StoredReport $report): int
{
$payload = is_array($report->payload) ? $report->payload : [];
$requiredCount = max(0, (int) ($payload['required_count'] ?? 0));
$grantedCount = max(0, (int) ($payload['granted_count'] ?? 0));
return max(0, $requiredCount - $grantedCount);
}
/**
* @return list<string>
*/
public static function permissionPostureAtRiskPermissions(StoredReport $report): array
{
$payload = is_array($report->payload) ? $report->payload : [];
$permissions = is_array($payload['permissions'] ?? null) ? $payload['permissions'] : [];
return collect($permissions)
->filter(static fn (mixed $permission): bool => is_array($permission) && ($permission['status'] ?? null) !== 'granted')
->take(5)
->map(static function (array $permission): string {
$key = trim((string) ($permission['key'] ?? 'Unknown permission'));
$status = trim((string) ($permission['status'] ?? 'unknown'));
return sprintf('%s (%s)', $key !== '' ? $key : 'Unknown permission', $status !== '' ? $status : 'unknown');
})
->values()
->all();
}
public static function highestRiskAssignmentLabel(StoredReport $report): ?string
{
$payload = is_array($report->payload) ? $report->payload : [];
$assignments = is_array($payload['high_privilege'] ?? null) ? $payload['high_privilege'] : [];
$assignment = collect($assignments)
->filter(static fn (mixed $assignment): bool => is_array($assignment))
->sortBy(static fn (array $assignment): int => match ((string) ($assignment['severity'] ?? '')) {
'critical' => 0,
'high' => 1,
'medium' => 2,
'low' => 3,
default => 4,
})
->first();
if (! is_array($assignment)) {
return null;
}
$role = trim((string) ($assignment['role_display_name'] ?? 'Unknown role'));
$principal = trim((string) ($assignment['principal_display_name'] ?? 'Unknown principal'));
$severity = trim((string) ($assignment['severity'] ?? 'unknown'));
return sprintf('%s assigned to %s (%s)', $role !== '' ? $role : 'Unknown role', $principal !== '' ? $principal : 'Unknown principal', $severity !== '' ? $severity : 'unknown');
}
public static function currentReportFor(StoredReport $report): ?StoredReport
{
$current = StoredReport::query()
->where('tenant_id', (int) $report->tenant_id)
->where('workspace_id', (int) $report->workspace_id)
->where('report_type', (string) $report->report_type)
->orderByDesc('created_at')
->orderByDesc('id')
->first();
return $current instanceof StoredReport && ! $current->is($report) ? $current : null;
}
public static function currentReportUrlFor(StoredReport $report): ?string
{
$current = static::currentReportFor($report);
if (! $current instanceof StoredReport) {
return null;
}
$tenant = $current->tenant ?? static::resolveTenantContextForCurrentPanel();
if (! $tenant instanceof Tenant) {
return null;
}
return static::getUrl('view', ['record' => $current], panel: 'tenant', tenant: $tenant);
}
public static function scopeCurrentRecords(Builder $query): Builder
{
return $query->whereNotExists(function ($subQuery): void {
$subQuery
->selectRaw('1')
->from('stored_reports as newer_stored_reports')
->whereColumn('newer_stored_reports.tenant_id', 'stored_reports.tenant_id')
->whereColumn('newer_stored_reports.workspace_id', 'stored_reports.workspace_id')
->whereColumn('newer_stored_reports.report_type', 'stored_reports.report_type')
->where(function ($newerQuery): void {
$newerQuery
->whereColumn('newer_stored_reports.created_at', '>', 'stored_reports.created_at')
->orWhere(function ($tieQuery): void {
$tieQuery
->whereColumn('newer_stored_reports.created_at', 'stored_reports.created_at')
->whereColumn('newer_stored_reports.id', '>', 'stored_reports.id');
});
});
});
}
public static function applyReportSearch(Builder $query, string $search): Builder
{
$search = trim($search);
if ($search === '') {
return $query;
}
$normalizedSearch = Str::lower($search);
$matchingReportTypes = collect(static::reportFamilyOptions())
->filter(static fn (string $label, string $reportType): bool => str_contains(Str::lower($label), $normalizedSearch)
|| str_contains(Str::lower(static::reportFamilyReportLabel($reportType)), $normalizedSearch)
|| str_contains(Str::lower($reportType), $normalizedSearch))
->keys()
->all();
return $query->where(function (Builder $searchQuery) use ($search, $matchingReportTypes): void {
$searchQuery->where('report_type', 'like', '%'.$search.'%');
if (is_numeric($search)) {
$searchQuery->orWhereKey((int) $search);
}
if ($matchingReportTypes !== []) {
$searchQuery->orWhereIn('report_type', $matchingReportTypes);
}
});
}
/**
* @return list<array{label: string, value: string}>
*/
private static function permissionPostureHighlights(StoredReport $report): array
{
$payload = is_array($report->payload) ? $report->payload : [];
$postureScore = $payload['posture_score'] ?? null;
$requiredCount = (int) ($payload['required_count'] ?? 0);
$grantedCount = (int) ($payload['granted_count'] ?? 0);
return [
['label' => 'Posture score', 'value' => is_numeric($postureScore) ? (string) ((int) $postureScore) : '—'],
['label' => 'Required', 'value' => (string) $requiredCount],
['label' => 'Granted', 'value' => (string) $grantedCount],
['label' => 'Missing', 'value' => (string) static::permissionPostureMissingCount($report)],
];
}
/**
* @return list<array{label: string, value: string}>
*/
private static function entraAdminRolesHighlights(StoredReport $report): array
{
$payload = is_array($report->payload) ? $report->payload : [];
$totals = is_array($payload['totals'] ?? null) ? $payload['totals'] : [];
return [
['label' => 'Roles', 'value' => (string) ((int) ($totals['roles_total'] ?? 0))],
['label' => 'Assignments', 'value' => (string) ((int) ($totals['assignments_total'] ?? 0))],
['label' => 'High privilege', 'value' => (string) ((int) ($totals['high_privilege_assignments'] ?? 0))],
['label' => 'Highest risk', 'value' => static::highestRiskAssignmentLabel($report) ?? '—'],
];
}
private static function tenantOverviewUrl(): string
{
$tenant = static::resolveTenantContextForCurrentPanel();
if (! $tenant instanceof Tenant) {
return '#';
}
return TenantDashboard::getUrl(panel: 'tenant', tenant: $tenant);
}
}

View File

@ -0,0 +1,18 @@
<?php
declare(strict_types=1);
namespace App\Filament\Resources\StoredReportResource\Pages;
use App\Filament\Resources\StoredReportResource;
use Filament\Resources\Pages\ListRecords;
class ListStoredReports extends ListRecords
{
protected static string $resource = StoredReportResource::class;
protected function getHeaderActions(): array
{
return [];
}
}

View File

@ -0,0 +1,35 @@
<?php
declare(strict_types=1);
namespace App\Filament\Resources\StoredReportResource\Pages;
use App\Filament\Resources\StoredReportResource;
use App\Models\StoredReport;
use Filament\Actions;
use Filament\Resources\Pages\ViewRecord;
use Illuminate\Database\Eloquent\Model;
class ViewStoredReport extends ViewRecord
{
protected static string $resource = StoredReportResource::class;
protected function resolveRecord(int|string $key): Model
{
return StoredReportResource::resolveScopedRecordOrFail($key);
}
protected function getHeaderActions(): array
{
return [
Actions\Action::make('open_current_report')
->label('Open current report')
->icon('heroicon-o-arrow-top-right-on-square')
->url(fn (): ?string => $this->record instanceof StoredReport
? StoredReportResource::currentReportUrlFor($this->record)
: null)
->visible(fn (): bool => $this->record instanceof StoredReport
&& StoredReportResource::currentReportUrlFor($this->record) !== null),
];
}
}

View File

@ -4,6 +4,7 @@
namespace App\Filament\Widgets\Tenant; namespace App\Filament\Widgets\Tenant;
use App\Filament\Resources\StoredReportResource;
use App\Jobs\ScanEntraAdminRolesJob; use App\Jobs\ScanEntraAdminRolesJob;
use App\Models\StoredReport; use App\Models\StoredReport;
use App\Models\Tenant; use App\Models\Tenant;
@ -131,6 +132,7 @@ protected function getViewData(): array
->where('tenant_id', (int) $tenant->getKey()) ->where('tenant_id', (int) $tenant->getKey())
->where('report_type', StoredReport::REPORT_TYPE_ENTRA_ADMIN_ROLES) ->where('report_type', StoredReport::REPORT_TYPE_ENTRA_ADMIN_ROLES)
->orderByDesc('created_at') ->orderByDesc('created_at')
->orderByDesc('id')
->first(); ->first();
if (! $report instanceof StoredReport) { if (! $report instanceof StoredReport) {
@ -156,7 +158,9 @@ protected function getViewData(): array
'highPrivilegeCount' => $highPrivilegeCount, 'highPrivilegeCount' => $highPrivilegeCount,
'canManage' => $canManage, 'canManage' => $canManage,
'canView' => $canView, 'canView' => $canView,
'viewReportUrl' => null, 'viewReportUrl' => $canView
? StoredReportResource::getUrl('view', ['record' => $report], panel: 'tenant', tenant: $tenant)
: null,
]; ];
} }

View File

@ -51,6 +51,8 @@ class RoleCapabilityMap
Capabilities::ENTRA_ROLES_VIEW, Capabilities::ENTRA_ROLES_VIEW,
Capabilities::ENTRA_ROLES_MANAGE, Capabilities::ENTRA_ROLES_MANAGE,
Capabilities::PERMISSION_POSTURE_VIEW,
Capabilities::REVIEW_PACK_VIEW, Capabilities::REVIEW_PACK_VIEW,
Capabilities::REVIEW_PACK_MANAGE, Capabilities::REVIEW_PACK_MANAGE,
Capabilities::TENANT_REVIEW_VIEW, Capabilities::TENANT_REVIEW_VIEW,
@ -93,6 +95,8 @@ class RoleCapabilityMap
Capabilities::ENTRA_ROLES_VIEW, Capabilities::ENTRA_ROLES_VIEW,
Capabilities::ENTRA_ROLES_MANAGE, Capabilities::ENTRA_ROLES_MANAGE,
Capabilities::PERMISSION_POSTURE_VIEW,
Capabilities::REVIEW_PACK_VIEW, Capabilities::REVIEW_PACK_VIEW,
Capabilities::REVIEW_PACK_MANAGE, Capabilities::REVIEW_PACK_MANAGE,
Capabilities::TENANT_REVIEW_VIEW, Capabilities::TENANT_REVIEW_VIEW,
@ -124,6 +128,8 @@ class RoleCapabilityMap
Capabilities::ENTRA_ROLES_VIEW, Capabilities::ENTRA_ROLES_VIEW,
Capabilities::PERMISSION_POSTURE_VIEW,
Capabilities::REVIEW_PACK_VIEW, Capabilities::REVIEW_PACK_VIEW,
Capabilities::TENANT_REVIEW_VIEW, Capabilities::TENANT_REVIEW_VIEW,
Capabilities::TENANT_TRIAGE_REVIEW_MANAGE, Capabilities::TENANT_TRIAGE_REVIEW_MANAGE,
@ -144,6 +150,8 @@ class RoleCapabilityMap
Capabilities::ENTRA_ROLES_VIEW, Capabilities::ENTRA_ROLES_VIEW,
Capabilities::PERMISSION_POSTURE_VIEW,
Capabilities::REVIEW_PACK_VIEW, Capabilities::REVIEW_PACK_VIEW,
Capabilities::TENANT_REVIEW_VIEW, Capabilities::TENANT_REVIEW_VIEW,
Capabilities::EVIDENCE_VIEW, Capabilities::EVIDENCE_VIEW,

View File

@ -137,6 +137,9 @@ class Capabilities
public const ENTRA_ROLES_MANAGE = 'entra_roles.manage'; public const ENTRA_ROLES_MANAGE = 'entra_roles.manage';
// Permission posture
public const PERMISSION_POSTURE_VIEW = 'permission_posture.view';
// Review packs // Review packs
public const REVIEW_PACK_VIEW = 'review_pack.view'; public const REVIEW_PACK_VIEW = 'review_pack.view';

View File

@ -14,6 +14,7 @@
use App\Filament\Resources\PolicyResource; use App\Filament\Resources\PolicyResource;
use App\Filament\Resources\PolicyVersionResource; use App\Filament\Resources\PolicyVersionResource;
use App\Filament\Resources\RestoreRunResource; use App\Filament\Resources\RestoreRunResource;
use App\Filament\Resources\StoredReportResource;
use App\Filament\Resources\TenantReviewResource; use App\Filament\Resources\TenantReviewResource;
use App\Models\BackupSchedule; use App\Models\BackupSchedule;
use App\Models\BackupSet; use App\Models\BackupSet;
@ -25,6 +26,7 @@
use App\Models\Policy; use App\Models\Policy;
use App\Models\PolicyVersion; use App\Models\PolicyVersion;
use App\Models\RestoreRun; use App\Models\RestoreRun;
use App\Models\StoredReport;
use App\Models\TenantReview; use App\Models\TenantReview;
final class TenantOwnedModelFamilies final class TenantOwnedModelFamilies
@ -154,6 +156,16 @@ public static function firstSlice(): array
'action_surface_reason' => 'TenantReviewResource declares its action surface contract directly.', 'action_surface_reason' => 'TenantReviewResource declares its action surface contract directly.',
'notes' => 'Tenant reviews stay out of global search and are surfaced through tenant detail plus the canonical register.', 'notes' => 'Tenant reviews stay out of global search and are surfaced through tenant detail plus the canonical register.',
], ],
'StoredReport' => [
'table' => 'stored_reports',
'model' => StoredReport::class,
'resource' => StoredReportResource::class,
'tenant_relationship' => 'tenant',
'search_posture' => 'disabled',
'action_surface' => 'declared',
'action_surface_reason' => 'StoredReportResource declares its read-only stored-report action surface contract directly.',
'notes' => 'Stored reports stay out of global search and are surfaced through the tenant-scoped register and detail route.',
],
]; ];
} }

View File

@ -35,6 +35,7 @@ public static function firstSlice(): array
'evidence_snapshots', 'evidence_snapshots',
'inventory_items', 'inventory_items',
'entra_groups', 'entra_groups',
'stored_reports',
'tenant_reviews', 'tenant_reviews',
]; ];
} }

View File

@ -45,4 +45,66 @@ public function definition(): array
], ],
]; ];
} }
/**
* @param array<string, mixed> $payload
*/
public function permissionPosture(array $payload = []): self
{
return $this->state(fn (): array => [
'report_type' => StoredReport::REPORT_TYPE_PERMISSION_POSTURE,
'payload' => array_replace_recursive([
'posture_score' => 86,
'required_count' => 14,
'granted_count' => 12,
'checked_at' => now()->toIso8601String(),
'permissions' => [
[
'key' => 'DeviceManagementConfiguration.ReadWrite.All',
'type' => 'application',
'status' => 'granted',
'features' => ['policy-sync', 'backup', 'restore'],
],
[
'key' => 'DeviceManagementApps.ReadWrite.All',
'type' => 'application',
'status' => 'missing',
'features' => ['policy-sync', 'backup'],
],
],
], $payload),
]);
}
/**
* @param array<string, mixed> $payload
*/
public function entraAdminRoles(array $payload = []): self
{
return $this->state(fn (): array => [
'report_type' => StoredReport::REPORT_TYPE_ENTRA_ADMIN_ROLES,
'payload' => array_replace_recursive([
'provider_key' => 'microsoft',
'domain' => 'entra.admin_roles',
'measured_at' => now()->toIso8601String(),
'role_definitions' => [],
'role_assignments' => [],
'totals' => [
'roles_total' => 8,
'assignments_total' => 12,
'high_privilege_assignments' => 5,
],
'high_privilege' => [
[
'role_display_name' => 'Global Administrator',
'role_template_id' => '62e90394-69f5-4237-9190-012177145e10',
'principal_id' => 'user-1',
'principal_display_name' => 'Admin User',
'assignment_scope' => '/',
'severity' => 'critical',
],
],
], $payload),
]);
}
} }

View File

@ -0,0 +1,60 @@
<?php
declare(strict_types=1);
use App\Filament\Resources\StoredReportResource;
use App\Models\StoredReport;
use App\Support\Workspaces\WorkspaceContext;
use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
pest()->browser()->timeout(20_000);
it('smokes the tenant stored-reports register to detail handoff', function (): void {
[$user, $tenant] = createUserWithTenant(
role: 'owner',
workspaceRole: 'manager',
ensureDefaultMicrosoftProviderConnection: false,
);
StoredReport::factory()
->permissionPosture([
'posture_score' => 91,
'required_count' => 8,
'granted_count' => 7,
'permissions' => [
['key' => 'DeviceManagementConfiguration.Read.All', 'status' => 'granted'],
['key' => 'DeviceManagementApps.ReadWrite.All', 'status' => 'missing'],
],
])
->create([
'tenant_id' => (int) $tenant->getKey(),
'workspace_id' => (int) $tenant->workspace_id,
'fingerprint' => 'spec-277-browser-fingerprint',
]);
$this->actingAs($user)->withSession([
WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id,
WorkspaceContext::LAST_TENANT_IDS_SESSION_KEY => [
(string) $tenant->workspace_id => (int) $tenant->getKey(),
],
]);
visit(StoredReportResource::getUrl('index', tenant: $tenant, panel: 'tenant'))
->waitForText('Stored reports')
->assertSee('Permission posture report')
->assertSee('Current')
->assertSee('Posture score: 91')
->assertNoJavaScriptErrors()
->assertNoConsoleLogs()
->click('Permission posture report')
->waitForText('Permission posture summary')
->assertSee('Outcome summary')
->assertSee('Stored report')
->assertSee('Missing permissions')
->assertSee('DeviceManagementApps.ReadWrite.All')
->assertSee('Raw payload')
->assertNoJavaScriptErrors()
->assertNoConsoleLogs();
});

View File

@ -2,12 +2,15 @@
declare(strict_types=1); declare(strict_types=1);
use App\Filament\Resources\StoredReportResource;
use App\Filament\Widgets\Tenant\AdminRolesSummaryWidget; use App\Filament\Widgets\Tenant\AdminRolesSummaryWidget;
use App\Jobs\ScanEntraAdminRolesJob; use App\Jobs\ScanEntraAdminRolesJob;
use App\Models\StoredReport; use App\Models\StoredReport;
use App\Models\Tenant; use App\Models\Tenant;
use App\Support\Auth\Capabilities;
use Filament\Facades\Filament; use Filament\Facades\Filament;
use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Gate;
use Illuminate\Support\Facades\Queue; use Illuminate\Support\Facades\Queue;
use Livewire\Livewire; use Livewire\Livewire;
@ -73,6 +76,39 @@ function createAdminRolesReport(Tenant $tenant, ?array $summaryOverrides = null)
->assertSuccessful(); ->assertSuccessful();
}); });
it('links report-present state to the canonical stored-report detail route', function (): void {
[$user, $tenant] = createUserWithTenant(role: 'owner');
Filament::setTenant($tenant, true);
$report = createAdminRolesReport($tenant, [
'high_privilege_assignments' => 7,
]);
$expectedUrl = StoredReportResource::getUrl('view', ['record' => $report], panel: 'tenant', tenant: $tenant);
Livewire::actingAs($user)
->test(AdminRolesSummaryWidget::class, ['record' => $tenant])
->assertSee('View latest report')
->assertSeeHtml('href="'.$expectedUrl.'"')
->assertSuccessful();
});
it('does not expose the report drilldown when Entra admin roles view is denied', function (): void {
[$user, $tenant] = createUserWithTenant(role: 'owner');
Filament::setTenant($tenant, true);
createAdminRolesReport($tenant);
Gate::define(Capabilities::ENTRA_ROLES_VIEW, fn (): bool => false);
Livewire::actingAs($user)
->test(AdminRolesSummaryWidget::class, ['record' => $tenant])
->assertDontSee('View latest report')
->assertSuccessful();
});
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Empty state // Empty state
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------

View File

@ -0,0 +1,174 @@
<?php
declare(strict_types=1);
use App\Filament\Resources\StoredReportResource;
use App\Filament\Resources\StoredReportResource\Pages\ViewStoredReport;
use App\Models\StoredReport;
use App\Models\Tenant;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Livewire\Livewire;
uses(RefreshDatabase::class);
function storedReportDetailPermissionReport(Tenant $tenant, array $payload = [], array $attributes = []): StoredReport
{
return StoredReport::factory()
->permissionPosture($payload)
->create(array_merge([
'tenant_id' => (int) $tenant->getKey(),
'workspace_id' => (int) $tenant->workspace_id,
'fingerprint' => 'permission-detail',
], $attributes));
}
function storedReportDetailEntraReport(Tenant $tenant, array $payload = [], array $attributes = []): StoredReport
{
return StoredReport::factory()
->entraAdminRoles($payload)
->create(array_merge([
'tenant_id' => (int) $tenant->getKey(),
'workspace_id' => (int) $tenant->workspace_id,
'fingerprint' => 'entra-detail',
], $attributes));
}
function storedReportDetailHeaderActionNames(ViewStoredReport $page): array
{
if ($page->getCachedHeaderActions() === []) {
$page->cacheInteractsWithHeaderActions();
}
return collect($page->getCachedHeaderActions())
->map(static fn ($action): ?string => method_exists($action, 'getName') ? $action->getName() : null)
->filter()
->values()
->all();
}
it('renders current permission-posture detail with summary before raw payload', function (): void {
[$user, $tenant] = createUserWithTenant(role: 'owner');
$report = storedReportDetailPermissionReport($tenant, [
'posture_score' => 75,
'required_count' => 4,
'granted_count' => 3,
'permissions' => [
['key' => 'DeviceManagementConfiguration.Read.All', 'status' => 'granted'],
['key' => 'DeviceManagementApps.ReadWrite.All', 'status' => 'missing'],
],
], [
'fingerprint' => 'permission-fingerprint',
]);
$this->actingAs($user)
->get(StoredReportResource::getUrl('view', ['record' => $report], tenant: $tenant, panel: 'tenant'))
->assertOk()
->assertSeeInOrder([
'Outcome summary',
'Stored report',
'Permission posture summary',
'Posture score',
'75',
'Required permissions',
'4',
'Granted permissions',
'3',
'Missing permissions',
'1',
'DeviceManagementApps.ReadWrite.All',
'Raw payload',
])
->assertSee('Current')
->assertSee('Measured at')
->assertSee('permission-fingerprint');
$this->actingAs($user);
setTenantPanelContext($tenant);
$component = Livewire::test(ViewStoredReport::class, ['record' => $report->getKey()])
->assertActionHidden('open_current_report')
->assertSuccessful();
expect(storedReportDetailHeaderActionNames($component->instance()))->toBe(['open_current_report']);
});
it('renders historical Entra admin-roles detail with a single current-report jump', function (): void {
[$user, $tenant] = createUserWithTenant(role: 'owner');
$historical = storedReportDetailEntraReport($tenant, [
'totals' => [
'roles_total' => 10,
'assignments_total' => 20,
'high_privilege_assignments' => 8,
],
'high_privilege' => [
[
'role_display_name' => 'Privileged Role Administrator',
'principal_display_name' => 'Bob Operator',
'severity' => 'high',
],
[
'role_display_name' => 'Global Administrator',
'principal_display_name' => 'Alice Admin',
'severity' => 'critical',
],
],
], [
'created_at' => now()->subDays(2),
'updated_at' => now()->subDays(2),
'fingerprint' => 'entra-old-fingerprint',
]);
$current = storedReportDetailEntraReport($tenant, [
'totals' => [
'roles_total' => 11,
'assignments_total' => 21,
'high_privilege_assignments' => 3,
],
], [
'created_at' => now()->subDay(),
'updated_at' => now()->subDay(),
'fingerprint' => 'entra-current-fingerprint',
'previous_fingerprint' => 'entra-old-fingerprint',
]);
$this->actingAs($user)
->get(StoredReportResource::getUrl('view', ['record' => $historical], tenant: $tenant, panel: 'tenant'))
->assertOk()
->assertSeeInOrder([
'Historical',
'Entra admin roles summary',
'Roles total',
'10',
'Assignments total',
'20',
'High-privilege assignments',
'8',
'Global Administrator assigned to Alice Admin (critical)',
'Raw payload',
]);
$this->actingAs($user);
setTenantPanelContext($tenant);
Livewire::test(ViewStoredReport::class, ['record' => $historical->getKey()])
->assertActionVisible('open_current_report')
->assertActionExists('open_current_report', fn ($action): bool => $action->getLabel() === 'Open current report'
&& $action->getUrl() === StoredReportResource::getUrl('view', ['record' => $current], panel: 'tenant', tenant: $tenant))
->assertSuccessful();
});
it('does not render unsupported report families through the v1 detail route', function (): void {
[$user, $tenant] = createUserWithTenant(role: 'owner');
$unsupported = StoredReport::query()->create([
'tenant_id' => (int) $tenant->getKey(),
'workspace_id' => (int) $tenant->workspace_id,
'report_type' => 'future.report',
'payload' => ['summary' => 'Unsupported'],
]);
$this->actingAs($user)
->get(StoredReportResource::getUrl('view', ['record' => $unsupported], tenant: $tenant, panel: 'tenant'))
->assertNotFound();
});

View File

@ -0,0 +1,139 @@
<?php
declare(strict_types=1);
use App\Filament\Resources\StoredReportResource;
use App\Filament\Resources\StoredReportResource\Pages\ListStoredReports;
use App\Models\StoredReport;
use App\Models\Tenant;
use App\Models\Workspace;
use App\Services\Auth\RoleCapabilityMap;
use App\Support\Auth\Capabilities;
use App\Support\TenantRole;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Gate;
use Livewire\Livewire;
uses(RefreshDatabase::class);
function storedReportEntitlementPermissionReport(Tenant $tenant): StoredReport
{
return StoredReport::factory()
->permissionPosture()
->create([
'tenant_id' => (int) $tenant->getKey(),
'workspace_id' => (int) $tenant->workspace_id,
'fingerprint' => 'permission-entitlement',
]);
}
function storedReportEntitlementEntraReport(Tenant $tenant): StoredReport
{
return StoredReport::factory()
->entraAdminRoles()
->create([
'tenant_id' => (int) $tenant->getKey(),
'workspace_id' => (int) $tenant->workspace_id,
'fingerprint' => 'entra-entitlement',
]);
}
it('registers the bounded permission posture read capability for read-only tenant roles', function (): void {
expect(Capabilities::PERMISSION_POSTURE_VIEW)->toBe('permission_posture.view')
->and(Capabilities::all())->toContain(Capabilities::PERMISSION_POSTURE_VIEW)
->and(RoleCapabilityMap::hasCapability(TenantRole::Owner, Capabilities::PERMISSION_POSTURE_VIEW))->toBeTrue()
->and(RoleCapabilityMap::hasCapability(TenantRole::Manager, Capabilities::PERMISSION_POSTURE_VIEW))->toBeTrue()
->and(RoleCapabilityMap::hasCapability(TenantRole::Operator, Capabilities::PERMISSION_POSTURE_VIEW))->toBeTrue()
->and(RoleCapabilityMap::hasCapability(TenantRole::Readonly, Capabilities::PERMISSION_POSTURE_VIEW))->toBeTrue();
});
it('returns 404 for non-members on stored-report collection and direct detail routes', function (): void {
$user = \App\Models\User::factory()->create();
$tenant = Tenant::factory()->create();
$report = storedReportEntitlementPermissionReport($tenant);
$this->actingAs($user)
->get(StoredReportResource::getUrl('index', tenant: $tenant, panel: 'tenant'))
->assertNotFound();
$this->actingAs($user)
->get(StoredReportResource::getUrl('view', ['record' => $report], tenant: $tenant, panel: 'tenant'))
->assertNotFound();
});
it('returns 403 for tenant members without any supported report capability on the collection route', function (): void {
[$user, $tenant] = createUserWithTenant(role: 'owner');
Gate::define(Capabilities::PERMISSION_POSTURE_VIEW, fn (): bool => false);
Gate::define(Capabilities::ENTRA_ROLES_VIEW, fn (): bool => false);
$this->actingAs($user)
->get(StoredReportResource::getUrl('index', tenant: $tenant, panel: 'tenant'))
->assertForbidden();
});
it('filters collection rows and filter options by report-family capability', function (): void {
[$user, $tenant] = createUserWithTenant(role: 'owner');
$permissionReport = storedReportEntitlementPermissionReport($tenant);
$entraReport = storedReportEntitlementEntraReport($tenant);
Gate::define(Capabilities::PERMISSION_POSTURE_VIEW, fn (): bool => false);
$this->actingAs($user);
setTenantPanelContext($tenant);
Livewire::test(ListStoredReports::class)
->assertCanSeeTableRecords([$entraReport])
->assertCanNotSeeTableRecords([$permissionReport])
->assertSee('Entra admin roles report')
->assertDontSee('Permission posture report');
expect(StoredReportResource::visibleReportFamilyOptions())
->toBe([StoredReport::REPORT_TYPE_ENTRA_ADMIN_ROLES => 'Entra admin roles']);
});
it('returns 403 for direct detail access when the member lacks that report-family capability', function (): void {
[$user, $tenant] = createUserWithTenant(role: 'owner');
$permissionReport = storedReportEntitlementPermissionReport($tenant);
Gate::define(Capabilities::PERMISSION_POSTURE_VIEW, fn (): bool => false);
$this->actingAs($user)
->get(StoredReportResource::getUrl('view', ['record' => $permissionReport], tenant: $tenant, panel: 'tenant'))
->assertForbidden();
});
it('returns 404 for unsupported stored-report families even when the actor is entitled to the tenant', function (): void {
[$user, $tenant] = createUserWithTenant(role: 'owner');
$unsupported = StoredReport::query()->create([
'tenant_id' => (int) $tenant->getKey(),
'workspace_id' => (int) $tenant->workspace_id,
'report_type' => 'future.report',
'payload' => ['name' => 'Unexpected'],
]);
$this->actingAs($user)
->get(StoredReportResource::getUrl('view', ['record' => $unsupported], tenant: $tenant, panel: 'tenant'))
->assertNotFound();
});
it('returns 404 for same-tenant stored reports outside the active workspace boundary', function (): void {
[$user, $tenant] = createUserWithTenant(role: 'owner');
$wrongWorkspace = Workspace::factory()->create();
$wrongWorkspaceReportId = DB::table('stored_reports')->insertGetId([
'tenant_id' => (int) $tenant->getKey(),
'workspace_id' => (int) $wrongWorkspace->getKey(),
'report_type' => StoredReport::REPORT_TYPE_PERMISSION_POSTURE,
'payload' => json_encode(['posture_score' => 1], JSON_THROW_ON_ERROR),
'fingerprint' => 'permission-wrong-workspace-detail',
'created_at' => now(),
'updated_at' => now(),
]);
$this->actingAs($user)
->get(StoredReportResource::getUrl('view', ['record' => $wrongWorkspaceReportId], tenant: $tenant, panel: 'tenant'))
->assertNotFound();
});

View File

@ -0,0 +1,143 @@
<?php
declare(strict_types=1);
use App\Filament\Resources\StoredReportResource;
use App\Filament\Resources\StoredReportResource\Pages\ListStoredReports;
use App\Models\StoredReport;
use App\Models\Tenant;
use App\Models\Workspace;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\DB;
use Livewire\Livewire;
uses(RefreshDatabase::class);
function storedReportResourcePermissionReport(Tenant $tenant, array $payload = [], array $attributes = []): StoredReport
{
return StoredReport::factory()
->permissionPosture($payload)
->create(array_merge([
'tenant_id' => (int) $tenant->getKey(),
'workspace_id' => (int) $tenant->workspace_id,
'fingerprint' => hash('sha256', 'permission-'.$tenant->getKey().'-'.microtime(true)),
], $attributes));
}
function storedReportResourceEntraReport(Tenant $tenant, array $payload = [], array $attributes = []): StoredReport
{
return StoredReport::factory()
->entraAdminRoles($payload)
->create(array_merge([
'tenant_id' => (int) $tenant->getKey(),
'workspace_id' => (int) $tenant->workspace_id,
'fingerprint' => hash('sha256', 'entra-'.$tenant->getKey().'-'.microtime(true)),
], $attributes));
}
it('renders the tenant stored-reports register and keeps global search disabled', function (): void {
[$user, $tenant] = createUserWithTenant(role: 'owner');
$this->actingAs($user)
->get(StoredReportResource::getUrl('index', tenant: $tenant, panel: 'tenant'))
->assertOk()
->assertSee('Stored reports');
$reflection = new ReflectionClass(StoredReportResource::class);
expect($reflection->getStaticPropertyValue('isGloballySearchable'))->toBeFalse()
->and(array_keys(StoredReportResource::getPages()))->toContain('view');
});
it('lists only current supported tenant reports by default and can reveal history', function (): void {
[$user, $tenant] = createUserWithTenant(role: 'owner');
$otherTenant = Tenant::factory()->create();
$historical = storedReportResourceEntraReport($tenant, [], [
'created_at' => now()->subDays(2),
'updated_at' => now()->subDays(2),
'fingerprint' => 'entra-old',
]);
$current = storedReportResourceEntraReport($tenant, [], [
'created_at' => now()->subDay(),
'updated_at' => now()->subDay(),
'fingerprint' => 'entra-current',
'previous_fingerprint' => 'entra-old',
]);
$permission = storedReportResourcePermissionReport($tenant, [
'posture_score' => 92,
'required_count' => 10,
'granted_count' => 9,
]);
$wrongWorkspace = Workspace::factory()->create();
$wrongWorkspaceReportId = DB::table('stored_reports')->insertGetId([
'tenant_id' => (int) $tenant->getKey(),
'workspace_id' => (int) $wrongWorkspace->getKey(),
'report_type' => StoredReport::REPORT_TYPE_PERMISSION_POSTURE,
'payload' => json_encode([
'posture_score' => 1,
'required_count' => 1,
'granted_count' => 0,
], JSON_THROW_ON_ERROR),
'fingerprint' => 'permission-wrong-workspace',
'created_at' => now()->addMinute(),
'updated_at' => now()->addMinute(),
]);
$wrongWorkspaceReport = StoredReport::query()->findOrFail($wrongWorkspaceReportId);
storedReportResourcePermissionReport($otherTenant);
StoredReport::query()->create([
'tenant_id' => (int) $tenant->getKey(),
'workspace_id' => (int) $tenant->workspace_id,
'report_type' => 'future.report',
'payload' => ['name' => 'Unexpected'],
]);
$this->actingAs($user);
setTenantPanelContext($tenant);
Livewire::test(ListStoredReports::class)
->assertCanSeeTableRecords([$current, $permission])
->assertCanNotSeeTableRecords([$historical, $wrongWorkspaceReport])
->assertSee('Entra admin roles report')
->assertSee('Permission posture report')
->searchTable('Permission posture')
->assertCanSeeTableRecords([$permission])
->assertCanNotSeeTableRecords([$current]);
Livewire::test(ListStoredReports::class)
->filterTable('report_type', StoredReport::REPORT_TYPE_ENTRA_ADMIN_ROLES)
->assertCanSeeTableRecords([$current])
->assertCanNotSeeTableRecords([$permission])
->filterTable('history', 'all')
->assertCanSeeTableRecords([$historical, $current]);
});
it('uses clickable rows without row or bulk mutation actions', function (): void {
[$user, $tenant] = createUserWithTenant(role: 'owner');
$report = storedReportResourcePermissionReport($tenant);
$this->actingAs($user);
setTenantPanelContext($tenant);
$component = Livewire::test(ListStoredReports::class)
->assertCanSeeTableRecords([$report]);
$table = $component->instance()->getTable();
expect($table->getActions())->toBeEmpty()
->and($table->getBulkActions())->toBeEmpty()
->and($table->getRecordUrl($report))->toBe(StoredReportResource::getUrl('view', ['record' => $report]));
});
it('shows an honest empty state that points back to the tenant overview', function (): void {
[$user, $tenant] = createUserWithTenant(role: 'owner');
$this->actingAs($user);
setTenantPanelContext($tenant);
Livewire::test(ListStoredReports::class)
->assertSee('No stored reports yet')
->assertSee('Stored reports appear here after their origin surfaces create retained report records.')
->assertTableEmptyStateActionsExistInOrder(['open_tenant_overview']);
});

View File

@ -1,10 +1,10 @@
# TenantPilot Implementation Ledger # TenantPilot Implementation Ledger
> **Status:** Active > **Status:** Active
> **Last reviewed:** 2026-05-02 > **Last reviewed:** 2026-05-06
> **Use for:** Repo-based implementation status and product-surface maturity assessment > **Use for:** Repo-based implementation status and product-surface maturity assessment
> **Do not use for:** Roadmap priority, spec priority, or proof that tests were executed in the current branch > **Do not use for:** Roadmap priority, spec priority, or proof that tests were executed in the current branch
> **Scoped maintenance:** 2026-05-02 ledger drift correction and alignment with `docs/product/roadmap.md` plus `docs/product/spec-candidates.md` after the repo-truth review of roadmap drift, manual-promotion backlog, deep-research alignment, and current spec promotions. > **Scoped maintenance:** 2026-05-06 ledger conflict cleanup plus alignment with `docs/product/roadmap.md` and `docs/product/spec-candidates.md` after the cross-domain indicator candidate intake and the current manual-promotion backlog review.
## Purpose ## Purpose
@ -219,49 +219,34 @@ ## Open Gaps & Blockers
| Gap | Type | Impact | Roadmap Area | Recommended Spec | | Gap | Type | Impact | Roadmap Area | Recommended Spec |
|---|---|---|---|---| |---|---|---|---|---|
| No safe automatic next-best-prep target is currently active | Planning boundary | `docs/product/spec-candidates.md` now keeps the active queue empty, so the next slice must be promoted deliberately instead of selected automatically | Product planning / queue hygiene | none - require explicit manual promotion | | No safe automatic next-best-prep target is currently active | Planning boundary | `docs/product/spec-candidates.md` now keeps the active queue empty, so the next slice must be promoted deliberately instead of selected automatically | Product planning / queue hygiene | none - require explicit manual promotion |
<<<<<<< HEAD
| Auditor-ready executive export is still missing | Productization blocker | Review truth remains short of auditor-/executive-ready delivery, even though the dedicated follow-through is now spec-backed | R2 review delivery | `specs/263-auditor-pack-executive-export/spec.md` | | Auditor-ready executive export is still missing | Productization blocker | Review truth remains short of auditor-/executive-ready delivery, even though the dedicated follow-through is now spec-backed | R2 review delivery | `specs/263-auditor-pack-executive-export/spec.md` |
| Cross-tenant promotion execution is still missing | Product blocker | Compare preview and preflight are repo-real, but the actual portfolio action remains absent even though the execution package is now spec-backed | MSP Portfolio & Operations | `specs/264-cross-tenant-promotion-execution/spec.md` | | Cross-tenant promotion execution is still missing | Product blocker | Compare preview and preflight are repo-real, but the actual portfolio action remains absent even though the execution package is now spec-backed | MSP Portfolio & Operations | `specs/264-cross-tenant-promotion-execution/spec.md` |
| Decision register and approval workflow is still missing | Product blocker | Decision-based operating still lacks a bounded approval-ready closure and decision-record package with audit trail | Decision-based operating | `Decision Register & Approval Workflow v1` | | Decision register and approval workflow is still missing | Product blocker | Decision-based operating still lacks a bounded approval-ready closure and decision-record package with audit trail | Decision-based operating | `Decision Register & Approval Workflow v1` |
| Governance-artifact lifecycle runtime is still missing | Trust / auditability blocker | Lifecycle taxonomy and point retention rules exist, but governance artifacts still lack immutable-reference, hold, export, delete, and suspended/read-only runtime semantics | Lifecycle governance / enterprise trust | `Governance Artifact Lifecycle & Retention v1` | | Governance-artifact lifecycle runtime is still missing | Trust / auditability blocker | Lifecycle taxonomy and point retention rules exist, but governance artifacts still lack immutable-reference, hold, export, delete, and suspended/read-only runtime semantics | Lifecycle governance / enterprise trust | `Governance Artifact Lifecycle & Retention v1` |
======= | Cross-domain progress and indicator semantics guardrail is still missing | UX / trust guardrail | Bars, percentages, scores, readiness, risk, usage, and generation-state hints still lack one shared taxonomy and standards layer above the OperationRun-specific rules | UI semantics / product trust | `Cross-Domain Progress / Indicator Semantics candidate group` |
| Auditor-ready executive export is still missing | Productization blocker | Review truth remains short of auditor-/executive-ready delivery without dedicated packaging | R2 review delivery | `Auditor Pack Delivery & Executive Export v1` |
| Cross-tenant promotion execution is still missing | Product blocker | Compare preview and preflight are repo-real, but the actual portfolio action remains absent | MSP Portfolio & Operations | `Cross-Tenant Promotion Execution v1` |
| Governance decision pack and approval workflow is still missing | Product blocker | Decision-based operating still lacks a bounded approval-ready action package with audit trail | Decision-based operating | `Governance Decision Pack & Approval Workflow v1` |
>>>>>>> da5b12ae (docs: realign implementation ledger)
| Customer-facing localization adoption is incomplete | Productization blocker | Locale groundwork is repo-real, but customer-safe adoption remains incomplete | Localization / review productization | `Customer-Facing Localization Adoption v1` | | Customer-facing localization adoption is incomplete | Productization blocker | Locale groundwork is repo-real, but customer-safe adoption remains incomplete | Localization / review productization | `Customer-Facing Localization Adoption v1` |
| Billing and subscription truth is missing | Commercial blocker | Entitlements and lifecycle state handling stop short of a durable billing/subscription truth layer | Commercial readiness | `Billing & Subscription Truth Layer v1` | | Billing and subscription truth is missing | Commercial blocker | Entitlements and lifecycle state handling stop short of a durable billing/subscription truth layer | Commercial readiness | `Billing & Subscription Truth Layer v1` |
| Stored reports still lack a clear product surface | Product blocker | Retained evidence and review artifacts remain harder to consume than they should be | Reports / evidence consumption | `Stored Reports Surface v1` | | Stored reports still lack a clear product surface | Product blocker | Retained evidence and review artifacts remain harder to consume than they should be | Reports / evidence consumption | `Stored Reports Surface v1` |
| Workspace and tenant closure follow-through is not started | Strategic blocker | The taxonomy exists, but closure/runtime semantics are not yet productized | Lifecycle governance / enterprise trust | `Workspace & Tenant Closure Lifecycle v1` | | Workspace and tenant closure follow-through is not started | Strategic blocker | The taxonomy exists, but closure/runtime semantics are not yet productized | Lifecycle governance / enterprise trust | `Workspace & Tenant Closure Lifecycle v1` |
<<<<<<< HEAD
| Support-access governance is still missing | Access governance blocker | Break-glass and support access seams exist, but customer-visible TTL, reason, approval, and export semantics are not productized | Enterprise access boundary | `Enterprise Access Boundary & Support Access Governance v1` | | Support-access governance is still missing | Access governance blocker | Break-glass and support access seams exist, but customer-visible TTL, reason, approval, and export semantics are not productized | Enterprise access boundary | `Enterprise Access Boundary & Support Access Governance v1` |
=======
>>>>>>> da5b12ae (docs: realign implementation ledger)
| First governed AI runtime consumer is missing | Architecture blocker | The policy foundation exists, but there is no bounded runtime consumer proving the model end-to-end | Governed AI follow-through | `First Governed AI Runtime Consumer v1` | | First governed AI runtime consumer is missing | Architecture blocker | The policy foundation exists, but there is no bounded runtime consumer proving the model end-to-end | Governed AI follow-through | `First Governed AI Runtime Consumer v1` |
## Recommended Manual Promotions ## Recommended Manual Promotions
<<<<<<< HEAD - `Cross-Domain Progress / Indicator Semantics candidate group` -> anchored by `specs/268-operationrun-activity-feedback/spec.md`, `specs/270-operationrun-progress-contract/spec.md`, `specs/271-counted-progress-rollout/spec.md`, `specs/272-operationrun-phase-composite-progress/spec.md`, `docs/ui/tenantpilot-enterprise-ui-standards.md`, and the current progress-like UI seams called out in `docs/product/spec-candidates.md`
- `Decision Register & Approval Workflow v1` -> anchored by `specs/250-decision-governance-inbox/spec.md`, `specs/257-governance-decision-convergence/spec.md`, and `docs/product/roadmap.md` - `Decision Register & Approval Workflow v1` -> anchored by `specs/250-decision-governance-inbox/spec.md`, `specs/257-governance-decision-convergence/spec.md`, and `docs/product/roadmap.md`
- `Governance Artifact Lifecycle & Retention v1` -> anchored by `specs/158-artifact-truth-semantics/spec.md`, `specs/262-lifecycle-governance-taxonomy/spec.md`, and `docs/product/standards/lifecycle-governance.md` - `Governance Artifact Lifecycle & Retention v1` -> anchored by `specs/158-artifact-truth-semantics/spec.md`, `specs/262-lifecycle-governance-taxonomy/spec.md`, and `docs/product/standards/lifecycle-governance.md`
- `Customer-Facing Localization Adoption v1` -> anchored by `specs/252-platform-localization-v1/spec.md`, `specs/258-customer-review-productization/spec.md`, and `specs/260-governance-service-packaging/spec.md`
- `Billing & Subscription Truth Layer v1` -> anchored by `specs/247-plans-entitlements-billing-readiness/spec.md` and `specs/251-commercial-entitlements-billing-state/spec.md` - `Billing & Subscription Truth Layer v1` -> anchored by `specs/247-plans-entitlements-billing-readiness/spec.md` and `specs/251-commercial-entitlements-billing-state/spec.md`
- `Customer-Facing Localization Adoption v1` -> anchored by `specs/252-platform-localization-v1/spec.md`, `specs/258-customer-review-productization/spec.md`, and `specs/260-governance-service-packaging/spec.md`
- `Enterprise Access Boundary & Support Access Governance v1` -> anchored by `docs/audits/2026-03-09-enterprise-rbac-scope-audit.md`, `docs/HANDOVER.md`, `specs/065-tenant-rbac-v1/spec.md`, and `specs/066-rbac-ui-enforcement-helper/spec.md` - `Enterprise Access Boundary & Support Access Governance v1` -> anchored by `docs/audits/2026-03-09-enterprise-rbac-scope-audit.md`, `docs/HANDOVER.md`, `specs/065-tenant-rbac-v1/spec.md`, and `specs/066-rbac-ui-enforcement-helper/spec.md`
=======
- `Auditor Pack Delivery & Executive Export v1` -> anchored by `specs/109-review-pack-export/spec.md`, `specs/153-evidence-domain-foundation/spec.md`, `specs/155-tenant-review-layer/spec.md`, `specs/258-customer-review-productization/spec.md`, `specs/259-compliance-evidence-mapping/spec.md`, and `specs/260-governance-service-packaging/spec.md`
- `Cross-Tenant Promotion Execution v1` -> anchored by `specs/043-cross-tenant-compare-and-promotion/spec.md`
- `Governance Decision Pack & Approval Workflow v1` -> anchored by `specs/257-governance-decision-convergence/spec.md` and `docs/product/roadmap.md`
- `Customer-Facing Localization Adoption v1` -> anchored by `specs/252-platform-localization-v1/spec.md`, `specs/258-customer-review-productization/spec.md`, and `specs/260-governance-service-packaging/spec.md`
- `Billing & Subscription Truth Layer v1` -> anchored by `specs/247-plans-entitlements-billing-readiness/spec.md` and `specs/251-commercial-entitlements-billing-state/spec.md`
>>>>>>> da5b12ae (docs: realign implementation ledger)
- `Stored Reports Surface v1` -> anchored by `specs/153-evidence-domain-foundation/spec.md`, `specs/155-tenant-review-layer/spec.md`, `specs/260-governance-service-packaging/spec.md`, and `docs/product/implementation-ledger.md` - `Stored Reports Surface v1` -> anchored by `specs/153-evidence-domain-foundation/spec.md`, `specs/155-tenant-review-layer/spec.md`, `specs/260-governance-service-packaging/spec.md`, and `docs/product/implementation-ledger.md`
- `Workspace & Tenant Closure Lifecycle v1` -> anchored by `specs/262-lifecycle-governance-taxonomy/spec.md` - `Workspace & Tenant Closure Lifecycle v1` -> anchored by `specs/262-lifecycle-governance-taxonomy/spec.md`
- `First Governed AI Runtime Consumer v1` -> anchored by `specs/248-private-ai-policy-foundation/spec.md` - `First Governed AI Runtime Consumer v1` -> anchored by `specs/248-private-ai-policy-foundation/spec.md`
## Roadmap Drift Notes ## Roadmap Drift Notes
- `docs/product/roadmap.md` and `docs/product/spec-candidates.md` were corrected on 2026-05-02 to reflect manual-promotion-only backlog handling and repo-real follow-through on compare/preflight, governance-package delivery, compliance overlays, commercial lifecycle handling, support handoff, and AI foundation. - `docs/product/roadmap.md` and `docs/product/spec-candidates.md` were corrected on 2026-05-06 to reflect the cross-domain indicator candidate intake, the current manual-promotion backlog, and the resolved ledger conflict state.
- The remaining documentation risk is no longer queue drift alone; it is overstating sellability on still-open follow-through slices such as auditor-ready export, promotion execution, governance decision packs, billing/subscription truth, stored reports surface, and the first governed AI runtime consumer. - The remaining documentation risk is no longer queue drift alone; it is overstating sellability on still-open follow-through slices such as auditor-ready export, promotion execution, governance decision workflow, cross-domain indicator semantics, billing/subscription truth, stored reports surface, and the first governed AI runtime consumer.
- This ledger therefore treats review-driven governance and portfolio preparation as `fast sellable` or `implemented but not productized`, not `sellable`, until those explicit manual-promotion slices land. - This ledger therefore treats review-driven governance and portfolio preparation as `fast sellable` or `implemented but not productized`, not `sellable`, until those explicit manual-promotion slices land.
- Tests referenced here remain repo-present only. They were not executed for this ledger update. - Tests referenced here remain repo-present only. They were not executed for this ledger update.

View File

@ -1,10 +1,10 @@
# Product Roadmap # Product Roadmap
> **Status:** Active > **Status:** Active
> **Last reviewed:** 2026-05-02 > **Last reviewed:** 2026-05-06
> **Use for:** Current product roadmap, release themes, and prioritization context > **Use for:** Current product roadmap, release themes, and prioritization context
> **Do not use for:** Implementation truth, spec completion status, or delivery guarantees without repo verification > **Do not use for:** Implementation truth, spec completion status, or delivery guarantees without repo verification
> **Scoped maintenance:** 2026-05-02 repo-based roadmap drift correction, manual-promotion backlog alignment, and enterprise-SaaS deep-research calibration against current specs, standards, and product-truth docs. > **Scoped maintenance:** 2026-05-06 roadmap cleanup after ledger conflict resolution and cross-domain progress / indicator semantics candidate intake; 2026-05-02 repo-based roadmap drift correction, manual-promotion backlog alignment, and enterprise-SaaS deep-research calibration against current specs, standards, and product-truth docs.
> >
> Strategic thematic blocks and release trajectory. > Strategic thematic blocks and release trajectory.
> This is the "big picture" — not individual specs. > This is the "big picture" — not individual specs.
@ -65,6 +65,8 @@ ### UI & Product Maturity Polish
Empty state consistency, list-expand parity, workspace chooser refinement, navigation semantics. Empty state consistency, list-expand parity, workspace chooser refinement, navigation semantics.
Goal: Every surface feels intentional and guided for first-run evaluation. Goal: Every surface feels intentional and guided for first-run evaluation.
Parallel manual-promotion guardrail: `docs/product/spec-candidates.md` now carries a dedicated Cross-Domain Progress / Indicator Semantics candidate group so progress, coverage, readiness, risk, usage, score, and generation-state surfaces do not keep drifting behind OperationRun-specific rules.
**Active specs**: 122, 121, 112 **Active specs**: 122, 121, 112
### Secret & Security Hardening ### Secret & Security Hardening
@ -422,6 +424,8 @@ ## Priority Ranking (Current Manual Promotion Order)
This ranking applies only to still-unspecced or still-manual follow-through items. Auditor-ready delivery and cross-tenant promotion execution already have spec packages and therefore no longer belong in the manual-promotion ordering list. This ranking applies only to still-unspecced or still-manual follow-through items. Auditor-ready delivery and cross-tenant promotion execution already have spec packages and therefore no longer belong in the manual-promotion ordering list.
Parallel immediate guardrail lane: the Cross-Domain Progress / Indicator Semantics candidate group in `docs/product/spec-candidates.md` should be promoted alongside OperationRun maturity when UI semantic drift is the active concern. It stays outside the main sellability ordering below because it is a cross-cutting semantics and standards package rather than a standalone customer-facing delivery lane.
1. Decision Register & Approval Workflow v1 1. Decision Register & Approval Workflow v1
2. Governance Artifact Lifecycle & Retention v1 2. Governance Artifact Lifecycle & Retention v1
3. Billing & Subscription Truth Layer v1 3. Billing & Subscription Truth Layer v1

View File

@ -1,10 +1,10 @@
# Spec Candidates # Spec Candidates
> **Status:** Active > **Status:** Active
> **Last reviewed:** 2026-05-04 > **Last reviewed:** 2026-05-06
> **Use for:** The active repo-based queue of spec candidates that may still need new or refreshed specs > **Use for:** The active repo-based queue of spec candidates that may still need new or refreshed specs
> **Do not use for:** Proof that a candidate is already specced, implemented, or prioritized above the roadmap without repo verification > **Do not use for:** Proof that a candidate is already specced, implemented, or prioritized above the roadmap without repo verification
> **Scoped maintenance:** 2026-05-04 OperationRun progress maturity plus Tenant Dashboard active-operations summary candidate intake; 2026-05-03 OperationRun activity feedback candidate intake plus the 2026-05-02 repo-based queue re-audit and enterprise-SaaS deep-research alignment against current `specs/` truth, including Specs 263 and current-branch 264. > **Scoped maintenance:** 2026-05-06 cross-domain progress and indicator semantics candidate intake; 2026-05-04 OperationRun progress maturity plus Tenant Dashboard active-operations summary candidate intake; 2026-05-03 OperationRun activity feedback candidate intake plus the 2026-05-02 repo-based queue re-audit and enterprise-SaaS deep-research alignment against current `specs/` truth, including Specs 263 and current-branch 264.
> >
> Repo-based next-spec queue for TenantPilot. > Repo-based next-spec queue for TenantPilot.
> This file is not a wishlist. It tracks only open gaps that are still worth turning into new or refreshed specs. > This file is not a wishlist. It tracks only open gaps that are still worth turning into new or refreshed specs.
@ -448,6 +448,169 @@ ##### 272 — OperationRun Phase & Composite Progress v1
- `apps/platform/tests/Feature/Notifications/OperationRunNotificationTest.php` - `apps/platform/tests/Feature/Notifications/OperationRunNotificationTest.php`
- `apps/platform/tests/Feature/Monitoring/OperationsDashboardDrillthroughTest.php` - `apps/platform/tests/Feature/Monitoring/OperationsDashboardDrillthroughTest.php`
### Cross-Domain Progress / Indicator Semantics candidate group
- **Priority posture**: immediate manual-promotion audit plus standards guardrail in parallel with OperationRun Activity Feedback, then contract, shared components, quality gates, and domain migration
- **Repo truth**: Specs 268, 270, 271, and 272 now sharpen OperationRun-specific execution semantics, but the repo already contains other progress-like seams with different meanings: execution activity in `BulkOperationProgress`, coverage truth in `InventoryKpiHeader`, readiness messaging in `RecoveryReadiness` and `CustomerReviewWorkspace`, coverage summaries in `BaselineSnapshotPresenter`, and usage signals passed through `ReviewPackService`. `docs/ui/tenantpilot-enterprise-ui-standards.md` currently codifies the OperationRun activity-feedback pattern, but it does not yet provide one product-wide indicator taxonomy above those domain slices.
- **Why promotable now**: the OperationRun candidates close run-specific UI drift, but they do not yet tell TenantPilot how to distinguish execution progress from coverage, completion, readiness, risk, pressure, usage, score, and generation state across the rest of the product.
- **Why manual promotion only**: the first safe slice must stay taxonomy-first and repo-grounded. It should inventory and classify existing indicators before introducing a shared contract or component family, and it must not silently turn into a charting project, analytics rewrite, score recalculation program, or broad design-system reboot.
- **Primary manual-promotion target (promote together)**:
- **Candidate 8 — Cross-Domain Progress Indicator Semantics Audit**
- inventory every progress-like bar, percentage, score, health meter, readiness label, usage indicator, and generation-state hint across current repo surfaces
- classify each instance as execution progress, activity state, coverage, completion, health/readiness, risk/pressure, usage/capacity, score, generation state, or unknown/ambiguous
- produce the inventory table, risk matrix, cleanup list, shared-component recommendation set, and standards-delta input that later specs can reuse
- **Candidate 13 — Cross-Domain UI Standards & Constitution Patch**
- extend `docs/ui/tenantpilot-enterprise-ui-standards.md` with product-wide indicator semantics rules that sit above individual domains
- define allowed indicator categories, direction rules, determinate-progress eligibility, missing/stale-data rules, dashboard constraints, customer-safe wording, and anti-patterns such as fake progress or usage bars that look like success progress
- add a constitution pointer only if repeated indicator drift proves the standards patch alone is not enough
- **Recommended promotion order after the audit pair**:
1. **Candidate 9 — Metric & Indicator Contract Foundation**
2. **Candidate 10 — Shared Progress / Indicator Component System**
3. **Candidate 12 — Progress / Indicator Quality Gates**
4. **Candidate 11 — Domain Progress Cleanup & Migration Pass**
##### Candidate 8 — Cross-Domain Progress Indicator Semantics Audit
- **Classification**:
- repo-verified audit candidate over existing indicator surfaces
- gap: the product has multiple progress-like cues with different meanings, but no single inventory or semantics classification
- maturity: immediate enterprise UX audit and cleanup foundation
- **Problem**: the same percentage or bar can currently mean incompatible things depending on the surface: execution progress, coverage, review completion, readiness, health, usage, or risk. Without an explicit inventory and classification pass, later component or contract work will still miss ambiguous surfaces and repeat local interpretation drift.
- **Goal**: inventory every current progress-like indicator repo-based and classify what it actually measures.
- **Scope**:
- include OperationRuns, dashboard and tenant dashboard surfaces, workspace or tenant overview, operations-adjacent widgets, baseline compare and drift surfaces, evidence and review surfaces, provider health/readiness, permissions posture, stored reports, supportability, commercial entitlements, and cross-tenant or portfolio views where current repo truth already exposes indicator-like cues
- for every discovered indicator capture: file or component, surface, domain, visible label, visual pattern, data source, calculation basis, category, determinism, likely user interpretation, misunderstanding risk, and recommendation
- classify each instance into exactly one of: execution progress, activity state, coverage, completion, health, readiness, risk, pressure, usage, capacity, score, generation state, or unknown/ambiguous
- **Out of scope**:
- no component migration yet
- no score recalculation program
- no analytics module or charting layer
- **Acceptance criteria**:
- every progress-like indicator found in current repo surfaces is inventoried repo-based
- every inventoried indicator has a documented semantic category
- ambiguous indicators are explicitly marked for cleanup or product decision follow-up
- OperationRun progress is clearly separated from coverage, readiness, risk, score, usage, and generation-state semantics
##### Candidate 9 — Metric & Indicator Contract Foundation
- **Classification**:
- foundation candidate
- gap: new and existing indicators can still render naked values with no contract for interpretation, freshness, or next action
- maturity: enterprise UX and correctness foundation
- **Problem**: a percentage or score without category, direction, freshness, and explanation invites the wrong operator conclusion. `80%` can read as success, risk, consumption, or incomplete work depending on context, and the UI has no shared contract to disambiguate it.
- **Goal**: define one provider-neutral indicator contract so values carry their semantics with them.
- **Core contract fields**:
- `id`, `domain`, `label`, `description`, `category`, `value`, `unit`, `max_value`, `percentage`, `direction`, `severity`, `freshness`, `source`, `calculation_basis`, `confidence`, `missing_data_reason`, `reason`, `impact`, `next_action_label`, `next_action_url`, `last_updated_at`
- **Required semantics**:
- categories must distinguish execution progress, activity state, coverage, completion, health, readiness, risk, pressure, usage, capacity, score, and generation state
- direction must state whether higher is better, lower is better, threshold-based, neutral, inverted, or not numeric
- freshness must state whether data is fresh, stale, unknown, or not applicable
- missing or stale data must not render as a healthy `0%` default
- **Acceptance criteria**:
- one documented contract exists as DTO, presenter, view model, or value object chosen repo-based
- new indicator work must use that contract or document why not
- category, direction, and stale/missing-data semantics become explicit instead of local conventions
##### Candidate 10 — Shared Progress / Indicator Component System
- **Classification**:
- shared-component candidate
- gap: even with a contract, domains can still reinvent visuals and blur semantics again
- maturity: Filament-native UX system follow-through
- **Problem**: once indicator semantics are defined, local Blade, Livewire, or widget implementations can still drift visually if every domain builds its own bars, colors, and labels.
- **Goal**: create a small family of Filament-compatible indicator components chosen by category, not one universal bar.
- **Proposed family**:
- execution progress indicator
- activity state indicator
- coverage indicator
- completion tracker
- health/readiness indicator
- risk/pressure indicator
- usage/capacity indicator
- score indicator
- generation-state indicator
- **Guardrails**:
- category determines which component is allowed
- color semantics must follow direction and category instead of one generic fill rule
- dashboards stay decision-first and summary-first; detail belongs on detail pages
- accessibility must not depend on color alone
- **Acceptance criteria**:
- categories are visually distinguishable
- components consume the shared indicator contract
- no component family implies fake execution progress for risk, health, usage, or generation states without deterministic data
##### Candidate 11 — Domain Progress Cleanup & Migration Pass
- **Classification**:
- migration and cleanup candidate
- gap: the audit and shared system are not useful if legacy domain surfaces keep their old ad hoc indicators
- maturity: bounded adoption rollout
- **Problem**: old domain-specific indicators will continue to confuse operators unless the product migrates or explicitly retires them after the shared semantics work lands.
- **Goal**: migrate existing domains in bounded passes from the audit inventory onto the new contract and component family.
- **Scope strategy**:
- derive one migration inventory from Candidate 8 with classes such as quick fix, component migration, copy-only change, needs product decision, or defer
- implement the rollout domain by domain instead of in one giant PR
- preferred order: dashboard and tenant dashboard, operations-adjacent widgets, evidence and reviews, provider health and permissions, governance and drift, customer health, commercial entitlements, portfolio or cross-tenant views, and reports or supportability
- **Guardrails**:
- domains keep their own product meaning; they only adopt shared indicator semantics
- label cleanup must prefer specific nouns such as evidence coverage, review completion, provider readiness, permission risk, plan usage, and report generation over generic `Progress` or `Score`
- **Acceptance criteria**:
- ambiguous indicators from the audit are either migrated or explicitly deferred with a reason
- progressbar semantics are no longer misused for risk, pressure, readiness, or usage in migrated domains
- dashboard surfaces remain calm and customer-safe where required
##### Candidate 12 — Progress / Indicator Quality Gates
- **Classification**:
- guardrail and quality-gate candidate
- gap: later contributors can reintroduce local indicator drift even after the first migration wave
- maturity: prevention and regression safety
- **Problem**: without review and test guardrails, new surfaces can still ship their own percentages, widths, and score visuals with no shared meaning.
- **Goal**: make new or changed indicator drift visible early through static checks, contract tests, smoke coverage, and review guidance.
- **Candidate guardrails**:
- static scan for progress-like UI primitives and keywords
- shared component usage rule for new indicator surfaces
- contract tests that reject invalid determinate progress or missing semantics
- focused browser smoke checks on key dashboard, review, provider, and commercial surfaces
- UX checklist that asks what is measured, whether higher is better, how fresh the data is, what happens when data is missing, and what action the operator should take next
- **Acceptance criteria**:
- new or changed indicator surfaces surface deviations visibly
- invalid determinate progress, missing semantics, and stale-data handling are testable
- legacy findings are advisory until migrated, but new or changed work is expected to follow the shared rules
##### Candidate 13 — Cross-Domain UI Standards & Constitution Patch
- **Classification**:
- documentation and standards candidate
- gap: current standards document has OperationRun-specific progress rules but no durable product-wide indicator taxonomy
- maturity: immediate guardrail follow-through
- **Problem**: without a written product-wide standard, later specs and implementations will keep encoding indicator meaning locally.
- **Goal**: patch `docs/ui/tenantpilot-enterprise-ui-standards.md` with the canonical cross-domain rules for bars, percentages, scores, meters, readiness states, risk indicators, and generation states.
- **Patch focus**:
- principle: every indicator must state what it measures and how to interpret it
- allowed indicator categories
- direction rules
- determinate-progress eligibility rules
- missing and stale data treatment
- dashboard and customer-safe indicator rules
- anti-patterns such as fake progressbars, unexplained scores, stale data shown as current truth, and usage bars that read like success progress
- **Acceptance criteria**:
- `docs/ui/tenantpilot-enterprise-ui-standards.md` becomes the canonical reference for cross-domain indicator semantics
- future specs can reference one durable standard instead of rewriting indicator rules locally
- **Anchors**:
- `specs/268-operationrun-activity-feedback/spec.md`
- `specs/270-operationrun-progress-contract/spec.md`
- `specs/271-counted-progress-rollout/spec.md`
- `specs/272-operationrun-phase-composite-progress/spec.md`
- `apps/platform/app/Livewire/BulkOperationProgress.php`
- `apps/platform/app/Filament/Widgets/Inventory/InventoryKpiHeader.php`
- `apps/platform/app/Filament/Widgets/Dashboard/RecoveryReadiness.php`
- `apps/platform/app/Filament/Pages/Reviews/CustomerReviewWorkspace.php`
- `apps/platform/app/Services/Baselines/SnapshotRendering/BaselineSnapshotPresenter.php`
- `apps/platform/app/Services/ReviewPackService.php`
- `docs/ui/tenantpilot-enterprise-ui-standards.md`
### Decision Register & Approval Workflow v1 ### Decision Register & Approval Workflow v1
- **Priority**: 1 - **Priority**: 1

View File

@ -0,0 +1,54 @@
# Specification Quality Checklist: Stored Reports Surface v1
**Purpose**: Validate specification completeness, boundedness, and readiness before implementation
**Created**: 2026-05-06
**Feature**: [spec.md](../spec.md)
## Content Quality
- [x] The package stays on repo-real stored-report truth instead of inventing a report engine, analytics console, or generic artifact framework.
- [x] The spec remains product- and behavior-oriented rather than reading like a low-level code diff.
- [x] The package explicitly names the repo-real anchors it builds on: `StoredReport`, `ArtifactTruthPresenter`, `AdminRolesSummaryWidget`, `EntraAdminRolesReportService`, and `PermissionPostureFindingGenerator`.
- [x] Mandatory repo sections for scope, RBAC, shared-pattern reuse, testing, proportionality, and candidate rationale are completed.
## Requirement Completeness
- [x] No `[NEEDS CLARIFICATION]` markers remain.
- [x] Requirements are testable and bounded to one tenant register, one read-only detail surface, two supported report families, one new read capability, and current repo-real drilldown seams only.
- [x] The package explicitly forbids report generation, raw export, global search, cross-tenant browse, and lifecycle mutation.
- [x] The package keeps evidence snapshots, tenant reviews, review packs, and stored reports as separate artifacts.
- [x] Canonical proof commands match across `spec.md`, `plan.md`, `quickstart.md`, and `tasks.md`.
## Repo Truth Anchoring
- [x] The package reflects that `StoredReport` already exists and is tenant-owned with both `workspace_id` and `tenant_id`.
- [x] The package reflects that `ArtifactTruthPresenter::forStoredReport()` already provides current versus historical retained lifecycle truth.
- [x] The package reflects that `AdminRolesSummaryWidget` currently resolves report data but leaves `viewReportUrl` unset.
- [x] The package does not assume a broader existing Filament stored-report viewer than the repo currently shows.
## Feature Readiness
- [x] The package keeps Filament on Livewire v4, provider registration unchanged in `apps/platform/bootstrap/providers.php`, stored-report global search disabled, and assets unchanged.
- [x] The package keeps authorization tenant-scoped and family-aware, with non-members denied as `404` and in-scope capability denials as `403`.
- [x] The package introduces only one new bounded capability, `permission_posture.view`, rather than a generic reporting permission family.
- [x] V1 stays limited to the two supported report families, and any unexpected family remains outside browse and detail scope until a follow-up spec expands support.
## Test Governance
- [x] Planned proof stays bounded to focused `Feature` suites plus one updated widget test.
- [x] No new heavy-governance or browser family is introduced by default.
- [x] Fixture growth remains bounded to existing tenant, membership, and stored-report factory setup.
- [x] The review outcome, workflow outcome, and test-governance outcome are carried into `plan.md` and `tasks.md`.
## Notes
- Reviewed against `.specify/memory/constitution.md`, `docs/product/spec-candidates.md`, `docs/product/roadmap.md`, `specs/267-artifact-lifecycle-retention/spec.md`, and current stored-report, widget, evidence, and review code under `apps/platform` on 2026-05-06. `docs/product/implementation-ledger.md` was not used as candidate source-of-truth because the current section contains unresolved conflict markers.
- No application implementation was performed while preparing this package.
## Review Outcome
- **Outcome class**: `acceptable-special-case`
- **Workflow outcome**: `keep`
- **Test-governance outcome**: `keep`
- **Reason**: The package productizes one real operator gap on top of existing stored-report truth, stays read-only, and resists drift into generic reporting infrastructure.
- **Workflow result**: Ready for implementation.

View File

@ -0,0 +1,296 @@
openapi: 3.0.3
info:
title: TenantPilot Admin - Stored Reports Surface v1 (Conceptual)
version: 0.1.0
description: |
Conceptual contract for the tenant-scoped stored-reports register and
detail surface.
NOTE: These routes are implemented as Filament resource pages and
Livewire-backed interactions inside the existing tenant panel. The file
captures logical route boundaries, family-aware visibility rules, and the
canonical drilldown destination. Exact Livewire payloads are out of scope.
paths:
/t/{tenant}/stored-reports:
servers:
- url: /admin
get:
summary: View the stored-reports register for the active tenant
description: |
The register is visible only when the actor is a member of the active
tenant and has at least one supported report-family read capability.
V1 register rows are limited to families with explicit capability
mapping:
- `permission_posture` -> `permission_posture.view`
- `entra.admin_roles` -> `entra_roles.view`
Unexpected future report families stay outside v1 register visibility.
parameters:
- $ref: '#/components/parameters/TenantId'
- name: family
in: query
required: false
schema:
type: string
enum: [permission_posture, entra.admin_roles]
- name: history
in: query
required: false
schema:
type: boolean
- name: search
in: query
required: false
schema:
type: string
responses:
'200':
description: Stored-reports register rendered
content:
text/html:
schema:
type: string
x-logical-view-model:
$ref: '#/components/schemas/StoredReportListView'
'403':
$ref: '#/components/responses/Forbidden'
'404':
$ref: '#/components/responses/NotFound'
x-capability-rules:
collection_visibility: actor must have at least one supported report-family capability
row_visibility:
permission_posture: permission_posture.view
entra.admin_roles: entra_roles.view
/t/{tenant}/stored-reports/{report}:
servers:
- url: /admin
get:
summary: View one stored report for the active tenant
description: |
This is the canonical drilldown destination for repo-real stored-report
launch seams such as the tenant admin-roles widget.
Detail access remains tenant-scoped and family-aware. Only the two
supported families can reach the v1 detail route.
parameters:
- $ref: '#/components/parameters/TenantId'
- $ref: '#/components/parameters/StoredReportId'
responses:
'200':
description: Stored-report detail rendered
content:
text/html:
schema:
type: string
x-logical-view-model:
$ref: '#/components/schemas/StoredReportDetailView'
'403':
$ref: '#/components/responses/Forbidden'
'404':
$ref: '#/components/responses/NotFound'
x-capability-rules:
permission_posture: permission_posture.view
entra.admin_roles: entra_roles.view
components:
parameters:
TenantId:
name: tenant
in: path
required: true
schema:
type: integer
StoredReportId:
name: report
in: path
required: true
schema:
type: integer
responses:
Forbidden:
description: Actor is a member of the tenant scope but lacks the required report-family capability
NotFound:
description: Wrong tenant, wrong workspace context, or non-member access is hidden as not found
schemas:
StoredReportListView:
type: object
required:
- tenant_id
- filters
- rows
properties:
tenant_id:
type: integer
filters:
type: object
properties:
supported_families:
type: array
items:
type: string
enum: [permission_posture, entra.admin_roles]
history:
type: boolean
search:
type: string
rows:
type: array
items:
$ref: '#/components/schemas/StoredReportRow'
StoredReportRow:
type: object
required:
- id
- display_reference
- report_type
- report_family_label
- lifecycle_state
- retention_state
- measured_at
- summary_highlights
properties:
id:
type: integer
display_reference:
type: string
report_type:
type: string
report_family_label:
type: string
lifecycle_state:
type: string
enum: [current, historical]
retention_state:
type: string
enum: [retained]
measured_at:
type: string
format: date-time
summary_highlights:
type: array
items:
type: object
required: [label, value]
properties:
label:
type: string
value:
type: string
StoredReportDetailView:
type: object
required:
- report
- artifact_truth
- summary_branch
- raw_payload
properties:
report:
type: object
required:
- id
- display_reference
- report_type
- report_family_label
- measured_at
- lifecycle_state
- retention_state
properties:
id:
type: integer
display_reference:
type: string
report_type:
type: string
report_family_label:
type: string
measured_at:
type: string
format: date-time
lifecycle_state:
type: string
enum: [current, historical]
retention_state:
type: string
enum: [retained]
integrity_anchor:
type: string
nullable: true
previous_fingerprint:
type: string
nullable: true
current_report_url:
type: string
nullable: true
artifact_truth:
type: object
description: Existing ArtifactTruthPresenter envelope rendered for the stored report
summary_branch:
oneOf:
- $ref: '#/components/schemas/PermissionPostureSummary'
- $ref: '#/components/schemas/EntraAdminRolesSummary'
raw_payload:
type: object
description: Present but collapsed by default in the rendered UI
PermissionPostureSummary:
type: object
required:
- family
- posture_score
- required_count
- granted_count
- missing_count
properties:
family:
type: string
enum: [permission_posture]
posture_score:
type: integer
nullable: true
required_count:
type: integer
granted_count:
type: integer
missing_count:
type: integer
at_risk_permissions:
type: array
items:
type: object
properties:
key:
type: string
status:
type: string
features:
type: array
items:
type: string
EntraAdminRolesSummary:
type: object
required:
- family
- roles_total
- assignments_total
- high_privilege_assignments
properties:
family:
type: string
enum: [entra.admin_roles]
roles_total:
type: integer
assignments_total:
type: integer
high_privilege_assignments:
type: integer
highest_risk_assignment:
type: object
nullable: true
properties:
role_display_name:
type: string
principal_display_name:
type: string
severity:
type: string
directory_scope_id:
type: string

View File

@ -0,0 +1,164 @@
# Data Model: Stored Reports Surface v1
**Date**: 2026-05-06
**Branch**: `277-stored-reports-surface`
## Overview
This slice introduces no new persisted entity. `StoredReport` remains the only stored-report source of truth. The new surface adds tenant-scoped, read-only view models over that truth and keeps downstream evidence, review, and review-pack consumers separate.
## Existing Persisted Truth
### 1. Stored Report
**Persistence**: Existing `stored_reports` table
**Ownership**: Tenant-owned
**Scope**: Many retained rows per tenant and report family
| Field | Type | Nullable | Notes |
|-------|------|----------|-------|
| `id` | bigint | no | Internal stored-report id |
| `workspace_id` | bigint | no | Required workspace isolation anchor |
| `tenant_id` | bigint | no | Required tenant isolation anchor |
| `report_type` | string | no | Current v1 supported values: `permission_posture`, `entra.admin_roles` |
| `payload` | jsonb | no | Family-specific report payload |
| `fingerprint` | string | yes | Integrity anchor for the current row |
| `previous_fingerprint` | string | yes | Historical lineage anchor |
| `created_at` | datetime | yes | Persisted creation time |
| `updated_at` | datetime | yes | Standard timestamp |
**Behavior rules**:
- Stored reports are immutable retained artifacts after creation.
- `current` versus `historical` is derived by comparing the row with the latest row for the same `tenant_id` and `report_type`.
- `retention_state` stays derived as `retained` in this slice.
- No new persisted lifecycle, publication, or browse metadata is introduced.
### 2. Evidence Snapshot Item Source Reference
**Persistence**: Existing `evidence_snapshot_items.source_record_type` and `source_record_id` fields
**Ownership**: Existing evidence domain
These fields remain downstream identity anchors when evidence items point to a stored report. They are context only in this slice and do not create an additional v1 launch seam.
### 3. Review Pack / Tenant Review Consumption
**Persistence**: Existing review-pack and tenant-review summaries
**Ownership**: Existing reporting domains
These consumers remain separate business truth. The stored-report surface may describe that stored reports are reused downstream, but it does not converge their operator routes in v1.
## Derived Read Models
### 4. Stored Report Row Summary
**Persistence**: none, derived at runtime
**Owner**: stored-report register
| Field | Type | Required | Notes |
|-------|------|----------|-------|
| `report_id` | int | yes | Backed by `stored_reports.id` |
| `display_reference` | string | yes | `Stored report #{id} ({family label})` from existing artifact-truth wording |
| `report_type` | string | yes | Raw family key |
| `report_family_label` | string | yes | Headline-style label such as `Permission posture report` |
| `lifecycle_state` | string | yes | `current` or `historical` from artifact truth |
| `retention_state` | string | yes | `retained` |
| `measured_at` | datetime | yes | Derived from payload timestamp when present, otherwise `created_at` |
| `summary_highlights` | list | yes | Bounded, family-specific summary facts |
| `history_visibility` | bool | yes | Whether the row is included only when history is revealed |
### 5. Stored Report Detail View
**Persistence**: none, derived at runtime
**Owner**: stored-report detail page
| Field | Type | Required | Notes |
|-------|------|----------|-------|
| `artifact_truth` | array | yes | Reused `ArtifactTruthPresenter::forStoredReport()` envelope |
| `display_reference` | string | yes | Stable stored-report reference |
| `report_family_label` | string | yes | Headline family label |
| `measured_at` | datetime | yes | Payload timestamp or `created_at` |
| `integrity_anchor` | string | no | `fingerprint`, shown when present |
| `previous_fingerprint` | string | no | Historical lineage anchor |
| `current_report_id` | int | no | Latest same-family row when viewing a historical record |
| `current_report_url` | string | no | Canonical detail URL for the current row |
| `summary_branch` | object | yes | One of the two supported family-specific summary shapes below |
| `raw_payload` | array | yes | Present but collapsed by default |
### 6. Permission Posture Summary
**Persistence**: none, derived from `StoredReport.payload`
Payload anchors from current repo truth:
- `posture_score`
- `required_count`
- `granted_count`
- `checked_at`
- `permissions[]` entries with `key`, `type`, `status`, and `features`
Derived summary fields:
| Field | Type | Notes |
|-------|------|-------|
| `posture_score` | int or null | Primary posture score |
| `required_count` | int | Required permissions count |
| `granted_count` | int | Granted permissions count |
| `missing_count` | int | Derived as `required_count - granted_count`, never persisted |
| `at_risk_permissions` | list | First few non-granted permission entries for operator summary |
| `checked_at` | datetime or null | Preferred measurement timestamp when present |
### 7. Entra Admin Roles Summary
**Persistence**: none, derived from `StoredReport.payload`
Payload anchors from current repo truth:
- `measured_at`
- `totals.roles_total`
- `totals.assignments_total`
- `totals.high_privilege_assignments`
- `high_privilege[]` entries with role, principal, scope, and severity fields
Derived summary fields:
| Field | Type | Notes |
|-------|------|-------|
| `roles_total` | int | Total role definitions captured |
| `assignments_total` | int | Total assignments captured |
| `high_privilege_assignments` | int | Count of privileged assignments |
| `highest_risk_assignment` | array or null | First highest-severity assignment for operator context |
| `measured_at` | datetime or null | Preferred measurement timestamp |
### 8. Stored Report Launch Seam
**Persistence**: none, derived from existing operator routes
| Field | Type | Required | Notes |
|-------|------|----------|-------|
| `source_surface` | string | yes | Current v1 source surface name |
| `target_url` | string | yes | Canonical stored-report detail URL |
**Current v1 seam**:
- `AdminRolesSummaryWidget` is the only confirmed canonical drilldown source in this slice.
## Derived State Rules
| Rule | Derived Behavior |
|------|------------------|
| Current row selection | Latest row by `created_at desc, id desc` for the same `tenant_id` and `report_type` |
| Lifecycle state | `current` for the latest row, otherwise `historical` |
| Retention state | `retained` for v1 stored-report browsing |
| Measured time | `payload.measured_at` or `payload.checked_at` when available, otherwise `created_at` |
| Register visibility | Only rows with an explicit family-to-capability mapping are listed |
| Detail authorization | Family-aware capability check after workspace and tenant membership is established |
| Unexpected report family | Outside v1 browse and detail scope until a follow-up spec adds support |
## Boundaries Explicitly Preserved
- No new report-generation, export, or lifecycle-mutation truth is introduced.
- Evidence snapshots, tenant reviews, review packs, and stored reports remain separate artifacts.
- Current versus historical stays derived and is not persisted as a new domain state.
- No generic report registry, schema catalog, or renderer framework is created.
- Evidence, review, and review-pack routes remain context only and are not v1 convergence targets.

View File

@ -0,0 +1,280 @@
# Implementation Plan: Stored Reports Surface v1
**Branch**: `277-stored-reports-surface` | **Date**: 2026-05-06 | **Spec**: [spec.md](./spec.md)
**Input**: Feature specification from `specs/277-stored-reports-surface/spec.md`
## Summary
Prepare one bounded tenant-scoped browse and inspect surface over the repos existing `StoredReport` truth. The narrow implementation path is to add one read-only Filament resource with a register and a view page under the tenant panel, reuse `ArtifactTruthPresenter::forStoredReport()` for current versus historical retained lifecycle truth, introduce one explicit `permission_posture.view` capability alongside the existing `entra_roles.view` gate, and point the current admin-roles widget at the canonical stored-report detail route.
This slice stays explicitly narrow. Filament remains v5 on Livewire v4, provider registration remains unchanged in `apps/platform/bootstrap/providers.php`, the stored-report resource stays out of global search in v1, no new asset registration is expected, and no new report engine, analytics console, cross-tenant hub, raw-download surface, or lifecycle-taxonomy rewrite is planned.
## Inherited Baseline / Explicit Delta
### Inherited baseline
- `StoredReport` already exists as tenant-owned persisted truth with `workspace_id`, `tenant_id`, `report_type`, payload, fingerprint, and previous-fingerprint lineage.
- `ArtifactTruthPresenter::forStoredReport()` already derives the retained lifecycle contract for stored reports and classifies each record as `current` or `historical`.
- `EntraAdminRolesReportService` and `PermissionPostureFindingGenerator` already produce the only two repo-real stored-report families in scope for v1.
- Evidence-source providers already anchor permission-posture and Entra-admin-roles evidence items to `StoredReport` identity via `source_record_type` and `source_record_id`.
- `AdminRolesSummaryWidget` already resolves the latest Entra admin-roles report for the active tenant, but today it leaves `viewReportUrl` unset and therefore lacks a first-class drilldown.
- There is no current first-class Filament stored-report register or detail surface in the tenant panel.
### Explicit delta in this plan
- Add one tenant-scoped, read-only stored-reports register at `/admin/t/{tenant}/stored-reports`.
- Add one tenant-scoped, read-only stored-report detail page at `/admin/t/{tenant}/stored-reports/{report}`.
- Add one explicit `permission_posture.view` capability and keep list and detail visibility family-aware.
- Reuse stored-report lifecycle, retention, and badge semantics from existing artifact-truth helpers instead of creating a second report-status system.
- Adopt the new detail route as the canonical drilldown target from `AdminRolesSummaryWidget`.
- Keep any unexpected report family outside v1 browse and detail scope instead of adding a local fallback renderer.
## Technical Context
**Language/Version**: PHP 8.4, Laravel 12
**Primary Dependencies**: Filament v5, Livewire v4, Pest v4, existing `StoredReport`, `ArtifactTruthPresenter`, `BadgeCatalog`/`BadgeRenderer`, existing tenant-panel resource patterns, current report-producing services for permission posture and Entra admin roles
**Storage**: PostgreSQL via existing `stored_reports`, `evidence_snapshots`, `tenant_reviews`, and `review_packs`; no new persistence planned
**Testing**: Pest v4 feature coverage plus focused updates to an existing widget test
**Validation Lanes**: fast-feedback, confidence
**Target Platform**: Laravel monolith in `apps/platform`, tenant/admin plane under `/admin/t/{tenant}/...`
**Project Type**: web application
**Performance Goals**: keep list and detail DB-only, tenant-scoped, and eager-load-light; avoid new queues, Graph calls, or report regeneration side effects
**Constraints**: no new report engine, no analytics console, no global-search exposure, no cross-tenant browse surface, no raw payload download, no new persisted truth, and no new generic report registry
**Scale/Scope**: 1 new read-only resource surface, 2 supported stored-report families, 1 bounded new capability, and convergence on the current admin-roles widget seam only
## Likely Affected Repo Surfaces
- `apps/platform/app/Filament/Resources/StoredReportResource.php` as the new tenant-scoped read-only resource.
- `apps/platform/app/Filament/Resources/StoredReportResource/Pages/ListStoredReports.php` for the register.
- `apps/platform/app/Filament/Resources/StoredReportResource/Pages/ViewStoredReport.php` for the detail surface.
- `apps/platform/app/Models/StoredReport.php` and `apps/platform/database/factories/StoredReportFactory.php` for query shape and test setup reuse.
- `apps/platform/app/Support/Auth/Capabilities.php` and `apps/platform/app/Services/Auth/RoleCapabilityMap.php` for the new `permission_posture.view` capability and bounded role mapping.
- `apps/platform/app/Support/Ui/GovernanceArtifactTruth/ArtifactTruthPresenter.php` as the existing lifecycle-truth dependency for stored reports, likely reused without broadening the artifact envelope.
- `apps/platform/app/Filament/Widgets/Tenant/AdminRolesSummaryWidget.php` and `apps/platform/resources/views/filament/widgets/tenant/admin-roles-summary.blade.php` for the canonical detail launch target.
- `apps/platform/app/Services/PermissionPosture/PermissionPostureFindingGenerator.php` and `apps/platform/app/Services/EntraAdminRoles/EntraAdminRolesReportService.php` as payload-shape anchors only, not as new workflow scope.
- `apps/platform/app/Services/Evidence/Sources/PermissionPostureSource.php` and `apps/platform/app/Services/Evidence/Sources/EntraAdminRolesSource.php` as source-record anchors that explain downstream consumer truth.
- `apps/platform/tests/Feature/StoredReports/StoredReportResourceTest.php`, `apps/platform/tests/Feature/StoredReports/StoredReportEntitlementEnforcementTest.php`, `apps/platform/tests/Feature/StoredReports/StoredReportDetailPresentationTest.php`, and `apps/platform/tests/Feature/EntraAdminRoles/AdminRolesSummaryWidgetTest.php` as the bounded proving path.
## Filament v5 / Panel Notes
- **Livewire v4.0+ compliance**: The new register and detail surfaces should be native Filament resource pages under Livewire v4. No Livewire v3 compatibility or custom page framework is planned.
- **Provider registration location**: No new panel or provider is introduced. Existing provider registration remains in `apps/platform/bootstrap/providers.php`.
- **Global search**: The stored-report surface stays out of global search in v1. If a resource is used, it should remain `isGloballySearchable = false`, so the Filament view-page rule is not relied on for search exposure.
- **Destructive actions**: None are in scope. The surface remains read-only and must not acquire edit, delete, rerun, or retention-mutation actions.
- **Asset strategy**: No new Filament assets are planned. If a later implementation unexpectedly registers shared assets, deployment remains the standard `cd apps/platform && php artisan filament:assets` path.
## Stored-Report Surface Fit
- Implement the stored-report surface as one native Filament resource family rather than a custom dashboard page or local widget-only view.
- Use a tenant-scoped register as the primary decision surface with clickable rows and no competing inline `View` action.
- Use a read-only view page with an Infolist-style inspection layout rather than a disabled edit form.
- Reuse `ArtifactTruthPresenter::forStoredReport()` for lifecycle and retention truth and keep any extra “current report” lookup local to the detail surface.
- Keep list state bounded to search, report-family filter, and history visibility. Avoid local JavaScript state machines or a second query-string vocabulary beyond what Filament table state needs.
- Keep support limited to the two repo-real report families in v1. Any unexpected family remains outside browse and detail scope until a follow-up spec expands support.
## RBAC / Data Ownership / Auditability Fit
- `StoredReport` remains tenant-owned truth with both `workspace_id` and `tenant_id` required. No workspace-owned mirror, generic artifact table, or reporting aggregate is introduced.
- Collection visibility should require at least one in-scope stored-report family capability for the current tenant.
- Detail access should remain family-aware:
- `entra.admin_roles` uses existing `Capabilities::ENTRA_ROLES_VIEW`
- `permission_posture` adds explicit `Capabilities::PERMISSION_POSTURE_VIEW`
- The new `permission_posture.view` capability should map to the same read-only tenant roles that already consume governance evidence (`owner`, `manager`, `operator`, `readonly`) unless implementation review proves a narrower current-release truth.
- Non-members or actors outside workspace or tenant scope remain `404`. In-scope actors missing the relevant report-family capability remain `403`.
- Read-only browsing does not justify a new audit action family. Auditability for this slice comes from immutable stored-report identity, measured time, fingerprint lineage, and existing lifecycle truth rather than new browse-access logging.
## UI / Surface Guardrail Plan
- **Guardrail scope**: changed surfaces
- **Native vs custom classification summary**: native Filament
- **Shared-family relevance**: evidence/report viewers, navigation entry points, artifact status messaging, and retained-artifact detail disclosure
- **State layers in scope**: page, detail, URL-query
- **Audience modes in scope**: operator-MSP, support-platform
- **Decision/diagnostic/raw hierarchy plan**: decision-first, diagnostics-second, support-raw-third
- **Raw/support gating plan**: raw payload and provider-shaped identifiers remain collapsed and lower-priority on detail; no raw payload appears on the register
- **One-primary-action / duplicate-truth control**: the register keeps one primary open model through row click; the detail surface exposes `Open current report` only when viewing a historical row and avoids repeating lifecycle truth in multiple sections
- **Handling modes by drift class or surface**: review-mandatory
- **Repository-signal treatment**: review-mandatory now; future hard-stop candidate if the slice drifts into a report console, generic viewer framework, or cross-tenant hub
- **Special surface test profiles**: standard-native-filament, shared-detail-family
- **Required tests or manual smoke**: functional-core, state-contract
- **Exception path and spread control**: none planned; any request to widen support beyond the two repo-real families requires a follow-up spec rather than a local fallback
- **Active feature PR close-out entry**: Guardrail
## Shared Pattern & System Fit
- **Cross-cutting feature marker**: yes
- **Systems touched**: `StoredReport`, `ArtifactTruthPresenter`, centralized badge semantics, `AdminRolesSummaryWidget`, and report-producing services as payload anchors
- **Shared abstractions reused**: `ArtifactTruthPresenter`, `ArtifactTruthEnvelope`, `BadgeCatalog`, `BadgeRenderer`, current tenant-panel resource conventions, and the existing report payload shapes already produced by permission posture and Entra admin roles
- **New abstraction introduced? why?**: none planned. If implementation needs a tiny private extraction helper for family summaries, keep it local to the stored-report surface and do not turn it into a registry or shared framework.
- **Why the existing abstraction was sufficient or insufficient**: lifecycle truth, badge semantics, and stored-report identity already exist. What is insufficient today is a first-class tenant browse and inspect destination.
- **Bounded deviation / spread control**: only the two repo-real report families get full summary rendering in v1; additional families require a follow-up spec instead of local fallback logic.
## OperationRun UX Impact
- **Touches OperationRun start/completion/link UX?**: no
- **Central contract reused**: `N/A`
- **Delegated UX behaviors**: `N/A`
- **Surface-owned behavior kept local**: read-only routing and disclosure only
- **Queued DB-notification policy**: `N/A`
- **Terminal notification path**: `N/A`
- **Exception path**: none
## Provider Boundary & Portability Fit
- **Shared provider/platform boundary touched?**: yes
- **Provider-owned seams**: stored-report payload interpretation for `permission_posture` and `entra.admin_roles`
- **Platform-core seams**: `stored report`, `current`, `historical`, `retained`, `measured at`, register/detail routing, and downstream proof-link wording
- **Neutral platform terms / contracts preserved**: `stored report`, `current record`, `historical record`, `retained history`, `measured at`, `open report`
- **Retained provider-specific semantics and why**: the two report-family labels and their bounded summary facts stay visible because they are already current product truth and are not being generalized into a broader platform taxonomy
- **Bounded extraction or follow-up path**: future report-family expansion stays local to this surface until additional repo-real families justify broader normalization
## Constitution Check
*GATE: Must pass before implementation begins and again after the design artifacts are complete.*
- Inventory-first / snapshot truth: PASS. The slice surfaces already-retained report truth only and does not change inventory or snapshot semantics.
- Read/write separation: PASS. The surface is read-only and introduces no mutation path.
- Graph contract path: PASS. No new Graph call, provider client, or scan path is added.
- Deterministic capabilities: PASS. Report-family visibility stays on explicit capability constants and role mappings.
- Workspace and tenant isolation: PASS. `StoredReport` remains tenant-owned and all routes stay tenant-scoped.
- RBAC-UX plane separation: PASS. Everything remains in the tenant/admin plane under `/admin/t/{tenant}/...`; no `/system` crossover is added.
- Destructive action discipline: PASS by non-use. No destructive or mutating actions are introduced.
- Global search safety: PASS. The new surface is not globally searchable in v1.
- OperationRun / Ops-UX: PASS by non-use. Existing scan or generation actions remain on their current surfaces.
- Data minimization: PASS. No new persisted truth or raw payload export is introduced.
- Test governance (TEST-GOV-001): PASS. Proof stays in focused feature coverage and one updated widget test.
- Proportionality / no premature abstraction: PASS. The slice adds one bounded capability and one read-only surface family without a registry, engine, or new persistence.
- Persisted truth (PERSIST-001): PASS. No new table, entity, or stored projection is planned.
- Behavioral state (STATE-001): PASS. Current versus historical remains derived from existing stored-report truth.
- UI semantics / shared pattern first / Filament-native UI: PASS. Native Filament resource pages and existing artifact-truth helpers remain the first path.
- Provider boundary (PROV-001): PASS. Provider-shaped detail stays in family summaries and does not become platform-core route or taxonomy truth.
- Filament / Laravel planning contract: PASS. Filament stays v5 on Livewire v4, provider registration remains in `apps/platform/bootstrap/providers.php`, the stored-report resource stays out of global search, and no asset changes are planned.
**Gate evaluation**: PASS.
**Post-design re-check**: PASS once `research.md`, `data-model.md`, `quickstart.md`, `contracts/tenant-stored-reports-surface.logical.openapi.yaml`, `checklists/requirements.md`, and `tasks.md` are present and aligned with this package.
## Test Governance Check
- **Test purpose / classification by changed surface**: `Feature` for register access, list behavior, detail presentation, and widget drilldown; no default `Browser` family planned
- **Affected validation lanes**: fast-feedback, confidence
- **Why this lane mix is the narrowest sufficient proof**: the slice is a native Filament register plus view surface over existing persisted truth, so list/detail behavior and widget drilldown can be proven in feature tests without widening into browser-only interaction coverage by default
- **Narrowest proving command(s)**:
- `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/StoredReports/StoredReportResourceTest.php tests/Feature/StoredReports/StoredReportEntitlementEnforcementTest.php`
- `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/StoredReports/StoredReportDetailPresentationTest.php tests/Feature/EntraAdminRoles/AdminRolesSummaryWidgetTest.php`
- `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail bin pint --dirty --format agent`
- **Fixture / helper / factory / seed / context cost risks**: low to moderate; reuse current workspace, tenant, membership, and stored-report factory setup instead of introducing full provider or queue fixtures
- **Expensive defaults or shared helper growth introduced?**: no; any family-summary helper should stay local and explicit
- **Heavy-family additions, promotions, or visibility changes**: none planned
- **Surface-class relief / special coverage rule**: standard-native-filament relief for the register; shared-detail-family coverage for the detail page
- **Closing validation and reviewer handoff**: rerun the exact commands above, verify family-aware filtering and `404` versus `403` semantics, verify current versus historical truth is visible before raw diagnostics, confirm `AdminRolesSummaryWidget` launches the canonical detail route, and confirm no global-search exposure or raw-download action appears
- **Budget / baseline / trend follow-up**: none expected beyond a contained feature-local increase
- **Review-stop questions**: did the slice add a generic report framework, did it widen support beyond the two named families, did it leak hidden report families in rows or filter options, and did it turn raw payload into default-visible content
- **Escalation path**: `reject-or-split` if implementation widens into analytics, cross-tenant browse, a report engine, or support for additional report families
- **Active feature PR close-out entry**: Guardrail
- **Why no dedicated follow-up spec is needed**: this package already captures the bounded browse/detail productization of current stored-report truth; broader reporting or customer-facing consumption remains explicitly separate
- **Test-governance outcome**: keep
## Review Checklist Status
- **Review checklist artifact**: `checklists/requirements.md`
- **Review outcome class**: `acceptable-special-case`
- **Workflow outcome**: `keep`
- **Test-governance outcome**: `keep`
- **Escalation rule**: if implementation adds report generation, global search, raw export, cross-tenant browse, or a generic report-schema framework, flip the workflow outcome to `split` or `reject-or-split` before continuing
## Rollout Considerations
- Land the new `permission_posture.view` capability and the stored-report resource shell first so route ownership and authorization are clear before UI polish.
- Keep the canonical entry point on the new tenant stored-reports register and the canonical secondary surface on the stored-report detail page.
- Adopt the widget drilldown next, because `AdminRolesSummaryWidget` is the clearest current repo-real launch seam.
- Keep convergence bounded to `AdminRolesSummaryWidget`; do not add new stored-report links on evidence, review, or review-pack pages in v1.
- Keep the resource out of global search and keep deployment unchanged because no new assets or providers are expected.
## Risk Controls
- Reject any implementation that adds report generation, rerun, schedule, analytics, export, or delete behavior to this surface.
- Reject any implementation that introduces a generic report registry, renderer framework, or new persisted artifact family.
- Reject any implementation that exposes hidden report families through rows, filter options, or error semantics.
- Reject any implementation that shows raw payload or provider identifiers before the calm operator summary.
- Reject any implementation that widens support beyond the two named families without a follow-up spec.
## Research & Design Outputs
- `research.md` records the resource-versus-custom-surface decision, capability choice, canonical drilldown scope, supported-family boundary, and test-lane choice.
- `data-model.md` captures existing `StoredReport` truth plus the derived row, detail, summary, and launch-seam contracts.
- `quickstart.md` provides the bounded reviewer flow and focused validation commands.
- `contracts/tenant-stored-reports-surface.logical.openapi.yaml` captures the logical tenant register and detail routes plus family-aware visibility rules.
- `checklists/requirements.md` records the prep review outcome, workflow outcome, and test-governance outcome.
- `tasks.md` keeps implementation bounded to the read-only stored-report surface and the current admin-roles widget seam.
## Project Structure
### Documentation (this feature)
```text
specs/277-stored-reports-surface/
├── checklists/
│ └── requirements.md
├── contracts/
│ └── tenant-stored-reports-surface.logical.openapi.yaml
├── data-model.md
├── plan.md
├── quickstart.md
├── research.md
├── spec.md
└── tasks.md
```
### Source Code (expected implementation surfaces)
```text
apps/platform/
├── app/
│ ├── Filament/
│ │ ├── Resources/
│ │ │ ├── StoredReportResource.php
│ │ │ └── StoredReportResource/
│ │ │ └── Pages/
│ │ │ ├── ListStoredReports.php
│ │ │ └── ViewStoredReport.php
│ │ └── Widgets/
│ │ └── Tenant/
│ │ └── AdminRolesSummaryWidget.php
│ ├── Models/
│ │ └── StoredReport.php
│ ├── Services/
│ │ ├── Auth/
│ │ │ └── RoleCapabilityMap.php
│ │ ├── EntraAdminRoles/
│ │ │ └── EntraAdminRolesReportService.php
│ │ ├── Evidence/Sources/
│ │ │ ├── EntraAdminRolesSource.php
│ │ │ └── PermissionPostureSource.php
│ │ └── PermissionPosture/
│ │ └── PermissionPostureFindingGenerator.php
│ └── Support/
│ ├── Auth/
│ │ └── Capabilities.php
│ └── Ui/GovernanceArtifactTruth/
│ └── ArtifactTruthPresenter.php
├── database/
│ └── factories/
│ └── StoredReportFactory.php
└── tests/
└── Feature/
├── EntraAdminRoles/
│ └── AdminRolesSummaryWidgetTest.php
└── StoredReports/
├── StoredReportDetailPresentationTest.php
├── StoredReportEntitlementEnforcementTest.php
└── StoredReportResourceTest.php
```
**Structure Decision**: Keep the work inside the existing Laravel monolith and tenant-panel Filament resource conventions. No new base folders, panels, or shared framework layers are justified for this slice.
## Complexity Tracking
| Violation | Why Needed | Simpler Alternative Rejected Because |
|-----------|------------|-------------------------------------|
| none | N/A | The plan stays inside existing stored-report truth, Filament resource conventions, and existing artifact-truth semantics. |

View File

@ -0,0 +1,90 @@
# Quickstart: Stored Reports Surface v1
**Date**: 2026-05-06
**Branch**: `277-stored-reports-surface`
This quickstart is the intended reviewer flow after implementation. It stays bounded to tenant-scoped stored-report browsing, detail inspection, family-aware authorization, and the canonical widget drilldown.
## Prerequisites
1. Start the local platform stack.
- `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail up -d`
2. Ensure one tenant has:
- one current `permission_posture` stored report
- one current `entra.admin_roles` stored report
- one historical `entra.admin_roles` stored report
3. Ensure one actor can view both report families in the tenant, one actor can view only Entra admin roles, and one actor is not a tenant member.
4. Keep `AdminRolesSummaryWidget` available on the tenant overview page so the canonical drilldown can be verified.
## Scenario 1: Browse the tenant stored-reports register
1. Open `/admin/t/{tenant}/stored-reports` as an entitled actor.
2. Confirm the register shows only visible report families for the active tenant.
3. Confirm the current row for each visible family shows:
- report family
- current versus historical truth
- measured time
- concise family summary
4. Reveal history.
5. Confirm historical rows stay readable and clearly distinct from the current row.
6. Filter by one family and search by family label or stored-report reference.
## Scenario 2: Inspect a current permission-posture report
1. Open the current permission-posture row from the register.
2. Confirm the detail page shows stored-report identity, lifecycle truth, retention truth, measured time, and the integrity anchor when present before any raw payload.
3. Confirm the page shows the bounded permission-posture summary:
- posture score
- required count
- granted count
- missing or at-risk permission context
4. Confirm raw payload remains collapsed and secondary.
## Scenario 3: Inspect a historical Entra admin-roles report
1. Open a historical Entra admin-roles row.
2. Confirm the detail page clearly states that the row is retained history and not the current report.
3. Confirm the page shows the bounded Entra admin-roles summary:
- roles total
- assignments total
- high-privilege assignment count
- highest-risk assignment context
4. Confirm the page exposes `Open current report` as the one dominant next action.
## Scenario 4: Verify family-aware authorization and deny semantics
1. Sign in as the actor who can view only Entra admin roles.
2. Confirm the register does not show permission-posture rows or a permission-posture family filter.
3. Attempt to open a permission-posture stored-report detail route directly.
4. Confirm the response is `403` after tenant membership is established.
5. Sign in as the non-member actor and attempt to open the register or a detail route.
6. Confirm the response is `404` and no stored-report presence leaks.
## Scenario 5: Follow the canonical widget drilldown
1. Open the tenant overview page that renders `AdminRolesSummaryWidget`.
2. Confirm the widget exposes a report link only when the actor can view Entra admin roles.
3. Follow the link.
4. Confirm the app opens the canonical stored-report detail route for the current tenant and current Entra admin-roles report.
5. Confirm no additional evidence, review, or review-pack pseudo-view was introduced as part of this slice.
## Targeted Validation Commands
```bash
export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/StoredReports/StoredReportResourceTest.php tests/Feature/StoredReports/StoredReportEntitlementEnforcementTest.php
export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/StoredReports/StoredReportDetailPresentationTest.php tests/Feature/EntraAdminRoles/AdminRolesSummaryWidgetTest.php
export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail bin pint --dirty --format agent
```
## Out of Scope Confirmations
While validating this slice, confirm that implementation does not add or imply:
- report generation, rerun, or scheduling from the stored-report surface
- raw JSON download or export from the stored-report surface
- cross-tenant or workspace-wide stored-report browsing
- global-search exposure for stored reports
- a generic report registry or analytics console
- new local report cards or pseudo-view links on evidence or review pages when no repo-real launch affordance already exists

View File

@ -0,0 +1,61 @@
# Research: Stored Reports Surface v1
**Date**: 2026-05-06
**Branch**: `277-stored-reports-surface`
## Decision 1: Use one tenant-panel Filament resource with list and view pages
- **Decision**: Implement the new surface as one tenant-scoped Filament resource with a register and a read-only view page.
- **Rationale**: The repo already uses tenant-panel Filament resources with clickable-row inspection and infolist-style view pages for read-only registry surfaces. That keeps the slice inside the current UI contract, keeps Livewire behavior native, and avoids a bespoke report-view shell.
- **Alternatives considered**:
- Custom dashboard page or widget-only viewer: rejected because it would create a parallel navigation and interaction model for a standard read-only registry surface.
- Edit-page-as-view workaround: rejected because the constitution requires inspection surfaces to use view/infolist semantics rather than disabled edit forms.
## Decision 2: Reuse `StoredReport` and `ArtifactTruthPresenter` instead of creating new lifecycle truth
- **Decision**: Keep `StoredReport` as the only persisted truth and reuse `ArtifactTruthPresenter::forStoredReport()` for current versus historical retained state.
- **Rationale**: The presenter already computes retained lifecycle truth by comparing the current row to the latest same-family row for the tenant. A second status column, mirror record, or new presenter layer would duplicate truth the repo already has.
- **Alternatives considered**:
- Persist a `current` flag or second lifecycle column on `stored_reports`: rejected because current versus historical is already derivable from existing rows.
- Introduce a new stored-report-specific presenter or envelope system: rejected because `ArtifactTruthPresenter` already covers stored reports.
## Decision 3: Add explicit `permission_posture.view` instead of piggybacking on unrelated capabilities
- **Decision**: Introduce one bounded `permission_posture.view` capability alongside the existing `entra_roles.view` capability.
- **Rationale**: The spec requires family-aware browse and detail visibility. Reusing `provider.view`, `review_pack.view`, or `evidence.view` would blur ownership between the stored-report surface and downstream consumers.
- **Alternatives considered**:
- Reuse provider or evidence capabilities: rejected because the stored-report surface would become coupled to unrelated read paths.
- Introduce one generic `stored_report.view` capability: rejected because the repo already has two concrete families with separate product meanings, and family-aware filtering is part of the feature value.
## Decision 4: Support exactly two family summary branches and keep all other families out of v1
- **Decision**: Render explicit summary branches only for `permission_posture` and `entra.admin_roles`, and keep any unexpected report family entirely outside v1 browse and detail scope.
- **Rationale**: The repo has exactly two concrete stored-report families today. The narrowest correct implementation is explicit presentation for those families only. A local catch-all renderer would still need an authorization story and would widen the surface contract beyond the two repo-real families.
- **Alternatives considered**:
- Generic report-schema registry or plugin renderer: rejected because two concrete cases do not justify a framework.
- Local catch-all renderer for unexpected families: rejected because it widens the browse/detail contract without a bounded authorization story.
## Decision 5: Canonical drilldown stays on the one confirmed widget seam
- **Decision**: Make the stored-report detail route the canonical drilldown target for `AdminRolesSummaryWidget` only.
- **Rationale**: The widget is the clearest current repo-real missing launch point. The codebase does not currently expose broad Filament proof links to stored reports, so the package should not invent new local report cards or pseudo-view links just to claim convergence.
- **Alternatives considered**:
- Widget-only link with no register: rejected because it would leave stored-report browsing fragmented.
- Add new stored-report links to every evidence or review surface up front: rejected because repo truth does not currently show broad operator-facing stored-report launch affordances there.
## Decision 6: Keep proof in focused feature tests and skip browser by default
- **Decision**: Plan feature coverage for register behavior, entitlement behavior, detail presentation, and widget drilldown, with no default browser lane.
- **Rationale**: The surface is native Filament list and view behavior plus a widget link. The main risks are authorization and disclosure rules, not browser-only choreography.
- **Alternatives considered**:
- Browser smoke as a default requirement: rejected because the slice does not introduce a custom interaction model.
- Large unitized presentation layers: rejected because that would create indirection just to make testing easier.
## Final Research Outcome
- One native tenant-panel Filament resource is the narrowest correct surface shape.
- Existing stored-report truth and artifact-truth lifecycle semantics are sufficient; no new persistence or lifecycle framework is needed.
- `permission_posture.view` is the only new capability required.
- Summary rendering stays explicit for the two repo-real families and avoids a registry.
- Canonical drilldown starts and ends in v1 with the admin-roles widget seam.
- Focused feature tests are the honest default proving path.

View File

@ -0,0 +1,372 @@
# Feature Specification: Stored Reports Surface v1
**Feature Branch**: `277-stored-reports-surface`
**Created**: 2026-05-06
**Status**: Ready for implementation
**Input**: User description: "Promote the remaining manual candidate `Stored Reports Surface v1` as one bounded product surface over existing stored-report truth, so operators can browse and consume retained reports without introducing a new reporting engine, analytics console, customer portal, generic artifact framework, or lifecycle rewrite."
## Spec Candidate Check *(mandatory - SPEC-GATE-001)*
- **Problem**: TenantPilot already persists stored reports that feed evidence snapshots, tenant reviews, governance-package delivery, support context, and tenant-level diagnostics, but those artifacts still lack one first-class browse and detail surface.
- **Today's failure**: Operators can infer stored-report truth only indirectly through a tenant widget, evidence and review summaries, support-context identity references, or direct database-style tests. The current repo even shows `AdminRolesSummaryWidget` with `viewReportUrl` unresolved, so retained report truth exists without a calm product entry point.
- **User-visible improvement**: An entitled operator can open one tenant-scoped stored-reports surface, see which retained reports are current or historical, and inspect the report summary safely without dropping into database-style lookup or widget-only dead ends.
- **Smallest enterprise-capable version**: Add one tenant-scoped stored-reports register plus one read-only detail surface over the existing `StoredReport` rows, support the current two report families (`permission_posture` and `entra.admin_roles`) with bounded summary rendering, reuse existing lifecycle and retention truth, and adopt the new detail route as the drilldown target from the current tenant admin-roles widget.
- **Explicit non-goals**: No new report-generation engine, no analytics console, no customer portal, no report authoring flow, no report-edit or delete workflow, no raw JSON download surface, no generic report-schema registry, no artifact-lifecycle rewrite, no cross-tenant portfolio report hub, and no reopening of evidence, review, governance-packaging, or retention specs.
- **Permanent complexity imported**: One new tenant-scoped operator surface family, one bounded per-report-family presentation contract for two existing report types, one new read capability for permission-posture report browsing, and focused feature coverage for browse, detail, deep-link, and authorization behavior.
- **Why now**: The product owner explicitly promoted this remaining manual candidate, the backlog ranks it as priority 6 after higher-ranked manual items that are already specced or closed, and Spec 267 explicitly deferred stored-report browsing as a dedicated follow-up instead of hiding it inside lifecycle work.
- **Why not local**: A widget-only link with no first-class browse surface would repeat the current problem. Stored reports are already shared truth across tenant diagnostics, evidence, and review packaging, so they need one consistent browse and detail contract rather than more point-specific affordances.
- **Approval class**: Workflow Compression
- **Red flags triggered**: New operator surface, new read capability, and report-family-specific presentation logic. Defense: the slice introduces no new persistence, no generic framework, no workflow engine, and no customer-facing expansion; it productizes two repo-real report families only.
- **Score**: Nutzen: 2 | Dringlichkeit: 1 | Scope: 2 | Komplexitaet: 1 | Produktnaehe: 2 | Wiederverwendung: 2 | **Gesamt: 10/12**
- **Decision**: approve
## Spec Scope Fields *(mandatory)*
- **Scope**: tenant
- **Primary Routes**:
- new tenant-scoped stored-reports collection at `/admin/t/{tenant}/stored-reports`
- new tenant-scoped stored-report detail at `/admin/t/{tenant}/stored-reports/{report}`
- existing tenant overview at `/admin/t/{tenant}` as a secondary launch point where the admin-roles widget gains a first-class report link
- **Data Ownership**:
- `StoredReport` remains the tenant-owned persisted source of truth and continues to require both `workspace_id` and `tenant_id`
- evidence snapshots, tenant reviews, review packs, and support bundles remain separate tenant-owned consumers of stored-report truth and do not become re-owned by the new surface
- no new workspace-owned mirror, aggregate store, or generic artifact table is introduced
- **RBAC**:
- workspace membership and tenant entitlement remain the first isolation boundaries
- non-members or actors outside the entitled workspace or tenant scope remain deny-as-not-found (`404`)
- report-family browsing is capability-first and type-aware: `entra.admin_roles` uses existing `Capabilities::ENTRA_ROLES_VIEW`, and `permission_posture` gains a bounded `permission_posture.view` read capability so the new surface does not piggyback on unrelated review or provider gates
- collection visibility requires at least one in-scope stored-report read capability for the current tenant; row visibility and direct detail access remain filtered by report family
- existing manage or rerun actions stay on their origin surfaces and keep their current manage gates; the stored-reports surface itself is read-only in v1
## Cross-Cutting / Shared Pattern Reuse *(mandatory when the feature touches notifications, status messaging, action links, header actions, dashboard signals/cards, alerts, navigation entry points, evidence/report viewers, or any other existing shared operator interaction family; otherwise write `N/A - no shared interaction family touched`)*
- **Cross-cutting feature?**: yes
- **Interaction class(es)**: evidence/report viewers, navigation entry points, artifact status messaging, and retained-artifact detail disclosure
- **Systems touched**: `StoredReport`, `ArtifactTruthPresenter`, badge rendering, the tenant admin-roles widget, and existing artifact-reference vocabulary used in support and review contexts
- **Existing pattern(s) to extend**: shared governance-artifact truth, existing tenant-scoped read-only detail patterns, and the existing artifact-reference vocabulary already used in support and review contexts
- **Shared contract / presenter / builder / renderer to reuse**: `ArtifactTruthPresenter`, `ArtifactTruthEnvelope`, centralized badge semantics, and existing report-family summary truth already embedded in `AdminRolesSummaryWidget`, evidence-source providers, and tenant-review composition
- **Why the existing shared path is sufficient or insufficient**: existing shared paths already know how to classify stored reports as current vs historical retained artifacts and how to summarize both report families. They are insufficient only because there is no first-class browse and detail destination that every report-backed surface can reuse.
- **Allowed deviation and why**: none. V1 may use one bounded per-family presentation branch for the two repo-real report types, but it must not create a registry, plugin system, or second lifecycle language.
- **Consistency impact**: `Stored report`, `current`, `historical`, `retained`, `measured at`, `permission posture`, `Entra admin roles`, and report-family summary labels must mean the same thing across the new list, detail, and widget drilldown.
- **Review focus**: reviewers must confirm that the new surface reuses shared artifact truth and existing report-family summary semantics, keeps raw payloads secondary, and does not introduce a parallel local status or wording system.
## OperationRun UX Impact *(mandatory when the feature creates, queues, deduplicates, resumes, blocks, completes, or deep-links to an `OperationRun`; otherwise write `N/A - no OperationRun start or link semantics touched`)*
N/A - no `OperationRun` start, dedupe, terminal feedback, or new run link semantics are introduced in this slice. Existing scan, verification, or report-generation actions remain owned by their current surfaces and reuse their existing run UX unchanged.
## Provider Boundary / Platform Core Check *(mandatory when the feature changes shared provider/platform seams, identity scope, governed-subject taxonomy, compare strategy selection, provider connection descriptors, or operator vocabulary that may leak provider-specific semantics into platform-core truth; otherwise write `N/A - no shared provider/platform boundary touched`)*
- **Shared provider/platform boundary touched?**: yes
- **Boundary classification**: mixed
- **Seams affected**: report-type labels, report-family summaries, artifact-reference wording, and widget drilldown language
- **Neutral platform terms preserved or introduced**: `stored report`, `current record`, `historical record`, `retained history`, `measured at`, `report family`, and `used by`
- **Provider-specific semantics retained and why**: `permission_posture` and `entra.admin_roles` remain the two repo-real report families, and their type-specific summary content stays visible because those records are already provider-shaped truth captured by the current product.
- **Why this does not deepen provider coupling accidentally**: v1 productizes the existing retained artifacts only. It does not introduce a new platform-wide report taxonomy, analytics layer, or provider-derived global navigation model. Provider-specific detail stays inside the report-family summary, while list and lifecycle semantics remain platform-owned.
- **Follow-up path**: future report-family expansion can stay local to stored-report presentation until the repo has multiple additional concrete report families that justify broader normalization
## UI / Surface Guardrail Impact *(mandatory when operator-facing surfaces are changed; otherwise write `N/A`)*
| Surface / Change | Operator-facing surface change? | Native vs Custom | Shared-Family Relevance | State Layers Touched | Exception Needed? | Low-Impact / `N/A` Note |
|---|---|---|---|---|---|---|
| Tenant stored-reports register | yes | Native Filament resource/page with shared badge and artifact-truth primitives | navigation, report viewers, status messaging | page, filters, list state | no | New primary browse surface for stored-report truth; no local design system or custom dashboard card |
| Stored-report detail | yes | Native Filament view/detail surface with shared artifact-truth primitives | report viewers, proof drilldowns, audience-aware disclosure | detail sections, progressive disclosure | no | New read-only inspection surface; no edit form, no report authoring, no destructive actions |
## Decision-First Surface Role *(mandatory when operator-facing surfaces are changed)*
| Surface | Decision Role | Human-in-the-loop Moment | Immediately Visible for First Decision | On-Demand Detail / Evidence | Why This Is Primary or Why Not | Workflow Alignment | Attention-load Reduction |
|---|---|---|---|---|---|---|---|
| Tenant stored-reports register | Primary Decision Surface | Operator decides which retained report is current enough to inspect or which historical record matters for a current tenant conversation | report family, current vs historical state, measured time, and concise report-family summary | full report detail and fingerprint lineage | Primary because this is the first calm place to answer whether a report exists and whether the latest retained record is usable | Follows tenant-scoped diagnostics and widget launch workflows instead of forcing database-style lookup or widget-hunting | Removes the need to infer report truth from separate widgets, review summaries, or support bundles |
| Stored-report detail | Secondary Context Surface | Operator confirms what the retained report actually says and where it already matters downstream | artifact reference, current or historical truth, measured time, bounded family summary, and one related next step | raw payload, fingerprint lineage, and lower-level diagnostics only when explicitly revealed | Secondary because the operator should choose the report first, then read it in one focused detail surface | Keeps report consumption inside tenant scope and existing downstream proof workflows | Removes page-to-page reconstruction and keeps report truth from looking calmer than it is |
## Audience-Aware Disclosure *(mandatory when operator-facing surfaces are changed)*
| Surface | Audience Modes In Scope | Decision-First Default-Visible Content | Operator Diagnostics | Support / Raw Evidence | One Dominant Next Action | Hidden / Gated By Default | Duplicate-Truth Prevention |
|---|---|---|---|---|---|---|---|
| Tenant stored-reports register | operator-MSP, support-platform | report family, lifecycle state, measured time, and concise summary values for the current tenant | fingerprint presence and current vs historical relationship | raw payloads and provider-shaped low-level detail stay off the list | `Open report` | raw payload and deep diagnostics stay secondary | The list states one calm summary per row; the detail page owns the full explanation |
| Stored-report detail | operator-MSP, support-platform | artifact reference, lifecycle and retention truth, measured time, and bounded report-family summary | fingerprint anchor and previous-fingerprint lineage | raw payload JSON, provider identifiers, and redacted support detail remain explicitly secondary or gated | `Open current report` on historical records only | raw payload and support-only context never replace the default summary | The detail header states current vs historical truth once; lower sections add supporting context without re-labelling the same state |
## UI/UX Surface Classification *(mandatory when operator-facing surfaces are changed)*
| Surface | Action Surface Class | Surface Type | Likely Next Operator Action | Primary Inspect/Open Model | Row Click | Secondary Actions Placement | Destructive Actions Placement | Canonical Collection Route | Canonical Detail Route | Scope Signals | Canonical Noun | Critical Truth Visible by Default | Exception Type / Justification |
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| Tenant stored-reports register | List / Table / Read-only | Read-only registry report | Open the current or relevant historical report | full-row click to detail | required | filter controls and history visibility stay secondary | none | `/admin/t/{tenant}/stored-reports` | `/admin/t/{tenant}/stored-reports/{report}` | active tenant, report family, lifecycle state | Stored reports / Stored report | whether a report exists, whether it is current, and when it was measured | none |
| Stored-report detail | Detail / Report viewer | Read-only detail report | Review report truth and, for historical rows, open the current retained report | sectioned detail page | forbidden | history navigation stays secondary | none | `/admin/t/{tenant}/stored-reports` | `/admin/t/{tenant}/stored-reports/{report}` | active tenant, report family, lifecycle state, measured time | Stored report | what this report says and whether it is current or historical | none |
## Operator Surface Contract *(mandatory when operator-facing surfaces are changed)*
| Surface | Primary Persona | Decision / Operator Action Supported | Surface Type | Primary Operator Question | Default-visible Information | Diagnostics-only Information | Status Dimensions Used | Mutation Scope | Primary Actions | Dangerous Actions |
|---|---|---|---|---|---|---|---|---|---|---|
| Tenant stored-reports register | Tenant operator or support operator | Decide which retained report should be inspected now | Read-only registry | What stored reports exist for this tenant, and which one is current? | report family, current vs historical state, measured time, and concise summary values | fingerprint lineage and deeper diagnostic context | lifecycle, retention, measured-time freshness signal | none | Open report | none |
| Stored-report detail | Tenant operator or support operator | Decide whether the retained report is sufficient for the current tenant conversation and whether the latest row should be opened instead | Read-only detail report | What does this report say, and how current is it? | artifact reference, lifecycle truth, measured time, bounded type-specific summary, and fingerprint lineage | raw payload, fingerprint lineage, redaction notes, and support detail | lifecycle, retention, measured-time freshness signal | none | Open current report | none |
## Proportionality Review *(mandatory when structural complexity is introduced)*
- **New source of truth?**: no
- **New persisted entity/table/artifact?**: no
- **New abstraction?**: no new generic framework; one bounded per-family presentation branch only
- **New enum/state/reason family?**: no
- **New cross-domain UI framework/taxonomy?**: no
- **Current operator problem**: retained stored-report truth exists in the database and in downstream consumers, but operators still lack one first-class place to browse and inspect it safely.
- **Existing structure is insufficient because**: widgets, evidence summaries, review packaging, and support identity references all expose only fragments of stored-report truth, and none of them is the canonical browse or detail destination.
- **Narrowest correct implementation**: add one tenant-scoped register and one read-only detail surface over the existing `StoredReport` rows, reuse existing artifact-truth and summary semantics, and keep report generation and lifecycle mutation out of scope.
- **Ownership cost**: one new read-only surface family, one additional permission-posture read capability, focused tests, and review discipline around default-visible vs diagnostic detail.
- **Alternative intentionally rejected**: a generic report engine, analytics console, or artifact-framework expansion was rejected as too broad; local widget-only links were rejected because they would keep browse and detail truth fragmented.
- **Release truth**: current-release productization over already persisted report artifacts and already-real downstream consumers
### Compatibility posture
This feature assumes a pre-production environment.
Backward compatibility, legacy aliases, migration shims, historical fixtures, and compatibility-only tests are out of scope unless a later implementation explicitly proves them necessary.
Canonical productization of the current stored-report truth is preferred over building compatibility layers for future report families.
## Testing / Lane / Runtime Impact *(mandatory for runtime behavior changes)*
- **Test purpose / classification**: Feature
- **Validation lane(s)**: fast-feedback, confidence
- **Why this classification and these lanes are sufficient**: the slice is a tenant-scoped browse and detail surface over existing persisted truth. Focused feature coverage is the narrowest sufficient proof for list filtering, detail rendering, deep-link continuity, and authorization behavior. No new queue, browser-only interaction model, or heavy-governance harness is required by default.
- **New or expanded test families**: one new stored-reports surface family for register and detail behavior, plus focused updates to current widget or proof-link families that now launch into report detail
- **Fixture / helper cost impact**: low to moderate; reuse existing workspace, tenant, membership, `StoredReport`, evidence, and tenant-review fixtures without adding provider sync or queue defaults
- **Heavy-family visibility / justification**: none
- **Special surface test profile**: standard-native-filament, shared-detail-family
- **Standard-native relief or required special coverage**: standard Filament feature coverage is sufficient for list, detail, and link behavior; no browser smoke is required unless later implementation proves a rendered widget interaction cannot be verified reliably in feature tests
- **Reviewer handoff**: reviewers must confirm that report rows are type-filtered by capability, history remains readable without implying freshness, raw payload stays secondary, the admin-roles widget adopts the canonical detail route, and no new report-generation or analytics behavior appears
- **Budget / baseline / trend impact**: low feature-local increase only
- **Escalation needed**: none
- **Active feature PR close-out entry**: Guardrail
- **Planned validation commands**:
- `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/StoredReports/StoredReportResourceTest.php tests/Feature/StoredReports/StoredReportEntitlementEnforcementTest.php`
- `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/StoredReports/StoredReportDetailPresentationTest.php tests/Feature/EntraAdminRoles/AdminRolesSummaryWidgetTest.php`
- `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail bin pint --dirty --format agent`
## Scope Boundaries *(required for this slice)*
### In Scope
- one tenant-scoped stored-reports register with search, report-family filters, and current vs historical visibility
- one read-only stored-report detail surface for the current two repo-real report families
- shared lifecycle and retention truth for stored reports reused from `ArtifactTruthPresenter`
- deep links into the new detail route from the current tenant admin-roles widget
- default-visible operator summary plus progressive disclosure for raw payload and lower-level diagnostics
- type-aware, capability-first row filtering and direct-route authorization
- explicit family-boundary behavior if an unexpected stored-report type appears in storage
### Non-Goals
- report generation, rescanning, scheduling, or analytics workflows
- raw JSON download, bulk export, or standalone support-bundle delivery from the stored-reports surface
- a customer-facing stored-report portal or customer-safe default-visible raw-report drilldown
- cross-tenant or workspace-wide stored-report registry in v1
- review, evidence, governance-package, or artifact-lifecycle contract rewrites
- generic report-schema registry, renderer framework, or artifact meta-framework
## Dependencies
- `docs/product/spec-candidates.md` manual-promotion backlog entry for `Stored Reports Surface v1`
- `docs/product/roadmap.md` manual-promotion order entry naming stored reports as priority 6
- `specs/153-evidence-domain-foundation/spec.md` as context only for evidence-source reuse
- `specs/155-tenant-review-layer/spec.md` as context only for report consumption inside tenant reviews
- `specs/260-governance-service-packaging/spec.md` as context only for report-backed proof and package consumption
- `specs/267-artifact-lifecycle-retention/spec.md` as context only for stored-report lifecycle and retention truth already prepared elsewhere
- current `StoredReport` model, evidence-source providers, tenant-review composition, and `ArtifactTruthPresenter`
## Assumptions
- v1 supports exactly the two repo-real stored-report families that already exist in code: `permission_posture` and `entra.admin_roles`
- stored reports remain immutable retained artifacts; current vs historical is derived from the latest retained record per tenant and report family rather than from a second state store
- the new surface is read-only and does not become the place where operators rerun scans, fix tenant configuration, or manage retention
- existing evidence and review surfaces continue to own their own business truth; the stored-reports surface only exposes the report artifact more directly
- customer-safe review flows may keep stored-report drilldowns secondary or hidden; customer-facing report consumption is not part of this slice
## Risks
- scope could drift into a generic reporting or analytics console if the surface starts to aggregate cross-tenant or multi-family reporting behavior
- raw payload disclosure could overexpose provider-shaped detail if progressive disclosure is not enforced consistently
- current vs historical state could look calmer than it is if measured time is not shown alongside lifecycle truth
- introducing a new permission-posture read capability could create RBAC drift if role mapping is widened carelessly instead of staying symmetrical and bounded
- unexpected future report families would require a follow-up spec before they become browseable on this surface
## Candidate Selection Gate Summary
- **Selected candidate**: Stored Reports Surface v1
- **Source locations**:
- `docs/product/spec-candidates.md` manual-promotion backlog priority 6
- `docs/product/roadmap.md` current manual-promotion ranking item 6
- `specs/153-evidence-domain-foundation/spec.md`, `specs/155-tenant-review-layer/spec.md`, and `specs/260-governance-service-packaging/spec.md` as context-only anchors
- **Why selected now**: this was an explicit product decision, the higher-ranked remaining manual-promotion items are already specced or closed in current repo truth, and this is the next valid bounded product-surface follow-up without reopening already prepared packages.
- **Higher-ranked candidate guardrail result**:
- `specs/265-decision-register-approval/spec.md` already exists and is ready for implementation
- `specs/267-artifact-lifecycle-retention/spec.md` already exists and has implementation close-out context
- `specs/274-billing-subscription-truth/spec.md` already exists
- `specs/275-customer-facing-localization-adoption/spec.md` already exists
- `specs/276-support-access-governance/spec.md` already exists
- none of the above are reopened by this spec
- **Related-anchor guardrail result**:
- `specs/153-evidence-domain-foundation/spec.md` remains context only and is not rewritten
- `specs/155-tenant-review-layer/spec.md` remains context only and is not rewritten
- `specs/260-governance-service-packaging/spec.md` remains context only and is not rewritten
- `specs/267-artifact-lifecycle-retention/spec.md` remains context only and is not rewritten
- **Why this is the smallest viable implementation slice**: it adds one tenant-scoped browse and detail surface over existing stored-report rows, adopts that detail surface as the canonical drilldown target from the current admin-roles widget, and keeps generation, export, analytics, and lifecycle-mutation work out of scope.
- **Why close alternatives are deferred**:
- a workspace-wide or cross-tenant stored-report hub is broader than the current tenant-scoped productization gap
- a customer-facing report portal belongs to a separate customer-consumption decision, not this operator-first browse slice
- report-generation, raw export, and analytics-console work are distinct workflow or platform layers and are not prerequisites for safe browsing
- lifecycle-runtime work was already intentionally separated into Spec 267 and must stay separate from this browse/detail package
## Completed-Spec Guardrail Result
- **Spec 153 - Evidence Domain Foundation**: context only. It already established stored reports as evidence inputs and is not reopened by this surface slice.
- **Spec 155 - Tenant Review Layer**: context only. It already established review consumption of stored-report-backed evidence and is not reopened.
- **Spec 260 - Governance-as-a-Service Packaging v1**: context only. It remains the package and proof-consumption anchor and is not refreshed here.
- **Spec 267 - Governance Artifact Lifecycle & Retention v1**: context only. It already prepared stored-report lifecycle and retention semantics; this slice reuses them instead of recreating them.
## Follow-Up Candidates Explicitly Kept Out of Scope
- Workspace & Tenant Closure Lifecycle v1
- First Governed AI Runtime Consumer v1
- any future customer-facing stored-report consumption candidate beyond current operator-first browse/detail
- any future report-family expansion candidate beyond the current two repo-real stored-report types
- any future raw export or support-bundle delivery candidate for stored reports
## User Scenarios & Testing *(mandatory)*
### User Story 1 - Browse current stored reports for one tenant (Priority: P1)
As a tenant operator, I want one first-class stored-reports register for the active tenant so I can see which retained reports exist and which one is current without reconstructing that truth from widgets or downstream consumers.
**Why this priority**: This is the core productization gap. Without the register, stored reports remain substrate only.
**Independent Test**: Can be fully tested by seeding both report families for one tenant, opening the new tenant-scoped register, and verifying that current and historical rows, filters, and type-aware summaries render correctly for an entitled actor.
**Acceptance Scenarios**:
1. **Given** a tenant has current stored reports for both in-scope report families, **When** an entitled operator opens the stored-reports register, **Then** the list shows the current retained records with report-family summaries and measured time.
2. **Given** one report family also has older retained rows, **When** the operator enables history visibility, **Then** historical rows remain readable and clearly distinguished from the current record.
3. **Given** the same tenant has a report family the actor is not entitled to view, **When** the register loads, **Then** the hidden family does not appear in rows or filter options.
---
### User Story 2 - Inspect a retained report without false calmness (Priority: P1)
As an operator or support user, I want the stored-report detail to show what the report says, whether it is current or historical, and when it was measured so I can use it safely in evidence or review conversations.
**Why this priority**: A browse surface is not useful if the detail view still forces operators back into raw payloads or inferred freshness.
**Independent Test**: Can be fully tested by opening both a current and a historical stored-report detail page and verifying that lifecycle truth, measured time, and the type-specific summary are visible before any raw diagnostics.
**Acceptance Scenarios**:
1. **Given** an operator opens a current permission-posture stored report, **When** the detail page loads, **Then** it shows the posture score, required vs granted counts, measured time, and that the report is the current retained record.
2. **Given** an operator opens a historical Entra admin-roles report, **When** the detail page loads, **Then** it shows that the record remains readable history and offers a path to the current retained record without implying the historical report is still current.
3. **Given** raw payload detail exists, **When** the operator stays in the default detail view, **Then** the page keeps raw provider-shaped payloads secondary and does not replace the calm summary with low-level JSON.
---
### User Story 3 - Follow stored-report truth from the tenant admin-roles widget (Priority: P2)
As an operator using the tenant overview, I want the admin-roles widget to open one canonical stored-report detail so I can inspect the retained artifact without losing tenant context.
**Why this priority**: Product value comes from converging the one confirmed repo-real launch seam on one canonical drilldown target, not from adding one more isolated page.
**Independent Test**: Can be fully tested by opening the tenant admin-roles widget, following its report link into the new stored-report detail page, and confirming that tenant context and authorization remain intact.
**Acceptance Scenarios**:
1. **Given** the tenant admin-roles widget shows a stored report, **When** the actor opens the report from that widget, **Then** the app launches the canonical stored-report detail page for the active tenant.
2. **Given** the actor can open the tenant overview but cannot view Entra admin-roles reports, **When** the overview renders, **Then** the widget does not expose a stored-report launch affordance.
3. **Given** the actor copies a stored-report detail route from an entitled session into a session without the report-family capability, **When** the route is opened directly, **Then** the app preserves `404` or `403` behavior according to the established entitlement and capability rules without leaking hidden report identity.
### Edge Cases
- A tenant has only one retained row for a report family, but it is old; the surface must still show it as the current retained record while making the measured time obvious.
- An operator can view Entra admin-roles reports but not permission-posture reports; the list must not hint that hidden report families exist.
- A historical report is opened after a newer retained row is created; the detail page must keep the old row readable and clearly identify the newer current report.
- An unexpected future `report_type` appears in storage; the surface must fail safely without exposing new rows, filter options, or direct detail access until a follow-up spec adds that family.
- A stored-report detail route is opened after the row was pruned or otherwise removed; the app must fail safely without implying the artifact is still inspectable.
## Requirements *(mandatory)*
**Constitution alignment (required):** This feature adds no new Microsoft Graph calls, no write or mutate workflow, and no new queued background execution. It productizes read-only browsing and consumption of existing tenant-owned stored-report artifacts only.
**Constitution alignment (PROP-001 / ABSTR-001 / PERSIST-001 / STATE-001 / BLOAT-001):** The slice introduces no new persistence, no new status family, and no generic framework. One bounded read capability plus one bounded two-family presentation surface is justified because two concrete report families already exist and are already consumed by evidence and review features.
**Constitution alignment (XCUT-001):** The feature extends existing artifact-truth, badge, and widget-drilldown paths. It must not introduce page-local lifecycle labels, custom badge maps, or a second report-viewer vocabulary.
**Constitution alignment (DECIDE-AUD-001 / OPSURF-001):** Default-visible content stays operator-first and calm: report family, current vs historical truth, measured time, and concise summary first; raw payload and provider-shaped detail remain explicitly secondary.
**Constitution alignment (PROV-001):** The browse surface stays platform-owned while acknowledging provider-shaped report families. It must not turn provider-specific payload fields into platform-core navigation or taxonomy truth.
**Constitution alignment (TEST-GOV-001):** Proof stays in focused feature coverage. No hidden browser or heavy-governance family may appear by default.
**Constitution alignment (OPS-UX / OPS-UX-START-001):** No new `OperationRun` lifecycle, start path, or notification model is introduced. Existing scan or verification actions remain where they already live.
**Constitution alignment (RBAC-UX):** The feature stays in the tenant/admin plane. Non-members or actors outside workspace or tenant scope remain `404`. Members lacking the required report-family capability remain `403`. Server-side authorization remains authoritative for list filtering, detail access, and deep-link routes.
**Constitution alignment (BADGE-001):** Current vs historical and retained-artifact status must reuse centralized badge semantics from artifact truth rather than page-local color mapping.
**Constitution alignment (UI-FIL-001):** The surface must be built with native Filament list and detail primitives, existing shared badge or artifact-truth helpers, and no ad-hoc card, button, or row styling.
**Constitution alignment (UI-NAMING-001):** Primary operator labels stay specific and stable: `Stored reports`, `Stored report`, `Open report`, `Current`, `Historical`, `Measured at`, `Permission posture`, and `Entra admin roles`. Implementation terms such as `payload`, `projection`, or `report schema` do not become primary surface labels.
**Constitution alignment (DECIDE-001):** The new tenant register is the primary decision surface, while detail is the secondary context surface. `AdminRolesSummaryWidget` becomes a launch point, not a competing primary surface.
**Constitution alignment (UI-CONST-001 / UI-SURF-001 / ACTSURF-001 / UI-HARD-001 / UI-EX-001 / UI-REVIEW-001 / HDR-001):** The list uses one primary open model, no redundant `View` action, no mixed catch-all action groups, and no destructive controls. The detail surface remains read-only and keeps raw diagnostics clearly secondary.
**Constitution alignment (UI-SEM-001 / LAYER-001 / TEST-TRUTH-001):** The feature must map directly from existing stored-report truth and existing report-family summaries to the surface. It must not add a new semantic envelope or report-interpretation layer beyond the already-existing artifact-truth path.
**Constitution alignment (Filament Action Surfaces):** The Action Surface Contract is satisfied if the collection uses one canonical row-open affordance, the detail is read-only, redundant `View` actions are absent, empty action groups are absent, and the surface adds no destructive actions.
**Constitution alignment (UX-001 - Layout & Information Architecture):** The list must provide search, report-family filters, and history visibility controls for the core browsing dimensions. The detail surface must use an Infolist-style inspection layout rather than a disabled edit form. Empty states must be specific and honest about where report generation actually happens.
### Functional Requirements
- **FR-277-001**: The system MUST provide one tenant-scoped stored-reports register for the active tenant.
- **FR-277-002**: The register MUST list only stored-report rows that belong to the active tenant and that the current actor is entitled to inspect.
- **FR-277-003**: The register MUST use type-aware capability filtering so report-family rows and filter options never leak hidden report families.
- **FR-277-004**: V1 MUST support full browse and detail presentation for exactly these repo-real stored-report families: `permission_posture` and `entra.admin_roles`.
- **FR-277-005**: The register MUST show report family, current vs historical state, measured time, and a bounded report-family summary for each visible row.
- **FR-277-006**: The register MUST let the actor search by report-family label or report reference and explicitly reveal retained history without breaking direct detail access to historical rows.
- **FR-277-007**: The detail surface MUST show the stored-report reference, report-family label, measured time, lifecycle truth, retention truth, and the integrity anchor when present.
- **FR-277-008**: The detail surface MUST reuse shared artifact-truth semantics so stored reports appear as `current` or `historical` retained artifacts using the existing lifecycle and retention contract.
- **FR-277-009**: Permission-posture detail MUST show a bounded summary of posture score, required vs granted counts, and missing or at-risk permission context from the stored payload.
- **FR-277-010**: Entra admin-roles detail MUST show a bounded summary of role totals, assignment totals, high-privilege assignment count, and the highest-risk assignment context from the stored payload.
- **FR-277-011**: Raw payloads, provider identifiers, and lower-level diagnostics MUST remain hidden or collapsed by default and MUST NOT replace the default-visible operator summary.
- **FR-277-012**: Historical report detail MUST remain readable and MUST provide a path to the current retained record when a newer report of the same family exists.
- **FR-277-013**: `AdminRolesSummaryWidget` MUST adopt the new stored-report detail route as its canonical drilldown target when the actor is entitled.
- **FR-277-014**: The stored-reports surface MUST remain read-only in v1 and MUST NOT introduce report generation, rerun, download, edit, delete, or retention-mutation actions.
- **FR-277-015**: The feature MUST NOT introduce global-search exposure or cross-tenant discovery for stored-report records in v1.
- **FR-277-016**: If an unexpected stored-report family appears in storage, v1 MUST keep it out of collection rows, filter options, and direct detail routes until a separate follow-up spec expands the supported family set.
- **FR-277-017**: The feature MUST add one positive and one negative authorization proof for both collection access and direct detail access.
- **FR-277-018**: The feature MUST preserve source-of-truth clarity by keeping evidence snapshots, tenant reviews, review packs, and stored reports as separate artifacts rather than flattening them into one synthetic report domain.
## 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 |
|---|---|---|---|---|---|---|---|---|---|---|
| Stored-reports register | `/admin/t/{tenant}/stored-reports` | none | clickable row to stored-report detail | none | none | `Open tenant overview` | N/A | N/A | no new audit event; read-only browse only | Action-surface contract stays satisfied through one row-open model and no competing row actions |
| Stored-report detail | `/admin/t/{tenant}/stored-reports/{report}` | none | N/A | none | none | N/A | conditional `Open current report` on historical rows only | N/A | no new audit event; read-only inspect only | Detail is an inspection surface and must not become a disabled edit form or a mutation hub |
### Key Entities *(include if feature involves data)*
- **Stored Report**: A tenant-owned retained report artifact with `workspace_id`, `tenant_id`, `report_type`, payload, fingerprint, and optional previous-fingerprint lineage.
- **Stored Report Family Summary**: A bounded read-only presentation of the key operator-relevant values for one supported report family.
- **Stored Report Launch Seam**: The confirmed operator-facing path that opens the canonical stored-report detail route in v1, currently limited to `AdminRolesSummaryWidget`.
## Success Criteria *(mandatory)*
### Measurable Outcomes
- **SC-001**: An entitled operator can reach a current stored report for a tenant in three interactions or fewer from the tenant overview via `AdminRolesSummaryWidget`.
- **SC-002**: In 100% of validated current vs historical scenarios, operators can distinguish the current retained report from historical retained reports in one inspection step.
- **SC-003**: In 100% of validated unauthorized scenarios, the surface leaks no hidden report-family presence across workspace, tenant, or capability boundaries.
- **SC-004**: In 100% of validated default-detail scenarios, the page shows operator-relevant summary first and keeps raw payload or provider-shaped diagnostics secondary.
- **SC-005**: Both repo-real report families become available through one first-class browse and detail surface without introducing a new report-generation or analytics workflow.
## Open Questions
- none
## Final Direction
This spec productizes stored reports as first-class retained artifacts without changing what they are. The surface stays deliberately narrow: one tenant-scoped register, one read-only detail page, two supported report families, shared lifecycle truth, and one confirmed widget drilldown seam. It closes a real operator gap while keeping report generation, customer-facing consumption, cross-tenant analytics, broader proof-link convergence, and lifecycle mutation as separate decisions.

View File

@ -0,0 +1,183 @@
---
description: "Task list for Stored Reports Surface v1"
---
# Tasks: Stored Reports Surface v1
**Input**: Design documents from `specs/277-stored-reports-surface/`
**Prerequisites**: `specs/277-stored-reports-surface/spec.md`, `specs/277-stored-reports-surface/plan.md`, `specs/277-stored-reports-surface/checklists/requirements.md`, `specs/277-stored-reports-surface/research.md`, `specs/277-stored-reports-surface/data-model.md`, `specs/277-stored-reports-surface/quickstart.md`, `specs/277-stored-reports-surface/contracts/tenant-stored-reports-surface.logical.openapi.yaml`
**Tests**: REQUIRED (Pest). Keep proof bounded to focused `Feature` coverage in `apps/platform/tests/Feature/StoredReports/`, a narrow update to `apps/platform/tests/Feature/EntraAdminRoles/AdminRolesSummaryWidgetTest.php`, and the required UI smoke coverage in `apps/platform/tests/Browser/Spec277StoredReportsSurfaceSmokeTest.php`.
**Operations**: No new `OperationRun` family. Existing scan and generation actions remain on their current surfaces.
**RBAC**: Wrong-tenant or non-member access remains `404`; in-scope actors missing the relevant report-family capability remain `403`; collection visibility requires at least one supported report-family capability.
**Shared Pattern Reuse**: Reuse `StoredReport`, `ArtifactTruthPresenter`, centralized badge semantics, and the current admin-roles widget launch seam. Do not create a report registry, analytics console, or second lifecycle system.
**Filament / Panel Guardrails**: Filament remains v5 on Livewire v4. Provider registration remains unchanged in `apps/platform/bootstrap/providers.php`. The new stored-report resource is not globally searchable and adds no new asset strategy.
**Organization**: Tasks are grouped by user story so browse, detail, and canonical drilldown behavior stay independently implementable and testable. This package productizes existing stored-report truth and stops before generic reporting scope.
**Review Outcome**: `acceptable-special-case`
**Workflow Outcome**: `keep`
**Test-governance Outcome**: `keep`
## Test Governance Checklist
- [x] Lane assignment stays `fast-feedback` and `confidence` and remains the narrowest sufficient proof.
- [x] New or changed tests stay in `apps/platform/tests/Feature/StoredReports/`, `apps/platform/tests/Feature/EntraAdminRoles/`, and the required narrow browser smoke file only.
- [x] Shared helpers stay cheap by default; stored-report setup should reuse the current factory and tenant fixtures.
- [x] Planned validation commands cover register behavior, entitlement behavior, detail presentation, widget drilldown, and the required narrow browser smoke without widening into heavy-governance lanes.
- [x] The declared surface test profile remains `standard-native-filament` and `shared-detail-family` only.
- [x] Any drift toward a report engine, cross-tenant hub, or generic registry resolves as `reject-or-split`, not hidden scope.
## Phase 1: Setup (Shared Context)
**Purpose**: Confirm the current stored-report, artifact-truth, and launch seams before runtime changes begin.
- [x] T001 Review `specs/277-stored-reports-surface/spec.md`, `specs/277-stored-reports-surface/plan.md`, `specs/277-stored-reports-surface/checklists/requirements.md`, `specs/277-stored-reports-surface/research.md`, `specs/277-stored-reports-surface/data-model.md`, and `specs/277-stored-reports-surface/quickstart.md` together so the slice stays on existing stored-report truth.
- [x] T002 [P] Confirm the current stored-report truth and lifecycle anchors in `apps/platform/app/Models/StoredReport.php`, `apps/platform/database/factories/StoredReportFactory.php`, and `apps/platform/app/Support/Ui/GovernanceArtifactTruth/ArtifactTruthPresenter.php`.
- [x] T003 [P] Confirm the current repo-real launch seam in `apps/platform/app/Filament/Widgets/Tenant/AdminRolesSummaryWidget.php` and `apps/platform/resources/views/filament/widgets/tenant/admin-roles-summary.blade.php`.
- [x] T004 [P] Confirm the current tenant-panel read-only resource patterns in `apps/platform/app/Filament/Resources/EvidenceSnapshotResource.php`, `apps/platform/app/Filament/Resources/ReviewPackResource.php`, and their related feature tests.
---
## Phase 2: Foundational (Blocking Prerequisites)
**Purpose**: Lock authorization, test coverage, and setup before the new surface is built.
**Critical**: No user-story work should begin until this phase is complete.
- [x] T005 [P] Add failing feature coverage in `apps/platform/tests/Feature/StoredReports/StoredReportEntitlementEnforcementTest.php` for collection visibility, direct-detail access, family-filtered rows, and `404` versus `403` semantics.
- [x] T006 [P] Add failing feature coverage in `apps/platform/tests/Feature/StoredReports/StoredReportResourceTest.php` for tenant-scoped list behavior, current versus historical visibility, search, filter options, and honest empty-state behavior.
- [x] T007 [P] Add failing feature coverage in `apps/platform/tests/Feature/StoredReports/StoredReportDetailPresentationTest.php` for allowed current permission-posture detail, allowed historical Entra admin-roles detail, collapsed raw payload disclosure, and integrity-anchor rendering when present.
- [x] T008 [P] Prepare the minimal widget test fixtures or helpers in `apps/platform/tests/Feature/EntraAdminRoles/AdminRolesSummaryWidgetTest.php` for a report-present state and a capability-blocked state so US3 can add one bounded assertion set.
- [x] T009 Add `Capabilities::PERMISSION_POSTURE_VIEW` in `apps/platform/app/Support/Auth/Capabilities.php` and map it in `apps/platform/app/Services/Auth/RoleCapabilityMap.php` with bounded read-only role coverage.
- [x] T010 Keep stored-report test setup cheap by extending `apps/platform/database/factories/StoredReportFactory.php` only as needed for current and historical supported-family fixtures.
**Checkpoint**: Authorization and proving seams are ready for the stored-report resource implementation.
---
## Phase 3: User Story 1 - Browse current stored reports for one tenant (Priority: P1)
**Goal**: Entitled actors can browse current and historical stored reports for the active tenant in one first-class register.
**Independent Test**: Open `/admin/t/{tenant}/stored-reports`, confirm current and historical behavior, and verify family-aware visibility and filters.
### Tests for User Story 1
- [x] T011 [P] [US1] Extend `apps/platform/tests/Feature/StoredReports/StoredReportResourceTest.php` to cover clickable-row inspection, history toggle behavior, and family-aware filter visibility on the tenant panel.
### Implementation for User Story 1
- [x] T012 [US1] Create `apps/platform/app/Filament/Resources/StoredReportResource.php` with tenant ownership, `isGloballySearchable = false`, family-aware `canViewAny()` behavior, and a read-only action-surface declaration.
- [x] T013 [US1] Create `apps/platform/app/Filament/Resources/StoredReportResource/Pages/ListStoredReports.php` with a tenant-scoped query, report-family filters, history visibility, search, clickable rows, and no row or bulk mutation actions.
- [x] T014 [US1] Add bounded family-summary extraction on the stored-report resource or list page using current payload shapes and existing artifact-truth badges rather than a shared registry.
- [x] T015 [US1] Wire the list empty state to an honest next step such as the tenant overview, without implying the stored-report surface can generate reports itself.
**Checkpoint**: The tenant panel has one calm stored-report register that stays tenant-scoped, read-only, and family-aware.
---
## Phase 4: User Story 2 - Inspect a retained report without false calmness (Priority: P1)
**Goal**: Entitled actors can inspect one stored report and immediately understand what it says, whether it is current or historical, and when it was measured.
**Independent Test**: Open one current permission-posture report and one historical Entra admin-roles report and verify lifecycle truth, measured time, bounded summary, and progressive disclosure.
### Tests for User Story 2
- [x] T016 [P] [US2] Extend `apps/platform/tests/Feature/StoredReports/StoredReportDetailPresentationTest.php` to cover the one-primary-action rule, current-report jump on historical rows, and the absence of speculative unsupported-family rendering in v1.
### Implementation for User Story 2
- [x] T017 [US2] Create `apps/platform/app/Filament/Resources/StoredReportResource/Pages/ViewStoredReport.php` as a read-only view page using infolist sections for artifact truth, family summary, lineage, and collapsed raw payload.
- [x] T018 [US2] Reuse `ArtifactTruthPresenter::forStoredReport()` in the detail surface and keep any “current report” lookup local to `StoredReportResource` and `ViewStoredReport` instead of broadening the artifact envelope.
- [x] T019 [US2] Keep the detail page to one dominant next action, `Open current report`, only when the viewed row is historical, and keep all mutation actions out of scope.
**Checkpoint**: Stored-report detail behaves like a true inspection surface rather than a pseudo-editor or diagnostics dump.
---
## Phase 5: User Story 3 - Follow stored-report truth from the tenant admin-roles widget (Priority: P2)
**Goal**: The current admin-roles widget launch seam points to the canonical stored-report detail route instead of leaving operators in a fragmented local view.
**Independent Test**: Launch the current Entra admin-roles report from `AdminRolesSummaryWidget` and confirm the canonical tenant stored-report detail route opens.
### Tests for User Story 3
- [x] T020 [P] [US3] Extend `apps/platform/tests/Feature/EntraAdminRoles/AdminRolesSummaryWidgetTest.php` to prove canonical stored-report drilldown and capability-gated absence without widening the proving suite.
### Implementation for User Story 3
- [x] T021 [US3] Update `apps/platform/app/Filament/Widgets/Tenant/AdminRolesSummaryWidget.php` and `apps/platform/resources/views/filament/widgets/tenant/admin-roles-summary.blade.php` so `viewReportUrl` resolves to the canonical tenant stored-report detail route when the actor can view Entra admin roles.
- [x] T022 [US3] Keep convergence bounded to `AdminRolesSummaryWidget`; do not add new stored-report links to `EvidenceSnapshotResource`, `ReviewPackResource`, or `TenantReviewResource` in v1.
- [x] T023 [US3] Ensure generated detail URLs preserve tenant-panel routing semantics and that direct detail access keeps family-aware authorization behavior.
**Checkpoint**: The admin-roles widget definitely launches the canonical detail route, and any additional convergence remains bounded to repo-real affordances.
---
## Phase 6: Polish & Cross-Cutting Validation
**Purpose**: Validate the bounded slice and stop before reporting-platform scope creeps in.
- [x] T024 [P] Run `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/StoredReports/StoredReportResourceTest.php tests/Feature/StoredReports/StoredReportEntitlementEnforcementTest.php`.
- [x] T025 [P] Run `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/StoredReports/StoredReportDetailPresentationTest.php tests/Feature/EntraAdminRoles/AdminRolesSummaryWidgetTest.php`.
- [x] T026 [P] Run `cd apps/platform && ./vendor/bin/sail pint` on the touched application and test files; `./vendor/bin/sail pint --dirty` reported no tracked dirty files because several feature files were new.
- [x] T027 [P] Review touched code to confirm Filament stays on Livewire v4, provider registration remains unchanged in `apps/platform/bootstrap/providers.php`, the new resource stays out of global search, no new asset strategy appears, and no report engine or analytics scope slipped in.
- [x] T028 [P] Record the final guardrail and test-governance outcome in the active feature close-out without widening into cross-tenant browse, raw export, or generic report-framework work.
---
## Dependencies & Execution Order
### Phase Dependencies
- **Phase 1 (Setup)**: no dependencies; start immediately.
- **Phase 2 (Foundational)**: depends on Phase 1 and blocks all user stories.
- **Phase 3 (US1)**: depends on Phase 2 and establishes the primary browse surface.
- **Phase 4 (US2)**: depends on Phase 2 and should ship with US1 so the register opens into a finished detail surface.
- **Phase 5 (US3)**: depends on Phase 2 and should follow once the canonical detail route exists.
- **Phase 6 (Polish)**: depends on all desired user stories being complete.
### User Story Dependencies
- **US1 (P1)**: independently testable after Phase 2 and delivers the primary operator value.
- **US2 (P1)**: independently testable after Phase 2 and should ship with US1 so the browse surface is meaningful.
- **US3 (P2)**: independently testable after Phase 2 and can follow once the canonical detail route is in place.
### Within Each User Story
- Write the listed Pest coverage first and make it fail for the intended gap.
- Keep implementation inside the resource, widget, capability, and existing report-truth seams named above.
- Re-run the narrowest relevant validation command after each story checkpoint before moving on.
---
## Implementation Strategy
### Suggested MVP Scope
- MVP = **US1 + US2 together**. The feature only closes the operator gap when the register and detail surface both exist.
### Incremental Delivery
1. Complete Phase 1 and Phase 2.
2. Deliver US1 so operators can browse current and historical stored reports.
3. Deliver US2 so the browse surface opens into a trustworthy detail surface.
4. Deliver US3 by wiring the canonical detail route into the current admin-roles widget seam.
5. Finish with the focused validation and guardrail review in Phase 6.
### Team Strategy
1. Settle capability and test shape first.
2. Parallelize failing test authoring within the stored-report feature family before runtime edits.
3. Serialize merges around `StoredReportResource`, `ViewStoredReport`, and `AdminRolesSummaryWidget` so route and vocabulary drift does not spread.
---
## Deferred Follow-Ups / Non-Goals
- report generation, scheduling, or rerun actions from the stored-report surface
- raw payload download or export
- cross-tenant or workspace-wide stored-report browsing
- customer-facing stored-report consumption
- generic report registry, renderer framework, or analytics console