feat: add tenant review layer (#185)
## Summary - add the tenant review domain with tenant-scoped review library, canonical workspace review register, lifecycle actions, and review-derived executive pack export - extend review pack, operations, audit, capability, and badge infrastructure to support review composition, publication, export, and recurring review cycles - add product backlog and audit documentation updates for tenant review and semantic-clarity follow-up candidates ## Testing - `vendor/bin/sail bin pint --dirty --format agent` - `vendor/bin/sail artisan test --compact --filter="TenantReview"` - `CI=1 vendor/bin/sail artisan test --compact` ## Notes - Livewire v4+ compliant via existing Filament v5 stack - panel providers remain in `bootstrap/providers.php` via existing Laravel 12 structure; no provider registration moved to `bootstrap/app.php` - `TenantReviewResource` is not globally searchable, so the Filament edit/view global-search constraint does not apply - destructive review actions use action handlers with confirmation and policy enforcement Co-authored-by: Ahmed Darrazi <ahmed.darrazi@live.de> Reviewed-on: #185
This commit is contained in:
parent
b1e1e06861
commit
a4f2629493
4
.github/agents/copilot-instructions.md
vendored
4
.github/agents/copilot-instructions.md
vendored
@ -94,6 +94,8 @@ ## Active Technologies
|
||||
- PostgreSQL with JSONB-backed snapshot metadata; existing private storage remains a downstream-consumer concern, not a primary evidence-foundation store (153-evidence-domain-foundation)
|
||||
- PHP 8.4.15 + Laravel 12, Filament v5, Livewire v4, Pest v4, existing Finding, AuditLog, EvidenceSnapshot, CapabilityResolver, WorkspaceCapabilityResolver, and UiEnforcement patterns (001-finding-risk-acceptance)
|
||||
- PostgreSQL with new tenant-owned exception tables and JSONB-backed supporting metadata (001-finding-risk-acceptance)
|
||||
- PHP 8.4, Laravel 12, Livewire 4, Filament 5 + Filament resources/pages/actions, Eloquent models, queued Laravel jobs, existing `EvidenceSnapshotService`, existing `ReviewPackService`, capability registry, `OperationRunService` (155-tenant-review-layer)
|
||||
- PostgreSQL with JSONB-backed summary payloads and tenant/workspace ownership columns (155-tenant-review-layer)
|
||||
|
||||
- PHP 8.4.15 (feat/005-bulk-operations)
|
||||
|
||||
@ -113,8 +115,8 @@ ## Code Style
|
||||
PHP 8.4.15: Follow standard conventions
|
||||
|
||||
## Recent Changes
|
||||
- 155-tenant-review-layer: Added PHP 8.4, Laravel 12, Livewire 4, Filament 5 + Filament resources/pages/actions, Eloquent models, queued Laravel jobs, existing `EvidenceSnapshotService`, existing `ReviewPackService`, capability registry, `OperationRunService`
|
||||
- 001-finding-risk-acceptance: Added PHP 8.4.15 + Laravel 12, Filament v5, Livewire v4, Pest v4, existing Finding, AuditLog, EvidenceSnapshot, CapabilityResolver, WorkspaceCapabilityResolver, and UiEnforcement patterns
|
||||
- 153-evidence-domain-foundation: Added PHP 8.4.15 + Laravel 12, Filament v5, Livewire v4, Pest v4, existing `StoredReport`, `Finding`, `OperationRun`, and `AuditLog` infrastructure
|
||||
- 152-livewire-context-locking: Added PHP 8.4.15 + Laravel 12, Filament v5, Livewire v4, PostgreSQL, Laravel Sail, Pest v4
|
||||
<!-- MANUAL ADDITIONS START -->
|
||||
<!-- MANUAL ADDITIONS END -->
|
||||
|
||||
307
app/Filament/Pages/Reviews/ReviewRegister.php
Normal file
307
app/Filament/Pages/Reviews/ReviewRegister.php
Normal file
@ -0,0 +1,307 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Filament\Pages\Reviews;
|
||||
|
||||
use App\Filament\Resources\TenantReviewResource;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\TenantReview;
|
||||
use App\Models\User;
|
||||
use App\Models\Workspace;
|
||||
use App\Services\TenantReviews\TenantReviewRegisterService;
|
||||
use App\Support\Auth\Capabilities;
|
||||
use App\Support\Badges\BadgeDomain;
|
||||
use App\Support\Badges\BadgeRenderer;
|
||||
use App\Support\Filament\CanonicalAdminTenantFilterState;
|
||||
use App\Support\Filament\FilterPresets;
|
||||
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\Workspaces\WorkspaceContext;
|
||||
use BackedEnum;
|
||||
use Filament\Actions\Action;
|
||||
use Filament\Pages\Page;
|
||||
use Filament\Tables\Columns\TextColumn;
|
||||
use Filament\Tables\Concerns\InteractsWithTable;
|
||||
use Filament\Tables\Contracts\HasTable;
|
||||
use Filament\Tables\Filters\SelectFilter;
|
||||
use Filament\Tables\Table;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
|
||||
use UnitEnum;
|
||||
|
||||
class ReviewRegister extends Page implements HasTable
|
||||
{
|
||||
use InteractsWithTable;
|
||||
|
||||
protected static bool $isDiscovered = false;
|
||||
|
||||
protected static string|BackedEnum|null $navigationIcon = 'heroicon-o-document-magnifying-glass';
|
||||
|
||||
protected static string|UnitEnum|null $navigationGroup = 'Reporting';
|
||||
|
||||
protected static ?string $navigationLabel = 'Reviews';
|
||||
|
||||
protected static ?string $title = 'Review Register';
|
||||
|
||||
protected static ?string $slug = 'reviews';
|
||||
|
||||
protected string $view = 'filament.pages.reviews.review-register';
|
||||
|
||||
/**
|
||||
* @var array<int, Tenant>|null
|
||||
*/
|
||||
private ?array $authorizedTenants = null;
|
||||
|
||||
public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration
|
||||
{
|
||||
return ActionSurfaceDeclaration::forPage(ActionSurfaceProfile::RunLog)
|
||||
->satisfy(ActionSurfaceSlot::ListHeader, 'Header actions provide a single Clear filters action for the canonical review register.')
|
||||
->satisfy(ActionSurfaceSlot::InspectAffordance, ActionSurfaceInspectAffordance::ClickableRow->value)
|
||||
->exempt(ActionSurfaceSlot::ListBulkMoreGroup, 'The review register does not expose bulk actions in the first slice.')
|
||||
->satisfy(ActionSurfaceSlot::ListEmptyState, 'The empty state keeps exactly one Clear filters CTA.')
|
||||
->exempt(ActionSurfaceSlot::DetailHeader, 'Row navigation returns to the tenant-scoped review detail rather than opening an inline canonical detail panel.');
|
||||
}
|
||||
|
||||
public function mount(): void
|
||||
{
|
||||
$this->authorizePageAccess();
|
||||
|
||||
app(CanonicalAdminTenantFilterState::class)->sync(
|
||||
$this->getTableFiltersSessionKey(),
|
||||
['status', 'published_state', 'completeness_state'],
|
||||
request(),
|
||||
);
|
||||
|
||||
$this->applyRequestedTenantPrefilter();
|
||||
$this->mountInteractsWithTable();
|
||||
}
|
||||
|
||||
protected function getHeaderActions(): array
|
||||
{
|
||||
return [
|
||||
Action::make('clear_filters')
|
||||
->label('Clear filters')
|
||||
->icon('heroicon-o-x-mark')
|
||||
->color('gray')
|
||||
->visible(fn (): bool => $this->hasActiveFilters())
|
||||
->action(function (): void {
|
||||
$this->resetTable();
|
||||
}),
|
||||
];
|
||||
}
|
||||
|
||||
public function table(Table $table): Table
|
||||
{
|
||||
return $table
|
||||
->query(fn (): Builder => $this->registerQuery())
|
||||
->defaultSort('generated_at', 'desc')
|
||||
->paginated(TablePaginationProfiles::customPage())
|
||||
->persistFiltersInSession()
|
||||
->persistSearchInSession()
|
||||
->persistSortInSession()
|
||||
->recordUrl(fn (TenantReview $record): string => TenantReviewResource::tenantScopedUrl('view', ['record' => $record], $record->tenant, 'tenant'))
|
||||
->columns([
|
||||
TextColumn::make('tenant.name')->label('Tenant')->searchable(),
|
||||
TextColumn::make('status')
|
||||
->badge()
|
||||
->formatStateUsing(BadgeRenderer::label(BadgeDomain::TenantReviewStatus))
|
||||
->color(BadgeRenderer::color(BadgeDomain::TenantReviewStatus))
|
||||
->icon(BadgeRenderer::icon(BadgeDomain::TenantReviewStatus))
|
||||
->iconColor(BadgeRenderer::iconColor(BadgeDomain::TenantReviewStatus)),
|
||||
TextColumn::make('completeness_state')
|
||||
->label('Completeness')
|
||||
->badge()
|
||||
->formatStateUsing(BadgeRenderer::label(BadgeDomain::TenantReviewCompleteness))
|
||||
->color(BadgeRenderer::color(BadgeDomain::TenantReviewCompleteness))
|
||||
->icon(BadgeRenderer::icon(BadgeDomain::TenantReviewCompleteness))
|
||||
->iconColor(BadgeRenderer::iconColor(BadgeDomain::TenantReviewCompleteness)),
|
||||
TextColumn::make('generated_at')->dateTime()->placeholder('—')->sortable(),
|
||||
TextColumn::make('published_at')->dateTime()->placeholder('—')->sortable(),
|
||||
TextColumn::make('summary.publish_blockers')
|
||||
->label('Publish blockers')
|
||||
->formatStateUsing(static function (mixed $state): string {
|
||||
if (! is_array($state) || $state === []) {
|
||||
return '0';
|
||||
}
|
||||
|
||||
return (string) count($state);
|
||||
}),
|
||||
])
|
||||
->filters([
|
||||
SelectFilter::make('tenant_id')
|
||||
->label('Tenant')
|
||||
->options(fn (): array => $this->tenantFilterOptions())
|
||||
->default(fn (): ?string => $this->defaultTenantFilter())
|
||||
->searchable(),
|
||||
SelectFilter::make('status')
|
||||
->options([
|
||||
'draft' => 'Draft',
|
||||
'ready' => 'Ready',
|
||||
'published' => 'Published',
|
||||
'archived' => 'Archived',
|
||||
'superseded' => 'Superseded',
|
||||
'failed' => 'Failed',
|
||||
]),
|
||||
SelectFilter::make('completeness_state')
|
||||
->label('Completeness')
|
||||
->options([
|
||||
'complete' => 'Complete',
|
||||
'partial' => 'Partial',
|
||||
'missing' => 'Missing',
|
||||
'stale' => 'Stale',
|
||||
]),
|
||||
SelectFilter::make('published_state')
|
||||
->label('Published state')
|
||||
->options([
|
||||
'published' => 'Published',
|
||||
'unpublished' => 'Not published',
|
||||
])
|
||||
->query(function (Builder $query, array $data): Builder {
|
||||
return match ($data['value'] ?? null) {
|
||||
'published' => $query->whereNotNull('published_at'),
|
||||
'unpublished' => $query->whereNull('published_at'),
|
||||
default => $query,
|
||||
};
|
||||
}),
|
||||
FilterPresets::dateRange('review_date', 'Review date', 'generated_at'),
|
||||
])
|
||||
->actions([
|
||||
Action::make('view_review')
|
||||
->label('View review')
|
||||
->url(fn (TenantReview $record): string => TenantReviewResource::tenantScopedUrl('view', ['record' => $record], $record->tenant, 'tenant')),
|
||||
Action::make('export_executive_pack')
|
||||
->label('Export executive pack')
|
||||
->icon('heroicon-o-arrow-down-tray')
|
||||
->visible(fn (TenantReview $record): bool => auth()->user() instanceof User
|
||||
&& auth()->user()->can(Capabilities::TENANT_REVIEW_MANAGE, $record->tenant)
|
||||
&& in_array($record->status, ['ready', 'published'], true))
|
||||
->action(fn (TenantReview $record): mixed => TenantReviewResource::executeExport($record)),
|
||||
])
|
||||
->bulkActions([])
|
||||
->emptyStateHeading('No review records match this view')
|
||||
->emptyStateDescription('Clear the current filters to return to the full review register for your entitled tenants.')
|
||||
->emptyStateActions([
|
||||
Action::make('clear_filters_empty')
|
||||
->label('Clear filters')
|
||||
->icon('heroicon-o-x-mark')
|
||||
->color('gray')
|
||||
->action(fn (): mixed => $this->resetTable()),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, Tenant>
|
||||
*/
|
||||
public function authorizedTenants(): array
|
||||
{
|
||||
if ($this->authorizedTenants !== null) {
|
||||
return $this->authorizedTenants;
|
||||
}
|
||||
|
||||
$user = auth()->user();
|
||||
$workspace = $this->workspace();
|
||||
|
||||
if (! $user instanceof User || ! $workspace instanceof Workspace) {
|
||||
return $this->authorizedTenants = [];
|
||||
}
|
||||
|
||||
return $this->authorizedTenants = app(TenantReviewRegisterService::class)->authorizedTenants($user, $workspace);
|
||||
}
|
||||
|
||||
private function authorizePageAccess(): void
|
||||
{
|
||||
$user = auth()->user();
|
||||
$workspace = $this->workspace();
|
||||
|
||||
if (! $user instanceof User) {
|
||||
abort(403);
|
||||
}
|
||||
|
||||
if (! $workspace instanceof Workspace) {
|
||||
throw new NotFoundHttpException;
|
||||
}
|
||||
|
||||
$service = app(TenantReviewRegisterService::class);
|
||||
|
||||
if (! $service->canAccessWorkspace($user, $workspace)) {
|
||||
throw new NotFoundHttpException;
|
||||
}
|
||||
|
||||
if ($this->authorizedTenants() === []) {
|
||||
throw new NotFoundHttpException;
|
||||
}
|
||||
}
|
||||
|
||||
private function registerQuery(): Builder
|
||||
{
|
||||
$user = auth()->user();
|
||||
$workspace = $this->workspace();
|
||||
|
||||
if (! $user instanceof User || ! $workspace instanceof Workspace) {
|
||||
return TenantReview::query()->whereRaw('1 = 0');
|
||||
}
|
||||
|
||||
return app(TenantReviewRegisterService::class)->query($user, $workspace);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, string>
|
||||
*/
|
||||
private function tenantFilterOptions(): array
|
||||
{
|
||||
return collect($this->authorizedTenants())
|
||||
->mapWithKeys(static fn (Tenant $tenant): array => [
|
||||
(string) $tenant->getKey() => $tenant->name,
|
||||
])
|
||||
->all();
|
||||
}
|
||||
|
||||
private function defaultTenantFilter(): ?string
|
||||
{
|
||||
$tenantId = app(WorkspaceContext::class)->lastTenantId(request());
|
||||
|
||||
return is_int($tenantId) && array_key_exists($tenantId, $this->authorizedTenants())
|
||||
? (string) $tenantId
|
||||
: null;
|
||||
}
|
||||
|
||||
private function applyRequestedTenantPrefilter(): void
|
||||
{
|
||||
$requestedTenant = request()->query('tenant');
|
||||
|
||||
if (! is_string($requestedTenant) && ! is_numeric($requestedTenant)) {
|
||||
return;
|
||||
}
|
||||
|
||||
foreach ($this->authorizedTenants() as $tenant) {
|
||||
if ((string) $tenant->getKey() !== (string) $requestedTenant && (string) $tenant->external_id !== (string) $requestedTenant) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$this->tableFilters['tenant_id']['value'] = (string) $tenant->getKey();
|
||||
$this->tableDeferredFilters['tenant_id']['value'] = (string) $tenant->getKey();
|
||||
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
private function hasActiveFilters(): bool
|
||||
{
|
||||
$filters = array_filter((array) $this->tableFilters);
|
||||
|
||||
return $filters !== [];
|
||||
}
|
||||
|
||||
private function workspace(): ?Workspace
|
||||
{
|
||||
$workspaceId = app(WorkspaceContext::class)->currentWorkspaceId(request());
|
||||
|
||||
return is_numeric($workspaceId)
|
||||
? Workspace::query()->whereKey((int) $workspaceId)->first()
|
||||
: null;
|
||||
}
|
||||
}
|
||||
@ -166,6 +166,21 @@ public static function infolist(Schema $schema): Schema
|
||||
Section::make('Metadata')
|
||||
->schema([
|
||||
TextEntry::make('initiator.name')->label('Initiated by')->placeholder('—'),
|
||||
TextEntry::make('tenantReview.id')
|
||||
->label('Tenant review')
|
||||
->formatStateUsing(fn (?int $state): string => $state ? '#'.$state : '—')
|
||||
->url(fn (ReviewPack $record): ?string => $record->tenantReview && $record->tenant
|
||||
? TenantReviewResource::tenantScopedUrl('view', ['record' => $record->tenantReview], $record->tenant)
|
||||
: null)
|
||||
->placeholder('—'),
|
||||
TextEntry::make('summary.review_status')
|
||||
->label('Review status')
|
||||
->badge()
|
||||
->formatStateUsing(BadgeRenderer::label(BadgeDomain::TenantReviewStatus))
|
||||
->color(BadgeRenderer::color(BadgeDomain::TenantReviewStatus))
|
||||
->icon(BadgeRenderer::icon(BadgeDomain::TenantReviewStatus))
|
||||
->iconColor(BadgeRenderer::iconColor(BadgeDomain::TenantReviewStatus))
|
||||
->placeholder('—'),
|
||||
TextEntry::make('operationRun.id')
|
||||
->label('Operation run')
|
||||
->url(fn (ReviewPack $record): ?string => $record->operation_run_id
|
||||
@ -230,6 +245,10 @@ public static function table(Table $table): Table
|
||||
->dateTime()
|
||||
->sortable()
|
||||
->placeholder('—'),
|
||||
Tables\Columns\TextColumn::make('tenantReview.id')
|
||||
->label('Review')
|
||||
->formatStateUsing(fn (?int $state): string => $state ? '#'.$state : '—')
|
||||
->toggleable(isToggledHiddenByDefault: true),
|
||||
Tables\Columns\TextColumn::make('expires_at')
|
||||
->dateTime()
|
||||
->sortable()
|
||||
|
||||
562
app/Filament/Resources/TenantReviewResource.php
Normal file
562
app/Filament/Resources/TenantReviewResource.php
Normal file
@ -0,0 +1,562 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Filament\Resources;
|
||||
|
||||
use App\Filament\Concerns\InteractsWithTenantOwnedRecords;
|
||||
use App\Filament\Concerns\ResolvesPanelTenantContext;
|
||||
use App\Filament\Resources\TenantReviewResource\Pages;
|
||||
use App\Models\EvidenceSnapshot;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\TenantReview;
|
||||
use App\Models\TenantReviewSection;
|
||||
use App\Models\User;
|
||||
use App\Services\ReviewPackService;
|
||||
use App\Services\TenantReviews\TenantReviewService;
|
||||
use App\Support\Auth\Capabilities;
|
||||
use App\Support\Badges\BadgeDomain;
|
||||
use App\Support\Badges\BadgeRenderer;
|
||||
use App\Support\OperationRunLinks;
|
||||
use App\Support\OperationRunType;
|
||||
use App\Support\OpsUx\OperationUxPresenter;
|
||||
use App\Support\Rbac\UiEnforcement;
|
||||
use App\Support\TenantReviewStatus;
|
||||
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 BackedEnum;
|
||||
use Filament\Actions;
|
||||
use Filament\Facades\Filament;
|
||||
use Filament\Forms\Components\Select;
|
||||
use Filament\Infolists\Components\RepeatableEntry;
|
||||
use Filament\Infolists\Components\TextEntry;
|
||||
use Filament\Infolists\Components\ViewEntry;
|
||||
use Filament\Notifications\Notification;
|
||||
use Filament\Resources\Resource;
|
||||
use Filament\Schemas\Components\Section;
|
||||
use Filament\Schemas\Schema;
|
||||
use Filament\Support\Enums\TextSize;
|
||||
use Filament\Tables;
|
||||
use Filament\Tables\Table;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Support\Str;
|
||||
use UnitEnum;
|
||||
|
||||
class TenantReviewResource extends Resource
|
||||
{
|
||||
use InteractsWithTenantOwnedRecords;
|
||||
use ResolvesPanelTenantContext;
|
||||
|
||||
protected static bool $isDiscovered = false;
|
||||
|
||||
protected static ?string $model = TenantReview::class;
|
||||
|
||||
protected static ?string $slug = 'reviews';
|
||||
|
||||
protected static ?string $tenantOwnershipRelationshipName = 'tenant';
|
||||
|
||||
protected static bool $isGloballySearchable = false;
|
||||
|
||||
protected static string|BackedEnum|null $navigationIcon = 'heroicon-o-document-magnifying-glass';
|
||||
|
||||
protected static string|UnitEnum|null $navigationGroup = 'Reporting';
|
||||
|
||||
protected static ?string $navigationLabel = 'Reviews';
|
||||
|
||||
protected static ?int $navigationSort = 45;
|
||||
|
||||
public static function shouldRegisterNavigation(): bool
|
||||
{
|
||||
return Filament::getCurrentPanel()?->getId() === 'tenant';
|
||||
}
|
||||
|
||||
public static function canViewAny(): bool
|
||||
{
|
||||
$tenant = static::resolveTenantContextForCurrentPanel();
|
||||
$user = auth()->user();
|
||||
|
||||
if (! $tenant instanceof Tenant || ! $user instanceof User) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (! $user->canAccessTenant($tenant)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return $user->can(Capabilities::TENANT_REVIEW_VIEW, $tenant);
|
||||
}
|
||||
|
||||
public static function canView(Model $record): bool
|
||||
{
|
||||
$tenant = static::resolveTenantContextForCurrentPanel();
|
||||
$user = auth()->user();
|
||||
|
||||
if (! $tenant instanceof Tenant || ! $user instanceof User || ! $record instanceof TenantReview) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (! $user->canAccessTenant($tenant)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if ((int) $record->tenant_id !== (int) $tenant->getKey()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return $user->can('view', $record);
|
||||
}
|
||||
|
||||
public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration
|
||||
{
|
||||
return ActionSurfaceDeclaration::forResource(ActionSurfaceProfile::CrudListAndView)
|
||||
->satisfy(ActionSurfaceSlot::ListHeader, 'Create review is available from the review library header.')
|
||||
->satisfy(ActionSurfaceSlot::InspectAffordance, ActionSurfaceInspectAffordance::ClickableRow->value)
|
||||
->satisfy(ActionSurfaceSlot::ListEmptyState, 'Empty state includes exactly one Create first review CTA.')
|
||||
->exempt(ActionSurfaceSlot::ListBulkMoreGroup, 'Tenant reviews do not expose bulk actions in the first slice.')
|
||||
->exempt(ActionSurfaceSlot::ListRowMoreMenu, 'Primary row actions stay limited to View review and Export executive pack.')
|
||||
->satisfy(ActionSurfaceSlot::DetailHeader, 'Detail exposes Refresh review, Publish review, Export executive pack, Archive review, and Create next review as applicable.');
|
||||
}
|
||||
|
||||
public static function getEloquentQuery(): Builder
|
||||
{
|
||||
return static::getTenantOwnedEloquentQuery()
|
||||
->with(['tenant', 'evidenceSnapshot', 'operationRun', 'initiator', 'publisher', 'currentExportReviewPack', 'sections'])
|
||||
->latest('generated_at')
|
||||
->latest('id');
|
||||
}
|
||||
|
||||
public static function resolveScopedRecordOrFail(int|string|null $record): Model
|
||||
{
|
||||
return static::resolveTenantOwnedRecordOrFail($record);
|
||||
}
|
||||
|
||||
public static function form(Schema $schema): Schema
|
||||
{
|
||||
return $schema;
|
||||
}
|
||||
|
||||
public static function infolist(Schema $schema): Schema
|
||||
{
|
||||
return $schema->schema([
|
||||
Section::make('Review')
|
||||
->schema([
|
||||
TextEntry::make('status')
|
||||
->badge()
|
||||
->formatStateUsing(BadgeRenderer::label(BadgeDomain::TenantReviewStatus))
|
||||
->color(BadgeRenderer::color(BadgeDomain::TenantReviewStatus))
|
||||
->icon(BadgeRenderer::icon(BadgeDomain::TenantReviewStatus))
|
||||
->iconColor(BadgeRenderer::iconColor(BadgeDomain::TenantReviewStatus)),
|
||||
TextEntry::make('completeness_state')
|
||||
->label('Completeness')
|
||||
->badge()
|
||||
->formatStateUsing(BadgeRenderer::label(BadgeDomain::TenantReviewCompleteness))
|
||||
->color(BadgeRenderer::color(BadgeDomain::TenantReviewCompleteness))
|
||||
->icon(BadgeRenderer::icon(BadgeDomain::TenantReviewCompleteness))
|
||||
->iconColor(BadgeRenderer::iconColor(BadgeDomain::TenantReviewCompleteness)),
|
||||
TextEntry::make('tenant.name')->label('Tenant'),
|
||||
TextEntry::make('generated_at')->dateTime()->placeholder('—'),
|
||||
TextEntry::make('published_at')->dateTime()->placeholder('—'),
|
||||
TextEntry::make('evidenceSnapshot.id')
|
||||
->label('Evidence snapshot')
|
||||
->formatStateUsing(fn (?int $state): string => $state ? '#'.$state : '—')
|
||||
->url(fn (TenantReview $record): ?string => $record->evidenceSnapshot
|
||||
? EvidenceSnapshotResource::getUrl('view', ['record' => $record->evidenceSnapshot], tenant: $record->tenant)
|
||||
: null),
|
||||
TextEntry::make('currentExportReviewPack.id')
|
||||
->label('Current export')
|
||||
->formatStateUsing(fn (?int $state): string => $state ? '#'.$state : '—')
|
||||
->url(fn (TenantReview $record): ?string => $record->currentExportReviewPack
|
||||
? ReviewPackResource::getUrl('view', ['record' => $record->currentExportReviewPack], tenant: $record->tenant)
|
||||
: null),
|
||||
TextEntry::make('fingerprint')
|
||||
->copyable()
|
||||
->placeholder('—')
|
||||
->columnSpanFull()
|
||||
->fontFamily('mono')
|
||||
->size(TextSize::ExtraSmall),
|
||||
])
|
||||
->columns(2)
|
||||
->columnSpanFull(),
|
||||
Section::make('Executive posture')
|
||||
->schema([
|
||||
ViewEntry::make('review_summary')
|
||||
->hiddenLabel()
|
||||
->view('filament.infolists.entries.tenant-review-summary')
|
||||
->state(fn (TenantReview $record): array => static::summaryPresentation($record))
|
||||
->columnSpanFull(),
|
||||
])
|
||||
->columnSpanFull(),
|
||||
Section::make('Sections')
|
||||
->schema([
|
||||
RepeatableEntry::make('sections')
|
||||
->hiddenLabel()
|
||||
->schema([
|
||||
TextEntry::make('title'),
|
||||
TextEntry::make('completeness_state')
|
||||
->label('Completeness')
|
||||
->badge()
|
||||
->formatStateUsing(BadgeRenderer::label(BadgeDomain::TenantReviewCompleteness))
|
||||
->color(BadgeRenderer::color(BadgeDomain::TenantReviewCompleteness))
|
||||
->icon(BadgeRenderer::icon(BadgeDomain::TenantReviewCompleteness))
|
||||
->iconColor(BadgeRenderer::iconColor(BadgeDomain::TenantReviewCompleteness)),
|
||||
TextEntry::make('measured_at')->dateTime()->placeholder('—'),
|
||||
Section::make('Details')
|
||||
->schema([
|
||||
ViewEntry::make('section_payload')
|
||||
->hiddenLabel()
|
||||
->view('filament.infolists.entries.tenant-review-section')
|
||||
->state(fn (TenantReviewSection $record): array => static::sectionPresentation($record))
|
||||
->columnSpanFull(),
|
||||
])
|
||||
->collapsible()
|
||||
->collapsed()
|
||||
->columnSpanFull(),
|
||||
])
|
||||
->columns(3),
|
||||
])
|
||||
->columnSpanFull(),
|
||||
]);
|
||||
}
|
||||
|
||||
public static function table(Table $table): Table
|
||||
{
|
||||
return $table
|
||||
->defaultSort('generated_at', 'desc')
|
||||
->persistFiltersInSession()
|
||||
->persistSearchInSession()
|
||||
->persistSortInSession()
|
||||
->recordUrl(fn (TenantReview $record): string => static::tenantScopedUrl('view', ['record' => $record], $record->tenant))
|
||||
->columns([
|
||||
Tables\Columns\TextColumn::make('status')
|
||||
->badge()
|
||||
->formatStateUsing(BadgeRenderer::label(BadgeDomain::TenantReviewStatus))
|
||||
->color(BadgeRenderer::color(BadgeDomain::TenantReviewStatus))
|
||||
->icon(BadgeRenderer::icon(BadgeDomain::TenantReviewStatus))
|
||||
->iconColor(BadgeRenderer::iconColor(BadgeDomain::TenantReviewStatus))
|
||||
->sortable(),
|
||||
Tables\Columns\TextColumn::make('completeness_state')
|
||||
->label('Completeness')
|
||||
->badge()
|
||||
->formatStateUsing(BadgeRenderer::label(BadgeDomain::TenantReviewCompleteness))
|
||||
->color(BadgeRenderer::color(BadgeDomain::TenantReviewCompleteness))
|
||||
->icon(BadgeRenderer::icon(BadgeDomain::TenantReviewCompleteness))
|
||||
->iconColor(BadgeRenderer::iconColor(BadgeDomain::TenantReviewCompleteness))
|
||||
->sortable(),
|
||||
Tables\Columns\TextColumn::make('generated_at')->dateTime()->placeholder('—')->sortable(),
|
||||
Tables\Columns\TextColumn::make('published_at')->dateTime()->placeholder('—')->sortable(),
|
||||
Tables\Columns\TextColumn::make('summary.finding_count')->label('Findings'),
|
||||
Tables\Columns\TextColumn::make('summary.section_state_counts.missing')->label('Missing'),
|
||||
Tables\Columns\IconColumn::make('summary.has_ready_export')
|
||||
->label('Export')
|
||||
->boolean(),
|
||||
Tables\Columns\TextColumn::make('fingerprint')
|
||||
->toggleable(isToggledHiddenByDefault: true)
|
||||
->searchable(),
|
||||
])
|
||||
->filters([
|
||||
Tables\Filters\SelectFilter::make('status')
|
||||
->options(collect(TenantReviewStatus::cases())
|
||||
->mapWithKeys(fn (TenantReviewStatus $status): array => [$status->value => Str::headline($status->value)])
|
||||
->all()),
|
||||
Tables\Filters\SelectFilter::make('completeness_state')
|
||||
->options([
|
||||
'complete' => 'Complete',
|
||||
'partial' => 'Partial',
|
||||
'missing' => 'Missing',
|
||||
'stale' => 'Stale',
|
||||
]),
|
||||
\App\Support\Filament\FilterPresets::dateRange('review_date', 'Review date', 'generated_at'),
|
||||
])
|
||||
->actions([
|
||||
Actions\Action::make('view_review')
|
||||
->label('View review')
|
||||
->url(fn (TenantReview $record): string => static::tenantScopedUrl('view', ['record' => $record], $record->tenant)),
|
||||
UiEnforcement::forTableAction(
|
||||
Actions\Action::make('export_executive_pack')
|
||||
->label('Export executive pack')
|
||||
->icon('heroicon-o-arrow-down-tray')
|
||||
->visible(fn (TenantReview $record): bool => in_array($record->status, [
|
||||
TenantReviewStatus::Ready->value,
|
||||
TenantReviewStatus::Published->value,
|
||||
], true))
|
||||
->action(fn (TenantReview $record): mixed => static::executeExport($record)),
|
||||
fn (TenantReview $record): TenantReview => $record,
|
||||
)
|
||||
->requireCapability(Capabilities::TENANT_REVIEW_MANAGE)
|
||||
->preserveVisibility()
|
||||
->apply(),
|
||||
])
|
||||
->bulkActions([])
|
||||
->emptyStateHeading('No tenant reviews yet')
|
||||
->emptyStateDescription('Create the first review from an anchored evidence snapshot to start the recurring review history for this tenant.')
|
||||
->emptyStateActions([
|
||||
static::makeCreateReviewAction(
|
||||
name: 'create_first_review',
|
||||
label: 'Create first review',
|
||||
icon: 'heroicon-o-plus',
|
||||
),
|
||||
]);
|
||||
}
|
||||
|
||||
public static function getPages(): array
|
||||
{
|
||||
return [
|
||||
'index' => Pages\ListTenantReviews::route('/'),
|
||||
'view' => Pages\ViewTenantReview::route('/{record}'),
|
||||
];
|
||||
}
|
||||
|
||||
public static function makeCreateReviewAction(
|
||||
string $name = 'create_review',
|
||||
string $label = 'Create review',
|
||||
string $icon = 'heroicon-o-plus',
|
||||
): Actions\Action {
|
||||
return UiEnforcement::forAction(
|
||||
Actions\Action::make($name)
|
||||
->label($label)
|
||||
->icon($icon)
|
||||
->form([
|
||||
Section::make('Evidence basis')
|
||||
->schema([
|
||||
Select::make('evidence_snapshot_id')
|
||||
->label('Evidence snapshot')
|
||||
->required()
|
||||
->options(fn (): array => static::evidenceSnapshotOptions())
|
||||
->searchable()
|
||||
->helperText('Choose the anchored evidence snapshot for this review.'),
|
||||
]),
|
||||
])
|
||||
->action(fn (array $data): mixed => static::executeCreateReview($data)),
|
||||
)
|
||||
->requireCapability(Capabilities::TENANT_REVIEW_MANAGE)
|
||||
->apply();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $data
|
||||
*/
|
||||
public static function executeCreateReview(array $data): void
|
||||
{
|
||||
$tenant = Filament::getTenant();
|
||||
$user = auth()->user();
|
||||
|
||||
if (! $tenant instanceof Tenant || ! $user instanceof User) {
|
||||
Notification::make()->danger()->title('Unable to create review — missing context.')->send();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (! $user->canAccessTenant($tenant)) {
|
||||
abort(404);
|
||||
}
|
||||
|
||||
if (! $user->can(Capabilities::TENANT_REVIEW_MANAGE, $tenant)) {
|
||||
abort(403);
|
||||
}
|
||||
|
||||
$snapshotId = $data['evidence_snapshot_id'] ?? null;
|
||||
$snapshot = is_numeric($snapshotId)
|
||||
? EvidenceSnapshot::query()
|
||||
->whereKey((int) $snapshotId)
|
||||
->where('tenant_id', (int) $tenant->getKey())
|
||||
->first()
|
||||
: null;
|
||||
|
||||
if (! $snapshot instanceof EvidenceSnapshot) {
|
||||
Notification::make()->danger()->title('Select a valid evidence snapshot.')->send();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
$review = app(TenantReviewService::class)->create($tenant, $snapshot, $user);
|
||||
} catch (\Throwable $throwable) {
|
||||
Notification::make()->danger()->title('Unable to create review')->body($throwable->getMessage())->send();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (! $review->wasRecentlyCreated) {
|
||||
Notification::make()
|
||||
->success()
|
||||
->title('Review already available')
|
||||
->body('A matching mutable review already exists for this evidence basis.')
|
||||
->actions([
|
||||
Actions\Action::make('view_review')
|
||||
->label('View review')
|
||||
->url(static::tenantScopedUrl('view', ['record' => $review], $tenant)),
|
||||
])
|
||||
->send();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$toast = OperationUxPresenter::queuedToast(OperationRunType::TenantReviewCompose->value)
|
||||
->body('The review is being composed in the background.');
|
||||
|
||||
if ($review->operation_run_id) {
|
||||
$toast->actions([
|
||||
Actions\Action::make('view_run')
|
||||
->label('View run')
|
||||
->url(OperationRunLinks::tenantlessView((int) $review->operation_run_id)),
|
||||
]);
|
||||
}
|
||||
|
||||
$toast->send();
|
||||
}
|
||||
|
||||
public static function executeExport(TenantReview $review): void
|
||||
{
|
||||
$review->loadMissing(['tenant', 'currentExportReviewPack']);
|
||||
$user = auth()->user();
|
||||
|
||||
if (! $user instanceof User || ! $review->tenant instanceof Tenant) {
|
||||
Notification::make()->danger()->title('Unable to export review — missing context.')->send();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (! $user->canAccessTenant($review->tenant)) {
|
||||
abort(404);
|
||||
}
|
||||
|
||||
if (! $user->can('export', $review)) {
|
||||
abort(403);
|
||||
}
|
||||
|
||||
$service = app(ReviewPackService::class);
|
||||
|
||||
if ($service->checkActiveRunForReview($review)) {
|
||||
OperationUxPresenter::alreadyQueuedToast(OperationRunType::ReviewPackGenerate->value)
|
||||
->body('An executive pack export is already queued or running for this review.')
|
||||
->send();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
$pack = $service->generateFromReview($review, $user, [
|
||||
'include_pii' => true,
|
||||
'include_operations' => true,
|
||||
]);
|
||||
} catch (\Throwable $throwable) {
|
||||
Notification::make()->danger()->title('Unable to export executive pack')->body($throwable->getMessage())->send();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (! $pack->wasRecentlyCreated) {
|
||||
Notification::make()
|
||||
->success()
|
||||
->title('Executive pack already available')
|
||||
->body('A matching executive pack already exists for this review.')
|
||||
->actions([
|
||||
Actions\Action::make('view_pack')
|
||||
->label('View pack')
|
||||
->url(ReviewPackResource::getUrl('view', ['record' => $pack], tenant: $review->tenant)),
|
||||
])
|
||||
->send();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
OperationUxPresenter::queuedToast(OperationRunType::ReviewPackGenerate->value)
|
||||
->body('The executive pack is being generated in the background.')
|
||||
->send();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $parameters
|
||||
*/
|
||||
public static function tenantScopedUrl(
|
||||
string $page = 'index',
|
||||
array $parameters = [],
|
||||
?Tenant $tenant = null,
|
||||
?string $panel = null,
|
||||
): string {
|
||||
$panelId = $panel ?? 'tenant';
|
||||
|
||||
return static::getUrl($page, $parameters, panel: $panelId, tenant: $tenant);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, string>
|
||||
*/
|
||||
private static function evidenceSnapshotOptions(): array
|
||||
{
|
||||
$tenant = Filament::getTenant();
|
||||
|
||||
if (! $tenant instanceof Tenant) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return EvidenceSnapshot::query()
|
||||
->where('tenant_id', (int) $tenant->getKey())
|
||||
->whereNotNull('generated_at')
|
||||
->orderByDesc('generated_at')
|
||||
->orderByDesc('id')
|
||||
->get()
|
||||
->mapWithKeys(static fn (EvidenceSnapshot $snapshot): array => [
|
||||
(string) $snapshot->getKey() => sprintf(
|
||||
'#%d · %s · %s',
|
||||
(int) $snapshot->getKey(),
|
||||
Str::headline((string) $snapshot->completeness_state),
|
||||
$snapshot->generated_at?->format('Y-m-d H:i') ?? 'Pending'
|
||||
),
|
||||
])
|
||||
->all();
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private static function summaryPresentation(TenantReview $record): array
|
||||
{
|
||||
$summary = is_array($record->summary) ? $record->summary : [];
|
||||
|
||||
return [
|
||||
'highlights' => is_array($summary['highlights'] ?? null) ? $summary['highlights'] : [],
|
||||
'next_actions' => is_array($summary['recommended_next_actions'] ?? null) ? $summary['recommended_next_actions'] : [],
|
||||
'publish_blockers' => is_array($summary['publish_blockers'] ?? null) ? $summary['publish_blockers'] : [],
|
||||
'metrics' => [
|
||||
['label' => 'Findings', 'value' => (string) ($summary['finding_count'] ?? 0)],
|
||||
['label' => 'Reports', 'value' => (string) ($summary['report_count'] ?? 0)],
|
||||
['label' => 'Operations', 'value' => (string) ($summary['operation_count'] ?? 0)],
|
||||
['label' => 'Sections', 'value' => (string) ($summary['section_count'] ?? 0)],
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private static function sectionPresentation(TenantReviewSection $section): array
|
||||
{
|
||||
$summary = is_array($section->summary_payload) ? $section->summary_payload : [];
|
||||
$render = is_array($section->render_payload) ? $section->render_payload : [];
|
||||
$review = $section->tenantReview;
|
||||
$tenant = $section->tenant;
|
||||
|
||||
return [
|
||||
'summary' => collect($summary)->map(function (mixed $value, string $key): ?array {
|
||||
if (is_array($value) || $value === null || $value === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
return [
|
||||
'label' => Str::headline($key),
|
||||
'value' => (string) $value,
|
||||
];
|
||||
})->filter()->values()->all(),
|
||||
'highlights' => is_array($render['highlights'] ?? null) ? $render['highlights'] : [],
|
||||
'entries' => is_array($render['entries'] ?? null) ? $render['entries'] : [],
|
||||
'disclosure' => is_string($render['disclosure'] ?? null) ? $render['disclosure'] : null,
|
||||
'next_actions' => is_array($render['next_actions'] ?? null) ? $render['next_actions'] : [],
|
||||
'empty_state' => is_string($render['empty_state'] ?? null) ? $render['empty_state'] : null,
|
||||
'links' => [],
|
||||
];
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,20 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Filament\Resources\TenantReviewResource\Pages;
|
||||
|
||||
use App\Filament\Resources\TenantReviewResource;
|
||||
use Filament\Resources\Pages\ListRecords;
|
||||
|
||||
class ListTenantReviews extends ListRecords
|
||||
{
|
||||
protected static string $resource = TenantReviewResource::class;
|
||||
|
||||
protected function getHeaderActions(): array
|
||||
{
|
||||
return [
|
||||
TenantReviewResource::makeCreateReviewAction(),
|
||||
];
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,205 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Filament\Resources\TenantReviewResource\Pages;
|
||||
|
||||
use App\Filament\Resources\TenantReviewResource;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\TenantReview;
|
||||
use App\Models\User;
|
||||
use App\Services\TenantReviews\TenantReviewLifecycleService;
|
||||
use App\Services\TenantReviews\TenantReviewService;
|
||||
use App\Support\Auth\Capabilities;
|
||||
use App\Support\OperationRunLinks;
|
||||
use App\Support\Rbac\UiEnforcement;
|
||||
use App\Support\TenantReviewStatus;
|
||||
use Filament\Actions;
|
||||
use Filament\Notifications\Notification;
|
||||
use Filament\Resources\Pages\ViewRecord;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
class ViewTenantReview extends ViewRecord
|
||||
{
|
||||
protected static string $resource = TenantReviewResource::class;
|
||||
|
||||
protected function resolveRecord(int|string $key): Model
|
||||
{
|
||||
return TenantReviewResource::resolveScopedRecordOrFail($key);
|
||||
}
|
||||
|
||||
protected function authorizeAccess(): void
|
||||
{
|
||||
$tenant = TenantReviewResource::panelTenantContext();
|
||||
$record = $this->getRecord();
|
||||
$user = auth()->user();
|
||||
|
||||
if (! $user instanceof User || ! $tenant instanceof Tenant || ! $record instanceof TenantReview) {
|
||||
abort(404);
|
||||
}
|
||||
|
||||
if ((int) $record->tenant_id !== (int) $tenant->getKey()) {
|
||||
abort(404);
|
||||
}
|
||||
|
||||
if (! $user->canAccessTenant($tenant)) {
|
||||
abort(404);
|
||||
}
|
||||
|
||||
if (! $user->can('view', $record)) {
|
||||
abort(403);
|
||||
}
|
||||
}
|
||||
|
||||
protected function getHeaderActions(): array
|
||||
{
|
||||
return [
|
||||
Actions\Action::make('view_run')
|
||||
->label('View run')
|
||||
->icon('heroicon-o-eye')
|
||||
->color('gray')
|
||||
->hidden(fn (): bool => ! is_numeric($this->record->operation_run_id))
|
||||
->url(fn (): ?string => $this->record->operation_run_id
|
||||
? OperationRunLinks::tenantlessView((int) $this->record->operation_run_id)
|
||||
: null),
|
||||
Actions\Action::make('view_export')
|
||||
->label('View executive pack')
|
||||
->icon('heroicon-o-document-arrow-down')
|
||||
->color('gray')
|
||||
->hidden(fn (): bool => ! $this->record->currentExportReviewPack)
|
||||
->url(fn (): ?string => $this->record->currentExportReviewPack
|
||||
? \App\Filament\Resources\ReviewPackResource::getUrl('view', ['record' => $this->record->currentExportReviewPack], tenant: $this->record->tenant)
|
||||
: null),
|
||||
Actions\Action::make('view_evidence')
|
||||
->label('View evidence snapshot')
|
||||
->icon('heroicon-o-shield-check')
|
||||
->color('gray')
|
||||
->hidden(fn (): bool => ! $this->record->evidenceSnapshot)
|
||||
->url(fn (): ?string => $this->record->evidenceSnapshot
|
||||
? \App\Filament\Resources\EvidenceSnapshotResource::getUrl('view', ['record' => $this->record->evidenceSnapshot], tenant: $this->record->tenant)
|
||||
: null),
|
||||
UiEnforcement::forAction(
|
||||
Actions\Action::make('refresh_review')
|
||||
->label('Refresh review')
|
||||
->icon('heroicon-o-arrow-path')
|
||||
->hidden(fn (): bool => ! $this->record->isMutable())
|
||||
->requiresConfirmation()
|
||||
->action(function (): void {
|
||||
$user = auth()->user();
|
||||
|
||||
if (! $user instanceof User) {
|
||||
abort(403);
|
||||
}
|
||||
|
||||
try {
|
||||
app(TenantReviewService::class)->refresh($this->record, $user);
|
||||
} catch (\Throwable $throwable) {
|
||||
Notification::make()->danger()->title('Unable to refresh review')->body($throwable->getMessage())->send();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
Notification::make()->success()->title('Refresh review queued')->send();
|
||||
}),
|
||||
)
|
||||
->requireCapability(Capabilities::TENANT_REVIEW_MANAGE)
|
||||
->preserveVisibility()
|
||||
->apply(),
|
||||
UiEnforcement::forAction(
|
||||
Actions\Action::make('publish_review')
|
||||
->label('Publish review')
|
||||
->icon('heroicon-o-check-badge')
|
||||
->hidden(fn (): bool => ! $this->record->isMutable())
|
||||
->requiresConfirmation()
|
||||
->action(function (): void {
|
||||
$user = auth()->user();
|
||||
|
||||
if (! $user instanceof User) {
|
||||
abort(403);
|
||||
}
|
||||
|
||||
try {
|
||||
app(TenantReviewLifecycleService::class)->publish($this->record, $user);
|
||||
} catch (\Throwable $throwable) {
|
||||
Notification::make()->danger()->title('Unable to publish review')->body($throwable->getMessage())->send();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$this->refreshFormData(['status', 'published_at', 'published_by_user_id', 'summary']);
|
||||
Notification::make()->success()->title('Review published')->send();
|
||||
}),
|
||||
)
|
||||
->requireCapability(Capabilities::TENANT_REVIEW_MANAGE)
|
||||
->preserveVisibility()
|
||||
->apply(),
|
||||
UiEnforcement::forAction(
|
||||
Actions\Action::make('export_executive_pack')
|
||||
->label('Export executive pack')
|
||||
->icon('heroicon-o-arrow-down-tray')
|
||||
->hidden(fn (): bool => ! in_array($this->record->status, [
|
||||
TenantReviewStatus::Ready->value,
|
||||
TenantReviewStatus::Published->value,
|
||||
], true))
|
||||
->action(fn (): mixed => TenantReviewResource::executeExport($this->record)),
|
||||
)
|
||||
->requireCapability(Capabilities::TENANT_REVIEW_MANAGE)
|
||||
->preserveVisibility()
|
||||
->apply(),
|
||||
Actions\ActionGroup::make([
|
||||
UiEnforcement::forAction(
|
||||
Actions\Action::make('create_next_review')
|
||||
->label('Create next review')
|
||||
->icon('heroicon-o-document-duplicate')
|
||||
->hidden(fn (): bool => ! $this->record->isPublished())
|
||||
->action(function (): void {
|
||||
$user = auth()->user();
|
||||
|
||||
if (! $user instanceof User) {
|
||||
abort(403);
|
||||
}
|
||||
|
||||
try {
|
||||
$nextReview = app(TenantReviewLifecycleService::class)->createNextReview($this->record, $user);
|
||||
} catch (\Throwable $throwable) {
|
||||
Notification::make()->danger()->title('Unable to create next review')->body($throwable->getMessage())->send();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$this->redirect(TenantReviewResource::tenantScopedUrl('view', ['record' => $nextReview], $nextReview->tenant));
|
||||
}),
|
||||
)
|
||||
->requireCapability(Capabilities::TENANT_REVIEW_MANAGE)
|
||||
->preserveVisibility()
|
||||
->apply(),
|
||||
UiEnforcement::forAction(
|
||||
Actions\Action::make('archive_review')
|
||||
->label('Archive review')
|
||||
->icon('heroicon-o-archive-box')
|
||||
->color('danger')
|
||||
->hidden(fn (): bool => $this->record->statusEnum()->isTerminal())
|
||||
->requiresConfirmation()
|
||||
->action(function (): void {
|
||||
$user = auth()->user();
|
||||
|
||||
if (! $user instanceof User) {
|
||||
abort(403);
|
||||
}
|
||||
|
||||
app(TenantReviewLifecycleService::class)->archive($this->record, $user);
|
||||
$this->refreshFormData(['status', 'archived_at']);
|
||||
|
||||
Notification::make()->success()->title('Review archived')->send();
|
||||
}),
|
||||
)
|
||||
->requireCapability(Capabilities::TENANT_REVIEW_MANAGE)
|
||||
->preserveVisibility()
|
||||
->apply(),
|
||||
])
|
||||
->label('More')
|
||||
->icon('heroicon-m-ellipsis-vertical')
|
||||
->color('gray'),
|
||||
];
|
||||
}
|
||||
}
|
||||
@ -131,6 +131,7 @@ protected function getViewData(): array
|
||||
$canManage = $isTenantMember && $user->can(Capabilities::REVIEW_PACK_MANAGE, $tenant);
|
||||
|
||||
$latestPack = ReviewPack::query()
|
||||
->with('tenantReview')
|
||||
->where('tenant_id', (int) $tenant->getKey())
|
||||
->orderByDesc('created_at')
|
||||
->orderByDesc('id')
|
||||
@ -146,6 +147,7 @@ protected function getViewData(): array
|
||||
'canManage' => $canManage,
|
||||
'downloadUrl' => null,
|
||||
'failedReason' => null,
|
||||
'reviewUrl' => null,
|
||||
];
|
||||
}
|
||||
|
||||
@ -158,6 +160,11 @@ protected function getViewData(): array
|
||||
$downloadUrl = $service->generateDownloadUrl($latestPack);
|
||||
}
|
||||
|
||||
$reviewUrl = null;
|
||||
if ($latestPack->tenantReview && $canView) {
|
||||
$reviewUrl = \App\Filament\Resources\TenantReviewResource::tenantScopedUrl('view', ['record' => $latestPack->tenantReview], $tenant);
|
||||
}
|
||||
|
||||
$failedReason = null;
|
||||
if ($statusEnum === ReviewPackStatus::Failed && $latestPack->operationRun) {
|
||||
$opContext = is_array($latestPack->operationRun->context) ? $latestPack->operationRun->context : [];
|
||||
@ -173,6 +180,7 @@ protected function getViewData(): array
|
||||
'canManage' => $canManage,
|
||||
'downloadUrl' => $downloadUrl,
|
||||
'failedReason' => $failedReason,
|
||||
'reviewUrl' => $reviewUrl,
|
||||
];
|
||||
}
|
||||
|
||||
@ -200,6 +208,7 @@ private function emptyState(): array
|
||||
'canManage' => false,
|
||||
'downloadUrl' => null,
|
||||
'failedReason' => null,
|
||||
'reviewUrl' => null,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
79
app/Jobs/ComposeTenantReviewJob.php
Normal file
79
app/Jobs/ComposeTenantReviewJob.php
Normal file
@ -0,0 +1,79 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Jobs;
|
||||
|
||||
use App\Models\OperationRun;
|
||||
use App\Models\TenantReview;
|
||||
use App\Services\OperationRunService;
|
||||
use App\Services\TenantReviews\TenantReviewService;
|
||||
use App\Support\OperationRunOutcome;
|
||||
use App\Support\OperationRunStatus;
|
||||
use App\Support\TenantReviewStatus;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Foundation\Queue\Queueable;
|
||||
use Throwable;
|
||||
|
||||
class ComposeTenantReviewJob implements ShouldQueue
|
||||
{
|
||||
use Queueable;
|
||||
|
||||
public function __construct(
|
||||
public int $tenantReviewId,
|
||||
public int $operationRunId,
|
||||
) {}
|
||||
|
||||
public function handle(TenantReviewService $service, OperationRunService $operationRuns): void
|
||||
{
|
||||
$review = TenantReview::query()->with(['tenant', 'evidenceSnapshot.items'])->find($this->tenantReviewId);
|
||||
$operationRun = OperationRun::query()->find($this->operationRunId);
|
||||
|
||||
if (! $review instanceof TenantReview || ! $operationRun instanceof OperationRun || ! $review->tenant) {
|
||||
return;
|
||||
}
|
||||
|
||||
$operationRuns->updateRun($operationRun, OperationRunStatus::Running->value, OperationRunOutcome::Pending->value);
|
||||
$review->update(['status' => TenantReviewStatus::Draft->value]);
|
||||
|
||||
try {
|
||||
$review = $service->compose($review);
|
||||
|
||||
$summary = is_array($review->summary) ? $review->summary : [];
|
||||
|
||||
$operationRuns->updateRun(
|
||||
$operationRun,
|
||||
status: OperationRunStatus::Completed->value,
|
||||
outcome: OperationRunOutcome::Succeeded->value,
|
||||
summaryCounts: [
|
||||
'created' => 1,
|
||||
'finding_count' => (int) ($summary['finding_count'] ?? 0),
|
||||
'report_count' => (int) ($summary['report_count'] ?? 0),
|
||||
'operation_count' => (int) ($summary['operation_count'] ?? 0),
|
||||
'errors_recorded' => 0,
|
||||
],
|
||||
);
|
||||
} catch (Throwable $throwable) {
|
||||
$review->update([
|
||||
'status' => TenantReviewStatus::Failed->value,
|
||||
'summary' => array_merge(is_array($review->summary) ? $review->summary : [], [
|
||||
'error' => $throwable->getMessage(),
|
||||
]),
|
||||
]);
|
||||
|
||||
$operationRuns->updateRun(
|
||||
$operationRun,
|
||||
status: OperationRunStatus::Completed->value,
|
||||
outcome: OperationRunOutcome::Failed->value,
|
||||
failures: [
|
||||
[
|
||||
'code' => 'tenant_review_compose.failed',
|
||||
'message' => $throwable->getMessage(),
|
||||
],
|
||||
],
|
||||
);
|
||||
|
||||
throw $throwable;
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -9,6 +9,7 @@
|
||||
use App\Models\OperationRun;
|
||||
use App\Models\ReviewPack;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\TenantReview;
|
||||
use App\Services\Intune\SecretClassificationService;
|
||||
use App\Services\OperationRunService;
|
||||
use App\Services\ReviewPackService;
|
||||
@ -34,7 +35,7 @@ public function __construct(
|
||||
|
||||
public function handle(OperationRunService $operationRunService): void
|
||||
{
|
||||
$reviewPack = ReviewPack::query()->with(['tenant', 'evidenceSnapshot.items'])->find($this->reviewPackId);
|
||||
$reviewPack = ReviewPack::query()->with(['tenant', 'evidenceSnapshot.items', 'tenantReview.sections'])->find($this->reviewPackId);
|
||||
$operationRun = OperationRun::query()->find($this->operationRunId);
|
||||
|
||||
if (! $reviewPack instanceof ReviewPack || ! $operationRun instanceof OperationRun) {
|
||||
@ -77,6 +78,14 @@ public function handle(OperationRunService $operationRunService): void
|
||||
|
||||
private function executeGeneration(ReviewPack $reviewPack, OperationRun $operationRun, Tenant $tenant, EvidenceSnapshot $snapshot, OperationRunService $operationRunService): void
|
||||
{
|
||||
$review = $reviewPack->tenantReview;
|
||||
|
||||
if ($review instanceof TenantReview) {
|
||||
$this->executeReviewDerivedGeneration($reviewPack, $review, $operationRun, $tenant, $snapshot, $operationRunService);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$options = $reviewPack->options ?? [];
|
||||
$includePii = (bool) ($options['include_pii'] ?? true);
|
||||
$includeOperations = (bool) ($options['include_operations'] ?? true);
|
||||
@ -175,6 +184,103 @@ private function executeGeneration(ReviewPack $reviewPack, OperationRun $operati
|
||||
);
|
||||
}
|
||||
|
||||
private function executeReviewDerivedGeneration(
|
||||
ReviewPack $reviewPack,
|
||||
TenantReview $review,
|
||||
OperationRun $operationRun,
|
||||
Tenant $tenant,
|
||||
EvidenceSnapshot $snapshot,
|
||||
OperationRunService $operationRunService,
|
||||
): void {
|
||||
$options = $reviewPack->options ?? [];
|
||||
$includePii = (bool) ($options['include_pii'] ?? true);
|
||||
$includeOperations = (bool) ($options['include_operations'] ?? true);
|
||||
|
||||
$fileMap = $this->buildReviewDerivedFileMap(
|
||||
review: $review,
|
||||
tenant: $tenant,
|
||||
snapshot: $snapshot,
|
||||
includePii: $includePii,
|
||||
includeOperations: $includeOperations,
|
||||
);
|
||||
|
||||
$tempFile = tempnam(sys_get_temp_dir(), 'review-pack-');
|
||||
|
||||
try {
|
||||
$this->assembleZip($tempFile, $fileMap);
|
||||
|
||||
$sha256 = hash_file('sha256', $tempFile);
|
||||
$fileSize = filesize($tempFile);
|
||||
$filePath = sprintf(
|
||||
'review-packs/%s/review-%d-%s.zip',
|
||||
$tenant->external_id,
|
||||
(int) $review->getKey(),
|
||||
now()->format('Y-m-d-His'),
|
||||
);
|
||||
|
||||
Storage::disk('exports')->put($filePath, file_get_contents($tempFile));
|
||||
} finally {
|
||||
if (file_exists($tempFile)) {
|
||||
unlink($tempFile);
|
||||
}
|
||||
}
|
||||
|
||||
$fingerprint = app(ReviewPackService::class)->computeFingerprintForReview($review, $options);
|
||||
$reviewSummary = is_array($review->summary) ? $review->summary : [];
|
||||
$summary = [
|
||||
'tenant_review_id' => (int) $review->getKey(),
|
||||
'review_status' => (string) $review->status,
|
||||
'review_completeness_state' => (string) $review->completeness_state,
|
||||
'section_count' => $review->sections->count(),
|
||||
'finding_count' => (int) ($reviewSummary['finding_count'] ?? 0),
|
||||
'report_count' => (int) ($reviewSummary['report_count'] ?? 0),
|
||||
'operation_count' => $includeOperations ? (int) ($reviewSummary['operation_count'] ?? 0) : 0,
|
||||
'highlights' => is_array($reviewSummary['highlights'] ?? null) ? $reviewSummary['highlights'] : [],
|
||||
'publish_blockers' => is_array($reviewSummary['publish_blockers'] ?? null) ? $reviewSummary['publish_blockers'] : [],
|
||||
'evidence_resolution' => [
|
||||
'outcome' => 'resolved',
|
||||
'snapshot_id' => (int) $snapshot->getKey(),
|
||||
'snapshot_fingerprint' => (string) $snapshot->fingerprint,
|
||||
'completeness_state' => (string) $snapshot->completeness_state,
|
||||
],
|
||||
];
|
||||
|
||||
$retentionDays = (int) config('tenantpilot.review_pack.retention_days', 90);
|
||||
$reviewPack->update([
|
||||
'status' => ReviewPackStatus::Ready->value,
|
||||
'evidence_snapshot_id' => (int) $snapshot->getKey(),
|
||||
'fingerprint' => $fingerprint,
|
||||
'sha256' => $sha256,
|
||||
'file_size' => $fileSize,
|
||||
'file_path' => $filePath,
|
||||
'file_disk' => 'exports',
|
||||
'generated_at' => now(),
|
||||
'expires_at' => now()->addDays($retentionDays),
|
||||
'summary' => $summary,
|
||||
]);
|
||||
|
||||
$review->update([
|
||||
'current_export_review_pack_id' => (int) $reviewPack->getKey(),
|
||||
'summary' => array_merge($reviewSummary, [
|
||||
'has_ready_export' => true,
|
||||
'current_export_review_pack_id' => (int) $reviewPack->getKey(),
|
||||
]),
|
||||
]);
|
||||
|
||||
$operationRunService->updateRun(
|
||||
$operationRun,
|
||||
status: OperationRunStatus::Completed->value,
|
||||
outcome: OperationRunOutcome::Succeeded->value,
|
||||
summaryCounts: [
|
||||
'created' => 1,
|
||||
'finding_count' => (int) ($summary['finding_count'] ?? 0),
|
||||
'report_count' => (int) ($summary['report_count'] ?? 0),
|
||||
'operation_count' => (int) ($summary['operation_count'] ?? 0),
|
||||
'errors_recorded' => 0,
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, ?string>
|
||||
*/
|
||||
@ -468,6 +574,87 @@ private function assembleZip(string $tempFile, array $fileMap): void
|
||||
$zip->close();
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, string>
|
||||
*/
|
||||
private function buildReviewDerivedFileMap(
|
||||
TenantReview $review,
|
||||
Tenant $tenant,
|
||||
EvidenceSnapshot $snapshot,
|
||||
bool $includePii,
|
||||
bool $includeOperations,
|
||||
): array {
|
||||
$reviewSummary = is_array($review->summary) ? $review->summary : [];
|
||||
|
||||
$sections = $review->sections
|
||||
->filter(fn (mixed $section): bool => $includeOperations || $section->section_key !== 'operations_health')
|
||||
->values();
|
||||
|
||||
$files = [
|
||||
'metadata.json' => json_encode([
|
||||
'version' => '1.0',
|
||||
'tenant_id' => $tenant->external_id,
|
||||
'tenant_name' => $includePii ? $tenant->name : '[REDACTED]',
|
||||
'generated_at' => now()->toIso8601String(),
|
||||
'tenant_review' => [
|
||||
'id' => (int) $review->getKey(),
|
||||
'status' => (string) $review->status,
|
||||
'completeness_state' => (string) $review->completeness_state,
|
||||
'published_at' => $review->published_at?->toIso8601String(),
|
||||
'fingerprint' => (string) $review->fingerprint,
|
||||
],
|
||||
'evidence_snapshot' => [
|
||||
'id' => (int) $snapshot->getKey(),
|
||||
'fingerprint' => (string) $snapshot->fingerprint,
|
||||
'completeness_state' => (string) $snapshot->completeness_state,
|
||||
'generated_at' => $snapshot->generated_at?->toIso8601String(),
|
||||
],
|
||||
'options' => [
|
||||
'include_pii' => $includePii,
|
||||
'include_operations' => $includeOperations,
|
||||
],
|
||||
'redaction_integrity' => [
|
||||
'protected_values_hidden' => true,
|
||||
'note' => RedactionIntegrity::protectedValueNote(),
|
||||
],
|
||||
], JSON_PRETTY_PRINT | JSON_THROW_ON_ERROR),
|
||||
'summary.json' => json_encode($this->redactReportPayload(array_merge([
|
||||
'tenant_review_id' => (int) $review->getKey(),
|
||||
'review_status' => (string) $review->status,
|
||||
'review_completeness_state' => (string) $review->completeness_state,
|
||||
], $reviewSummary), $includePii), JSON_PRETTY_PRINT | JSON_THROW_ON_ERROR),
|
||||
'sections.json' => json_encode($sections->map(function ($section) use ($includePii): array {
|
||||
$summaryPayload = is_array($section->summary_payload) ? $section->summary_payload : [];
|
||||
$renderPayload = is_array($section->render_payload) ? $section->render_payload : [];
|
||||
|
||||
return [
|
||||
'section_key' => (string) $section->section_key,
|
||||
'title' => (string) $section->title,
|
||||
'sort_order' => (int) $section->sort_order,
|
||||
'required' => (bool) $section->required,
|
||||
'completeness_state' => (string) $section->completeness_state,
|
||||
'summary_payload' => $this->redactReportPayload($summaryPayload, $includePii),
|
||||
'render_payload' => $this->redactReportPayload($renderPayload, $includePii),
|
||||
];
|
||||
})->all(), JSON_PRETTY_PRINT | JSON_THROW_ON_ERROR),
|
||||
];
|
||||
|
||||
foreach ($sections as $section) {
|
||||
$renderPayload = is_array($section->render_payload) ? $section->render_payload : [];
|
||||
$summaryPayload = is_array($section->summary_payload) ? $section->summary_payload : [];
|
||||
$filename = sprintf('sections/%02d-%s.json', (int) $section->sort_order, (string) $section->section_key);
|
||||
|
||||
$files[$filename] = json_encode([
|
||||
'title' => (string) $section->title,
|
||||
'completeness_state' => (string) $section->completeness_state,
|
||||
'summary_payload' => $this->redactReportPayload($summaryPayload, $includePii),
|
||||
'render_payload' => $this->redactReportPayload($renderPayload, $includePii),
|
||||
], JSON_PRETTY_PRINT | JSON_THROW_ON_ERROR);
|
||||
}
|
||||
|
||||
return $files;
|
||||
}
|
||||
|
||||
private function markFailed(ReviewPack $reviewPack, OperationRun $operationRun, OperationRunService $operationRunService, string $reasonCode, string $errorMessage): void
|
||||
{
|
||||
$reviewPack->update([
|
||||
|
||||
@ -80,6 +80,14 @@ public function reviewPacks(): HasMany
|
||||
return $this->hasMany(ReviewPack::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return HasMany<TenantReview, $this>
|
||||
*/
|
||||
public function tenantReviews(): HasMany
|
||||
{
|
||||
return $this->hasMany(TenantReview::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param Builder<self> $query
|
||||
* @return Builder<self>
|
||||
|
||||
@ -79,6 +79,14 @@ public function evidenceSnapshot(): BelongsTo
|
||||
return $this->belongsTo(EvidenceSnapshot::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return BelongsTo<TenantReview, $this>
|
||||
*/
|
||||
public function tenantReview(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(TenantReview::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param Builder<self> $query
|
||||
* @return Builder<self>
|
||||
|
||||
@ -271,6 +271,11 @@ public function evidenceSnapshots(): HasMany
|
||||
return $this->hasMany(EvidenceSnapshot::class);
|
||||
}
|
||||
|
||||
public function tenantReviews(): HasMany
|
||||
{
|
||||
return $this->hasMany(TenantReview::class);
|
||||
}
|
||||
|
||||
public function settings(): HasMany
|
||||
{
|
||||
return $this->hasMany(TenantSetting::class);
|
||||
|
||||
195
app/Models/TenantReview.php
Normal file
195
app/Models/TenantReview.php
Normal file
@ -0,0 +1,195 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use App\Support\Concerns\DerivesWorkspaceIdFromTenant;
|
||||
use App\Support\TenantReviewCompletenessState;
|
||||
use App\Support\TenantReviewStatus;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
|
||||
class TenantReview extends Model
|
||||
{
|
||||
use DerivesWorkspaceIdFromTenant;
|
||||
use HasFactory;
|
||||
|
||||
protected $guarded = [];
|
||||
|
||||
/**
|
||||
* @return array<string, string>
|
||||
*/
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'summary' => 'array',
|
||||
'generated_at' => 'datetime',
|
||||
'published_at' => 'datetime',
|
||||
'archived_at' => 'datetime',
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return BelongsTo<Workspace, $this>
|
||||
*/
|
||||
public function workspace(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Workspace::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return BelongsTo<Tenant, $this>
|
||||
*/
|
||||
public function tenant(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Tenant::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return BelongsTo<EvidenceSnapshot, $this>
|
||||
*/
|
||||
public function evidenceSnapshot(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(EvidenceSnapshot::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return BelongsTo<OperationRun, $this>
|
||||
*/
|
||||
public function operationRun(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(OperationRun::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return BelongsTo<User, $this>
|
||||
*/
|
||||
public function initiator(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class, 'initiated_by_user_id');
|
||||
}
|
||||
|
||||
/**
|
||||
* @return BelongsTo<User, $this>
|
||||
*/
|
||||
public function publisher(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class, 'published_by_user_id');
|
||||
}
|
||||
|
||||
/**
|
||||
* @return BelongsTo<ReviewPack, $this>
|
||||
*/
|
||||
public function currentExportReviewPack(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(ReviewPack::class, 'current_export_review_pack_id');
|
||||
}
|
||||
|
||||
/**
|
||||
* @return BelongsTo<self, $this>
|
||||
*/
|
||||
public function supersededByReview(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(self::class, 'superseded_by_review_id');
|
||||
}
|
||||
|
||||
/**
|
||||
* @return HasMany<self, $this>
|
||||
*/
|
||||
public function supersededReviews(): HasMany
|
||||
{
|
||||
return $this->hasMany(self::class, 'superseded_by_review_id');
|
||||
}
|
||||
|
||||
/**
|
||||
* @return HasMany<TenantReviewSection, $this>
|
||||
*/
|
||||
public function sections(): HasMany
|
||||
{
|
||||
return $this->hasMany(TenantReviewSection::class)->orderBy('sort_order')->orderBy('id');
|
||||
}
|
||||
|
||||
/**
|
||||
* @return HasMany<ReviewPack, $this>
|
||||
*/
|
||||
public function reviewPacks(): HasMany
|
||||
{
|
||||
return $this->hasMany(ReviewPack::class)->latest('generated_at');
|
||||
}
|
||||
|
||||
/**
|
||||
* @param Builder<self> $query
|
||||
* @return Builder<self>
|
||||
*/
|
||||
public function scopeForTenant(Builder $query, int $tenantId): Builder
|
||||
{
|
||||
return $query->where('tenant_id', $tenantId);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param Builder<self> $query
|
||||
* @return Builder<self>
|
||||
*/
|
||||
public function scopeForWorkspace(Builder $query, int $workspaceId): Builder
|
||||
{
|
||||
return $query->where('workspace_id', $workspaceId);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param Builder<self> $query
|
||||
* @return Builder<self>
|
||||
*/
|
||||
public function scopePublished(Builder $query): Builder
|
||||
{
|
||||
return $query->where('status', TenantReviewStatus::Published->value);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param Builder<self> $query
|
||||
* @return Builder<self>
|
||||
*/
|
||||
public function scopeMutable(Builder $query): Builder
|
||||
{
|
||||
return $query->whereIn('status', [
|
||||
TenantReviewStatus::Draft->value,
|
||||
TenantReviewStatus::Ready->value,
|
||||
TenantReviewStatus::Failed->value,
|
||||
]);
|
||||
}
|
||||
|
||||
public function statusEnum(): TenantReviewStatus
|
||||
{
|
||||
return TenantReviewStatus::from((string) $this->status);
|
||||
}
|
||||
|
||||
public function completenessEnum(): TenantReviewCompletenessState
|
||||
{
|
||||
return TenantReviewCompletenessState::tryFrom((string) $this->completeness_state)
|
||||
?? TenantReviewCompletenessState::Missing;
|
||||
}
|
||||
|
||||
public function isPublished(): bool
|
||||
{
|
||||
return $this->statusEnum()->isPublished();
|
||||
}
|
||||
|
||||
public function isMutable(): bool
|
||||
{
|
||||
return $this->statusEnum()->isMutable();
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<string>
|
||||
*/
|
||||
public function publishBlockers(): array
|
||||
{
|
||||
$summary = is_array($this->summary) ? $this->summary : [];
|
||||
$blockers = $summary['publish_blockers'] ?? [];
|
||||
|
||||
return is_array($blockers) ? array_values(array_map('strval', $blockers)) : [];
|
||||
}
|
||||
}
|
||||
70
app/Models/TenantReviewSection.php
Normal file
70
app/Models/TenantReviewSection.php
Normal file
@ -0,0 +1,70 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use App\Support\TenantReviewCompletenessState;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
class TenantReviewSection extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
protected $guarded = [];
|
||||
|
||||
/**
|
||||
* @return array<string, string>
|
||||
*/
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'required' => 'boolean',
|
||||
'summary_payload' => 'array',
|
||||
'render_payload' => 'array',
|
||||
'measured_at' => 'datetime',
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return BelongsTo<TenantReview, $this>
|
||||
*/
|
||||
public function tenantReview(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(TenantReview::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return BelongsTo<Workspace, $this>
|
||||
*/
|
||||
public function workspace(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Workspace::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return BelongsTo<Tenant, $this>
|
||||
*/
|
||||
public function tenant(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Tenant::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param Builder<self> $query
|
||||
* @return Builder<self>
|
||||
*/
|
||||
public function scopeRequired(Builder $query): Builder
|
||||
{
|
||||
return $query->where('required', true);
|
||||
}
|
||||
|
||||
public function completenessEnum(): TenantReviewCompletenessState
|
||||
{
|
||||
return TenantReviewCompletenessState::tryFrom((string) $this->completeness_state)
|
||||
?? TenantReviewCompletenessState::Missing;
|
||||
}
|
||||
}
|
||||
110
app/Policies/TenantReviewPolicy.php
Normal file
110
app/Policies/TenantReviewPolicy.php
Normal file
@ -0,0 +1,110 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Policies;
|
||||
|
||||
use App\Models\Tenant;
|
||||
use App\Models\TenantReview;
|
||||
use App\Models\User;
|
||||
use App\Services\Auth\CapabilityResolver;
|
||||
use App\Support\Auth\Capabilities;
|
||||
use Illuminate\Auth\Access\HandlesAuthorization;
|
||||
use Illuminate\Auth\Access\Response;
|
||||
|
||||
class TenantReviewPolicy
|
||||
{
|
||||
use HandlesAuthorization;
|
||||
|
||||
public function viewAny(User $user): bool
|
||||
{
|
||||
$tenant = Tenant::current();
|
||||
|
||||
if (! $tenant instanceof Tenant || ! $user->canAccessTenant($tenant)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return app(CapabilityResolver::class)->can($user, $tenant, Capabilities::TENANT_REVIEW_VIEW);
|
||||
}
|
||||
|
||||
public function view(User $user, TenantReview $review): Response|bool
|
||||
{
|
||||
$tenant = $this->authorizedTenantOrNull($user, $review);
|
||||
|
||||
if (! $tenant instanceof Tenant) {
|
||||
return Response::denyAsNotFound();
|
||||
}
|
||||
|
||||
return app(CapabilityResolver::class)->can($user, $tenant, Capabilities::TENANT_REVIEW_VIEW)
|
||||
? true
|
||||
: Response::deny();
|
||||
}
|
||||
|
||||
public function create(User $user): bool
|
||||
{
|
||||
$tenant = Tenant::current();
|
||||
|
||||
if (! $tenant instanceof Tenant || ! $user->canAccessTenant($tenant)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return app(CapabilityResolver::class)->can($user, $tenant, Capabilities::TENANT_REVIEW_MANAGE);
|
||||
}
|
||||
|
||||
public function refresh(User $user, TenantReview $review): Response|bool
|
||||
{
|
||||
return $this->authorizeManageAction($user, $review);
|
||||
}
|
||||
|
||||
public function publish(User $user, TenantReview $review): Response|bool
|
||||
{
|
||||
return $this->authorizeManageAction($user, $review);
|
||||
}
|
||||
|
||||
public function archive(User $user, TenantReview $review): Response|bool
|
||||
{
|
||||
return $this->authorizeManageAction($user, $review);
|
||||
}
|
||||
|
||||
public function export(User $user, TenantReview $review): Response|bool
|
||||
{
|
||||
return $this->authorizeManageAction($user, $review);
|
||||
}
|
||||
|
||||
public function createNextReview(User $user, TenantReview $review): Response|bool
|
||||
{
|
||||
return $this->authorizeManageAction($user, $review);
|
||||
}
|
||||
|
||||
private function authorizeManageAction(User $user, TenantReview $review): Response|bool
|
||||
{
|
||||
$tenant = $this->authorizedTenantOrNull($user, $review);
|
||||
|
||||
if (! $tenant instanceof Tenant) {
|
||||
return Response::denyAsNotFound();
|
||||
}
|
||||
|
||||
return app(CapabilityResolver::class)->can($user, $tenant, Capabilities::TENANT_REVIEW_MANAGE)
|
||||
? true
|
||||
: Response::deny();
|
||||
}
|
||||
|
||||
private function authorizedTenantOrNull(User $user, TenantReview $review): ?Tenant
|
||||
{
|
||||
$tenant = $review->tenant;
|
||||
|
||||
if (! $tenant instanceof Tenant) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (! $user->canAccessTenant($tenant)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if ((int) $review->workspace_id !== (int) $tenant->workspace_id) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $tenant;
|
||||
}
|
||||
}
|
||||
@ -9,6 +9,7 @@
|
||||
use App\Models\ProviderConnection;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\TenantOnboardingSession;
|
||||
use App\Models\TenantReview;
|
||||
use App\Models\User;
|
||||
use App\Models\Workspace;
|
||||
use App\Models\WorkspaceSetting;
|
||||
@ -17,6 +18,7 @@
|
||||
use App\Policies\AlertRulePolicy;
|
||||
use App\Policies\ProviderConnectionPolicy;
|
||||
use App\Policies\TenantOnboardingSessionPolicy;
|
||||
use App\Policies\TenantReviewPolicy;
|
||||
use App\Policies\WorkspaceSettingPolicy;
|
||||
use App\Services\Auth\CapabilityResolver;
|
||||
use App\Services\Auth\WorkspaceCapabilityResolver;
|
||||
@ -30,6 +32,7 @@ class AuthServiceProvider extends ServiceProvider
|
||||
protected $policies = [
|
||||
ProviderConnection::class => ProviderConnectionPolicy::class,
|
||||
TenantOnboardingSession::class => TenantOnboardingSessionPolicy::class,
|
||||
TenantReview::class => TenantReviewPolicy::class,
|
||||
WorkspaceSetting::class => WorkspaceSettingPolicy::class,
|
||||
AlertDestination::class => AlertDestinationPolicy::class,
|
||||
AlertDelivery::class => AlertDeliveryPolicy::class,
|
||||
|
||||
@ -8,6 +8,7 @@
|
||||
use App\Filament\Pages\InventoryCoverage;
|
||||
use App\Filament\Pages\Monitoring\FindingExceptionsQueue;
|
||||
use App\Filament\Pages\NoAccess;
|
||||
use App\Filament\Pages\Reviews\ReviewRegister;
|
||||
use App\Filament\Pages\Settings\WorkspaceSettings;
|
||||
use App\Filament\Pages\TenantRequiredPermissions;
|
||||
use App\Filament\Pages\WorkspaceOverview;
|
||||
@ -173,6 +174,7 @@ public function panel(Panel $panel): Panel
|
||||
TenantRequiredPermissions::class,
|
||||
WorkspaceSettings::class,
|
||||
FindingExceptionsQueue::class,
|
||||
ReviewRegister::class,
|
||||
])
|
||||
->widgets([
|
||||
AccountWidget::class,
|
||||
|
||||
@ -4,6 +4,7 @@
|
||||
|
||||
use App\Filament\Pages\Auth\Login;
|
||||
use App\Filament\Pages\TenantDashboard;
|
||||
use App\Filament\Resources\TenantReviewResource;
|
||||
use App\Models\Tenant;
|
||||
use App\Support\Middleware\DenyNonMemberTenantAccess;
|
||||
use Filament\Facades\Filament;
|
||||
@ -76,6 +77,9 @@ public function panel(Panel $panel): Panel
|
||||
: ''
|
||||
)
|
||||
->discoverClusters(in: app_path('Filament/Clusters'), for: 'App\Filament\Clusters')
|
||||
->resources([
|
||||
TenantReviewResource::class,
|
||||
])
|
||||
->discoverResources(in: app_path('Filament/Resources'), for: 'App\Filament\Resources')
|
||||
->discoverPages(in: app_path('Filament/Pages'), for: 'App\Filament\Pages')
|
||||
->pages([
|
||||
|
||||
@ -52,6 +52,8 @@ class RoleCapabilityMap
|
||||
|
||||
Capabilities::REVIEW_PACK_VIEW,
|
||||
Capabilities::REVIEW_PACK_MANAGE,
|
||||
Capabilities::TENANT_REVIEW_VIEW,
|
||||
Capabilities::TENANT_REVIEW_MANAGE,
|
||||
Capabilities::EVIDENCE_VIEW,
|
||||
Capabilities::EVIDENCE_MANAGE,
|
||||
],
|
||||
@ -90,6 +92,8 @@ class RoleCapabilityMap
|
||||
|
||||
Capabilities::REVIEW_PACK_VIEW,
|
||||
Capabilities::REVIEW_PACK_MANAGE,
|
||||
Capabilities::TENANT_REVIEW_VIEW,
|
||||
Capabilities::TENANT_REVIEW_MANAGE,
|
||||
Capabilities::EVIDENCE_VIEW,
|
||||
Capabilities::EVIDENCE_MANAGE,
|
||||
],
|
||||
@ -116,6 +120,7 @@ class RoleCapabilityMap
|
||||
Capabilities::ENTRA_ROLES_VIEW,
|
||||
|
||||
Capabilities::REVIEW_PACK_VIEW,
|
||||
Capabilities::TENANT_REVIEW_VIEW,
|
||||
Capabilities::EVIDENCE_VIEW,
|
||||
],
|
||||
|
||||
@ -134,6 +139,7 @@ class RoleCapabilityMap
|
||||
Capabilities::ENTRA_ROLES_VIEW,
|
||||
|
||||
Capabilities::REVIEW_PACK_VIEW,
|
||||
Capabilities::TENANT_REVIEW_VIEW,
|
||||
Capabilities::EVIDENCE_VIEW,
|
||||
],
|
||||
];
|
||||
|
||||
@ -10,9 +10,12 @@
|
||||
use App\Models\OperationRun;
|
||||
use App\Models\ReviewPack;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\TenantReview;
|
||||
use App\Models\User;
|
||||
use App\Services\Audit\WorkspaceAuditLogger;
|
||||
use App\Services\Evidence\EvidenceResolutionRequest;
|
||||
use App\Services\Evidence\EvidenceSnapshotResolver;
|
||||
use App\Support\Audit\AuditActionId;
|
||||
use App\Support\OperationRunType;
|
||||
use App\Support\ReviewPackStatus;
|
||||
use Illuminate\Support\Facades\URL;
|
||||
@ -22,6 +25,7 @@ class ReviewPackService
|
||||
public function __construct(
|
||||
private OperationRunService $operationRunService,
|
||||
private EvidenceSnapshotResolver $snapshotResolver,
|
||||
private WorkspaceAuditLogger $auditLogger,
|
||||
) {}
|
||||
|
||||
/**
|
||||
@ -62,6 +66,14 @@ public function generate(Tenant $tenant, User $user, array $options = []): Revie
|
||||
initiator: $user,
|
||||
);
|
||||
|
||||
if (! $operationRun->wasRecentlyCreated) {
|
||||
$queuedPack = $this->findPackForRun($tenant, $operationRun);
|
||||
|
||||
if ($queuedPack instanceof ReviewPack) {
|
||||
return $queuedPack;
|
||||
}
|
||||
}
|
||||
|
||||
$reviewPack = ReviewPack::create([
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
@ -94,6 +106,90 @@ public function generate(Tenant $tenant, User $user, array $options = []): Revie
|
||||
return $reviewPack;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a review-derived executive pack.
|
||||
*
|
||||
* @param array<string, mixed> $options
|
||||
*/
|
||||
public function generateFromReview(TenantReview $review, User $user, array $options = []): ReviewPack
|
||||
{
|
||||
$review->loadMissing(['tenant', 'evidenceSnapshot', 'sections']);
|
||||
|
||||
$tenant = $review->tenant;
|
||||
$snapshot = $review->evidenceSnapshot;
|
||||
|
||||
if (! $tenant instanceof Tenant || ! $snapshot instanceof EvidenceSnapshot) {
|
||||
throw new \InvalidArgumentException('Review exports require an anchored evidence snapshot.');
|
||||
}
|
||||
|
||||
$options = $this->normalizeOptions($options);
|
||||
$fingerprint = $this->computeFingerprintForReview($review, $options);
|
||||
$existing = $this->findExistingPackForReview($review, $fingerprint);
|
||||
|
||||
if ($existing instanceof ReviewPack) {
|
||||
$this->logReviewExport($review, $user, $existing, 'reused');
|
||||
|
||||
return $existing;
|
||||
}
|
||||
|
||||
$operationRun = $this->operationRunService->ensureRun(
|
||||
tenant: $tenant,
|
||||
type: OperationRunType::ReviewPackGenerate->value,
|
||||
inputs: [
|
||||
'tenant_review_id' => (int) $review->getKey(),
|
||||
'include_pii' => $options['include_pii'],
|
||||
'include_operations' => $options['include_operations'],
|
||||
'evidence_snapshot_id' => (int) $snapshot->getKey(),
|
||||
],
|
||||
initiator: $user,
|
||||
);
|
||||
|
||||
if (! $operationRun->wasRecentlyCreated) {
|
||||
$queuedPack = $this->findPackForRun($tenant, $operationRun);
|
||||
|
||||
if ($queuedPack instanceof ReviewPack) {
|
||||
$this->logReviewExport($review, $user, $queuedPack, 'reused_active_run');
|
||||
|
||||
return $queuedPack;
|
||||
}
|
||||
}
|
||||
|
||||
$reviewPack = ReviewPack::create([
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'tenant_review_id' => (int) $review->getKey(),
|
||||
'operation_run_id' => (int) $operationRun->getKey(),
|
||||
'evidence_snapshot_id' => (int) $snapshot->getKey(),
|
||||
'initiated_by_user_id' => (int) $user->getKey(),
|
||||
'status' => ReviewPackStatus::Queued->value,
|
||||
'options' => $options,
|
||||
'summary' => [
|
||||
'tenant_review_id' => (int) $review->getKey(),
|
||||
'review_status' => (string) $review->status,
|
||||
'review_completeness_state' => (string) $review->completeness_state,
|
||||
'section_count' => $review->sections->count(),
|
||||
'evidence_resolution' => [
|
||||
'outcome' => 'resolved',
|
||||
'snapshot_id' => (int) $snapshot->getKey(),
|
||||
'snapshot_fingerprint' => (string) $snapshot->fingerprint,
|
||||
'completeness_state' => (string) $snapshot->completeness_state,
|
||||
'required_dimensions' => self::REQUIRED_EVIDENCE_DIMENSIONS,
|
||||
],
|
||||
],
|
||||
]);
|
||||
|
||||
$this->operationRunService->dispatchOrFail($operationRun, function () use ($reviewPack, $operationRun): void {
|
||||
GenerateReviewPackJob::dispatch(
|
||||
reviewPackId: (int) $reviewPack->getKey(),
|
||||
operationRunId: (int) $operationRun->getKey(),
|
||||
);
|
||||
});
|
||||
|
||||
$this->logReviewExport($review, $user, $reviewPack, 'queued');
|
||||
|
||||
return $reviewPack;
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute a deterministic fingerprint for deduplication.
|
||||
*
|
||||
@ -131,6 +227,16 @@ public function findExistingPack(Tenant $tenant, string $fingerprint): ?ReviewPa
|
||||
->first();
|
||||
}
|
||||
|
||||
public function findExistingPackForReview(TenantReview $review, string $fingerprint): ?ReviewPack
|
||||
{
|
||||
return ReviewPack::query()
|
||||
->where('tenant_review_id', (int) $review->getKey())
|
||||
->ready()
|
||||
->where('fingerprint', $fingerprint)
|
||||
->where('expires_at', '>', now())
|
||||
->first();
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a generation run is currently active for this tenant.
|
||||
*/
|
||||
@ -143,6 +249,16 @@ public function checkActiveRun(Tenant $tenant): bool
|
||||
->exists();
|
||||
}
|
||||
|
||||
public function checkActiveRunForReview(TenantReview $review): bool
|
||||
{
|
||||
return OperationRun::query()
|
||||
->where('tenant_id', (int) $review->tenant_id)
|
||||
->where('type', OperationRunType::ReviewPackGenerate->value)
|
||||
->whereJsonContains('context->tenant_review_id', (int) $review->getKey())
|
||||
->active()
|
||||
->exists();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $options
|
||||
* @return array{include_pii: bool, include_operations: bool}
|
||||
@ -168,6 +284,19 @@ private function computeFingerprintForSnapshot(EvidenceSnapshot $snapshot, array
|
||||
return hash('sha256', json_encode($data, JSON_THROW_ON_ERROR));
|
||||
}
|
||||
|
||||
public function computeFingerprintForReview(TenantReview $review, array $options): string
|
||||
{
|
||||
$data = [
|
||||
'tenant_review_id' => (int) $review->getKey(),
|
||||
'review_fingerprint' => (string) $review->fingerprint,
|
||||
'review_status' => (string) $review->status,
|
||||
'include_pii' => (bool) ($options['include_pii'] ?? true),
|
||||
'include_operations' => (bool) ($options['include_operations'] ?? true),
|
||||
];
|
||||
|
||||
return hash('sha256', json_encode($data, JSON_THROW_ON_ERROR));
|
||||
}
|
||||
|
||||
private function resolveSnapshot(Tenant $tenant): EvidenceSnapshot
|
||||
{
|
||||
$result = $this->snapshotResolver->resolve(new EvidenceResolutionRequest(
|
||||
@ -182,4 +311,41 @@ private function resolveSnapshot(Tenant $tenant): EvidenceSnapshot
|
||||
|
||||
return $result->snapshot;
|
||||
}
|
||||
|
||||
private function findPackForRun(Tenant $tenant, OperationRun $operationRun): ?ReviewPack
|
||||
{
|
||||
return ReviewPack::query()
|
||||
->where('tenant_id', (int) $tenant->getKey())
|
||||
->where('operation_run_id', (int) $operationRun->getKey())
|
||||
->latest('id')
|
||||
->first();
|
||||
}
|
||||
|
||||
private function logReviewExport(TenantReview $review, User $user, ReviewPack $reviewPack, string $mode): void
|
||||
{
|
||||
$tenant = $review->tenant;
|
||||
|
||||
if (! $tenant instanceof Tenant) {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->auditLogger->log(
|
||||
workspace: $tenant->workspace,
|
||||
action: AuditActionId::TenantReviewExported,
|
||||
context: [
|
||||
'metadata' => [
|
||||
'review_id' => (int) $review->getKey(),
|
||||
'review_pack_id' => (int) $reviewPack->getKey(),
|
||||
'mode' => $mode,
|
||||
'status' => (string) $reviewPack->status,
|
||||
],
|
||||
],
|
||||
actor: $user,
|
||||
resourceType: 'tenant_review',
|
||||
resourceId: (string) $review->getKey(),
|
||||
targetLabel: sprintf('Tenant review #%d', (int) $review->getKey()),
|
||||
operationRunId: $reviewPack->operation_run_id,
|
||||
tenant: $tenant,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -22,6 +22,7 @@ final class OperationRunTriageService
|
||||
'rbac.health_check',
|
||||
'entra.admin_roles.scan',
|
||||
'tenant.review_pack.generate',
|
||||
'tenant.review.compose',
|
||||
];
|
||||
|
||||
private const CANCELABLE_TYPES = [
|
||||
@ -33,6 +34,7 @@ final class OperationRunTriageService
|
||||
'rbac.health_check',
|
||||
'entra.admin_roles.scan',
|
||||
'tenant.review_pack.generate',
|
||||
'tenant.review.compose',
|
||||
];
|
||||
|
||||
public function __construct(
|
||||
|
||||
71
app/Services/TenantReviews/TenantReviewComposer.php
Normal file
71
app/Services/TenantReviews/TenantReviewComposer.php
Normal file
@ -0,0 +1,71 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\TenantReviews;
|
||||
|
||||
use App\Models\EvidenceSnapshot;
|
||||
use App\Models\TenantReview;
|
||||
use App\Support\TenantReviewStatus;
|
||||
|
||||
final class TenantReviewComposer
|
||||
{
|
||||
public function __construct(
|
||||
private readonly TenantReviewFingerprint $fingerprint,
|
||||
private readonly TenantReviewSectionFactory $sectionFactory,
|
||||
private readonly TenantReviewReadinessGate $readinessGate,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* @return array{
|
||||
* fingerprint: string,
|
||||
* completeness_state: string,
|
||||
* status: string,
|
||||
* summary: array<string, mixed>,
|
||||
* sections: list<array<string, mixed>>
|
||||
* }
|
||||
*/
|
||||
public function compose(EvidenceSnapshot $snapshot, ?TenantReview $review = null): array
|
||||
{
|
||||
$tenant = $snapshot->tenant;
|
||||
|
||||
if ($tenant === null) {
|
||||
throw new \RuntimeException('Evidence snapshot tenant is required for review composition.');
|
||||
}
|
||||
|
||||
$sections = $this->sectionFactory->make($snapshot);
|
||||
$blockers = $this->readinessGate->blockersForSections($sections);
|
||||
$sectionStateCounts = $this->readinessGate->sectionStateCounts($sections);
|
||||
$completeness = $this->readinessGate->completenessForSections($sections);
|
||||
$status = $this->readinessGate->statusForSections($sections);
|
||||
|
||||
if ($review instanceof TenantReview && $review->isPublished()) {
|
||||
$status = TenantReviewStatus::Published;
|
||||
}
|
||||
|
||||
return [
|
||||
'fingerprint' => $this->fingerprint->forSnapshot($tenant, $snapshot),
|
||||
'completeness_state' => $completeness->value,
|
||||
'status' => $status->value,
|
||||
'summary' => [
|
||||
'evidence_basis' => [
|
||||
'snapshot_id' => (int) $snapshot->getKey(),
|
||||
'snapshot_fingerprint' => (string) $snapshot->fingerprint,
|
||||
'snapshot_completeness_state' => (string) $snapshot->completeness_state,
|
||||
'snapshot_generated_at' => $snapshot->generated_at?->toIso8601String(),
|
||||
],
|
||||
'section_count' => count($sections),
|
||||
'section_state_counts' => $sectionStateCounts,
|
||||
'publish_blockers' => $blockers,
|
||||
'has_ready_export' => false,
|
||||
'finding_count' => (int) data_get($sections, '0.summary_payload.finding_count', 0),
|
||||
'report_count' => 2,
|
||||
'operation_count' => (int) data_get($sections, '5.summary_payload.operation_count', 0),
|
||||
'highlights' => data_get($sections, '0.render_payload.highlights', []),
|
||||
'recommended_next_actions' => data_get($sections, '0.render_payload.next_actions', []),
|
||||
'last_composed_at' => now()->toIso8601String(),
|
||||
],
|
||||
'sections' => $sections,
|
||||
];
|
||||
}
|
||||
}
|
||||
41
app/Services/TenantReviews/TenantReviewFingerprint.php
Normal file
41
app/Services/TenantReviews/TenantReviewFingerprint.php
Normal file
@ -0,0 +1,41 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\TenantReviews;
|
||||
|
||||
use App\Models\EvidenceSnapshot;
|
||||
use App\Models\Tenant;
|
||||
use Illuminate\Support\Arr;
|
||||
|
||||
final class TenantReviewFingerprint
|
||||
{
|
||||
public function forSnapshot(Tenant $tenant, EvidenceSnapshot $snapshot): string
|
||||
{
|
||||
$summary = is_array($snapshot->summary) ? $snapshot->summary : [];
|
||||
|
||||
$payload = [
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'evidence_snapshot_id' => (int) $snapshot->getKey(),
|
||||
'snapshot_fingerprint' => (string) $snapshot->fingerprint,
|
||||
'snapshot_completeness' => (string) $snapshot->completeness_state,
|
||||
'dimension_states' => collect(Arr::wrap($summary['dimensions'] ?? []))
|
||||
->map(static fn (mixed $dimension): array => [
|
||||
'key' => (string) data_get($dimension, 'key'),
|
||||
'state' => (string) data_get($dimension, 'state'),
|
||||
'required' => (bool) data_get($dimension, 'required', false),
|
||||
])
|
||||
->sortBy('key')
|
||||
->values()
|
||||
->all(),
|
||||
'counts' => [
|
||||
'finding_count' => (int) ($summary['finding_count'] ?? 0),
|
||||
'report_count' => (int) ($summary['report_count'] ?? 0),
|
||||
'operation_count' => (int) ($summary['operation_count'] ?? 0),
|
||||
],
|
||||
];
|
||||
|
||||
return hash('sha256', json_encode($payload, JSON_THROW_ON_ERROR));
|
||||
}
|
||||
}
|
||||
160
app/Services/TenantReviews/TenantReviewLifecycleService.php
Normal file
160
app/Services/TenantReviews/TenantReviewLifecycleService.php
Normal file
@ -0,0 +1,160 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\TenantReviews;
|
||||
|
||||
use App\Models\EvidenceSnapshot;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\TenantReview;
|
||||
use App\Models\User;
|
||||
use App\Services\Audit\WorkspaceAuditLogger;
|
||||
use App\Support\Audit\AuditActionId;
|
||||
use App\Support\TenantReviewStatus;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use InvalidArgumentException;
|
||||
|
||||
final class TenantReviewLifecycleService
|
||||
{
|
||||
public function __construct(
|
||||
private readonly TenantReviewReadinessGate $readinessGate,
|
||||
private readonly TenantReviewService $reviewService,
|
||||
private readonly WorkspaceAuditLogger $auditLogger,
|
||||
) {}
|
||||
|
||||
public function publish(TenantReview $review, User $user): TenantReview
|
||||
{
|
||||
$review->loadMissing(['tenant', 'sections', 'currentExportReviewPack']);
|
||||
$tenant = $review->tenant;
|
||||
|
||||
if (! $tenant instanceof Tenant) {
|
||||
throw new InvalidArgumentException('Review tenant could not be resolved.');
|
||||
}
|
||||
|
||||
$blockers = $this->readinessGate->blockersForReview($review);
|
||||
$beforeStatus = (string) $review->status;
|
||||
|
||||
if ($blockers !== []) {
|
||||
throw new InvalidArgumentException(implode(' ', $blockers));
|
||||
}
|
||||
|
||||
$review->forceFill([
|
||||
'status' => TenantReviewStatus::Published->value,
|
||||
'published_at' => now(),
|
||||
'published_by_user_id' => (int) $user->getKey(),
|
||||
'summary' => array_merge(is_array($review->summary) ? $review->summary : [], [
|
||||
'publish_blockers' => [],
|
||||
]),
|
||||
])->save();
|
||||
|
||||
$this->auditLogger->log(
|
||||
workspace: $tenant->workspace,
|
||||
action: AuditActionId::TenantReviewPublished,
|
||||
context: [
|
||||
'metadata' => [
|
||||
'review_id' => (int) $review->getKey(),
|
||||
'before_status' => $beforeStatus,
|
||||
'after_status' => TenantReviewStatus::Published->value,
|
||||
],
|
||||
],
|
||||
actor: $user,
|
||||
resourceType: 'tenant_review',
|
||||
resourceId: (string) $review->getKey(),
|
||||
targetLabel: sprintf('Tenant review #%d', (int) $review->getKey()),
|
||||
tenant: $tenant,
|
||||
);
|
||||
|
||||
return $review->refresh()->load(['tenant', 'sections', 'currentExportReviewPack']);
|
||||
}
|
||||
|
||||
public function archive(TenantReview $review, User $user): TenantReview
|
||||
{
|
||||
$review->loadMissing('tenant');
|
||||
$tenant = $review->tenant;
|
||||
|
||||
if (! $tenant instanceof Tenant) {
|
||||
throw new InvalidArgumentException('Review tenant could not be resolved.');
|
||||
}
|
||||
|
||||
$beforeStatus = (string) $review->status;
|
||||
|
||||
if ($review->statusEnum()->isTerminal()) {
|
||||
return $review;
|
||||
}
|
||||
|
||||
$review->forceFill([
|
||||
'status' => TenantReviewStatus::Archived->value,
|
||||
'archived_at' => now(),
|
||||
])->save();
|
||||
|
||||
$this->auditLogger->log(
|
||||
workspace: $tenant->workspace,
|
||||
action: AuditActionId::TenantReviewArchived,
|
||||
context: [
|
||||
'metadata' => [
|
||||
'review_id' => (int) $review->getKey(),
|
||||
'before_status' => $beforeStatus,
|
||||
'after_status' => TenantReviewStatus::Archived->value,
|
||||
],
|
||||
],
|
||||
actor: $user,
|
||||
resourceType: 'tenant_review',
|
||||
resourceId: (string) $review->getKey(),
|
||||
targetLabel: sprintf('Tenant review #%d', (int) $review->getKey()),
|
||||
tenant: $tenant,
|
||||
);
|
||||
|
||||
return $review->refresh()->load(['tenant', 'sections', 'currentExportReviewPack']);
|
||||
}
|
||||
|
||||
public function createNextReview(TenantReview $review, User $user, ?EvidenceSnapshot $snapshot = null): TenantReview
|
||||
{
|
||||
$review->loadMissing(['tenant', 'evidenceSnapshot']);
|
||||
$tenant = $review->tenant;
|
||||
|
||||
if (! $tenant instanceof Tenant) {
|
||||
throw new InvalidArgumentException('Review tenant could not be resolved.');
|
||||
}
|
||||
|
||||
if (! $review->isPublished()) {
|
||||
throw new InvalidArgumentException('Only published reviews can start the next cycle.');
|
||||
}
|
||||
|
||||
$snapshot ??= $this->reviewService->resolveLatestSnapshot($tenant) ?? $review->evidenceSnapshot;
|
||||
|
||||
if (! $snapshot instanceof EvidenceSnapshot) {
|
||||
throw new InvalidArgumentException('An eligible evidence snapshot is required to create the next review.');
|
||||
}
|
||||
|
||||
return DB::transaction(function () use ($review, $user, $snapshot, $tenant): TenantReview {
|
||||
$nextReview = $this->reviewService->create($tenant, $snapshot, $user);
|
||||
|
||||
if ((int) $nextReview->getKey() !== (int) $review->getKey()) {
|
||||
$review->forceFill([
|
||||
'status' => TenantReviewStatus::Superseded->value,
|
||||
'superseded_by_review_id' => (int) $nextReview->getKey(),
|
||||
])->save();
|
||||
}
|
||||
|
||||
$this->auditLogger->log(
|
||||
workspace: $tenant->workspace,
|
||||
action: AuditActionId::TenantReviewSuccessorCreated,
|
||||
context: [
|
||||
'metadata' => [
|
||||
'review_id' => (int) $review->getKey(),
|
||||
'next_review_id' => (int) $nextReview->getKey(),
|
||||
'before_status' => TenantReviewStatus::Published->value,
|
||||
'after_status' => TenantReviewStatus::Superseded->value,
|
||||
],
|
||||
],
|
||||
actor: $user,
|
||||
resourceType: 'tenant_review',
|
||||
resourceId: (string) $nextReview->getKey(),
|
||||
targetLabel: sprintf('Tenant review #%d', (int) $nextReview->getKey()),
|
||||
tenant: $tenant,
|
||||
);
|
||||
|
||||
return $nextReview->refresh()->load(['tenant', 'evidenceSnapshot', 'sections', 'operationRun', 'initiator', 'publisher']);
|
||||
});
|
||||
}
|
||||
}
|
||||
137
app/Services/TenantReviews/TenantReviewReadinessGate.php
Normal file
137
app/Services/TenantReviews/TenantReviewReadinessGate.php
Normal file
@ -0,0 +1,137 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\TenantReviews;
|
||||
|
||||
use App\Models\TenantReview;
|
||||
use App\Support\TenantReviewCompletenessState;
|
||||
use App\Support\TenantReviewStatus;
|
||||
use Illuminate\Support\Collection;
|
||||
|
||||
final class TenantReviewReadinessGate
|
||||
{
|
||||
/**
|
||||
* @param iterable<array<string, mixed>> $sections
|
||||
* @return list<string>
|
||||
*/
|
||||
public function blockersForSections(iterable $sections): array
|
||||
{
|
||||
$blockers = [];
|
||||
|
||||
foreach ($sections as $section) {
|
||||
$required = (bool) ($section['required'] ?? false);
|
||||
$state = (string) ($section['completeness_state'] ?? TenantReviewCompletenessState::Missing->value);
|
||||
$title = (string) ($section['title'] ?? 'Review section');
|
||||
|
||||
if (! $required) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($state === TenantReviewCompletenessState::Missing->value) {
|
||||
$blockers[] = sprintf('%s is missing.', $title);
|
||||
}
|
||||
|
||||
if ($state === TenantReviewCompletenessState::Stale->value) {
|
||||
$blockers[] = sprintf('%s is stale and must be refreshed before publication.', $title);
|
||||
}
|
||||
}
|
||||
|
||||
return array_values(array_unique($blockers));
|
||||
}
|
||||
|
||||
/**
|
||||
* @param iterable<array<string, mixed>> $sections
|
||||
*/
|
||||
public function completenessForSections(iterable $sections): TenantReviewCompletenessState
|
||||
{
|
||||
$states = collect($sections)
|
||||
->map(static fn (array $section): string => (string) ($section['completeness_state'] ?? TenantReviewCompletenessState::Missing->value))
|
||||
->values();
|
||||
|
||||
if ($states->isEmpty()) {
|
||||
return TenantReviewCompletenessState::Missing;
|
||||
}
|
||||
|
||||
if ($states->contains(TenantReviewCompletenessState::Missing->value)) {
|
||||
return TenantReviewCompletenessState::Missing;
|
||||
}
|
||||
|
||||
if ($states->contains(TenantReviewCompletenessState::Stale->value)) {
|
||||
return TenantReviewCompletenessState::Stale;
|
||||
}
|
||||
|
||||
if ($states->contains(TenantReviewCompletenessState::Partial->value)) {
|
||||
return TenantReviewCompletenessState::Partial;
|
||||
}
|
||||
|
||||
return TenantReviewCompletenessState::Complete;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param iterable<array<string, mixed>> $sections
|
||||
*/
|
||||
public function statusForSections(iterable $sections): TenantReviewStatus
|
||||
{
|
||||
return $this->blockersForSections($sections) === []
|
||||
? TenantReviewStatus::Ready
|
||||
: TenantReviewStatus::Draft;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<string>
|
||||
*/
|
||||
public function blockersForReview(TenantReview $review): array
|
||||
{
|
||||
$sections = $review->relationLoaded('sections')
|
||||
? $review->sections
|
||||
: $review->sections()->get();
|
||||
|
||||
return $this->blockersForSections($sections->map(static function ($section): array {
|
||||
return [
|
||||
'title' => (string) $section->title,
|
||||
'required' => (bool) $section->required,
|
||||
'completeness_state' => (string) $section->completeness_state,
|
||||
];
|
||||
})->all());
|
||||
}
|
||||
|
||||
public function canPublish(TenantReview $review): bool
|
||||
{
|
||||
if (! $review->isMutable()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return $this->blockersForReview($review) === [];
|
||||
}
|
||||
|
||||
public function canExport(TenantReview $review): bool
|
||||
{
|
||||
if (! in_array($review->statusEnum(), [
|
||||
TenantReviewStatus::Ready,
|
||||
TenantReviewStatus::Published,
|
||||
], true)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return $this->blockersForReview($review) === [];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param iterable<array<string, mixed>> $sections
|
||||
* @return array<string, int>
|
||||
*/
|
||||
public function sectionStateCounts(iterable $sections): array
|
||||
{
|
||||
$counts = collect($sections)
|
||||
->groupBy(static fn (array $section): string => (string) ($section['completeness_state'] ?? TenantReviewCompletenessState::Missing->value))
|
||||
->map(static fn (Collection $group): int => $group->count());
|
||||
|
||||
return [
|
||||
'complete' => (int) ($counts[TenantReviewCompletenessState::Complete->value] ?? 0),
|
||||
'partial' => (int) ($counts[TenantReviewCompletenessState::Partial->value] ?? 0),
|
||||
'missing' => (int) ($counts[TenantReviewCompletenessState::Missing->value] ?? 0),
|
||||
'stale' => (int) ($counts[TenantReviewCompletenessState::Stale->value] ?? 0),
|
||||
];
|
||||
}
|
||||
}
|
||||
53
app/Services/TenantReviews/TenantReviewRegisterService.php
Normal file
53
app/Services/TenantReviews/TenantReviewRegisterService.php
Normal file
@ -0,0 +1,53 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\TenantReviews;
|
||||
|
||||
use App\Models\Tenant;
|
||||
use App\Models\TenantReview;
|
||||
use App\Models\User;
|
||||
use App\Models\Workspace;
|
||||
use App\Models\WorkspaceMembership;
|
||||
use App\Services\Auth\RoleCapabilityMap;
|
||||
use App\Support\Auth\Capabilities;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
|
||||
final class TenantReviewRegisterService
|
||||
{
|
||||
/**
|
||||
* @return array<int, Tenant>
|
||||
*/
|
||||
public function authorizedTenants(User $user, Workspace $workspace): array
|
||||
{
|
||||
$roles = RoleCapabilityMap::rolesWithCapability(Capabilities::TENANT_REVIEW_VIEW);
|
||||
|
||||
return $user->tenants()
|
||||
->where('tenants.workspace_id', (int) $workspace->getKey())
|
||||
->wherePivotIn('role', $roles)
|
||||
->orderBy('tenants.name')
|
||||
->get()
|
||||
->keyBy(static fn (Tenant $tenant): int => (int) $tenant->getKey())
|
||||
->all();
|
||||
}
|
||||
|
||||
public function query(User $user, Workspace $workspace): Builder
|
||||
{
|
||||
$tenantIds = array_keys($this->authorizedTenants($user, $workspace));
|
||||
|
||||
return TenantReview::query()
|
||||
->with(['tenant', 'evidenceSnapshot', 'currentExportReviewPack'])
|
||||
->forWorkspace((int) $workspace->getKey())
|
||||
->whereIn('tenant_id', $tenantIds === [] ? [-1] : $tenantIds)
|
||||
->latest('generated_at')
|
||||
->latest('id');
|
||||
}
|
||||
|
||||
public function canAccessWorkspace(User $user, Workspace $workspace): bool
|
||||
{
|
||||
return WorkspaceMembership::query()
|
||||
->where('workspace_id', (int) $workspace->getKey())
|
||||
->where('user_id', (int) $user->getKey())
|
||||
->exists();
|
||||
}
|
||||
}
|
||||
343
app/Services/TenantReviews/TenantReviewSectionFactory.php
Normal file
343
app/Services/TenantReviews/TenantReviewSectionFactory.php
Normal file
@ -0,0 +1,343 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\TenantReviews;
|
||||
|
||||
use App\Models\EvidenceSnapshot;
|
||||
use App\Models\EvidenceSnapshotItem;
|
||||
use App\Support\TenantReviewCompletenessState;
|
||||
use Illuminate\Support\Arr;
|
||||
use Illuminate\Support\Collection;
|
||||
|
||||
final class TenantReviewSectionFactory
|
||||
{
|
||||
/**
|
||||
* @return list<array<string, mixed>>
|
||||
*/
|
||||
public function make(EvidenceSnapshot $snapshot): array
|
||||
{
|
||||
$items = $snapshot->items->keyBy('dimension_key');
|
||||
$findingsItem = $this->item($items, 'findings_summary');
|
||||
$permissionItem = $this->item($items, 'permission_posture');
|
||||
$rolesItem = $this->item($items, 'entra_admin_roles');
|
||||
$baselineItem = $this->item($items, 'baseline_drift_posture');
|
||||
$operationsItem = $this->item($items, 'operations_summary');
|
||||
|
||||
return [
|
||||
$this->executiveSummarySection($snapshot, $findingsItem, $permissionItem, $rolesItem, $baselineItem, $operationsItem),
|
||||
$this->openRisksSection($findingsItem),
|
||||
$this->acceptedRisksSection($findingsItem),
|
||||
$this->permissionPostureSection($permissionItem, $rolesItem),
|
||||
$this->baselineDriftSection($baselineItem),
|
||||
$this->operationsHealthSection($operationsItem),
|
||||
];
|
||||
}
|
||||
|
||||
private function executiveSummarySection(
|
||||
EvidenceSnapshot $snapshot,
|
||||
?EvidenceSnapshotItem $findingsItem,
|
||||
?EvidenceSnapshotItem $permissionItem,
|
||||
?EvidenceSnapshotItem $rolesItem,
|
||||
?EvidenceSnapshotItem $baselineItem,
|
||||
?EvidenceSnapshotItem $operationsItem,
|
||||
): array {
|
||||
$findingsSummary = $this->summary($findingsItem);
|
||||
$permissionSummary = $this->summary($permissionItem);
|
||||
$rolesSummary = $this->summary($rolesItem);
|
||||
$baselineSummary = $this->summary($baselineItem);
|
||||
$operationsSummary = $this->summary($operationsItem);
|
||||
$riskAcceptance = is_array($findingsSummary['risk_acceptance'] ?? null) ? $findingsSummary['risk_acceptance'] : [];
|
||||
|
||||
$openCount = (int) ($findingsSummary['open_count'] ?? 0);
|
||||
$findingCount = (int) ($findingsSummary['count'] ?? 0);
|
||||
$driftCount = (int) ($baselineSummary['open_drift_count'] ?? 0);
|
||||
$postureScore = $permissionSummary['posture_score'] ?? null;
|
||||
$operationFailures = (int) ($operationsSummary['failed_count'] ?? 0);
|
||||
$partialOperations = (int) ($operationsSummary['partial_count'] ?? 0);
|
||||
|
||||
$highlights = array_values(array_filter([
|
||||
sprintf('%d open risks from %d tracked findings.', $openCount, $findingCount),
|
||||
$postureScore !== null ? sprintf('Permission posture score is %s.', $postureScore) : 'Permission posture report is unavailable.',
|
||||
sprintf('%d baseline drift findings remain open.', $driftCount),
|
||||
sprintf('%d recent operations failed and %d completed with warnings.', $operationFailures, $partialOperations),
|
||||
sprintf('%d risk-accepted findings are currently governed.', (int) ($riskAcceptance['valid_governed_count'] ?? 0)),
|
||||
sprintf('%d privileged Entra roles are captured in the evidence basis.', (int) ($rolesSummary['role_count'] ?? 0)),
|
||||
]));
|
||||
|
||||
return [
|
||||
'section_key' => 'executive_summary',
|
||||
'title' => 'Executive summary',
|
||||
'sort_order' => 10,
|
||||
'required' => true,
|
||||
'completeness_state' => $this->maxState([
|
||||
$this->state($findingsItem),
|
||||
$this->state($permissionItem),
|
||||
$this->state($rolesItem),
|
||||
$this->state($baselineItem),
|
||||
$this->state($operationsItem),
|
||||
])->value,
|
||||
'source_snapshot_fingerprint' => (string) $snapshot->fingerprint,
|
||||
'summary_payload' => [
|
||||
'finding_count' => $findingCount,
|
||||
'open_risk_count' => $openCount,
|
||||
'posture_score' => $postureScore,
|
||||
'baseline_drift_count' => $driftCount,
|
||||
'failed_operation_count' => $operationFailures,
|
||||
'partial_operation_count' => $partialOperations,
|
||||
'risk_acceptance' => $riskAcceptance,
|
||||
],
|
||||
'render_payload' => [
|
||||
'highlights' => $highlights,
|
||||
'next_actions' => $this->nextActions(
|
||||
openCount: $openCount,
|
||||
driftCount: $driftCount,
|
||||
operationFailures: $operationFailures,
|
||||
postureScore: is_numeric($postureScore) ? (int) $postureScore : null,
|
||||
riskWarnings: (int) ($riskAcceptance['warning_count'] ?? 0),
|
||||
),
|
||||
'included_dimensions' => collect($snapshot->items)
|
||||
->map(static fn (EvidenceSnapshotItem $item): array => [
|
||||
'key' => (string) $item->dimension_key,
|
||||
'state' => (string) $item->state,
|
||||
'required' => (bool) $item->required,
|
||||
])
|
||||
->values()
|
||||
->all(),
|
||||
],
|
||||
'measured_at' => $snapshot->generated_at,
|
||||
];
|
||||
}
|
||||
|
||||
private function openRisksSection(?EvidenceSnapshotItem $findingsItem): array
|
||||
{
|
||||
$summary = $this->summary($findingsItem);
|
||||
$entries = collect(Arr::wrap($summary['entries'] ?? []))
|
||||
->filter(static fn (mixed $entry): bool => is_array($entry) && in_array((string) ($entry['status'] ?? ''), ['open', 'in_progress', 'acknowledged'], true))
|
||||
->sortByDesc(static fn (array $entry): int => match ((string) ($entry['severity'] ?? 'low')) {
|
||||
'critical' => 4,
|
||||
'high' => 3,
|
||||
'medium' => 2,
|
||||
default => 1,
|
||||
})
|
||||
->take(5)
|
||||
->values()
|
||||
->all();
|
||||
|
||||
return [
|
||||
'section_key' => 'open_risks',
|
||||
'title' => 'Open risk highlights',
|
||||
'sort_order' => 20,
|
||||
'required' => true,
|
||||
'completeness_state' => $this->state($findingsItem)->value,
|
||||
'source_snapshot_fingerprint' => $this->sourceFingerprint($findingsItem),
|
||||
'summary_payload' => [
|
||||
'open_count' => (int) ($summary['open_count'] ?? 0),
|
||||
'severity_counts' => is_array($summary['severity_counts'] ?? null) ? $summary['severity_counts'] : [],
|
||||
],
|
||||
'render_payload' => [
|
||||
'entries' => $entries,
|
||||
'empty_state' => empty($entries) ? 'No open risks are recorded in the anchored evidence basis.' : null,
|
||||
],
|
||||
'measured_at' => $findingsItem?->measured_at,
|
||||
];
|
||||
}
|
||||
|
||||
private function acceptedRisksSection(?EvidenceSnapshotItem $findingsItem): array
|
||||
{
|
||||
$summary = $this->summary($findingsItem);
|
||||
$entries = collect(Arr::wrap($summary['entries'] ?? []))
|
||||
->filter(static fn (mixed $entry): bool => is_array($entry) && (string) ($entry['status'] ?? '') === 'risk_accepted')
|
||||
->take(5)
|
||||
->values()
|
||||
->all();
|
||||
$riskAcceptance = is_array($summary['risk_acceptance'] ?? null) ? $summary['risk_acceptance'] : [];
|
||||
|
||||
return [
|
||||
'section_key' => 'accepted_risks',
|
||||
'title' => 'Accepted risk summary',
|
||||
'sort_order' => 30,
|
||||
'required' => true,
|
||||
'completeness_state' => $this->state($findingsItem)->value,
|
||||
'source_snapshot_fingerprint' => $this->sourceFingerprint($findingsItem),
|
||||
'summary_payload' => [
|
||||
'status_marked_count' => (int) ($riskAcceptance['status_marked_count'] ?? 0),
|
||||
'valid_governed_count' => (int) ($riskAcceptance['valid_governed_count'] ?? 0),
|
||||
'warning_count' => (int) ($riskAcceptance['warning_count'] ?? 0),
|
||||
'expired_count' => (int) ($riskAcceptance['expired_count'] ?? 0),
|
||||
'revoked_count' => (int) ($riskAcceptance['revoked_count'] ?? 0),
|
||||
'missing_exception_count' => (int) ($riskAcceptance['missing_exception_count'] ?? 0),
|
||||
],
|
||||
'render_payload' => [
|
||||
'entries' => $entries,
|
||||
'disclosure' => (int) ($riskAcceptance['warning_count'] ?? 0) > 0
|
||||
? 'Some accepted risks need governance follow-up before stakeholder delivery.'
|
||||
: 'Accepted risks are governed by the anchored evidence basis.',
|
||||
],
|
||||
'measured_at' => $findingsItem?->measured_at,
|
||||
];
|
||||
}
|
||||
|
||||
private function permissionPostureSection(?EvidenceSnapshotItem $permissionItem, ?EvidenceSnapshotItem $rolesItem): array
|
||||
{
|
||||
$permissionSummary = $this->summary($permissionItem);
|
||||
$rolesSummary = $this->summary($rolesItem);
|
||||
|
||||
return [
|
||||
'section_key' => 'permission_posture',
|
||||
'title' => 'Permission posture',
|
||||
'sort_order' => 40,
|
||||
'required' => true,
|
||||
'completeness_state' => $this->maxState([
|
||||
$this->state($permissionItem),
|
||||
$this->state($rolesItem),
|
||||
])->value,
|
||||
'source_snapshot_fingerprint' => $this->sourceFingerprint($permissionItem) ?? $this->sourceFingerprint($rolesItem),
|
||||
'summary_payload' => [
|
||||
'posture_score' => $permissionSummary['posture_score'] ?? null,
|
||||
'required_count' => (int) ($permissionSummary['required_count'] ?? 0),
|
||||
'granted_count' => (int) ($permissionSummary['granted_count'] ?? 0),
|
||||
'role_count' => (int) ($rolesSummary['role_count'] ?? 0),
|
||||
],
|
||||
'render_payload' => [
|
||||
'permission_payload' => is_array($permissionSummary['payload'] ?? null) ? $permissionSummary['payload'] : [],
|
||||
'roles' => is_array($rolesSummary['roles'] ?? null) ? $rolesSummary['roles'] : [],
|
||||
],
|
||||
'measured_at' => $permissionItem?->measured_at ?? $rolesItem?->measured_at,
|
||||
];
|
||||
}
|
||||
|
||||
private function baselineDriftSection(?EvidenceSnapshotItem $baselineItem): array
|
||||
{
|
||||
$summary = $this->summary($baselineItem);
|
||||
|
||||
return [
|
||||
'section_key' => 'baseline_drift_posture',
|
||||
'title' => 'Baseline drift posture',
|
||||
'sort_order' => 50,
|
||||
'required' => true,
|
||||
'completeness_state' => $this->state($baselineItem)->value,
|
||||
'source_snapshot_fingerprint' => $this->sourceFingerprint($baselineItem),
|
||||
'summary_payload' => [
|
||||
'drift_count' => (int) ($summary['drift_count'] ?? 0),
|
||||
'open_drift_count' => (int) ($summary['open_drift_count'] ?? 0),
|
||||
],
|
||||
'render_payload' => [
|
||||
'disclosure' => (int) ($summary['open_drift_count'] ?? 0) > 0
|
||||
? 'Baseline drift remains visible in this review and should be discussed as hardening work.'
|
||||
: 'No open baseline drift findings are present in the anchored evidence basis.',
|
||||
],
|
||||
'measured_at' => $baselineItem?->measured_at,
|
||||
];
|
||||
}
|
||||
|
||||
private function operationsHealthSection(?EvidenceSnapshotItem $operationsItem): array
|
||||
{
|
||||
$summary = $this->summary($operationsItem);
|
||||
|
||||
return [
|
||||
'section_key' => 'operations_health',
|
||||
'title' => 'Operations health',
|
||||
'sort_order' => 60,
|
||||
'required' => true,
|
||||
'completeness_state' => $this->state($operationsItem)->value,
|
||||
'source_snapshot_fingerprint' => $this->sourceFingerprint($operationsItem),
|
||||
'summary_payload' => [
|
||||
'operation_count' => (int) ($summary['operation_count'] ?? 0),
|
||||
'failed_count' => (int) ($summary['failed_count'] ?? 0),
|
||||
'partial_count' => (int) ($summary['partial_count'] ?? 0),
|
||||
],
|
||||
'render_payload' => [
|
||||
'entries' => array_values(array_slice(Arr::wrap($summary['entries'] ?? []), 0, 10)),
|
||||
],
|
||||
'measured_at' => $operationsItem?->measured_at,
|
||||
];
|
||||
}
|
||||
|
||||
private function item(Collection $items, string $key): ?EvidenceSnapshotItem
|
||||
{
|
||||
$item = $items->get($key);
|
||||
|
||||
return $item instanceof EvidenceSnapshotItem ? $item : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function summary(?EvidenceSnapshotItem $item): array
|
||||
{
|
||||
return is_array($item?->summary_payload) ? $item->summary_payload : [];
|
||||
}
|
||||
|
||||
private function state(?EvidenceSnapshotItem $item): TenantReviewCompletenessState
|
||||
{
|
||||
return TenantReviewCompletenessState::tryFrom((string) $item?->state)
|
||||
?? TenantReviewCompletenessState::Missing;
|
||||
}
|
||||
|
||||
private function sourceFingerprint(?EvidenceSnapshotItem $item): ?string
|
||||
{
|
||||
$fingerprint = $item?->source_fingerprint;
|
||||
|
||||
return is_string($fingerprint) && $fingerprint !== '' ? $fingerprint : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, TenantReviewCompletenessState> $states
|
||||
*/
|
||||
private function maxState(array $states): TenantReviewCompletenessState
|
||||
{
|
||||
if (in_array(TenantReviewCompletenessState::Missing, $states, true)) {
|
||||
return TenantReviewCompletenessState::Missing;
|
||||
}
|
||||
|
||||
if (in_array(TenantReviewCompletenessState::Stale, $states, true)) {
|
||||
return TenantReviewCompletenessState::Stale;
|
||||
}
|
||||
|
||||
if (in_array(TenantReviewCompletenessState::Partial, $states, true)) {
|
||||
return TenantReviewCompletenessState::Partial;
|
||||
}
|
||||
|
||||
return TenantReviewCompletenessState::Complete;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<string>
|
||||
*/
|
||||
private function nextActions(
|
||||
int $openCount,
|
||||
int $driftCount,
|
||||
int $operationFailures,
|
||||
?int $postureScore,
|
||||
int $riskWarnings,
|
||||
): array {
|
||||
$actions = [];
|
||||
|
||||
if ($openCount > 0) {
|
||||
$actions[] = 'Review the highest-severity open findings with the tenant and confirm ownership.';
|
||||
}
|
||||
|
||||
if ($riskWarnings > 0) {
|
||||
$actions[] = 'Reconcile accepted-risk governance records before external delivery.';
|
||||
}
|
||||
|
||||
if ($postureScore !== null && $postureScore < 80) {
|
||||
$actions[] = 'Prioritize missing permissions or posture controls that materially affect review confidence.';
|
||||
}
|
||||
|
||||
if ($driftCount > 0) {
|
||||
$actions[] = 'Schedule remediation for recurring baseline drift to reduce repeated review findings.';
|
||||
}
|
||||
|
||||
if ($operationFailures > 0) {
|
||||
$actions[] = 'Inspect recent failed operations to confirm tenant management workflows are stable.';
|
||||
}
|
||||
|
||||
if ($actions === []) {
|
||||
$actions[] = 'No immediate corrective action is required beyond the normal review cadence.';
|
||||
}
|
||||
|
||||
return $actions;
|
||||
}
|
||||
}
|
||||
250
app/Services/TenantReviews/TenantReviewService.php
Normal file
250
app/Services/TenantReviews/TenantReviewService.php
Normal file
@ -0,0 +1,250 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\TenantReviews;
|
||||
|
||||
use App\Jobs\ComposeTenantReviewJob;
|
||||
use App\Models\EvidenceSnapshot;
|
||||
use App\Models\OperationRun;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\TenantReview;
|
||||
use App\Models\User;
|
||||
use App\Services\Audit\WorkspaceAuditLogger;
|
||||
use App\Services\OperationRunService;
|
||||
use App\Support\Audit\AuditActionId;
|
||||
use App\Support\OperationRunType;
|
||||
use App\Support\TenantReviewStatus;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use InvalidArgumentException;
|
||||
|
||||
final class TenantReviewService
|
||||
{
|
||||
public function __construct(
|
||||
private readonly OperationRunService $operationRuns,
|
||||
private readonly WorkspaceAuditLogger $auditLogger,
|
||||
private readonly TenantReviewComposer $composer,
|
||||
private readonly TenantReviewFingerprint $fingerprint,
|
||||
) {}
|
||||
|
||||
public function create(Tenant $tenant, EvidenceSnapshot $snapshot, User $user): TenantReview
|
||||
{
|
||||
return $this->queueComposition(
|
||||
tenant: $tenant,
|
||||
snapshot: $snapshot,
|
||||
user: $user,
|
||||
existingReview: null,
|
||||
auditAction: AuditActionId::TenantReviewCreated,
|
||||
);
|
||||
}
|
||||
|
||||
public function refresh(TenantReview $review, User $user, ?EvidenceSnapshot $snapshot = null): TenantReview
|
||||
{
|
||||
$tenant = $review->tenant;
|
||||
|
||||
if (! $tenant instanceof Tenant) {
|
||||
throw new InvalidArgumentException('Review tenant could not be resolved.');
|
||||
}
|
||||
|
||||
$snapshot ??= $this->resolveLatestSnapshot($tenant) ?? $review->evidenceSnapshot;
|
||||
|
||||
return $this->queueComposition(
|
||||
tenant: $tenant,
|
||||
snapshot: $snapshot,
|
||||
user: $user,
|
||||
existingReview: $review,
|
||||
auditAction: AuditActionId::TenantReviewRefreshed,
|
||||
);
|
||||
}
|
||||
|
||||
public function compose(TenantReview $review): TenantReview
|
||||
{
|
||||
$review->loadMissing(['tenant', 'evidenceSnapshot.items']);
|
||||
|
||||
$snapshot = $review->evidenceSnapshot;
|
||||
|
||||
if (! $snapshot instanceof EvidenceSnapshot) {
|
||||
throw new InvalidArgumentException('Review evidence snapshot could not be resolved.');
|
||||
}
|
||||
|
||||
$payload = $this->composer->compose($snapshot, $review);
|
||||
|
||||
DB::transaction(function () use ($review, $payload, $snapshot): void {
|
||||
$review->forceFill([
|
||||
'fingerprint' => $payload['fingerprint'],
|
||||
'completeness_state' => $payload['completeness_state'],
|
||||
'status' => $payload['status'],
|
||||
'summary' => $payload['summary'],
|
||||
'generated_at' => now(),
|
||||
'evidence_snapshot_id' => (int) $snapshot->getKey(),
|
||||
])->save();
|
||||
|
||||
$review->sections()->delete();
|
||||
|
||||
foreach ($payload['sections'] as $section) {
|
||||
$review->sections()->create([
|
||||
'workspace_id' => (int) $review->workspace_id,
|
||||
'tenant_id' => (int) $review->tenant_id,
|
||||
'section_key' => $section['section_key'],
|
||||
'title' => $section['title'],
|
||||
'sort_order' => $section['sort_order'],
|
||||
'required' => $section['required'],
|
||||
'completeness_state' => $section['completeness_state'],
|
||||
'source_snapshot_fingerprint' => $section['source_snapshot_fingerprint'],
|
||||
'summary_payload' => $section['summary_payload'],
|
||||
'render_payload' => $section['render_payload'],
|
||||
'measured_at' => $section['measured_at'],
|
||||
]);
|
||||
}
|
||||
});
|
||||
|
||||
return $review->refresh()->load(['tenant', 'evidenceSnapshot', 'sections', 'operationRun', 'initiator', 'publisher', 'currentExportReviewPack']);
|
||||
}
|
||||
|
||||
public function resolveLatestSnapshot(Tenant $tenant): ?EvidenceSnapshot
|
||||
{
|
||||
return EvidenceSnapshot::query()
|
||||
->forTenant((int) $tenant->getKey())
|
||||
->current()
|
||||
->latest('generated_at')
|
||||
->latest('id')
|
||||
->first();
|
||||
}
|
||||
|
||||
public function activeCompositionRun(Tenant $tenant, ?EvidenceSnapshot $snapshot = null): ?OperationRun
|
||||
{
|
||||
$snapshot ??= $this->resolveLatestSnapshot($tenant);
|
||||
|
||||
if (! $snapshot instanceof EvidenceSnapshot) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $this->operationRuns->findCanonicalRunWithIdentity(
|
||||
tenant: $tenant,
|
||||
type: OperationRunType::TenantReviewCompose->value,
|
||||
identityInputs: [
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'snapshot_id' => (int) $snapshot->getKey(),
|
||||
'fingerprint' => $this->fingerprint->forSnapshot($tenant, $snapshot),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
private function queueComposition(
|
||||
Tenant $tenant,
|
||||
?EvidenceSnapshot $snapshot,
|
||||
User $user,
|
||||
?TenantReview $existingReview,
|
||||
AuditActionId $auditAction,
|
||||
): TenantReview {
|
||||
if (! $snapshot instanceof EvidenceSnapshot) {
|
||||
throw new InvalidArgumentException('An eligible evidence snapshot is required.');
|
||||
}
|
||||
|
||||
if ((int) $snapshot->tenant_id !== (int) $tenant->getKey()) {
|
||||
throw new InvalidArgumentException('Evidence snapshot does not belong to the target tenant.');
|
||||
}
|
||||
|
||||
$fingerprint = $this->fingerprint->forSnapshot($tenant, $snapshot);
|
||||
$review = $existingReview;
|
||||
|
||||
if (! $review instanceof TenantReview) {
|
||||
$existing = $this->findExistingMutableReview($tenant, $fingerprint);
|
||||
|
||||
if ($existing instanceof TenantReview) {
|
||||
return $existing->load(['tenant', 'evidenceSnapshot', 'sections', 'operationRun', 'initiator', 'publisher']);
|
||||
}
|
||||
}
|
||||
|
||||
$operationRun = $this->operationRuns->ensureRunWithIdentity(
|
||||
tenant: $tenant,
|
||||
type: OperationRunType::TenantReviewCompose->value,
|
||||
identityInputs: [
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'snapshot_id' => (int) $snapshot->getKey(),
|
||||
'fingerprint' => $fingerprint,
|
||||
],
|
||||
context: [
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'evidence_snapshot_id' => (int) $snapshot->getKey(),
|
||||
'review_fingerprint' => $fingerprint,
|
||||
'review_id' => $existingReview?->getKey(),
|
||||
],
|
||||
initiator: $user,
|
||||
);
|
||||
|
||||
$review ??= TenantReview::query()->create([
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'evidence_snapshot_id' => (int) $snapshot->getKey(),
|
||||
'operation_run_id' => (int) $operationRun->getKey(),
|
||||
'initiated_by_user_id' => (int) $user->getKey(),
|
||||
'fingerprint' => $fingerprint,
|
||||
'status' => TenantReviewStatus::Draft->value,
|
||||
'completeness_state' => (string) $snapshot->completeness_state,
|
||||
'summary' => [
|
||||
'evidence_basis' => [
|
||||
'snapshot_id' => (int) $snapshot->getKey(),
|
||||
'snapshot_fingerprint' => (string) $snapshot->fingerprint,
|
||||
'snapshot_completeness_state' => (string) $snapshot->completeness_state,
|
||||
'snapshot_generated_at' => $snapshot->generated_at?->toIso8601String(),
|
||||
],
|
||||
'publish_blockers' => [],
|
||||
'has_ready_export' => false,
|
||||
'last_requested_at' => now()->toIso8601String(),
|
||||
],
|
||||
]);
|
||||
|
||||
if ($existingReview instanceof TenantReview) {
|
||||
$existingReview->forceFill([
|
||||
'evidence_snapshot_id' => (int) $snapshot->getKey(),
|
||||
'operation_run_id' => (int) $operationRun->getKey(),
|
||||
'fingerprint' => $fingerprint,
|
||||
'status' => TenantReviewStatus::Draft->value,
|
||||
])->save();
|
||||
|
||||
$review = $existingReview->refresh();
|
||||
}
|
||||
|
||||
if ($operationRun->wasRecentlyCreated) {
|
||||
$this->operationRuns->dispatchOrFail($operationRun, function () use ($review, $operationRun): void {
|
||||
ComposeTenantReviewJob::dispatch(
|
||||
tenantReviewId: (int) $review->getKey(),
|
||||
operationRunId: (int) $operationRun->getKey(),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
$this->auditLogger->log(
|
||||
workspace: $tenant->workspace,
|
||||
action: $auditAction,
|
||||
context: [
|
||||
'metadata' => [
|
||||
'review_id' => (int) $review->getKey(),
|
||||
'evidence_snapshot_id' => (int) $snapshot->getKey(),
|
||||
'status' => (string) $review->status,
|
||||
'fingerprint' => $fingerprint,
|
||||
],
|
||||
],
|
||||
actor: $user,
|
||||
resourceType: 'tenant_review',
|
||||
resourceId: (string) $review->getKey(),
|
||||
targetLabel: sprintf('Tenant review #%d', (int) $review->getKey()),
|
||||
operationRunId: (int) $operationRun->getKey(),
|
||||
tenant: $tenant,
|
||||
);
|
||||
|
||||
return $review->load(['tenant', 'evidenceSnapshot', 'sections', 'operationRun', 'initiator', 'publisher']);
|
||||
}
|
||||
|
||||
private function findExistingMutableReview(Tenant $tenant, string $fingerprint): ?TenantReview
|
||||
{
|
||||
return TenantReview::query()
|
||||
->forTenant((int) $tenant->getKey())
|
||||
->mutable()
|
||||
->where('fingerprint', $fingerprint)
|
||||
->latest('id')
|
||||
->first();
|
||||
}
|
||||
}
|
||||
@ -89,6 +89,12 @@ enum AuditActionId: string
|
||||
case EvidenceSnapshotCreated = 'evidence_snapshot.created';
|
||||
case EvidenceSnapshotRefreshed = 'evidence_snapshot.refreshed';
|
||||
case EvidenceSnapshotExpired = 'evidence_snapshot.expired';
|
||||
case TenantReviewCreated = 'tenant_review.created';
|
||||
case TenantReviewRefreshed = 'tenant_review.refreshed';
|
||||
case TenantReviewPublished = 'tenant_review.published';
|
||||
case TenantReviewArchived = 'tenant_review.archived';
|
||||
case TenantReviewExported = 'tenant_review.exported';
|
||||
case TenantReviewSuccessorCreated = 'tenant_review.successor_created';
|
||||
|
||||
// Workspace selection / switch events (Spec 107).
|
||||
case WorkspaceAutoSelected = 'workspace.auto_selected';
|
||||
@ -216,6 +222,12 @@ private static function labels(): array
|
||||
self::EvidenceSnapshotCreated->value => 'Evidence snapshot created',
|
||||
self::EvidenceSnapshotRefreshed->value => 'Evidence snapshot refreshed',
|
||||
self::EvidenceSnapshotExpired->value => 'Evidence snapshot expired',
|
||||
self::TenantReviewCreated->value => 'Tenant review created',
|
||||
self::TenantReviewRefreshed->value => 'Tenant review refreshed',
|
||||
self::TenantReviewPublished->value => 'Tenant review published',
|
||||
self::TenantReviewArchived->value => 'Tenant review archived',
|
||||
self::TenantReviewExported->value => 'Tenant review exported',
|
||||
self::TenantReviewSuccessorCreated->value => 'Tenant review next cycle created',
|
||||
'baseline.capture.started' => 'Baseline capture started',
|
||||
'baseline.capture.completed' => 'Baseline capture completed',
|
||||
'baseline.capture.failed' => 'Baseline capture failed',
|
||||
@ -290,6 +302,12 @@ private static function summaries(): array
|
||||
self::EvidenceSnapshotCreated->value => 'Evidence snapshot created',
|
||||
self::EvidenceSnapshotRefreshed->value => 'Evidence snapshot refreshed',
|
||||
self::EvidenceSnapshotExpired->value => 'Evidence snapshot expired',
|
||||
self::TenantReviewCreated->value => 'Tenant review created',
|
||||
self::TenantReviewRefreshed->value => 'Tenant review refreshed',
|
||||
self::TenantReviewPublished->value => 'Tenant review published',
|
||||
self::TenantReviewArchived->value => 'Tenant review archived',
|
||||
self::TenantReviewExported->value => 'Tenant review exported',
|
||||
self::TenantReviewSuccessorCreated->value => 'Tenant review next cycle created',
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
@ -138,6 +138,11 @@ class Capabilities
|
||||
|
||||
public const REVIEW_PACK_MANAGE = 'review_pack.manage';
|
||||
|
||||
// Tenant reviews
|
||||
public const TENANT_REVIEW_VIEW = 'tenant_review.view';
|
||||
|
||||
public const TENANT_REVIEW_MANAGE = 'tenant_review.manage';
|
||||
|
||||
// Evidence snapshots
|
||||
public const EVIDENCE_VIEW = 'evidence.view';
|
||||
|
||||
|
||||
@ -51,6 +51,8 @@ final class BadgeCatalog
|
||||
BadgeDomain::ReviewPackStatus->value => Domains\ReviewPackStatusBadge::class,
|
||||
BadgeDomain::EvidenceSnapshotStatus->value => Domains\EvidenceSnapshotStatusBadge::class,
|
||||
BadgeDomain::EvidenceCompleteness->value => Domains\EvidenceCompletenessBadge::class,
|
||||
BadgeDomain::TenantReviewStatus->value => Domains\TenantReviewStatusBadge::class,
|
||||
BadgeDomain::TenantReviewCompleteness->value => Domains\TenantReviewCompletenessStateBadge::class,
|
||||
BadgeDomain::SystemHealth->value => Domains\SystemHealthBadge::class,
|
||||
BadgeDomain::ReferenceResolutionState->value => Domains\ReferenceResolutionStateBadge::class,
|
||||
BadgeDomain::DiffRowStatus->value => Domains\DiffRowStatusBadge::class,
|
||||
|
||||
@ -42,6 +42,8 @@ enum BadgeDomain: string
|
||||
case ReviewPackStatus = 'review_pack_status';
|
||||
case EvidenceSnapshotStatus = 'evidence_snapshot_status';
|
||||
case EvidenceCompleteness = 'evidence_completeness';
|
||||
case TenantReviewStatus = 'tenant_review_status';
|
||||
case TenantReviewCompleteness = 'tenant_review_completeness';
|
||||
case SystemHealth = 'system_health';
|
||||
case ReferenceResolutionState = 'reference_resolution_state';
|
||||
case DiffRowStatus = 'diff_row_status';
|
||||
|
||||
@ -0,0 +1,26 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Support\Badges\Domains;
|
||||
|
||||
use App\Support\Badges\BadgeCatalog;
|
||||
use App\Support\Badges\BadgeMapper;
|
||||
use App\Support\Badges\BadgeSpec;
|
||||
use App\Support\TenantReviewCompletenessState;
|
||||
|
||||
final class TenantReviewCompletenessStateBadge implements BadgeMapper
|
||||
{
|
||||
public function spec(mixed $value): BadgeSpec
|
||||
{
|
||||
$state = BadgeCatalog::normalizeState($value);
|
||||
|
||||
return match ($state) {
|
||||
TenantReviewCompletenessState::Complete->value => new BadgeSpec('Complete', 'success', 'heroicon-m-check-circle'),
|
||||
TenantReviewCompletenessState::Partial->value => new BadgeSpec('Partial', 'warning', 'heroicon-m-exclamation-triangle'),
|
||||
TenantReviewCompletenessState::Missing->value => new BadgeSpec('Missing', 'danger', 'heroicon-m-x-circle'),
|
||||
TenantReviewCompletenessState::Stale->value => new BadgeSpec('Stale', 'gray', 'heroicon-m-clock'),
|
||||
default => BadgeSpec::unknown(),
|
||||
};
|
||||
}
|
||||
}
|
||||
28
app/Support/Badges/Domains/TenantReviewStatusBadge.php
Normal file
28
app/Support/Badges/Domains/TenantReviewStatusBadge.php
Normal file
@ -0,0 +1,28 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Support\Badges\Domains;
|
||||
|
||||
use App\Support\Badges\BadgeCatalog;
|
||||
use App\Support\Badges\BadgeMapper;
|
||||
use App\Support\Badges\BadgeSpec;
|
||||
use App\Support\TenantReviewStatus;
|
||||
|
||||
final class TenantReviewStatusBadge implements BadgeMapper
|
||||
{
|
||||
public function spec(mixed $value): BadgeSpec
|
||||
{
|
||||
$state = BadgeCatalog::normalizeState($value);
|
||||
|
||||
return match ($state) {
|
||||
TenantReviewStatus::Draft->value => new BadgeSpec('Draft', 'gray', 'heroicon-m-pencil-square'),
|
||||
TenantReviewStatus::Ready->value => new BadgeSpec('Ready', 'info', 'heroicon-m-check-circle'),
|
||||
TenantReviewStatus::Published->value => new BadgeSpec('Published', 'success', 'heroicon-m-check-badge'),
|
||||
TenantReviewStatus::Archived->value => new BadgeSpec('Archived', 'gray', 'heroicon-m-archive-box'),
|
||||
TenantReviewStatus::Superseded->value => new BadgeSpec('Superseded', 'warning', 'heroicon-m-arrow-path'),
|
||||
TenantReviewStatus::Failed->value => new BadgeSpec('Failed', 'danger', 'heroicon-m-x-circle'),
|
||||
default => BadgeSpec::unknown(),
|
||||
};
|
||||
}
|
||||
}
|
||||
@ -53,6 +53,7 @@ public static function labels(): array
|
||||
'permission_posture_check' => 'Permission posture check',
|
||||
'entra.admin_roles.scan' => 'Entra admin roles scan',
|
||||
'tenant.review_pack.generate' => 'Review pack generation',
|
||||
'tenant.review.compose' => 'Review composition',
|
||||
'tenant.evidence.snapshot.generate' => 'Evidence snapshot generation',
|
||||
'rbac.health_check' => 'RBAC health check',
|
||||
'findings.lifecycle.backfill' => 'Findings lifecycle backfill',
|
||||
@ -89,6 +90,7 @@ public static function expectedDurationSeconds(string $operationType): ?int
|
||||
'permission_posture_check' => 30,
|
||||
'entra.admin_roles.scan' => 60,
|
||||
'tenant.review_pack.generate' => 60,
|
||||
'tenant.review.compose' => 60,
|
||||
'tenant.evidence.snapshot.generate' => 120,
|
||||
'rbac.health_check' => 30,
|
||||
'findings.lifecycle.backfill' => 300,
|
||||
|
||||
@ -19,6 +19,7 @@ enum OperationRunType: string
|
||||
case RestoreExecute = 'restore.execute';
|
||||
case EntraAdminRolesScan = 'entra.admin_roles.scan';
|
||||
case ReviewPackGenerate = 'tenant.review_pack.generate';
|
||||
case TenantReviewCompose = 'tenant.review.compose';
|
||||
case EvidenceSnapshotGenerate = 'tenant.evidence.snapshot.generate';
|
||||
case RbacHealthCheck = 'rbac.health_check';
|
||||
|
||||
|
||||
@ -21,6 +21,7 @@ public function requiredCapabilityForType(string $operationType): ?string
|
||||
'restore.execute' => Capabilities::TENANT_MANAGE,
|
||||
'directory_role_definitions.sync' => Capabilities::TENANT_MANAGE,
|
||||
'alerts.evaluate', 'alerts.deliver' => Capabilities::ALERTS_VIEW,
|
||||
'tenant.review.compose' => Capabilities::TENANT_REVIEW_VIEW,
|
||||
|
||||
// Viewing verification reports should be possible for readonly members.
|
||||
// Starting verification is separately guarded by the verification service.
|
||||
@ -44,6 +45,7 @@ public function requiredExecutionCapabilityForType(string $operationType): ?stri
|
||||
'policy.sync', 'policy.sync_one', 'tenant.sync' => Capabilities::TENANT_SYNC,
|
||||
'policy.delete' => Capabilities::TENANT_MANAGE,
|
||||
'assignments.restore', 'restore.execute' => Capabilities::TENANT_MANAGE,
|
||||
'tenant.review.compose' => Capabilities::TENANT_REVIEW_MANAGE,
|
||||
default => $this->requiredCapabilityForType($operationType),
|
||||
};
|
||||
}
|
||||
|
||||
21
app/Support/TenantReviewCompletenessState.php
Normal file
21
app/Support/TenantReviewCompletenessState.php
Normal file
@ -0,0 +1,21 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Support;
|
||||
|
||||
enum TenantReviewCompletenessState: string
|
||||
{
|
||||
case Complete = 'complete';
|
||||
case Partial = 'partial';
|
||||
case Missing = 'missing';
|
||||
case Stale = 'stale';
|
||||
|
||||
/**
|
||||
* @return list<string>
|
||||
*/
|
||||
public static function values(): array
|
||||
{
|
||||
return array_map(static fn (self $case): string => $case->value, self::cases());
|
||||
}
|
||||
}
|
||||
38
app/Support/TenantReviewStatus.php
Normal file
38
app/Support/TenantReviewStatus.php
Normal file
@ -0,0 +1,38 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Support;
|
||||
|
||||
enum TenantReviewStatus: string
|
||||
{
|
||||
case Draft = 'draft';
|
||||
case Ready = 'ready';
|
||||
case Published = 'published';
|
||||
case Archived = 'archived';
|
||||
case Superseded = 'superseded';
|
||||
case Failed = 'failed';
|
||||
|
||||
/**
|
||||
* @return list<string>
|
||||
*/
|
||||
public static function values(): array
|
||||
{
|
||||
return array_map(static fn (self $case): string => $case->value, self::cases());
|
||||
}
|
||||
|
||||
public function isMutable(): bool
|
||||
{
|
||||
return in_array($this, [self::Draft, self::Ready, self::Failed], true);
|
||||
}
|
||||
|
||||
public function isTerminal(): bool
|
||||
{
|
||||
return in_array($this, [self::Archived, self::Superseded], true);
|
||||
}
|
||||
|
||||
public function isPublished(): bool
|
||||
{
|
||||
return $this === self::Published;
|
||||
}
|
||||
}
|
||||
@ -14,6 +14,7 @@
|
||||
use App\Filament\Resources\PolicyResource;
|
||||
use App\Filament\Resources\PolicyVersionResource;
|
||||
use App\Filament\Resources\RestoreRunResource;
|
||||
use App\Filament\Resources\TenantReviewResource;
|
||||
use App\Models\BackupSchedule;
|
||||
use App\Models\BackupSet;
|
||||
use App\Models\EntraGroup;
|
||||
@ -24,6 +25,7 @@
|
||||
use App\Models\Policy;
|
||||
use App\Models\PolicyVersion;
|
||||
use App\Models\RestoreRun;
|
||||
use App\Models\TenantReview;
|
||||
|
||||
final class TenantOwnedModelFamilies
|
||||
{
|
||||
@ -142,6 +144,16 @@ public static function firstSlice(): array
|
||||
'action_surface_reason' => 'EntraGroupResource declares its action surface contract directly.',
|
||||
'notes' => 'Directory groups already support tenant-safe global search.',
|
||||
],
|
||||
'TenantReview' => [
|
||||
'table' => 'tenant_reviews',
|
||||
'model' => TenantReview::class,
|
||||
'resource' => TenantReviewResource::class,
|
||||
'tenant_relationship' => 'tenant',
|
||||
'search_posture' => 'disabled',
|
||||
'action_surface' => 'declared',
|
||||
'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.',
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
86
database/factories/TenantReviewFactory.php
Normal file
86
database/factories/TenantReviewFactory.php
Normal file
@ -0,0 +1,86 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Database\Factories;
|
||||
|
||||
use App\Models\ReviewPack;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\TenantReview;
|
||||
use App\Models\User;
|
||||
use App\Models\Workspace;
|
||||
use App\Support\TenantReviewCompletenessState;
|
||||
use App\Support\TenantReviewStatus;
|
||||
use Illuminate\Database\Eloquent\Factories\Factory;
|
||||
|
||||
/**
|
||||
* @extends Factory<TenantReview>
|
||||
*/
|
||||
class TenantReviewFactory extends Factory
|
||||
{
|
||||
protected $model = TenantReview::class;
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function definition(): array
|
||||
{
|
||||
return [
|
||||
'tenant_id' => Tenant::factory()->for(Workspace::factory()),
|
||||
'workspace_id' => function (array $attributes): int {
|
||||
$tenantId = $attributes['tenant_id'] ?? null;
|
||||
|
||||
if (! is_numeric($tenantId)) {
|
||||
return (int) Workspace::factory()->create()->getKey();
|
||||
}
|
||||
|
||||
$tenant = Tenant::query()->whereKey((int) $tenantId)->first();
|
||||
|
||||
if (! $tenant instanceof Tenant || $tenant->workspace_id === null) {
|
||||
return (int) Workspace::factory()->create()->getKey();
|
||||
}
|
||||
|
||||
return (int) $tenant->workspace_id;
|
||||
},
|
||||
'evidence_snapshot_id' => null,
|
||||
'current_export_review_pack_id' => null,
|
||||
'operation_run_id' => null,
|
||||
'initiated_by_user_id' => User::factory(),
|
||||
'published_by_user_id' => null,
|
||||
'superseded_by_review_id' => null,
|
||||
'fingerprint' => fake()->sha256(),
|
||||
'status' => TenantReviewStatus::Draft->value,
|
||||
'completeness_state' => TenantReviewCompletenessState::Complete->value,
|
||||
'summary' => [
|
||||
'publish_blockers' => [],
|
||||
'required_section_count' => 6,
|
||||
'completed_section_count' => 6,
|
||||
],
|
||||
'generated_at' => now(),
|
||||
'published_at' => null,
|
||||
'archived_at' => null,
|
||||
];
|
||||
}
|
||||
|
||||
public function ready(): static
|
||||
{
|
||||
return $this->state(fn (): array => [
|
||||
'status' => TenantReviewStatus::Ready->value,
|
||||
'completeness_state' => TenantReviewCompletenessState::Complete->value,
|
||||
'summary' => [
|
||||
'publish_blockers' => [],
|
||||
'required_section_count' => 6,
|
||||
'completed_section_count' => 6,
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
public function published(?ReviewPack $reviewPack = null): static
|
||||
{
|
||||
return $this->state(fn (): array => [
|
||||
'current_export_review_pack_id' => $reviewPack?->getKey(),
|
||||
'status' => TenantReviewStatus::Published->value,
|
||||
'published_at' => now(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
52
database/factories/TenantReviewSectionFactory.php
Normal file
52
database/factories/TenantReviewSectionFactory.php
Normal file
@ -0,0 +1,52 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Database\Factories;
|
||||
|
||||
use App\Models\TenantReview;
|
||||
use App\Models\TenantReviewSection;
|
||||
use App\Support\TenantReviewCompletenessState;
|
||||
use Illuminate\Database\Eloquent\Factories\Factory;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
/**
|
||||
* @extends Factory<TenantReviewSection>
|
||||
*/
|
||||
class TenantReviewSectionFactory extends Factory
|
||||
{
|
||||
protected $model = TenantReviewSection::class;
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function definition(): array
|
||||
{
|
||||
return [
|
||||
'tenant_review_id' => TenantReview::factory(),
|
||||
'workspace_id' => function (array $attributes): int {
|
||||
$review = TenantReview::query()->whereKey((int) $attributes['tenant_review_id'])->firstOrFail();
|
||||
|
||||
return (int) $review->workspace_id;
|
||||
},
|
||||
'tenant_id' => function (array $attributes): int {
|
||||
$review = TenantReview::query()->whereKey((int) $attributes['tenant_review_id'])->firstOrFail();
|
||||
|
||||
return (int) $review->tenant_id;
|
||||
},
|
||||
'section_key' => Str::snake(fake()->words(2, true)),
|
||||
'title' => fake()->sentence(3),
|
||||
'sort_order' => fake()->numberBetween(0, 50),
|
||||
'required' => true,
|
||||
'completeness_state' => TenantReviewCompletenessState::Complete->value,
|
||||
'source_snapshot_fingerprint' => fake()->sha256(),
|
||||
'summary_payload' => [
|
||||
'summary' => fake()->sentence(),
|
||||
],
|
||||
'render_payload' => [
|
||||
'highlights' => [],
|
||||
],
|
||||
'measured_at' => now(),
|
||||
];
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,48 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('tenant_reviews', function (Blueprint $table): void {
|
||||
$table->id();
|
||||
$table->foreignId('workspace_id')->constrained('workspaces')->cascadeOnDelete();
|
||||
$table->foreignId('tenant_id')->constrained('tenants')->cascadeOnDelete();
|
||||
$table->foreignId('evidence_snapshot_id')->constrained('evidence_snapshots')->restrictOnDelete();
|
||||
$table->foreignId('current_export_review_pack_id')->nullable()->constrained('review_packs')->nullOnDelete();
|
||||
$table->foreignId('operation_run_id')->nullable()->constrained('operation_runs')->nullOnDelete();
|
||||
$table->foreignId('initiated_by_user_id')->nullable()->constrained('users')->nullOnDelete();
|
||||
$table->foreignId('published_by_user_id')->nullable()->constrained('users')->nullOnDelete();
|
||||
$table->foreignId('superseded_by_review_id')->nullable()->constrained('tenant_reviews')->nullOnDelete();
|
||||
$table->string('fingerprint', 64)->nullable();
|
||||
$table->string('status')->default('draft');
|
||||
$table->string('completeness_state')->default('missing');
|
||||
$table->jsonb('summary')->default('{}');
|
||||
$table->timestampTz('published_at')->nullable();
|
||||
$table->timestampTz('archived_at')->nullable();
|
||||
$table->timestampTz('generated_at')->nullable();
|
||||
$table->timestamps();
|
||||
|
||||
$table->index(['workspace_id', 'tenant_id', 'created_at']);
|
||||
$table->index(['tenant_id', 'status', 'published_at']);
|
||||
});
|
||||
|
||||
DB::statement("
|
||||
CREATE UNIQUE INDEX tenant_reviews_fingerprint_mutable_unique
|
||||
ON tenant_reviews (workspace_id, tenant_id, fingerprint)
|
||||
WHERE fingerprint IS NOT NULL AND status IN ('draft', 'ready', 'failed')
|
||||
");
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('tenant_reviews');
|
||||
}
|
||||
};
|
||||
@ -0,0 +1,39 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('tenant_review_sections', function (Blueprint $table): void {
|
||||
$table->id();
|
||||
$table->foreignId('tenant_review_id')->constrained('tenant_reviews')->cascadeOnDelete();
|
||||
$table->foreignId('workspace_id')->constrained('workspaces')->cascadeOnDelete();
|
||||
$table->foreignId('tenant_id')->constrained('tenants')->cascadeOnDelete();
|
||||
$table->string('section_key');
|
||||
$table->string('title');
|
||||
$table->unsignedInteger('sort_order')->default(0);
|
||||
$table->boolean('required')->default(true);
|
||||
$table->string('completeness_state')->default('missing');
|
||||
$table->string('source_snapshot_fingerprint', 64)->nullable();
|
||||
$table->jsonb('summary_payload')->default('{}');
|
||||
$table->jsonb('render_payload')->default('{}');
|
||||
$table->timestampTz('measured_at')->nullable();
|
||||
$table->timestamps();
|
||||
|
||||
$table->unique(['tenant_review_id', 'section_key']);
|
||||
$table->index(['workspace_id', 'tenant_id', 'section_key']);
|
||||
$table->index(['tenant_id', 'completeness_state']);
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('tenant_review_sections');
|
||||
}
|
||||
};
|
||||
@ -0,0 +1,30 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('review_packs', function (Blueprint $table): void {
|
||||
$table->foreignId('tenant_review_id')
|
||||
->nullable()
|
||||
->after('evidence_snapshot_id')
|
||||
->constrained('tenant_reviews')
|
||||
->nullOnDelete();
|
||||
|
||||
$table->index(['tenant_review_id', 'generated_at']);
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('review_packs', function (Blueprint $table): void {
|
||||
$table->dropConstrainedForeignId('tenant_review_id');
|
||||
});
|
||||
}
|
||||
};
|
||||
501
docs/audits/semantic-clarity-audit.md
Normal file
501
docs/audits/semantic-clarity-audit.md
Normal file
@ -0,0 +1,501 @@
|
||||
# Semantic Clarity & Operator-Language Audit
|
||||
|
||||
**Product:** TenantPilot / TenantAtlas
|
||||
**Date:** 2026-03-21
|
||||
**Scope:** System-wide audit of operator-facing semantics, terminology collisions, technical leakage, false urgency, and missing semantic separation
|
||||
**Auditor role:** Senior Staff Engineer / Enterprise SaaS UX Architect
|
||||
|
||||
---
|
||||
|
||||
## 1. Executive Summary
|
||||
|
||||
**Severity: HIGH — systemic, not localized.**
|
||||
|
||||
TenantPilot has a **pervasive semantic collision problem** where at least 5–7 distinct meaning axes are collapsed into the same small vocabulary of terms. The product uses ~12 overloaded words ("failed", "partial", "missing", "gaps", "unsupported", "stale", "blocked", "complete", "ready", "reference only", "metadata only", "needs attention") to communicate fundamentally different things across different domains.
|
||||
|
||||
**Why it matters for enterprise/MSP trust:**
|
||||
|
||||
1. **False alarm risk is real and frequent.** An operator scanning a baseline snapshot sees "Unsupported" (gray badge) and "Gaps present" (yellow badge) and cannot distinguish a product renderer limitation from a genuine governance gap. Every scan of every resource containing fallback-rendered policy types will surface these warnings, training operators to ignore them — which then masks real issues.
|
||||
|
||||
2. **The problem is structural, not cosmetic.** The badge system (`BadgeDomain`, 43 domains, ~500 case values) is architecturally sound and well-centralized. But it maps heterogeneous semantic axes onto a single severity/color channel (success/warning/danger/gray). The infrastructure is good; the taxonomy feeding it is wrong.
|
||||
|
||||
3. **The problem spans every major domain.** Operations, Baselines, Evidence, Findings, Reviews, Inventory, Restore, Onboarding, and Alerts all exhibit the same pattern: a single status badge or label is asked to communicate execution outcome + data quality + product support maturity + operator urgency simultaneously.
|
||||
|
||||
4. **The heaviest damage is in three areas:**
|
||||
- **Baseline Snapshots** — "Unsupported", "Reference only", "Partial", "Gaps present" all describe product/renderer limitations but read as data quality failures
|
||||
- **Restore Runs** — "Partial", "Manual required", "Dry run", "Completed with errors", "Skipped" create a 5-way ambiguity about what actually happened
|
||||
- **Evidence/Review Completeness** — "Partial", "Missing", "Stale" collapse three distinct axes (coverage, age, depth) into one badge
|
||||
|
||||
**Estimated impact:** ~60% of warning-colored badges in the product communicate something that is NOT a governance problem. This erodes the signal-to-noise ratio for the badges that actually matter.
|
||||
|
||||
---
|
||||
|
||||
## 2. Semantic Taxonomy Problems
|
||||
|
||||
The product needs to distinguish **at minimum 8 independent meaning axes**. Currently it conflates most of them.
|
||||
|
||||
### 2.1 Required Semantic Axes
|
||||
|
||||
| # | Axis | What it answers | Who cares |
|
||||
|---|------|----------------|-----------|
|
||||
| 1 | **Execution outcome** | Did the operation run succeed, fail, or partially complete? | Operator |
|
||||
| 2 | **Data completeness** | Is the captured data set complete for the scope requested? | Operator |
|
||||
| 3 | **Evidence depth / fidelity** | How much detail was captured per item (full settings vs. metadata envelope)? | Auditor / Compliance |
|
||||
| 4 | **Product support maturity** | Does TenantPilot have a specialized renderer/handler for this policy type or is it using a generic fallback? | Product team (not operator) |
|
||||
| 5 | **Governance deviation** | Is this finding, drift, or posture gap a real compliance/security concern? | Risk officer |
|
||||
| 6 | **Publication readiness** | Can this review/report be published to stakeholders? | Review author |
|
||||
| 7 | **Data freshness** | When was this data last updated, and is it still within acceptable thresholds? | Operator |
|
||||
| 8 | **Operator actionability** | Does this state require operator intervention, or is it informational? | Operator |
|
||||
|
||||
### 2.2 Where They Are Currently Conflated
|
||||
|
||||
| Conflation | Example | Axes mixed |
|
||||
|-----------|---------|------------|
|
||||
| Baseline snapshot with fallback renderer shows "Unsupported" badge + "Gaps present" | Axes 4 + 5 | Product support maturity presented as governance deviation |
|
||||
| Evidence completeness shows "Missing" (red/danger) when no findings exist yet | Axes 2 + 8 | Completeness treated as urgency |
|
||||
| Restore run shows "Partial" (yellow) | Axes 1 + 2 | Execution outcome mixed with scope completion |
|
||||
| Review completeness shows "Stale" | Axes 2 + 7 | Completeness mixed with freshness |
|
||||
| Operation outcome "Partially succeeded" | Axes 1 + 2 | Execution result mixed with item-level coverage |
|
||||
| "Blocked" used for both operation outcomes and verification reports | Axes 1 + 8 | Execution state mixed with actionability |
|
||||
| "Failed" used for 10+ different badge domains | Axes 1 + 4 + 5 | Everything that isn't success collapses to "failed" |
|
||||
|
||||
---
|
||||
|
||||
## 3. Findings by Domain
|
||||
|
||||
### 3.1 Operations / Runs
|
||||
|
||||
**Affected components:**
|
||||
- `OperationRunOutcome` enum + `OperationRunOutcomeBadge`
|
||||
- `OperationUxPresenter` (terminal notifications)
|
||||
- `SummaryCountsNormalizer` (run summary line)
|
||||
- `RunFailureSanitizer` (failure message formatting)
|
||||
- `OperationRunResource` (table, infolist)
|
||||
- `OperationRunQueued` / `OperationRunCompleted` notifications
|
||||
|
||||
**Current problematic states:**
|
||||
|
||||
| State | Badge | Problem |
|
||||
|-------|-------|---------|
|
||||
| `PartiallySucceeded` | "Partially succeeded" (yellow) | Does not explain: how many items succeeded vs. failed, whether core operation goals were met, or what items need attention. Operator cannot distinguish "99 of 100 policies synced" from "1 of 100 policies synced." |
|
||||
| `Blocked` | "Blocked" (yellow) | Same color as PartiallySucceeded. Does not explain: blocked by what (RBAC? provider? gate?), or what to do next. |
|
||||
| `Failed` | "Failed" (red) | Reasonable for execution failure, but failure messages are truncated to 140 chars and sanitized to internal reason codes (`RateLimited`, `ProviderAuthFailed`). Operator gets "Failed. Rate limited." with no retry guidance. |
|
||||
|
||||
**Notification problems:**
|
||||
- "Completed with warnings" — what warnings? Where are they?
|
||||
- "Execution was blocked." — no explanation or recovery action
|
||||
- Summary counts show raw keys like `errors_recorded`, `posture_score`, `report_deduped` without context
|
||||
- Queued notification says "Running in the background" — no ETA or queue position
|
||||
|
||||
**Classification:** Systemic (affects all run types across all domains)
|
||||
**Operator impact:** HIGH — every failed or partial operation leaves operator uncertain about actual state
|
||||
|
||||
### 3.2 Baselines / Snapshots / Compare
|
||||
|
||||
**Affected components:**
|
||||
- `FidelityState` enum (Full / Partial / ReferenceOnly / Unsupported)
|
||||
- `BaselineSnapshotFidelityBadge`
|
||||
- `BaselineSnapshotGapStatusBadge`
|
||||
- `BaselineSnapshotPresenter` + `RenderedSnapshot*` DTOs
|
||||
- `GapSummary` class
|
||||
- `FallbackSnapshotTypeRenderer`
|
||||
- `SnapshotTypeRendererRegistry`
|
||||
- `baseline-snapshot-summary-table.blade.php`
|
||||
- `baseline-snapshot-technical-detail.blade.php`
|
||||
- `BaselineSnapshotResource`, `BaselineProfileResource`
|
||||
|
||||
**This is the single worst-affected domain.** Four separate problems compound:
|
||||
|
||||
**Problem 1: FidelityState mixes product maturity with data quality**
|
||||
|
||||
| FidelityState | What it actually means | What operator reads |
|
||||
|--------------|----------------------|-------------------|
|
||||
| `Full` | Specialized renderer produced structured output | "Data is complete" |
|
||||
| `Partial` | Some items rendered fully, others fell back | "Data is partially missing" |
|
||||
| `ReferenceOnly` | Only metadata envelope captured, no settings payload | "Only a reference exists" (alarming) |
|
||||
| `Unsupported` | No specialized renderer exists; fallback used | "This policy type is broken" |
|
||||
|
||||
The `coverageHint` texts are better but still technical:
|
||||
- "Mixed evidence fidelity across this group."
|
||||
- "Metadata-only evidence is available."
|
||||
- "Fallback metadata rendering is being used."
|
||||
|
||||
**Problem 2: "Gaps" conflates multiple causes**
|
||||
|
||||
`GapSummary` messages include:
|
||||
- "Metadata-only evidence was captured for this item." — this is a capture depth choice, not a gap
|
||||
- "A fallback renderer is being used for this item." — this is a product maturity issue, not a data gap
|
||||
- Actual capture errors (e.g., Graph returned an error) — these ARE real gaps
|
||||
|
||||
All three are counted as "gaps" and surfaced under the same "Gaps present" (yellow) badge. An operator cannot distinguish "we chose to capture light" from "the API returned an error" from "we don't have a renderer for this type yet."
|
||||
|
||||
**Problem 3: "Captured with gaps" state label**
|
||||
|
||||
`BaselineSnapshotPresenter.stateLabel` returns either "Complete" or "Captured with gaps". This is the top-level snapshot state that operators see first. If any policy type uses a fallback renderer, the entire snapshot is labeled "Captured with gaps" — even if every single policy was successfully captured and the only "gap" is that the product's renderer coverage hasn't been built out yet.
|
||||
|
||||
**Problem 4: Technical detail exposure**
|
||||
|
||||
The `baseline-snapshot-technical-detail.blade.php` template includes the comment: "Technical payloads are secondary on purpose. Use them for debugging capture fidelity and renderer fallbacks." This is good intent, but the primary view still surfaces "Unsupported" and "Gaps present" badges that carry the same connotation.
|
||||
|
||||
**Classification:** P0 — actively damages operator trust
|
||||
**Operator impact:** CRITICAL — every baseline snapshot containing non-specialized policy types (which is most of them in a real tenant) will show yellow warnings that are not governance problems
|
||||
|
||||
### 3.3 Evidence / Evidence Snapshots
|
||||
|
||||
**Affected components:**
|
||||
- `EvidenceCompletenessState` enum (Complete / Partial / Missing / Stale)
|
||||
- `EvidenceCompletenessBadge`
|
||||
- `EvidenceSnapshotStatus` enum + `EvidenceSnapshotStatusBadge`
|
||||
- `EvidenceCompletenessEvaluator`
|
||||
- `EvidenceSnapshotResource`
|
||||
- Evidence source classes (`FindingsSummarySource`, `OperationsSummarySource`)
|
||||
|
||||
**Current problematic states:**
|
||||
|
||||
| State | Badge Color | Problem |
|
||||
|-------|------------|---------|
|
||||
| `Missing` | Red/danger | Used when a required evidence dimension has no data. But "missing" could mean: never collected, collection failed, data source empty (no findings exist yet = no evidence to collect). A new tenant with zero findings will show "Missing" in red for the findings dimension, which reads as a failure. |
|
||||
| `Partial` | Yellow/warning | Means some dimensions are incomplete. Does not explain which or why. |
|
||||
| `Stale` | Gray | Evidence collected > 30 days ago. Gray color suggests it's fine / archived, but stale evidence is actually operationally risky. Wrong severity mapping. |
|
||||
|
||||
**Source-level problems:**
|
||||
- `FindingsSummarySource`: returns `Complete` if findings exist, `Missing` otherwise. A tenant with no findings is not "missing evidence" — it has zero findings, which is valid.
|
||||
- `OperationsSummarySource`: returns `Complete` if operations ran in last 30 days, `Missing` otherwise. A stable tenant with no recent operations is not "missing" — it's stable.
|
||||
|
||||
**Classification:** P0 — "Missing" (red) for valid empty states is a false alarm
|
||||
**Operator impact:** HIGH — new tenants or stable tenants will always show red/yellow completeness badges that are not problems
|
||||
|
||||
### 3.4 Findings / Exceptions / Risk Acceptance
|
||||
|
||||
**Affected components:**
|
||||
- `FindingResource` (form, infolist, table)
|
||||
- `FindingExceptionResource`
|
||||
- `FindingStatusBadge`, `FindingSeverityBadge`
|
||||
- `FindingExceptionStatusBadge`
|
||||
- `FindingRiskGovernanceValidityBadge`
|
||||
|
||||
**Current problematic states:**
|
||||
|
||||
| Term | Location | Problem |
|
||||
|------|----------|---------|
|
||||
| "Validity" | Finding infolist | Label for governance validity badge. Values include `missing_support` which reads as a product defect, not a governance state. |
|
||||
| "Missing support" | `FindingRiskGovernanceValidityBadge` | Means "no exception record exists for this risk-accepted finding." This is a governance gap, not a product support issue. Label suggests the product is broken. |
|
||||
| "Exception status" | Finding infolist section | Section heading uses "exception" (internal concept) instead of operator-facing language like "Risk approval status" |
|
||||
| "Risk governance" | Finding infolist section | Appropriate term but not explained; grouped with "Validity" which is confusing |
|
||||
|
||||
**Drift diff unavailable messages leak technical language:**
|
||||
- "RBAC evidence unavailable — normalized role definition evidence is missing."
|
||||
- "Diff unavailable — missing baseline policy version reference."
|
||||
- "Diff unavailable — missing current policy version reference."
|
||||
|
||||
These are accurate but incomprehensible to operators. They should say what happened and what action to take.
|
||||
|
||||
**Classification:** P1 — confusing but less frequently seen than baselines/evidence
|
||||
**Operator impact:** MEDIUM — affects finding detail pages, which are high-attention moments
|
||||
|
||||
### 3.5 Tenant Reviews / Reports / Publication
|
||||
|
||||
**Affected components:**
|
||||
- `TenantReviewStatus` enum + `TenantReviewStatusBadge`
|
||||
- `TenantReviewCompletenessState` enum + badge
|
||||
- `TenantReviewReadinessGate`
|
||||
- `TenantReviewComposer` / `TenantReviewSectionFactory`
|
||||
- `ReviewPackResource`, `TenantReviewResource`
|
||||
|
||||
**Current problematic states:**
|
||||
|
||||
| State | Problem |
|
||||
|-------|---------|
|
||||
| "Partial" completeness | Same conflation as Evidence: partial coverage vs. partial depth vs. partial freshness all collapsed |
|
||||
| "Stale" completeness | A review section based on old evidence should be orange/yellow (action needed), not gray (archived/inert) |
|
||||
| "{Section} is stale and must be refreshed before publication" | Accurate blocker message, but no guidance on HOW to refresh |
|
||||
| "{Section} is missing" | Confuses "section text hasn't been generated yet" with "underlying data doesn't exist" |
|
||||
| "Anchored evidence snapshot" | Used in form helper text and empty states — jargon for "the snapshot this review is based on" |
|
||||
|
||||
**Publication readiness gate is well-designed** — it correctly separates blockers from completeness. But the messages surfaced to operators still use the collapsed vocabulary.
|
||||
|
||||
**Highlight bullets are good:** "3 open risks from 12 tracked findings" is clear operator language. The recommended next actions are also well-written. This domain is ahead of baselines/evidence in semantic clarity.
|
||||
|
||||
**Classification:** P1 — partially solved, but completeness/freshness conflation remains
|
||||
**Operator impact:** MEDIUM — review authors are typically more sophisticated users
|
||||
|
||||
### 3.6 Restore Runs
|
||||
|
||||
**Affected components:**
|
||||
- `RestoreRunStatus` enum + badge (13 states including legacy)
|
||||
- `RestoreResultStatusBadge` (7 states)
|
||||
- `RestorePreviewDecisionBadge` (5 states)
|
||||
- `RestoreCheckSeverityBadge`
|
||||
- `RestoreRunResource` (form, infolist)
|
||||
- `restore-results.blade.php`
|
||||
- `restore-run-checks.blade.php`
|
||||
|
||||
**This is the second-worst domain for semantic confusion.** The restore workflow has:
|
||||
|
||||
- **13 lifecycle states** (most products have 4-6) — including legacy states that are still displayable
|
||||
- **7 result statuses** per item — three of which are yellow/warning with different meanings
|
||||
- **5 preview decisions** — including "Created copy" (why?) and "Skipped" (intentional or forced?)
|
||||
|
||||
**Specific problems:**
|
||||
|
||||
| State | Badge | Problem |
|
||||
|-------|-------|---------|
|
||||
| `Partial` (run-level) | Yellow | "Some items restored; some failed." But which? How many? Is the tenant in a consistent state? |
|
||||
| `CompletedWithErrors` | Yellow | Legacy state. Does "completed" mean all items were attempted, or all succeeded and errors are secondary? |
|
||||
| `Partial` (item-level) | Yellow | Mean something different from run-level partial — "item partially applied" |
|
||||
| `Manual required` | Yellow | What manual action? Where? By whom? |
|
||||
| `Dry run` | Blue/info | Could be confused with success. "Applied" is green, "Dry run" is blue — but the operation did NOT apply. |
|
||||
| `Skipped` | Yellow | Intentional skip (user chose to exclude) vs. forced skip (precondition failed) conflated |
|
||||
| `Created copy` | Yellow | Implies a naming conflict was resolved by creating a copy. Warning color suggests this is bad, but it might be expected behavior. |
|
||||
|
||||
**Form helper text is especially problematic:**
|
||||
- "Preview-only types stay in dry-run" — operators don't know what "preview-only types" are
|
||||
- "Include foundations (scope tags, assignment filters) with policies to re-map IDs" — three jargon terms in one sentence
|
||||
- "Paste the target Entra ID group Object ID (GUID)" — technical protocol language
|
||||
|
||||
**Restore check display:**
|
||||
- "Blocking" (red) — operator may think the system is frozen, not that a validation check needs attention
|
||||
- "Unmapped groups" — no explanation of what mapping means or how to do it
|
||||
|
||||
**Classification:** P0 — restore is a high-risk, high-attention operation where confusion has real consequences
|
||||
**Operator impact:** CRITICAL — incorrect interpretation of restore state can lead to incorrect remediation decisions
|
||||
|
||||
### 3.7 Inventory / Coverage / Sync / Provider Health
|
||||
|
||||
**Affected components:**
|
||||
- `InventoryKpiBadges` / `InventoryKpiHeader` widget
|
||||
- `InventoryCoverage` page
|
||||
- `InventorySyncService`
|
||||
- `PolicySnapshotModeBadge` ("Full" / "Metadata only")
|
||||
- `ProviderConnectionStatusBadge`, `ProviderConnectionHealthBadge`
|
||||
- `PolicyResource` (sync action, snapshot mode helper)
|
||||
|
||||
**Current problematic states:**
|
||||
|
||||
| State | Problem |
|
||||
|-------|---------|
|
||||
| "Partial" items (yellow) in KPI | Means "preview-only restore mode" — a product support maturity fact, not a data quality issue. |
|
||||
| "Risk" items (red) in KPI | Correct usage — these are genuinely higher-risk policy types. |
|
||||
| "Metadata only" snapshot mode (yellow + warning icon) | Technical capture mode presented as if something is wrong. For some policy types, metadata-only capture IS the correct behavior. |
|
||||
| Sync error: "Inventory sync reported rate limited." | Mechanically constructed from error code (`str_replace('_', ' ', $errorCode)`). No retry guidance. |
|
||||
| Provider "Needs consent" | No context about what consent, where to grant it, or who can grant it. |
|
||||
| Provider "Degraded" | No detail on what is degraded or whether operations are affected. |
|
||||
|
||||
**Graph error leakage in PolicyResource:**
|
||||
- "Graph returned {status} for this policy type. Only local metadata was saved; settings and restore are unavailable until Graph works again."
|
||||
- This is accurate but uses internal API naming ("Graph") and makes the scope unclear (is restore blocked for one policy or all policies?).
|
||||
|
||||
**Classification:** P1 — inventory is read-frequently and yellow badges train operators to ignore warnings
|
||||
**Operator impact:** MEDIUM-HIGH — scanning inventory with perpetual yellow badges erodes warning credibility
|
||||
|
||||
### 3.8 Onboarding / Verification
|
||||
|
||||
**Affected components:**
|
||||
- `VerificationReportOverall` + badge ("Ready" / "Needs attention" / "Blocked")
|
||||
- `VerificationCheckStatus` + badge ("Pass" / "Fail" / "Warn" / "Skip")
|
||||
- `ManagedTenantOnboardingVerificationStatusBadge`
|
||||
- `VerificationAssistViewModelBuilder`
|
||||
- `ManagedTenantOnboardingWizard` (notifications, modals)
|
||||
- `verification-required-permissions-assist.blade.php`
|
||||
|
||||
**Current problematic states:**
|
||||
|
||||
| State | Problem |
|
||||
|-------|---------|
|
||||
| "Needs attention" | Non-specific. Used for both missing delegated permissions and stale data. |
|
||||
| "Blocked" | Means "missing required application permissions" but reads as "system is blocked." |
|
||||
| "Fail" check status | Ambiguous: check execution failed, or check assertion failed? Both are possible meanings. |
|
||||
| Wizard notifications: "Tenant not available", "Draft is not resumable" | No explanation of why or what to do. |
|
||||
| "Verification required" | No detail on what verification is needed or estimated effort. |
|
||||
|
||||
**The verification assist panel is well-designed** — it categorizes missing permissions by application vs. delegated and shows feature impact. This is a positive example of good semantic separation that could be replicated elsewhere.
|
||||
|
||||
**Classification:** P2 — onboarding is a one-time flow with guided steps
|
||||
**Operator impact:** LOW-MEDIUM — operators encounter this during setup, not daily operations
|
||||
|
||||
### 3.9 Alerts / Notifications
|
||||
|
||||
**Affected components:**
|
||||
- `EmailAlertNotification`
|
||||
- `AlertDeliveryStatusBadge`
|
||||
- `AlertDestinationLastTestStatusBadge`
|
||||
|
||||
**Current problematic states:**
|
||||
|
||||
| State | Problem |
|
||||
|-------|---------|
|
||||
| Email body includes "Delivery ID: {id}" and "Event type: {type}" | Internal metadata exposed to operators without context. |
|
||||
| "Failed" delivery status (red) | No distinction between transient (retry-able) and permanent failure. |
|
||||
| Queued notification: "Running in the background." | No ETA, no queue position, no way to check progress until terminal notification arrives. |
|
||||
|
||||
**Classification:** P2 — alerts are less frequently viewed
|
||||
**Operator impact:** LOW — these are supplementary signals, not primary operator workflows
|
||||
|
||||
---
|
||||
|
||||
## 4. Cross-Cutting Terminology Map
|
||||
|
||||
### 4.1 Most Problematic Terms
|
||||
|
||||
| Term | Appearances | Meanings it carries | Why dangerous | Should belong to |
|
||||
|------|-------------|-------------------|---------------|-----------------|
|
||||
| **"Failed"** | OperationRunOutcome, RestoreRunStatus, RestoreResultStatus, ReviewPackStatus, EvidenceSnapshotStatus, AlertDeliveryStatus, TenantReviewStatus, AlertDestinationLastTestStatus, BackupSetStatus, TenantRbacStatus, AuditOutcome | (1) Operation execution failure, (2) validation check assertion failure, (3) generation/composition failure, (4) delivery failure, (5) catch-all for non-success | Undifferentiated — operator cannot assess severity, retryability, or scope without drilling in. Red badge for all cases regardless of impact. | Axis 1 (execution outcome) only. Other uses need distinct terms: "check failed" → "not met"; delivery failed → "undeliverable"; generation failed → "generation error" |
|
||||
| **"Partial"** | OperationRunOutcome (PartiallySucceeded), RestoreRunStatus, RestoreResultStatus, EvidenceCompletenessState, TenantReviewCompletenessState, FidelityState, BackupSetStatus, AuditOutcome, InventoryKpiBadges | (1) Some items in a batch succeeded, others didn't, (2) evidence depth is limited, (3) capture coverage is incomplete, (4) product renderer coverage is partial, (5) restore mode is preview-only | 8+ different meanings across the product. Yellow badge always. An operator reading "Partial" has no way to know which kind without drilling into details. | Axis-specific terms: "N of M items processed" (Axis 1), "Limited detail" (Axis 3), "Incomplete coverage" (Axis 2), "Preview mode" (Axis 4) |
|
||||
| **"Missing"** | EvidenceCompletenessState, TenantReviewCompletenessState, TenantPermissionStatus, ReferenceResolutionState (DeletedOrMissing), FindingRiskGovernanceValidity (missing_support) | (1) Evidence dimension has no data, (2) required review section not generated, (3) permission not granted, (4) referenced record deleted/not found, (5) no exception exists for risk-accepted finding | "Missing" in red across all uses. But "zero findings" is not "missing findings" — it's an empty valid state. | Separate into: "Not collected" (evidence), "Not generated" (sections), "Not granted" (permissions), "Not found" (references), "No exception" (governance) |
|
||||
| **"Gaps"** | BaselineSnapshotGapStatus, GapSummary, BaselineSnapshotPresenter ("Captured with gaps") | (1) Fallback renderer was used, (2) metadata-only capture mode, (3) actual capture error, (4) missing coverage | Merges product limitation with data quality problem. Every snapshot with unsupported types shows "Gaps present" perpetually. | "Coverage notes" or "Capture notes" — with sub-categories for product limitation vs. actual data gap |
|
||||
| **"Unsupported"** | FidelityState, ReferenceClass | (1) No specialized renderer for this policy type (product maturity), (2) reference class not recognized | Reads as "this policy type is broken" when it actually means "we haven't built a dedicated viewer yet, but the data is captured." | "Generic view" or "Standard rendering" — product support state should never be operator-alarming |
|
||||
| **"Stale"** | EvidenceCompletenessState, TenantReviewCompletenessState, TenantRbacStatus | (1) Evidence older than 30 days, (2) review section based on old snapshot, (3) RBAC check results outdated | Gray badge color (passive) for freshness issues that may require action. Conflates with completeness (same enum). | Separate axis: "Last updated: 45 days ago" with age-based coloring. Not part of completeness axis. |
|
||||
| **"Blocked"** | OperationRunOutcome, VerificationReportOverall, ExecutionDenialClass, RestoreCheckSeverity | (1) Operation prevented by gate/RBAC, (2) missing required permissions, (3) pre-flight check preventing execution | Reads as "system is frozen" regardless of cause. Yellow for operation outcome, red for verification, red for restore checks. | "Prevented" or "Requires action" with specific cause shown |
|
||||
| **"Reference only"** | FidelityState | Only metadata envelope captured, no settings payload | Reads as "we only have a pointer, not the actual data" — technically correct but alarming. For some policy types, this IS the full capture because Graph doesn't expose settings. | "Metadata captured" or "Summary view" |
|
||||
| **"Metadata only"** | PolicySnapshotMode, restore-results.blade.php | (1) Capture mode was metadata-only, (2) restore created policy shell without settings | Two different uses. In capture, it's a valid mode. In restore, it means manual work is needed. | Separate: "Summary capture" (mode) vs. "Shell created — configure settings manually" (restore result) |
|
||||
| **"Ready"** | TenantReviewStatus, VerificationReportOverall, ReviewPackStatus, OnboardingLifecycleState (ReadyForActivation) | (1) Review cleared publication blockers, (2) permissions verified, (3) review pack downloadable, (4) onboarding ready for activation | Overloaded but less dangerous than others — context usually makes it clear. | Acceptable as-is, but should never be used alone without domain context |
|
||||
| **"Complete"** | EvidenceCompletenessState, TenantReviewCompletenessState, OnboardingDraftStatus/Stage/Lifecycle, OperationRunStatus (Completed) | (1) All evidence dimensions collected, (2) all review sections generated, (3) onboarding finished, (4) operation execution finished | "Completed" operation does not mean "succeeded" — the outcome must be checked separately. | Keep for lifecycle completion. Don't use for quality — use "Full coverage" instead. |
|
||||
|
||||
### 4.2 Terms That Are Correctly Used
|
||||
|
||||
| Term | Domain | Assessment |
|
||||
|------|--------|-----------|
|
||||
| "Drift" | Findings | Clear, domain-appropriate |
|
||||
| "Superseded" | Reviews, Snapshots | Correct lifecycle term |
|
||||
| "Archived" | Multiple | Consistent and clear |
|
||||
| "New" / "Triaged" / "In progress" / "Resolved" | Findings | Standard issue lifecycle |
|
||||
| "Risk accepted" | Findings | Precise governance term |
|
||||
| "Applied" / "Mapped" | Restore results | Clear restore outcomes |
|
||||
| "Pass" / "Warn" | Verification checks | Appropriate for boolean checks |
|
||||
|
||||
---
|
||||
|
||||
## 5. Priority Ranking
|
||||
|
||||
### P0 — Actively damages operator trust / causes false alarms
|
||||
|
||||
| # | Issue | Domain | Description |
|
||||
|---|-------|--------|-------------|
|
||||
| P0-1 | **FidelityState "Unsupported" displayed as badge** | Baselines | Every snapshot with a fallback-rendered policy type shows "Unsupported" (gray) and "Gaps present" (yellow). This is a product maturity fact, not operator-actionable. |
|
||||
| P0-2 | **"Captured with gaps" top-level snapshot state** | Baselines | One fallback renderer taints the entire snapshot label. Most snapshots will show this. |
|
||||
| P0-3 | **Evidence "Missing" (red) for valid empty states** | Evidence | A new tenant with zero findings shows red "Missing" badge. Zero findings is a valid state, not missing evidence. |
|
||||
| P0-4 | **Restore "Partial" ambiguity at run and item level** | Restore | Operator cannot determine scope of partial success or whether tenant is in consistent state. |
|
||||
| P0-5 | **OperationRunOutcome "Partially succeeded" with no item breakdown** | Operations | Yellow badge with no way to assess impact without drilling into run details. |
|
||||
|
||||
### P1 — Strongly confusing, should be fixed soon
|
||||
|
||||
| # | Issue | Domain | Description |
|
||||
|---|-------|--------|-------------|
|
||||
| P1-1 | **"Stale" gray badge for time-sensitive freshness** | Evidence, Reviews | Gray implies inert/archived; stale data requires action. Wrong color mapping. |
|
||||
| P1-2 | **"Blocked" with no explanation across 4 domains** | Operations, Verification, Restore, Execution | Operator reads "blocked" and cannot determine cause or action. |
|
||||
| P1-3 | **"Metadata only" in snapshot mode (yellow warning)** | Inventory, Policies | Valid capture mode presented as if something is wrong. |
|
||||
| P1-4 | **Restore "Manual required" without specifying what** | Restore | Yellow badge with no link to instructions or affected items. |
|
||||
| P1-5 | **"Gaps present" conflates 3+ causes** | Baselines | Renderer fallback, metadata-only capture, and actual errors all counted as "gaps." |
|
||||
| P1-6 | **"Missing support" in governance validity** | Findings | Means "no exception exists" but reads as "product doesn't support this." |
|
||||
| P1-7 | **Technical diff-unavailable messages** | Findings | "normalized role definition evidence is missing" is incomprehensible to operators. |
|
||||
| P1-8 | **Graph error codes in restore results** | Restore | "Graph error: {message} Code: {code}" shown directly to operators. |
|
||||
| P1-9 | **"Partial" in inventory KPI for preview-only types** | Inventory | Product maturity fact displayed as data quality warning. |
|
||||
|
||||
### P2 — Quality issue, not urgent
|
||||
|
||||
| # | Issue | Domain | Description |
|
||||
|---|-------|--------|-------------|
|
||||
| P2-1 | **Notification failure messages truncated to 140 chars** | Operations | Important context may be lost. Sanitized reason codes are still technical. |
|
||||
| P2-2 | **"Needs attention" without specifying what** | Onboarding, Verification | Non-specific label across multiple domains. |
|
||||
| P2-3 | **"Dry run" in blue/info for restore results** | Restore | Could be confused with success. Should be more clearly distinct. |
|
||||
| P2-4 | **"Anchored evidence snapshot" jargon** | Reviews | Used in form helpers and empty states without explanation. |
|
||||
| P2-5 | **Empty state messages lack next-step guidance** | Multiple | Most empty states describe what the feature does, not how to start. |
|
||||
| P2-6 | **Queued notifications lack progress indicators** | Operations | "Running in the background" with no ETA or monitoring guidance. |
|
||||
| P2-7 | **Email alerts include raw internal IDs** | Alerts | "Delivery ID: {id}" and "Event type: {type}" shown to operators. |
|
||||
|
||||
### P3 — Polish / later cleanup
|
||||
|
||||
| # | Issue | Domain | Description |
|
||||
|---|-------|--------|-------------|
|
||||
| P3-1 | **"Archiving is permanent in v1"** | Baselines | Version reference in modal text. |
|
||||
| P3-2 | **"foundations" term unexplained** | Baselines, Restore | Internal grouping concept used in helper text. |
|
||||
| P3-3 | **"Skipped" in restore without intent distinction** | Restore | Intentional vs. forced skip use same badge. |
|
||||
| P3-4 | **Summary count keys like "report_deduped"** | Operations | Internal metric name in operator-facing summary. |
|
||||
|
||||
---
|
||||
|
||||
## 6. Recommended Target Model
|
||||
|
||||
### 6.1 State Axes — Mandatory Separation
|
||||
|
||||
The product must maintain **independent** state tracking for these axes. They must never be flattened into a single badge or single enum:
|
||||
|
||||
| Axis | Scope | Operator-facing? | Values |
|
||||
|------|-------|-----------------|--------|
|
||||
| **Execution lifecycle** | Per operation run | Yes | Queued → Running → Completed |
|
||||
| **Execution outcome** | Per operation run | Yes | Succeeded / Completed with issues / Failed / Prevented |
|
||||
| **Item-level result** | Per item in a run | Yes (on drill-in) | Applied / Skipped (with reason) / Failed (with reason) |
|
||||
| **Data coverage** | Per evidence/review dimension | Yes | Full / Incomplete / Not collected |
|
||||
| **Evidence depth** | Per captured item | Yes (secondary) | Detailed / Summary / Metadata envelope |
|
||||
| **Product support tier** | Per policy type | Diagnostics only | Dedicated renderer / Standard renderer |
|
||||
| **Data freshness** | Per evidence item/dimension | Yes (separate from coverage) | Current (< N days) / Aging / Overdue |
|
||||
| **Governance status** | Per finding/exception | Yes | Valid / Expiring / Expired / None |
|
||||
| **Publication readiness** | Per review | Yes | Publishable / Blocked (with reasons) |
|
||||
| **Operator actionability** | Cross-cutting | Yes (determines badge urgency color) | Action required / Informational / No action needed |
|
||||
|
||||
### 6.2 Diagnostic-Only Terms (Never in Primary Operator View)
|
||||
|
||||
These terms should appear ONLY in expandable/collapsible diagnostic panels, never in primary badges or summary text:
|
||||
|
||||
- "Fallback renderer" → show "Standard rendering" in diagnostic, nothing in primary
|
||||
- "Metadata only" (as capture detail) → show "Summary view" in primary, mode detail in diagnostic
|
||||
- "Renderer registry" → never shown
|
||||
- "FidelityState" → replaced by "Evidence depth" in operator language
|
||||
- "Gap summary" → replaced by "Coverage notes"
|
||||
- Raw Graph error codes → replaced by categorized operator messages
|
||||
- Delivery IDs, event type codes → never in email body
|
||||
- `errors_recorded`, `report_deduped` → never in summary line; use operator terms
|
||||
|
||||
### 6.3 Warning vs. Info vs. Error — Decision Rules
|
||||
|
||||
| Color | When to use | Never use for |
|
||||
|-------|------------|--------------|
|
||||
| **Red (danger)** | Execution failed, governance violation active, permission denied, data loss risk | Product support limitations, empty valid states, expired but inactive records |
|
||||
| **Yellow (warning)** | Operator action recommended, approaching threshold, mixed outcome | Product maturity facts, informational states, things the operator cannot change |
|
||||
| **Blue (info)** | In progress, background activity, informational detail | States that could be confused with success outcomes |
|
||||
| **Green (success)** | Operation succeeded, check passed, data complete | States that could change (success should be stable) |
|
||||
| **Gray (neutral)** | Archived, not applicable, skipped | Stale data (should be yellow/orange), degraded states that need attention |
|
||||
|
||||
### 6.4 Where "Next Action" Is Mandatory
|
||||
|
||||
Every state that is NOT green or gray MUST include either:
|
||||
- An inline explanation (1 sentence) of what happened and whether action is needed
|
||||
- A link to a resolution path
|
||||
- An explicit "No action needed — this is expected" label
|
||||
|
||||
Mandatory coverage:
|
||||
- Failed operations → what failed, is it retryable, link to run details
|
||||
- Partial operations → N of M items processed, link to item-level details
|
||||
- Blocked operations → what blocked it, link to resolution (permissions page, provider settings, etc.)
|
||||
- Missing evidence → why missing (no data source, not yet collected, collection error), link to trigger collection
|
||||
- Stale data → when last updated, link to refresh action
|
||||
- Publication blockers → which sections, link directly to each section's refresh action
|
||||
|
||||
---
|
||||
|
||||
## 7. Spec Candidate Recommendations
|
||||
|
||||
### Recommended approach: **Foundation spec + domain follow-up specs**
|
||||
|
||||
The problem has two layers:
|
||||
1. A **cross-cutting taxonomy problem** that must be solved once, centrally, before domains can adopt it
|
||||
2. **Domain-specific adoption** that requires touching each resource/presenter/badge/view
|
||||
|
||||
### Spec Package
|
||||
|
||||
| Spec | Scope | Priority | Dependencies |
|
||||
|------|-------|----------|-------------|
|
||||
| **Spec: Operator Semantic Taxonomy** | Define the target state axes, term dictionary, color rules, diagnostic vs. primary classification, and "next action" policy. Produce a shared reference that all domain specs reference. Update `FidelityState`, `EvidenceCompletenessState`, `TenantReviewCompletenessState` enums to align with new taxonomy. Restructure `GapSummary` into axis-separated notes. | P0 | None |
|
||||
| **Spec: Baseline Snapshot Semantic Cleanup** | Apply taxonomy to BaselineSnapshotPresenter, FidelityState badge, GapSummary, snapshot summary table. Move renderer-tier info to diagnostics. Redefine "gaps" as categorized coverage notes. Fix "Captured with gaps" label. | P0 | Taxonomy spec |
|
||||
| **Spec: Evidence Completeness Reclassification** | Fix "Missing" for valid-empty states. Separate freshness from coverage. Add "Not applicable" state for dimensions with no expected data. Fix "Stale" color. | P0 | Taxonomy spec |
|
||||
| **Spec: Operation Outcome Clarity** | Replace "Partially succeeded" with item-breakdown-aware messaging. Add cause-specific "Blocked" explanations. Add next-action guidance to terminal notifications. Sanitize failure messages to operator language. | P1 | Taxonomy spec |
|
||||
| **Spec: Restore Semantic Cleanup** | Reduce 13 states to clear operator lifecycle. Distinguish intentional vs. forced skips. Replace "Manual required" with specific guidance. Fix "Partial" ambiguity. Move Graph errors behind diagnostic expandable. | P1 | Taxonomy spec |
|
||||
| **Spec: Inventory Coverage Language** | Replace "Partial" KPI badge for preview-only types. Replace "Metadata only" warning badge with neutral product-tier indicator. Fix sync error messages. | P1 | Taxonomy spec |
|
||||
| **Spec: Finding Governance Language** | Replace "missing_support", fix diff-unavailable messages, clarify "Validity" section. | P2 | Taxonomy spec |
|
||||
| **Spec: Notifications & Alerts Language** | Remove internal IDs from emails, add next-action to terminal notifications, fix summary count keys. | P2 | Taxonomy spec |
|
||||
|
||||
### Recommended approach summary:
|
||||
|
||||
**Foundation spec first** (Operator Semantic Taxonomy) that establishes the shared vocabulary, color rules, and diagnostic/primary boundary. Then **6–7 domain specs** that apply the taxonomy to each area. The foundation spec should be small and decisive — it defines rules, not implementations. The domain specs do the actual refactoring.
|
||||
|
||||
This avoids a monolithic spec that would be too large to review or implement incrementally, while ensuring consistency through the shared taxonomy.
|
||||
584
docs/audits/semantic-clarity-spec-candidates.md
Normal file
584
docs/audits/semantic-clarity-spec-candidates.md
Normal file
@ -0,0 +1,584 @@
|
||||
# Semantic Clarity — Spec Candidate Package
|
||||
|
||||
**Source audit:** `docs/audits/semantic-clarity-audit.md`
|
||||
**Date:** 2026-03-21
|
||||
**Author role:** Senior Staff Engineer / Enterprise SaaS Product Architect
|
||||
**Status:** Spec candidates — ready for spec authoring
|
||||
|
||||
---
|
||||
|
||||
## 1. Recommended Spec Package Overview
|
||||
|
||||
### Problem recap (one paragraph)
|
||||
|
||||
The audit identified a systemic semantic collision problem: ~12 overloaded terms carrying 5–8 distinct meanings each, ~60% of warning-colored badges communicating non-governance facts, and no shared rules governing when a term, color, or badge severity applies. The problem spans every major domain. Local wording fixes without a shared taxonomy will produce new inconsistencies faster than they resolve old ones.
|
||||
|
||||
### Package structure
|
||||
|
||||
| # | Spec Candidate | Type | Priority | Depends on | Vehicle |
|
||||
|---|---------------|------|----------|------------|--------|
|
||||
| **F1** | Operator Semantic Taxonomy & Diagnostic Boundary | Foundation | P0 | — | New spec 156 |
|
||||
| **D1** | Baseline Snapshot Fidelity Semantics | Domain adoption | P0 | F1 | New spec 157 |
|
||||
| **D2** | Evidence Completeness Reclassification | Domain adoption | P0 | F1 | New spec 158 or merged into 153 |
|
||||
| **D3** | Restore Lifecycle Semantic Clarity | Domain adoption | P0 | F1 | New spec 159 |
|
||||
| **D4** | Operation Outcome & Notification Language | Domain adoption | P1 | F1 | New spec 160 or merged into 055 |
|
||||
| **D5** | Inventory, Provider & Operability Semantics | Domain adoption | P1 | F1 | New spec 161 |
|
||||
| **D6** | Tenant Review & Publication Readiness Semantics | Domain adoption (integrated) | P1 | F1 + D2 | Integrated into existing spec 155 |
|
||||
|
||||
**Total: 1 foundation + 5 new domain specs + 1 integrated domain mandate = 7 adoption scopes in 6–7 specs.**
|
||||
|
||||
> **D6 note:** Tenant Review / Publication Readiness is NOT a standalone spec. It is an explicit semantic adoption mandate integrated into spec 155 (Tenant Review Layer), which is already in draft. This package defines what 155 must adopt from F1 and ensures review/publication semantics do not implicitly disappear from the cleanup program.
|
||||
|
||||
### Why this split — not fewer, not more
|
||||
|
||||
**Why not one giant spec?**
|
||||
- The foundation taxonomy is pure definition work (enums, color rules, term dictionary, classification rules). The domain specs are pure adoption work (update badge mappers, rewrite labels, restructure presenters). Mixing definition and adoption in one spec creates a 200+ task monster that blocks all domains until every domain is done.
|
||||
- Domains have different rollout risk. Restore is safety-critical; inventory is cosmetic. They cannot share a "ship when everything is ready" gate.
|
||||
|
||||
**Why not page-by-page micro-specs?**
|
||||
- Operations, notifications, and alerts share the same `OperationRunOutcome` + `OperationUxPresenter` pipeline. Splitting them would require coordinating term changes across three specs touching the same files.
|
||||
- Evidence completeness and evidence freshness are the same enum (`EvidenceCompletenessState`) and the same evaluator (`EvidenceCompletenessEvaluator`). One spec.
|
||||
- Inventory KPI, policy snapshot mode, and provider status all use the same "product support maturity" axis. One spec.
|
||||
|
||||
**Why these five domain splits specifically?**
|
||||
1. **Baselines** — touches `FidelityState`, `GapSummary`, `BaselineSnapshotPresenter`, all baseline badge mappers, and the snapshot summary table. None of these files are shared with other domains.
|
||||
2. **Evidence** — touches `EvidenceCompletenessState`, `EvidenceCompletenessEvaluator`, evidence sources, and evidence badge mappers. Overlaps with spec 153 (Evidence Domain Foundation) which is already in draft — D2 must coordinate with 153.
|
||||
3. **Restore** — touches `RestoreRunStatus`, `RestoreResultStatus`, `RestorePreviewDecision`, restore badge mappers, and restore result templates. Safety-critical: wrong terminology in restore → wrong remediation decisions.
|
||||
4. **Operations/Notifications** — touches `OperationRunOutcome`, `OperationUxPresenter`, `SummaryCountsNormalizer`, `RunFailureSanitizer`, and both notification classes. Cross-cuts run types. Overlaps with spec 055 (Ops-UX Rollout) — D4 must coordinate with 055.
|
||||
5. **Inventory/Provider/Operability** — touches `InventoryKpiBadges`, `PolicySnapshotModeBadge`, `ProviderConnectionStatusBadge`, sync error messages, verification badges (`VerificationReportOverall`, `VerificationCheckStatus`), onboarding wizard feedback, and prerequisite/operability states. All share the "product readiness / prerequisite / operability" semantic axis. Lower risk, lower urgency.
|
||||
6. **Review/Publication (into 155)** — touches `TenantReviewCompletenessState`, `TenantReviewReadinessGate` blocker messages, review section labels, publication blockers. These files belong to the review domain, which is already being designed by spec 155. Scoping review semantics into 155 avoids orphan ownership and ensures review-layer design incorporates the taxonomy from day one.
|
||||
|
||||
### What is NOT a separate spec
|
||||
|
||||
| Audit finding | Resolution | Why not a separate spec |
|
||||
|--------------|------------|------------------------|
|
||||
| Finding governance language (P1-6 "missing_support", P1-7 diff messages) | Foundation spec F1 adds "missing_support" → "No exception" to the term dictionary. Diff messages are adopted by spec 141 (Shared Diff Presentation Foundation) when it rolls out. | Fewer than 5 affected touchpoints. Two different existing specs already cover the adoption surface. |
|
||||
| Alert email internal IDs (P2-7) | Folded into D4 (Operations/Notifications). | Same notification pipeline, same files. |
|
||||
| Empty state next-step guidance (P2-5) | Already covered by spec 122 (Empty State Consistency Pass). | Spec 122 is in Ready for Implementation status. |
|
||||
| Onboarding "Needs attention" / "Blocked" (P2-2) | Folded into D5 (Inventory, Provider & Operability Semantics). Onboarding verification and prerequisite terminology shares the same operability axis as provider and sync semantics. | Same "product readiness" axis; same files touched during provider/operability cleanup. |
|
||||
| "Dry run" color (P2-3) | Folded into D3 (Restore). | Same restore badge mapper. |
|
||||
| "Anchored evidence snapshot" jargon (P2-4) | Folded into D6 (integrated into spec 155). Review-layer jargon belongs in the review domain spec. | Two words in helper text; not spec-worthy alone. |
|
||||
| "Stale" color for RBAC status | Foundation spec F1 color rules fix this globally. Badge mapper update is one line. | Trivially mechanical once foundation color rules exist. |
|
||||
|
||||
### Relationship to existing specs
|
||||
|
||||
| Existing spec | Relationship | Action |
|
||||
|--------------|-------------|--------|
|
||||
| **059-unified-badges** | F1 builds ON TOP of 059's infrastructure. 059 centralized the badge rendering pipeline. F1 defines what the pipeline should say. | No conflict. F1 references 059 as prerequisite infrastructure. |
|
||||
| **060-tag-badge-catalog** | No overlap. 060 covers neutral metadata tags; this package covers status/severity semantics. | None. |
|
||||
| **122-empty-state-consistency** | 122 covers empty state messaging. Some audit P2 findings (P2-5) are already addressed by 122. | Note in D-specs: "empty state language defers to 122." |
|
||||
| **130-structured-snapshot-rendering** | 130 defines snapshot page structure. D1 defines what terminology appears on that page. D1 MAY be sequenced after 130 or adopted during 130 rollout. | D1 spec must reference 130 as the page structure authority. |
|
||||
| **141-shared-diff-presentation-foundation** | 141 defines diff rendering. Audit finding P1-7 (diff-unavailable messages) is adopted when 141 rolls out. | P1-7 adoption note added to 141's scope, not a new spec. |
|
||||
| **143-tenant-lifecycle-operability-context-semantics** | 143 covers tenant lifecycle naming. This package covers status/severity badge semantics. Orthogonal. | None. |
|
||||
| **146-central-tenant-status-presentation** | 146 covers tenant lifecycle badge consistency. F1 color rules apply to 146's badge mappings, but 146's scope is lifecycle, not operational status. | F1 provides color rules that 146 adopts. |
|
||||
| **153-evidence-domain-foundation** | 153 is redesigning the evidence domain. D2 defines the semantic terms for evidence completeness. **D2 MUST be coordinated with 153** — either merged into 153 scope or sequenced as a post-153 cleanup. | If 153 is still in draft: merge D2 scope into 153. If 153 is already in implementation: D2 becomes a follow-up. |
|
||||
| **155-tenant-review-layer** | 155 is designing the review layer. **D6 is integrated into 155.** 155 must adopt F1 term dictionary for review completeness ("Partial" → coverage/freshness split), review readiness gate blocker messages (operator language + next-action), publication readiness labels, and section-level status terms. 155 references F1 as a hard dependency. | D6 adoption mandate added to 155 scope. 155 MUST NOT ship with collapsed completeness vocabulary. |
|
||||
| **055-ops-ux-rollout** | 055 covers operation feedback patterns. D4 covers operation terminology. **D4 MUST be coordinated with 055.** | If 055 is still in draft: merge D4 scope into 055. If 055 delivered: D4 is a follow-up semantic layer. |
|
||||
|
||||
---
|
||||
|
||||
## 2. Foundation Spec Candidate: Operator Semantic Taxonomy & Diagnostic Boundary
|
||||
|
||||
**Suggested spec number:** 156
|
||||
**Suggested slug:** `operator-semantic-taxonomy`
|
||||
|
||||
### Problem statement
|
||||
|
||||
TenantPilot uses ~12 overloaded terms ("failed", "partial", "missing", "gaps", "unsupported", "stale", "blocked", etc.) to communicate 5–8 fundamentally different things across different domains. No shared rules exist for when a term applies, what color it gets, or whether it belongs in the operator's primary view or a diagnostic panel. Each domain independently chose its vocabulary, resulting in the same word meaning different things on different pages and different things being called by the same name.
|
||||
|
||||
### Why this spec is needed separately
|
||||
|
||||
Every domain spec in this package depends on a shared term dictionary, color rule set, and diagnostic/primary classification. Without F1, each domain spec would independently reinvent these rules, producing 5 slightly different taxonomies that re-create the current problem at a higher level of abstraction.
|
||||
|
||||
### Scope
|
||||
|
||||
**In scope:**
|
||||
1. **Semantic axis definitions** — Formal definition of the 8–10 independent meaning axes the product must distinguish (execution outcome, data coverage, evidence depth, product support maturity, governance deviation, publication readiness, data freshness, operator actionability). Each axis gets a name, a definition, allowed values, and rules for which axis a given state belongs to.
|
||||
2. **Canonical term dictionary** — A mapping from every currently overloaded term to its replacement terms, organized by axis. Example: "Partial" → "N of M processed" (execution outcome axis), "Limited detail" (evidence depth axis), "Incomplete coverage" (data coverage axis).
|
||||
3. **Color severity rules** — Binding rules for when red/yellow/green/blue/gray apply, stated as decision criteria (not per-badge). These rules override domain-specific color choices where they conflict.
|
||||
4. **Diagnostic boundary classification** — Rules for which states belong in the operator's primary view vs. expandable diagnostic panels. Product support maturity states (e.g., "fallback renderer", "no specialized handler") are classified as diagnostic-only.
|
||||
5. **"Next action" policy** — Rule: every non-green, non-gray state MUST include either an inline explanation, a resolution link, or an explicit "no action needed" label. This is a design constraint, not an implementation spec.
|
||||
6. **BadgeDomain color enforcement** — Update the `BadgeSpec` contract or `BadgeRenderer` to enforce that no mapper can assign danger/warning to a diagnostic-only axis. This is the ONE piece of code F1 touches.
|
||||
7. **Term dictionary for cross-domain terms** — Finding governance "missing_support" → "No exception record", "Blocked" → "Prevented" with cause, "Reference only" → "Metadata captured" / "Summary view". These are terms that appear in fewer than 5 places and don't warrant a separate domain spec.
|
||||
|
||||
**Out of scope:**
|
||||
- Rewriting any domain-specific badge mapper (that's D1–D5 work)
|
||||
- Rewriting any presenter, DTO, or view template (that's D1–D5 work)
|
||||
- Restructuring enums (changing enum cases/values is domain spec work; F1 only defines what the values SHOULD be)
|
||||
- Any UI rendering changes, page layout changes, or Filament resource changes
|
||||
- Empty state messaging (covered by spec 122)
|
||||
- Diff presentation language (covered by spec 141)
|
||||
- Tenant lifecycle terminology (covered by specs 143/146)
|
||||
|
||||
### Affected domains/pages/components
|
||||
|
||||
- `app/Support/Badges/BadgeSpec.php` — may add a diagnostic tier flag or color constraint
|
||||
- `app/Support/Badges/BadgeRenderer.php` — may enforce color rules
|
||||
- Cross-domain badge mappers — F1 defines rules they must follow; D-specs implement
|
||||
|
||||
### Shared dependencies
|
||||
|
||||
- Requires spec 059 (Unified Badges) to be implemented (it is — the badge infrastructure exists)
|
||||
- Becomes a dependency for D1–D5 and for any future spec that introduces status badges
|
||||
|
||||
### Risks if deferred
|
||||
|
||||
- Every D-spec that ships without F1 invents its own term dictionary. When F1 finally ships, those D-specs need rework.
|
||||
- New features (e.g., 153, 155) will adopt the old collapsed vocabulary because no authority document exists.
|
||||
- The badge infrastructure (059) remains well-engineered but semantically wrong — good plumbing carrying bad water.
|
||||
|
||||
### Recommended priority: **P0**
|
||||
|
||||
### Recommended sequence: **First. Before any domain spec.**
|
||||
|
||||
### Deliverables
|
||||
|
||||
1. A `docs/product/operator-semantic-taxonomy.md` reference document (the term dictionary, axis definitions, color rules, diagnostic boundary)
|
||||
2. Minimal code changes to `BadgeSpec`/`BadgeRenderer` to support diagnostic-tier classification
|
||||
3. A test suite that validates: no badge mapper assigns danger/warning to a diagnostic-only axis value
|
||||
|
||||
---
|
||||
|
||||
## 3. Follow-Up Spec Candidates by Domain
|
||||
|
||||
### D1: Baseline Snapshot Fidelity Semantics
|
||||
|
||||
**Suggested spec number:** 157
|
||||
**Suggested slug:** `baseline-snapshot-fidelity-semantics`
|
||||
|
||||
**Problem statement:**
|
||||
Every baseline snapshot containing policy types without a specialized renderer shows "Unsupported" (gray) and "Gaps present" (yellow) badges to operators. The top-level snapshot state reads "Captured with gaps" even when all requested data was successfully captured. This is the single highest-volume source of false warnings in the product. The cause is that `FidelityState` conflates product renderer maturity with data capture quality, and `GapSummary` conflates three distinct gap causes (renderer fallback, metadata-only capture, actual API error) into one count.
|
||||
|
||||
**Why this spec is needed separately:**
|
||||
- Touches a cohesive set of files that no other domain shares: `FidelityState`, `GapSummary`, `BaselineSnapshotPresenter`, `RenderedSnapshot*` DTOs, `BaselineSnapshotFidelityBadge`, `BaselineSnapshotGapStatusBadge`, `baseline-snapshot-summary-table.blade.php`
|
||||
- The P0 items (P0-1, P0-2) are in this domain
|
||||
- Rollout risk is moderate: baselines are read-frequently but not safety-critical like restore
|
||||
|
||||
**Scope:**
|
||||
- Reclassify `FidelityState` values using F1 axis definitions: "Full" and "Partial" stay on the evidence depth axis; "Unsupported" moves to the product support maturity axis (diagnostic-only); "ReferenceOnly" is renamed to match F1 term dictionary
|
||||
- Restructure `GapSummary` to categorize gaps by cause: product limitation (diagnostic), capture mode choice (informational), actual API error (warning)
|
||||
- Replace "Captured with gaps" label with axis-appropriate language per F1 rules
|
||||
- Update `coverageHint` texts to use F1 operator language
|
||||
- Move renderer-tier information to the diagnostic/expandable section per F1 diagnostic boundary
|
||||
|
||||
**Non-goals:**
|
||||
- Changing the snapshot page layout or structure (that's spec 130)
|
||||
- Adding new renderers or expanding renderer coverage
|
||||
- Changing how snapshots are captured or stored
|
||||
- Baseline compare diff language (that's spec 141)
|
||||
- Evidence completeness evaluation or freshness logic (that's D2 — see boundary rule below)
|
||||
|
||||
**Boundary with D2 (evidence completeness):**
|
||||
D1 owns everything INSIDE a baseline snapshot's rendering pipeline: how the snapshot describes the quality and depth of its own captured items. D2 owns everything INSIDE the evidence completeness evaluation pipeline: whether an evidence dimension (baselines, findings, operations, permissions) has data and how fresh that data is. Concretely:
|
||||
- `FidelityState` → D1 (snapshot rendering quality per item)
|
||||
- `GapSummary` → D1 (capture-level notes within a snapshot)
|
||||
- `BaselineSnapshotPresenter` → D1 (snapshot-level presentation)
|
||||
- `EvidenceCompletenessState` → D2 (cross-dimension coverage evaluation)
|
||||
- `EvidenceCompletenessEvaluator` → D2 (aggregation across dimensions)
|
||||
- Evidence sources (e.g. `FindingsSummarySource`) → D2 (dimension-level data availability)
|
||||
|
||||
D1 does NOT touch evidence sources or completeness evaluators. D2 does NOT touch fidelity states, gap summaries, or snapshot presenters. The two specs share no files.
|
||||
|
||||
**Affected domains/pages/components:**
|
||||
- `FidelityState` enum
|
||||
- `GapSummary` class
|
||||
- `BaselineSnapshotPresenter`
|
||||
- `RenderedSnapshotGroup`, `RenderedSnapshotItem`, `RenderedSnapshot` DTOs
|
||||
- `BaselineSnapshotFidelityBadge`, `BaselineSnapshotGapStatusBadge`
|
||||
- `baseline-snapshot-summary-table.blade.php`
|
||||
- `baseline-snapshot-technical-detail.blade.php`
|
||||
- `FallbackSnapshotTypeRenderer` (label/message changes only)
|
||||
- `BaselineSnapshotResource` (column label changes)
|
||||
|
||||
**Shared dependencies:** F1 (Operator Semantic Taxonomy)
|
||||
|
||||
**Risks if deferred:**
|
||||
- Every baseline snapshot in every production tenant continues to show yellow warnings that are not governance problems
|
||||
- Operators learn to ignore yellow badges, which masks real issues when they appear
|
||||
- Spec 130 (Structured Snapshot Rendering) ships with the wrong vocabulary baked into its page structure
|
||||
|
||||
**Recommended priority:** P0
|
||||
**Recommended sequence:** 2nd (immediately after F1)
|
||||
|
||||
---
|
||||
|
||||
### D2: Evidence Completeness Reclassification
|
||||
|
||||
**Suggested spec number:** 158
|
||||
**Suggested slug:** `evidence-completeness-reclassification`
|
||||
|
||||
**Problem statement:**
|
||||
`EvidenceCompletenessState` conflates three independent axes: data coverage (is there data?), data freshness (is it recent?), and evidence depth (how detailed?). A new tenant with zero findings shows red "Missing" for the findings dimension, which is a valid empty state, not missing evidence. "Stale" uses gray (passive/archived), but stale evidence is operationally risky and should prompt action. Evidence sources like `FindingsSummarySource` return "Missing" when the answer should be "No findings" (valid zero state).
|
||||
|
||||
**Why this spec is needed separately:**
|
||||
- Touches `EvidenceCompletenessState` enum, `EvidenceCompletenessEvaluator`, all evidence source classes, and evidence badge mappers — files shared with no other domain
|
||||
- The P0 item (P0-3) is in this domain
|
||||
- **Must coordinate with spec 153 (Evidence Domain Foundation)** which is redesigning the evidence domain. If 153 is still in draft, D2 scope should be merged into 153. If 153 is in implementation, D2 becomes a follow-up.
|
||||
|
||||
**Scope:**
|
||||
- Separate `EvidenceCompletenessState` into two independent axes per F1: coverage (`Full` / `Incomplete` / `NotApplicable` / `NotCollected`) and freshness (`Current` / `Aging` / `Overdue`)
|
||||
- Fix evidence sources to return `NotApplicable` instead of `Missing` when the underlying data source is legitimately empty (e.g., zero findings is not "missing findings")
|
||||
- Apply F1 color rules: `Overdue` freshness gets yellow/warning (action recommended); `NotApplicable` coverage gets gray/neutral (no action needed); `NotCollected` gets blue/info (collection available but not yet performed)
|
||||
- Update `EvidenceCompletenessEvaluator` worst-case-wins logic to evaluate coverage and freshness independently
|
||||
|
||||
**Non-goals:**
|
||||
- Changing what evidence is captured or how snapshots are generated (that's spec 153)
|
||||
- Redesigning the evidence snapshot page layout
|
||||
- Adding new evidence dimensions or sources
|
||||
- Publication readiness logic changes (that's D6/spec 155)
|
||||
- Review completeness state (`TenantReviewCompletenessState`) — that is D6's scope, integrated into spec 155
|
||||
- Baseline snapshot fidelity states, gap summaries, or snapshot presenter changes — that is D1's scope (see D1 boundary rule)
|
||||
|
||||
**Boundary with D1 (baseline snapshot fidelity):**
|
||||
D2 owns cross-dimension evidence coverage and freshness evaluation. D2 does NOT touch anything inside the baseline snapshot rendering pipeline. Specifically: `FidelityState`, `GapSummary`, `BaselineSnapshotPresenter`, and all `RenderedSnapshot*` DTOs are exclusively D1 territory. D2 may consume D1's output (e.g., "does a baseline snapshot exist and is it current?") but never modifies how snapshots describe their internal quality.
|
||||
|
||||
**Affected domains/pages/components:**
|
||||
- `EvidenceCompletenessState` enum
|
||||
- `EvidenceCompletenessEvaluator`
|
||||
- `EvidenceCompletenessBadge`
|
||||
- `FindingsSummarySource`, `OperationsSummarySource`, and sibling evidence sources
|
||||
- `EvidenceSnapshotResource` (column labels)
|
||||
|
||||
**Shared dependencies:** F1 (Operator Semantic Taxonomy)
|
||||
|
||||
**Risks if deferred:**
|
||||
- Every new tenant and every stable tenant shows red/yellow evidence badges that are not real problems
|
||||
- Spec 153 (Evidence Domain Foundation) ships with the collapsed `Missing` / `Stale` vocabulary
|
||||
- Spec 155 (Tenant Review Layer) inherits wrong completeness signals for review readiness
|
||||
|
||||
**Recommended priority:** P0
|
||||
**Recommended sequence:** 3rd (after F1 and D1, or merged into spec 153 if 153 is still in draft)
|
||||
|
||||
---
|
||||
|
||||
### D3: Restore Lifecycle Semantic Clarity
|
||||
|
||||
**Suggested spec number:** 159
|
||||
**Suggested slug:** `restore-lifecycle-semantic-clarity`
|
||||
|
||||
**Problem statement:**
|
||||
The restore workflow has 13 run-level lifecycle states (most products have 4–6), 7 item-level result statuses, and 5 preview decisions. Three different yellow/warning states have three different meanings. "Partial" means something different at run level vs. item level. "Manual required" gives no guidance. "Skipped" conflates intentional exclusion with forced bypass. "Completed with errors" is a legacy state whose semantics are ambiguous. Form helper text uses jargon ("preview-only types", "foundations", "scope tags", "Entra ID group Object ID (GUID)") that operators cannot parse.
|
||||
|
||||
**Why this spec is needed separately:**
|
||||
- Restore is **safety-critical**: wrong interpretation of restore state → wrong remediation decisions → tenant in unknown configuration state
|
||||
- Touches a cohesive and isolated set of files: `RestoreRunStatus`, `RestoreResultStatus`, `RestorePreviewDecision`, all restore badge mappers, `restore-results.blade.php`, `restore-run-checks.blade.php`
|
||||
- Must be independently testable and deployable because restore rollout risk is the highest of any domain
|
||||
|
||||
**Scope:**
|
||||
- Rationalize `RestoreRunStatus` to a clear lifecycle: map 13 states to a smaller canonical set per F1 axis definitions, with legacy states explicitly deprecated and mapped to canonical equivalents
|
||||
- Separate item-level `RestoreResultStatus` meanings: "Applied" (success), "Skipped — excluded by operator" vs. "Skipped — precondition not met" (two distinct states), "Failed" with cause category
|
||||
- Replace "Manual required" with specific next-action guidance per F1 next-action policy
|
||||
- Replace "Partial" (yellow) at run level with "N of M items applied" (quantified outcome per F1 execution outcome axis)
|
||||
- Distinguish "Dry run" from success more clearly (per F1: blue/info for preview states, green for applied states)
|
||||
- Move Graph error codes behind diagnostic expandable per F1 diagnostic boundary; show operator-facing categorized messages
|
||||
- Replace jargon in form helper text with operator language
|
||||
|
||||
**Non-goals:**
|
||||
- Changing restore execution logic, job orchestration, or preview/apply mechanics
|
||||
- Adding new restore capabilities or policy type support
|
||||
- Restructuring the restore wizard flow (that's spec 011)
|
||||
- Changing pre-flight check logic (only changing how results are labeled)
|
||||
|
||||
**Affected domains/pages/components:**
|
||||
- `RestoreRunStatus` enum
|
||||
- `RestoreResultStatus` enum
|
||||
- `RestorePreviewDecision` enum
|
||||
- `RestoreRunStatusBadge`, `RestoreResultStatusBadge`, `RestorePreviewDecisionBadge`
|
||||
- `RestoreCheckSeverityBadge`
|
||||
- `RestoreRunResource` (form helper text, infolist labels, table columns)
|
||||
- `restore-results.blade.php`
|
||||
- `restore-run-checks.blade.php`
|
||||
|
||||
**Shared dependencies:** F1 (Operator Semantic Taxonomy)
|
||||
|
||||
**Risks if deferred:**
|
||||
- Operators misinterpret "Partial" restore state and take wrong remediation action
|
||||
- "Manual required" items are not actioned because operator doesn't know what to do
|
||||
- "Completed with errors" legacy state continues to create ambiguity about whether the tenant is in a consistent state
|
||||
|
||||
**Recommended priority:** P0
|
||||
**Recommended sequence:** 4th (after F1; can run in parallel with D1/D2 if different engineers work on them)
|
||||
|
||||
---
|
||||
|
||||
### D4: Operation Outcome & Notification Language
|
||||
|
||||
**Suggested spec number:** 160
|
||||
**Suggested slug:** `operation-outcome-notification-language`
|
||||
|
||||
**Problem statement:**
|
||||
`OperationRunOutcome.PartiallySucceeded` is a yellow badge with no item-level breakdown. `Blocked` gives no cause. Failure messages are truncated to 140 characters and contain internal reason codes (`RateLimited`, `ProviderAuthFailed`). Terminal notifications say "Completed with warnings" without explaining what warnings or where to find them. Summary counts use raw keys like `errors_recorded` and `report_deduped`. "Running in the background" provides no monitoring guidance.
|
||||
|
||||
**Why this spec is needed separately:**
|
||||
- Operations are the cross-cutting execution pipeline — every domain's actions flow through `OperationRunOutcome` and `OperationUxPresenter`
|
||||
- Touches notification classes, presenter layer, and summary normalizer — files that are shared across all run types
|
||||
- **Must coordinate with spec 055 (Ops-UX Rollout)** — if 055 is still in draft, merge D4 scope into 055; if 055 is delivered, D4 is a follow-up semantic layer
|
||||
|
||||
**Scope:**
|
||||
- Replace "Partially succeeded" with quantified outcome: show item success/failure counts in the badge or badge tooltip per F1 execution outcome axis
|
||||
- Add cause-specific explanations to "Blocked": "Prevented — insufficient permissions", "Prevented — provider not connected", etc. per F1 term dictionary
|
||||
- Replace `RunFailureSanitizer` internal reason codes with operator-facing categorized messages per F1 diagnostic boundary
|
||||
- Update `OperationRunQueued` and `OperationRunCompleted` notifications to include next-action guidance per F1 next-action policy
|
||||
- Replace summary count raw keys with operator terms in `SummaryCountsNormalizer`
|
||||
- Add monitoring guidance to queued notification ("View progress in Operations")
|
||||
- Remove internal delivery IDs and event type codes from email alert bodies (P2-7)
|
||||
|
||||
**Non-goals:**
|
||||
- Changing operation execution logic, job orchestration, or queue mechanics
|
||||
- Changing the operations table page layout or filters
|
||||
- Adding new operation types
|
||||
- Operation auto-refresh or polling behavior (that's spec 123)
|
||||
|
||||
**Affected domains/pages/components:**
|
||||
- `OperationRunOutcome` enum values (labels only, not case names)
|
||||
- `OperationRunOutcomeBadge`
|
||||
- `OperationUxPresenter`
|
||||
- `SummaryCountsNormalizer`
|
||||
- `RunFailureSanitizer`
|
||||
- `OperationRunQueued`, `OperationRunCompleted` notification classes
|
||||
- `EmailAlertNotification`
|
||||
- `OperationRunResource` (column labels, infolist descriptions)
|
||||
|
||||
**Shared dependencies:** F1 (Operator Semantic Taxonomy)
|
||||
|
||||
**Risks if deferred:**
|
||||
- Every partial or failed operation across all domains leaves the operator without enough information to decide next steps
|
||||
- "Blocked" operations across 4 domains continue to provide zero diagnostic value
|
||||
- New operation types introduced by other specs inherit the vague vocabulary
|
||||
|
||||
**Recommended priority:** P1
|
||||
**Recommended sequence:** 5th (after F1 and P0 domain specs; can run in parallel with D5)
|
||||
|
||||
---
|
||||
|
||||
### D5: Inventory, Provider & Operability Semantics
|
||||
|
||||
**Suggested spec number:** 161
|
||||
**Suggested slug:** `inventory-provider-operability-semantics`
|
||||
|
||||
**Problem statement:**
|
||||
Three related domains share the "product readiness / prerequisite / operability" semantic axis and all exhibit the same pattern: product or platform states presented as operator-actionable warnings.
|
||||
|
||||
1. **Inventory:** KPI badges show "Partial" (yellow) for policy types that are preview-only — a product support maturity fact, not a data quality signal. `PolicySnapshotModeBadge` shows "Metadata only" with warning color for capture modes that are correct and expected.
|
||||
2. **Sync & Provider:** Sync error messages are mechanically constructed from error codes (`str_replace('_', ' ', $errorCode)`) with no retry guidance. Provider connection statuses ("Needs consent", "Degraded") give no explanation of what, where, or who.
|
||||
3. **Onboarding & Verification:** `VerificationReportOverall` uses "Needs attention" (non-specific across missing delegated permissions and stale data) and "Blocked" (reads as "system is frozen" rather than "missing required application permissions"). `VerificationCheckStatus.Fail` is ambiguous (check execution failed vs. check assertion failed). Wizard notifications ("Tenant not available", "Draft is not resumable") give no explanation or recovery guidance.
|
||||
|
||||
All three sub-domains conflate product/platform prerequisites with operator-actionable urgency.
|
||||
|
||||
**Why this spec is needed separately:**
|
||||
- All three sub-domains sit on the same semantic axis: "is the platform/product ready for this operation?"
|
||||
- Inventory pages are high-frequency scanning surfaces (daily). Onboarding/verification is lower frequency but high-stakes (first impression, setup correctness).
|
||||
- The affected files form a cohesive group not shared with D1–D4: inventory badges, provider badges, verification badges, sync error formatting, onboarding wizard feedback.
|
||||
|
||||
**Scope:**
|
||||
|
||||
*Inventory:*
|
||||
- Reclassify "Partial" KPI items from warning to product-tier indicator (neutral/info) per F1 product support maturity axis (diagnostic-only)
|
||||
- Replace "Metadata only" warning badge with neutral "Summary capture" indicator per F1 term dictionary
|
||||
|
||||
*Sync & Provider:*
|
||||
- Replace `str_replace`-constructed sync error messages with operator-facing categorized messages per F1 next-action policy
|
||||
- Add explanation and resolution context to provider statuses: "Needs consent — grant admin consent in Azure portal" per F1 next-action policy
|
||||
- Add "Degraded — [specific impact]" to provider health badge per F1 next-action rules
|
||||
|
||||
*Onboarding & Verification:*
|
||||
- Replace "Needs attention" with cause-specific labels per F1 term dictionary: "Missing delegated permissions" vs. "Stale verification data" instead of a single collapsed term
|
||||
- Replace "Blocked" with "Requires application permissions" or equivalent actionable label per F1 next-action policy
|
||||
- Disambiguate `VerificationCheckStatus.Fail`: separate check execution failure from check assertion failure per F1 execution outcome axis
|
||||
- Replace wizard notification messages ("Tenant not available", "Draft is not resumable") with cause + recovery guidance per F1 next-action policy
|
||||
- Ensure verification assist panel labels use F1 operator language (the panel's structure is already well-designed; only labels change)
|
||||
|
||||
**Non-goals:**
|
||||
- Changing sync logic, provider connection mechanics, or capture modes
|
||||
- Adding new policy type support or inventory capabilities
|
||||
- Restructuring the inventory page layout (that's specs 040/041)
|
||||
- Coverage table design (that's spec 124)
|
||||
- Changing verification check logic or onboarding wizard flow mechanics
|
||||
- Changing provider connection mechanics (that's spec 081)
|
||||
- Permission posture evaluation logic (that's specs 083/104)
|
||||
|
||||
**Affected domains/pages/components:**
|
||||
- `InventoryKpiBadges` / `InventoryKpiHeader` widget
|
||||
- `PolicySnapshotModeBadge`
|
||||
- `ProviderConnectionStatusBadge`, `ProviderConnectionHealthBadge`
|
||||
- `InventorySyncService` (error message formatting only)
|
||||
- `PolicyResource` (sync action messages, snapshot mode helper text)
|
||||
- `InventoryCoverage` page (badge labels)
|
||||
- `VerificationReportOverall` enum and badge
|
||||
- `VerificationCheckStatus` enum and badge
|
||||
- `ManagedTenantOnboardingVerificationStatusBadge`
|
||||
- `VerificationAssistViewModelBuilder` (label changes only)
|
||||
- `ManagedTenantOnboardingWizard` (notification messages only)
|
||||
- `verification-required-permissions-assist.blade.php` (label changes only)
|
||||
|
||||
**Shared dependencies:** F1 (Operator Semantic Taxonomy)
|
||||
|
||||
**Risks if deferred:**
|
||||
- Perpetual yellow badges in daily-use inventory surfaces train operators to ignore all warnings
|
||||
- Provider "Degraded" and "Needs consent" states remain unactionable
|
||||
- New inventory features inherit mechanically constructed error messages
|
||||
- Onboarding first impression continues to show "Blocked" / "Needs attention" without explanation — damages trust during setup
|
||||
- Verification specs (074, 075, 084) that ship without F1-aligned terminology bake in the collapsed vocabulary
|
||||
|
||||
**Recommended priority:** P1
|
||||
**Recommended sequence:** 6th (after F1; can run in parallel with D4)
|
||||
|
||||
---
|
||||
|
||||
### D6: Tenant Review & Publication Readiness Semantics (integrated into spec 155)
|
||||
|
||||
**Vehicle:** Existing spec 155 (`tenant-review-layer`)
|
||||
**NOT a standalone spec.** This is an explicit adoption mandate that spec 155 must fulfill.
|
||||
|
||||
**Problem statement:**
|
||||
`TenantReviewCompletenessState` inherits the same "Partial" / "Stale" / "Missing" conflation as `EvidenceCompletenessState`. Publication readiness gate messages use collapsed vocabulary: "{Section} is stale and must be refreshed before publication" gives no guidance on HOW to refresh. "{Section} is missing" conflates "section text hasn't been generated yet" with "underlying data doesn't exist." "Anchored evidence snapshot" is jargon. Review highlight bullets are well-written (a positive example), but completeness/freshness/readiness labels are not.
|
||||
|
||||
**Why integrated into 155 instead of standalone:**
|
||||
- Spec 155 is already designing the review layer from scratch. Adding a parallel spec for review semantics would create dual ownership of the same files.
|
||||
- The review completeness model is being defined NOW in 155. Incorporating F1 rules during design is cheaper than retrofitting after 155 ships.
|
||||
- The affected file set (`TenantReviewCompletenessState`, `TenantReviewReadinessGate`, `TenantReviewComposer`, `TenantReviewSectionFactory`, `ReviewPackResource`) is entirely within 155's scope.
|
||||
|
||||
**What spec 155 MUST adopt from F1:**
|
||||
|
||||
1. **Completeness axis separation** — `TenantReviewCompletenessState` must separate coverage from freshness, matching D2's approach for evidence. "Partial" must be replaced with axis-specific terms (incomplete coverage vs. limited depth vs. outdated freshness). "Stale" must get action-prompting color (yellow/warning), not passive gray.
|
||||
|
||||
2. **Publication readiness gate messages** — Every blocker surfaced by `TenantReviewReadinessGate` must include:
|
||||
- What is blocked (specific section name)
|
||||
- Why it is blocked (specific cause: stale data, missing section, incomplete evidence)
|
||||
- How to resolve it (link to refresh action, link to evidence collection, link to missing data source)
|
||||
- Per F1 "next action" policy
|
||||
|
||||
3. **Section-level status labels** — Review sections must use F1-compliant terms:
|
||||
- "Missing" → "Not generated" (section) or "No data source" (underlying data absent)
|
||||
- "Stale" → "Outdated — last updated {date}" with yellow/warning color
|
||||
- "Anchored evidence snapshot" → "Review evidence base" or "Evidence snapshot"
|
||||
|
||||
4. **Publication readiness presentation** — "Ready for publication" remains appropriate. Blockers must use F1 severity colors (red for hard blockers, yellow for soft warnings). The current gate logic is well-designed; only the messages it surfaces need F1 alignment.
|
||||
|
||||
**Non-goals (for D6 mandate):**
|
||||
- Changing review composition logic, section generation, or pack export mechanics
|
||||
- Redesigning the review page layout
|
||||
- Adding new review sections or evidence dimensions
|
||||
- Evidence completeness reclassification (that's D2's scope — 155 consumes D2's output)
|
||||
|
||||
**Affected domains/pages/components:**
|
||||
- `TenantReviewCompletenessState` enum and badge
|
||||
- `TenantReviewReadinessGate` (message texts only)
|
||||
- `TenantReviewComposer` / `TenantReviewSectionFactory` (section labels and status descriptions)
|
||||
- `ReviewPackResource` (column labels, helper text)
|
||||
- `TenantReviewResource` (infolist labels, status descriptions)
|
||||
|
||||
**Shared dependencies:** F1 (Operator Semantic Taxonomy), D2 output (evidence completeness model)
|
||||
|
||||
**Risks if this mandate is omitted:**
|
||||
- Spec 155 ships with collapsed "Partial" / "Missing" / "Stale" vocabulary, requiring a retrofit PR after F1 lands
|
||||
- Review readiness gates surface vague blockers that operators cannot act on
|
||||
- The review layer — a stakeholder-facing surface — launches with the same semantic collisions found in the audit
|
||||
|
||||
**Recommended priority:** P1
|
||||
**Recommended sequence:** Ships with spec 155. If 155 is in implementation before F1 lands, D6 becomes a mandatory follow-up PR.
|
||||
|
||||
---
|
||||
|
||||
## 4. Recommended Implementation Order
|
||||
|
||||
```
|
||||
Phase 1: Foundation
|
||||
┌────────────────────────────────────────────────┐
|
||||
│ F1: Operator Semantic Taxonomy │
|
||||
│ (term dictionary, color rules, diagnostic │
|
||||
│ boundary, BadgeSpec enforcement) │
|
||||
└────────────────────┬───────────────────────────┘
|
||||
│
|
||||
Phase 2: P0 Domain Adoption (can run in parallel)
|
||||
┌────────────────────┼───────────────────────────┐
|
||||
│ │ │
|
||||
▼ ▼ ▼
|
||||
D1: Baseline D2: Evidence D3: Restore
|
||||
Snapshot Completeness Lifecycle
|
||||
Fidelity Reclassification Semantic
|
||||
Semantics Clarity
|
||||
│ │ │
|
||||
│ coordinate │ coordinate │
|
||||
│ with 130 │ with 153 │
|
||||
└────────────────────┼───────────────────────────┘
|
||||
│
|
||||
Phase 3: P1 Domain Adoption (can run in parallel)
|
||||
┌────────────────────┼───────────────────────────┐
|
||||
│ │ │
|
||||
▼ ▼ │
|
||||
D4: Operation D5: Inventory, │
|
||||
Outcome & Provider & │
|
||||
Notification Operability │
|
||||
Language Semantics │
|
||||
│ │ │
|
||||
│ coordinate │ │
|
||||
│ with 055 │ │
|
||||
└────────────────────┴───────────────────────────┘
|
||||
```
|
||||
|
||||
### Sequencing rationale
|
||||
|
||||
1. **F1 first** — non-negotiable. It produces the term dictionary and color rules that all 5 domain specs reference. Without it, domain specs invent their own terms.
|
||||
2. **D1 second** — highest-volume false warning source. Every baseline snapshot shows wrong badges. Most visible daily impact.
|
||||
3. **D2 alongside D1 or merged into 153** — P0 severity but scope may be absorbed by spec 153 (Evidence Domain Foundation) if 153 is still in draft. If 153 is already in implementation, D2 ships as a follow-up.
|
||||
4. **D3 alongside D1/D2** — P0 severity for safety reasons. Can be staffed in parallel with D1/D2 because file sets don't overlap.
|
||||
5. **D4 and D5 in Phase 3** — P1 priority. Both are independent and can run in parallel. D4 should coordinate with 055 (Ops-UX Rollout) if that spec is still active.
|
||||
6. **D6 concurrent with 155** — D6 is not a separate ship date. Its adoption mandate is fulfilled when spec 155 ships with F1-compliant vocabulary. If 155 is already in implementation when F1 lands, D6 becomes a mandatory follow-up PR into 155's branch.
|
||||
|
||||
### Calendar independence
|
||||
|
||||
Each D-spec can ship independently. There is no "all 5 must ship together" gate. The foundation spec is the only hard prerequisite. Domain specs can be reordered within their phase based on team capacity.
|
||||
|
||||
---
|
||||
|
||||
## 5. What Must Be Fixed in the Foundation First vs. Left to Domain Specs
|
||||
|
||||
### Foundation (F1) MUST deliver before any domain spec ships
|
||||
|
||||
| Deliverable | Why it blocks domain specs |
|
||||
|------------|--------------------------|
|
||||
| **Semantic axis definitions** (8–10 axes with names, definitions, allowed values) | Domain specs need to know which axis each of their states belongs to. Without this, D1 might put "FidelityState.Unsupported" on the "data quality" axis while D5 puts "PolicySnapshotMode.MetadataOnly" on the "product support" axis — same semantic problem, different words. |
|
||||
| **Canonical term dictionary** mapping old terms → new terms | Domain specs apply the dictionary. If the dictionary doesn't exist yet, each domain spec writes its own and we get 5 dialects. |
|
||||
| **Color severity rules** stated as decision criteria | Domain specs update badge mappers. If color rules aren't defined, D1 picks yellow for "Limited detail" while D2 picks gray — inconsistent again. |
|
||||
| **Diagnostic boundary classification** (which axis values are diagnostic-only) | Domain specs need to know what to move to expandable panels. Without this rule, each domain spec makes a different judgment about what operators "need" to see. |
|
||||
| **"Next action" policy** (non-green/non-gray states MUST include explanation or link) | Domain specs rewrite badge tooltips and helper text. Without this constraint, some will add guidance and some won't. |
|
||||
|
||||
### Foundation (F1) MUST NOT try to do
|
||||
|
||||
| Anti-scope | Why |
|
||||
|-----------|-----|
|
||||
| Rewrite any badge mapper | Badge mapper changes are domain-specific. F1 provides the rules; D-specs apply them. If F1 tries to update all 43 badge mappers, it becomes a 150-task mega-spec. |
|
||||
| Rename enum cases | Enum case changes trigger migration, factory, test, and code reference updates across the domain. This is domain spec work with domain-specific testing. |
|
||||
| Update view templates | Template changes require domain-specific context about page layout, user flow, and UX. F1 is a taxonomy rulebook, not a UI spec. |
|
||||
| Define page layout or information hierarchy | That's the job of domain page specs (130, 133, etc.). F1 defines what terms appear; page specs define where and how. |
|
||||
|
||||
---
|
||||
|
||||
## 6. Risks of Doing Local Wording Cleanups Without the Foundation Spec
|
||||
|
||||
### Risk 1: Dialect drift
|
||||
|
||||
Without a shared taxonomy, each domain spec independently defines what "Partial" means in its context. D1 replaces "Partial" with "Limited detail". D3 replaces "Partial" with "Incomplete restore". D4 replaces "Partially succeeded" with "Mixed outcome". An operator moving between domains still encounters 3 different words for overlapping concepts. The audit's central finding — semantic collision — is replaced by semantic fragmentation.
|
||||
|
||||
### Risk 2: Color rule conflicts
|
||||
|
||||
Without shared color rules, D1 might decide "metadata-only capture" is gray (neutral) while D5 decides the same concept ("metadata only snapshot mode") is yellow (warning). The badge infrastructure (059) faithfully renders both. The operator sees the same underlying fact in two different severity colors.
|
||||
|
||||
### Risk 3: Diagnostic boundary inconsistency
|
||||
|
||||
Without a shared diagnostic boundary, D1 moves "Unsupported" (product maturity) to a diagnostic panel, but D5 leaves "Metadata only" (same semantic axis — product support maturity) in the primary badge. The product now has partial diagnostic separation, which is arguably worse than none: it implies that items NOT moved to diagnostics are confirmed operator-relevant, which isn't true.
|
||||
|
||||
### Risk 4: Incompatible "next action" patterns
|
||||
|
||||
Without a shared next-action policy, D3 adds detailed resolution guidance to every restore warning badge (because restore is safety-critical) while D4 adds none to operation warnings (because operations seem lower risk). The product develops uneven affordance depth: restore badges are helpful, operation badges are vague. Operators trust restore status but don't trust operation status.
|
||||
|
||||
### Risk 5: Rework cost
|
||||
|
||||
Each domain spec shipped without the foundation will need partial rework when the foundation ships. The rework isn't catastrophic (term substitutions, color changes), but it adds test churn, review cycles, and regression risk. Shipping F1 first avoids this entirely.
|
||||
|
||||
### Risk 6: New specs inherit old vocabulary
|
||||
|
||||
Specs 153 (Evidence Domain Foundation) and 155 (Tenant Review Layer) are actively in draft. If they ship before F1, they will bake the collapsed vocabulary ("Missing", "Partial", "Stale") into their domain designs. These are foundational domain specs — retrofitting them is significantly more expensive than incorporating F1 rules during their initial design.
|
||||
|
||||
**Bottom line:** The foundation spec is small (one reference document + one badge enforcement constraint + one test suite). The cost of doing it first is low. The cost of NOT doing it first grows linearly with every domain spec that ships without it.
|
||||
@ -3,7 +3,7 @@ # Product Roadmap
|
||||
> Strategic thematic blocks and release trajectory.
|
||||
> This is the "big picture" — not individual specs.
|
||||
|
||||
**Last updated**: 2026-03-15
|
||||
**Last updated**: 2026-03-21
|
||||
|
||||
---
|
||||
|
||||
@ -26,7 +26,8 @@ ### Governance & Architecture Hardening
|
||||
|
||||
**Active specs**: 144
|
||||
**Next wave candidates**: queued execution reauthorization and scope continuity, tenant-owned query canon and wrong-tenant guards, findings workflow enforcement and audit backstop, Livewire context locking and trusted-state reduction
|
||||
**Source**: architecture audit 2026-03-15, audit constitution, product spec-candidates
|
||||
**Operator truth initiative** (sequenced): Operator Outcome Taxonomy → Reason Code Translation → Provider Dispatch Gate Unification (see spec-candidates.md — "Operator Truth Initiative" sequencing note)
|
||||
**Source**: architecture audit 2026-03-15, audit constitution, semantic clarity audit 2026-03-21, product spec-candidates
|
||||
|
||||
### UI & Product Maturity Polish
|
||||
Empty state consistency, list-expand parity, workspace chooser refinement, navigation semantics.
|
||||
|
||||
@ -5,7 +5,7 @@ # Spec Candidates
|
||||
>
|
||||
> **Flow**: Inbox → Qualified → Planned → Spec created → removed from this file
|
||||
|
||||
**Last reviewed**: 2026-03-18 (Help/guidance capability line refactored into 4 bounded candidates)
|
||||
**Last reviewed**: 2026-03-21 (operator semantic taxonomy, semantic-clarity domain follow-ups, and absorbed extension targets updated)
|
||||
|
||||
---
|
||||
|
||||
@ -72,6 +72,194 @@ ### Tenant Draft Discard Lifecycle and Orphaned Draft Visibility
|
||||
- **Related specs**: Spec 138 (draft identity), Spec 140 (lifecycle checkpoints), Spec 143 (lifecycle operability semantics)
|
||||
- **Priority**: medium
|
||||
|
||||
### Baseline Capture Truthful Outcomes and Upstream Guardrails
|
||||
- **Type**: hardening
|
||||
- **Source**: product/domain analysis 2026-03-21 — baseline capture no-op success case (Operation Run #112), follow-up to 2026-03-08 discovery "Drift engine hard-fail when no Inventory Sync exists"
|
||||
- **Problem**: Baseline Capture can currently finish as `Completed / Succeeded` even when no credible baseline was produced. In the observed case, the run accepted a completed-but-blocked upstream Inventory Sync, resolved `subjects_total = 0`, captured `items_captured = 0`, reused an empty snapshot, and still presented a successful baseline capture. The current contract treats all-zero capture as a benign empty result instead of a prerequisite or trust-semantics problem.
|
||||
- **Why it matters**: This is a governance credibility issue, not just a debugging bug. A green "Baseline capture succeeded" state implies that TenantPilot established or refreshed a trustworthy baseline artifact. When the actual result is "no usable inventory basis" or "no in-scope subjects", operators, auditors, and MSP reviewers are misled. False-green run outcomes weaken operations transparency, baseline trust, and the product's auditability story.
|
||||
- **Proposed direction**:
|
||||
- **Precondition guardrails for `baseline.capture`**: require a *usable* upstream inventory basis, not merely the existence of the latest `OperationRun` with `status = completed`. `outcome` and coverage usability must be part of the decision.
|
||||
- **Stricter upstream source selection**: Baseline Capture should select the latest *credible* Inventory Sync, not simply the latest completed one. `blocked` and `failed` inventory runs must not be accepted as baseline inputs.
|
||||
- **Truthful outcome semantics**: define stable terminal behavior for no-data and bad-upstream cases:
|
||||
- no inventory exists → `blocked`
|
||||
- latest inventory run is `blocked` → `blocked`
|
||||
- latest inventory run is `failed` → `blocked`
|
||||
- inventory is valid, but zero relevant subjects resolve → `partially_succeeded`
|
||||
- success is reserved for captures that produce a usable baseline result
|
||||
- **Stable reason-code contract**: add baseline-capture-specific reason codes for blocked/no-data outcomes so operator messaging, audit logs, and run detail pages are deterministic instead of heuristic.
|
||||
- **Empty snapshot semantics**: an empty snapshot must not be silently treated as a credible active baseline. If zero-subject capture remains representable for audit/history reasons, it must be visibly marked as a no-data artifact and must not auto-promote to `active_snapshot_id` by default.
|
||||
- **Operator-facing run UX**: the Operation Run detail page should lead with a primary message such as `No baseline was captured`, `Latest inventory sync was blocked`, `Run tenant sync first`, or `No subjects were in scope`, instead of allowing an all-zero run to read as neutral or successful. Raw JSON/context remains secondary.
|
||||
- **All-zero count visibility rule**: `0 / 0 / 0 / 0` counts must not read as semantically blank. If a run completes with all-zero counts, the surface must explain *why*.
|
||||
- **Key domain decisions to encode**:
|
||||
- `baseline.capture` must not accept a fachlich unbrauchbarer upstream inventory run
|
||||
- `status = completed` alone is insufficient for capture eligibility
|
||||
- `subjects_total = 0` must not render as a full success state
|
||||
- empty or reused empty snapshots are not inherently trustworthy baseline artifacts
|
||||
- **Scope boundaries**:
|
||||
- **In scope**: baseline capture runtime preconditions, inventory-run eligibility for capture, capture outcome mapping, baseline-capture reason codes, empty-snapshot promotion rules, operation-run detail messaging for baseline capture, focused start-surface preflight/copy if needed
|
||||
- **Out of scope**: full redesign of the entire `OperationRun` platform, broad rewrite of inventory coverage semantics, generalized platform-wide no-data heuristics for every operation type, drift-engine compare semantics (except where this candidate explicitly borrows its warning/reason-code pattern), roadmap-wide operations naming or badge redesign
|
||||
- **Strategic importance**: This is a small-scope but high-trust hardening item. It protects one of TenantPilot's core governance promises: that a baseline artifact is meaningful, reviewable, and safe to reason about. It also strengthens MSP/operator confidence that Monitoring surfaces are operationally truthful, not cosmetically green.
|
||||
- **Roadmap fit**: Aligns with **Active / Near-term — Governance & Architecture Hardening** (canonical run-view trust semantics) and acts as follow-through on **Baseline Drift Engine (Cutover)** by tightening the capture side of baseline truth, not just the compare side.
|
||||
- **Acceptance points**:
|
||||
- A capture run without a credible inventory basis cannot finish as `succeeded`
|
||||
- A capture run with valid inventory but zero in-scope subjects ends as `partially_succeeded` with explicit reason code
|
||||
- Operation Run detail for baseline capture exposes primary cause + next action before raw context payloads
|
||||
- Existing tests that encode "empty capture succeeds" are replaced with truthful-outcome coverage
|
||||
- Empty snapshots are not silently promoted as active baselines unless explicitly allowed and visibly marked by the spec
|
||||
- **Risks / open questions**:
|
||||
- Whether zero-item snapshots should still be persisted as audit traces or suppressed entirely needs one explicit product decision; recommendation is "persist only if visibly marked and not auto-promoted"
|
||||
- There is already a broader inconsistency in `blocked` / `failed` / `skipped` semantics across some operation families (notably Inventory Sync job vs service path). This candidate should mention that risk but remain tightly scoped to Baseline Capture truthfulness.
|
||||
- If product wants stale successful inventory fallback instead of strict "latest credible only", that needs an explicit rule rather than hidden fallback behavior.
|
||||
- **Dependencies**: Baseline drift engine stable (Specs 116–119), inventory coverage context, canonical operation-run presentation work (Specs 054, 114, 144), audit log foundation (Spec 134), tenant operability and execution legitimacy direction (Specs 148, 149)
|
||||
- **Related specs / candidates**: Spec 101 (baseline governance), Specs 116–119 (baseline drift engine), Spec 144 (canonical operation viewer context decoupling), Spec 149 (queued execution reauthorization), `discoveries.md` entry "Drift engine hard-fail when no Inventory Sync exists"
|
||||
- **Priority**: high
|
||||
|
||||
### Operator Outcome Taxonomy and Cross-Domain State Separation
|
||||
- **Type**: foundation / hardening
|
||||
- **Source**: semantic clarity & operator-language audit 2026-03-21, prerequisite-handling architecture analysis
|
||||
- **Problem**: TenantPilot uses ~12 overloaded words ("failed", "partial", "missing", "gaps", "unsupported", "stale", "blocked", "complete", "ready", "reference only", "metadata only", "needs attention") across at least 8 independent meaning axes that are systematically conflated. The product does not separate execution outcome from data completeness, evidence depth from product support maturity, governance deviation from publication readiness, data freshness from operator actionability. This produces a cross-product operator-trust problem: approximately 60% of warning-colored badges communicate something that is NOT a governance problem. Examples:
|
||||
- Baseline snapshots show "Unsupported" (gray badge) and "Gaps present" (yellow badge) for policy types where the product simply uses a standard renderer — this is a product maturity fact, not a data quality failure, but it reads as a governance concern.
|
||||
- Evidence completeness shows "Missing" (red/danger) when no findings exist yet — zero findings is a valid empty state, not missing evidence, but a new tenant permanently displays red badges.
|
||||
- Restore runs show "Partial" (yellow) at both run and item level with different meanings — operators cannot determine scope of partial success or whether the tenant is in a consistent state.
|
||||
- `OperationRunOutcome::PartiallySucceeded` provides no item-level breakdown — "99 of 100 succeeded" and "1 of 100 succeeded" are visually identical.
|
||||
- "Blocked" appears across 4+ domains (operations, verification, restore, execution) without cause-specific explanation or next-action guidance.
|
||||
- "Stale" is colored gray (passive/archived) when it actually represents data that requires operator attention (freshness issue, should be yellow/orange).
|
||||
- Product support tier (e.g. fallback renderer vs. dedicated renderer) is surfaced on operator-facing badges where it should be diagnostics-only.
|
||||
- **Why it matters now**: This is not cosmetic polish — it is a governance credibility problem. TenantPilot's core value proposition is trustworthy tenant governance and review. When 60% of warning badges are false alarms, operators are trained to ignore all warnings, which then masks the badges that represent real governance gaps. Every new domain (Entra Role Governance, Enterprise App Governance, Evidence Domain) will reproduce this conflation pattern unless a shared taxonomy is established first. The semantic-clarity audit classified three of the top five findings as P0 (actively damages operator trust). The problem is systemic and cross-domain — it cannot be solved by individual surface fixes without a shared foundation. The existing badge infrastructure (`BadgeCatalog`, `BadgeRenderer`, 43 badge domains, ~500 case values) is architecturally sound; the taxonomy feeding it is structurally wrong.
|
||||
- **Proposed direction**:
|
||||
- **Define mandatory state-axis separation**: establish at minimum 8 independent axes that must never be flattened into a single badge or enum: execution lifecycle, execution outcome, item-level result, data coverage, evidence depth, product support tier, data freshness, and operator actionability. Each axis has its own vocabulary, its own badge domain, and its own color rules.
|
||||
- **Cross-domain term dictionary**: produce a canonical vocabulary where each term has exactly one meaning across the entire product. Replace the 12 overloaded terms with axis-specific alternatives (e.g. "Partially succeeded" → item-breakdown-aware messaging; "Missing" → "Not collected" / "Not generated" / "Not granted" depending on axis; "Gaps" → categorized coverage notes with cause separation; "Unsupported" → "Standard rendering" moved to diagnostics only; "Stale" → freshness axis with correct severity color).
|
||||
- **Color-severity decision rules**: codify when red/yellow/blue/green/gray are appropriate. Red = execution failure, governance violation, data loss risk. Yellow = operator action recommended, approaching threshold, mixed outcome. Blue = in-progress, informational. Green = succeeded, complete. Gray = archived, not applicable. Never use yellow for product maturity facts. Never use gray for freshness issues. Never use red for valid-empty states.
|
||||
- **Diagnostic vs. primary classification**: every piece of state information must be classified as either primary (operator-facing badge/summary) or diagnostic (expandable/secondary technical detail). Product support tier, raw reason codes, Graph API error codes, internal IDs, and renderer metadata are diagnostic-only. Execution outcome, governance status, data freshness, and operator next-actions are primary.
|
||||
- **Mandatory next-action rule**: every non-green, non-gray state must include either an inline explanation of what happened and whether action is needed, a link to a resolution path, or an explicit "No action needed — this is expected" indicator. States that fail this rule are treated as incomplete operator surfaces.
|
||||
- **Shared reference document**: produce `docs/ui/operator-semantic-taxonomy.md` (or equivalent) that all domain specs, badge mappers, and new surface implementations reference. This becomes the cross-domain truth source for operator-facing state presentation.
|
||||
- **Key domain decisions to encode**:
|
||||
- Product support maturity (renderer tier, capture mode) is NEVER operator-alarming — it belongs in diagnostics
|
||||
- Valid empty states (zero findings, zero operations, no evidence yet) are NEVER "Missing" (red) — they are "Not yet collected" (neutral) or "Empty" (informational)
|
||||
- Freshness is a separate axis from completeness — stale data requires action (yellow/orange), not archival (gray)
|
||||
- "Partial" must always be qualified: partial execution (N of M items), partial coverage (which dimensions), partial depth (which items) — never bare "Partial" without context
|
||||
- "Blocked" must always specify cause and next action
|
||||
- **Scope boundaries**:
|
||||
- **In scope**: state-axis separation rules, term dictionary, color-severity rules, diagnostic/primary classification, next-action policy, enum/badge restructuring guidance, shared reference document, domain adoption sequence recommendations
|
||||
- **Out of scope**: individual domain adoption (baseline cleanup, evidence reclassification, restore semantic cleanup, etc. are separate domain follow-up specs that consume this foundation), badge rendering infrastructure changes (BadgeCatalog/BadgeRenderer are architecturally sound — the taxonomy they consume is the problem), visual design system or theme work, new component development, operation naming vocabulary (tracked separately as "Operations Naming Harmonization")
|
||||
- **Why this should be one coherent candidate rather than fragmented domain fixes**: The semantic-clarity audit proves the problem is structural, not local. The same 12 overloaded terms leak into every domain independently. Fixing baselines without a shared taxonomy produces a different vocabulary than fixing evidence, which produces a different vocabulary than fixing operations. Domain-by-domain cleanup without a shared foundation guarantees vocabulary drift between domains. This foundation spec defines rules; domain specs apply them. The foundation is small and decisive (it produces a reference document and restructuring guidelines); the domain adoption specs do the actual refactoring.
|
||||
- **Affected workflow families / surfaces**: Operations (all run types), Baselines (snapshots, profiles, compare), Evidence (snapshots, completeness), Findings (governance validity, diff messages), Reviews / Review Packs (completeness, freshness, publication readiness), Restore (run status, item results, preview decisions), Inventory (KPI badges, coverage, snapshot mode), Onboarding / Verification (report status, check status), Alerts / Notifications (delivery status, failure messages)
|
||||
- **Dependencies**: None — this is foundational work that other candidates consume. The badge infrastructure (`BadgeCatalog`, `BadgeRenderer`) is stable and does not need changes — only the taxonomy it serves.
|
||||
- **Related specs / candidates**: Baseline Capture Truthful Outcomes (consumes this taxonomy for baseline-specific reason codes), Operator Presentation & Lifecycle Action Hardening (complementary — rendering enforcement), Operations Naming Harmonization (complementary — operation type vocabulary), Surface Signal-to-Noise Optimization (complementary — visual weight hierarchy), Admin Visual Language Canon (broader visual convention codification), semantic-clarity-audit.md (source audit)
|
||||
- **Strategic sequencing**: This is the recommended FIRST candidate in the operator-truth initiative sequence. The Reason Code Translation candidate depends on this taxonomy to define human-readable label targets. The Provider Dispatch Gate candidate benefits from shared outcome vocabulary for preflight results. Without this foundation, both downstream candidates will invent local vocabularies.
|
||||
- **Priority**: high
|
||||
|
||||
### Operator Reason Code Translation and Humanization Contract
|
||||
- **Type**: hardening
|
||||
- **Source**: semantic clarity & operator-language audit 2026-03-21, prerequisite-handling architecture analysis, cross-domain reason code inventory
|
||||
- **Problem**: TenantPilot has 6 distinct reason-code artifacts across 4 different structural patterns (final class with string constants, backed enum with `->message()`, backed enum without `->message()`, spec-level taxonomy) spread across provider, baseline, execution, operability, RBAC, and verification domains. These reason codes are the backend's primary mechanism for explaining why an operation was blocked, denied, degraded, or failed. But the product lacks a consistent translation/humanization contract for surfacing these codes to operators:
|
||||
- `ProviderReasonCodes` (24 codes) and `BaselineReasonCodes` (7 codes) are raw string constants with **no human-readable translation** — they leak directly into run detail contexts, notifications, and banners as technical fragments like `RateLimited`, `ProviderAuthFailed`, `ConsentNotGranted`, `CredentialsInvalid`.
|
||||
- `TenantOperabilityReasonCode` (10 cases) and `RbacReason` (10 cases) are backed enums with **no `->message()` method** — they can reach operator-facing surfaces as raw enum values without translation.
|
||||
- `BaselineCompareReasonCode` (5 cases) and `ExecutionDenialReasonCode` (9 cases) have inline `->message()` methods with hardcoded English strings — better, but inconsistent with the rest.
|
||||
- `RunFailureSanitizer` already performs ad-hoc normalization across reason taxonomies using heuristic string-matching and its own `REASON_*` constants, proving the need for a systematic approach.
|
||||
- `ProviderNextStepsRegistry` maps some provider reason codes to link-only remediation steps — but this is limited to provider-domain codes and provides navigation links, not human-readable explanations.
|
||||
- Notification payloads (`OperationRunQueued`, `OperationRunCompleted`, `OperationUxPresenter`) include sanitized but still technical reason strings — operators receive "Execution was blocked. Rate limited." without retry guidance or contextual explanation.
|
||||
- Run summary counts expose raw internal keys (`errors_recorded`, `report_deduped`, `posture_score`) in operator-facing summary lines via `SummaryCountsNormalizer`.
|
||||
- **Why it matters now**: Reason codes are the backend's richest source of "why did this happen?" truth. The backend frequently already knows the cause, the prerequisite, and the next step — but this knowledge reaches operators as raw technical fragments because there is no systematic translation layer. As TenantPilot adds more provider domains, more operation types, and more governance workflows, every new reason code that lacks a human-readable translation reproduces the same operator-trust degradation. Some parts of the product already translate codes well (`BaselineCompareReasonCode::message()`, `RunbookReason::options()`), proving the pattern is viable. The gap is not the pattern — it is its inconsistent adoption across all 6+ reason-code families.
|
||||
- **Proposed direction**:
|
||||
- **Reason code humanization contract**: every reason-code artifact (whether `final class` constants or backed enums) must provide a `->label()` or equivalent method that returns a human-readable, operator-appropriate string. Raw string constants that cannot provide methods must be wrapped in or migrated to enums that can.
|
||||
- **Translation target vocabulary**: human-readable labels must use the vocabulary defined by the Operator Outcome Taxonomy. Internal codes remain stable for machines/logs/tests; operator-facing surfaces exclusively use translated labels. The translation contract is the bridge between internal precision and external clarity.
|
||||
- **Structured reason resolution**: reason code translation should return not just a label but a structured resolution envelope containing: (1) human-readable label, (2) optional short explanation, (3) optional next-action link/text, (4) severity classification (retryable transient vs. permanent configuration vs. prerequisite missing). This envelope replaces the current pattern of ad-hoc string formatting in `RunFailureSanitizer`, notification builders, and presenter classes.
|
||||
- **Cross-domain registry or convention**: either a central `ReasonCodeTranslator` service that dispatches to domain-specific translators, or a mandatory `Translatable` interface/trait that all reason-code artifacts must implement. Prefer the latter (each domain owns its translations) over a central monolith, but enforce the contract architecturally.
|
||||
- **Summary count humanization**: `SummaryCountsNormalizer` (or its successor) must map internal metric keys to operator-readable labels. `errors_recorded` → "Errors", `report_deduped` → "Reports deduplicated", etc. Raw internal keys must never reach operator-facing summary lines.
|
||||
- **Next-steps enrichment**: expand `ProviderNextStepsRegistry` pattern to all reason-code families — not just provider codes. Every operator-visible reason code that implies a prerequisite or recoverable condition should include actionable next-step guidance (link, instruction, or "contact support" fallback).
|
||||
- **Notification payload cleanup**: notification builders (`OperationUxPresenter`, terminal notifications) must consume translated labels, not raw reason strings. Failure messages must include cause + retryability + next action, not just a sanitized error string.
|
||||
- **Key decisions to encode**:
|
||||
- Internal codes remain stable and must not be renamed for cosmetic reasons — they are machine contracts used in logs, tests, and audit events
|
||||
- Operator-facing surfaces exclusively use translated labels — raw codes move to diagnostic/secondary detail areas
|
||||
- Every reason code must be classifiable as retryable-transient, permanent-configuration, or prerequisite-missing — this classification drives notification tone and next-action guidance
|
||||
- `RunFailureSanitizer` should be superseded by the structured translation contract, not extended with more heuristic string-matching
|
||||
- **Scope boundaries**:
|
||||
- **In scope**: reason code humanization contract, translation interface/trait, structured resolution envelope, migration plan for existing 6 artifacts, summary count humanization, notification payload cleanup, next-steps enrichment across all reason families
|
||||
- **Out of scope**: creating new reason codes (domain specs own that), changing the semantic meaning of existing codes, badge infrastructure changes (badges consume translated labels — the rendering infrastructure is stable), operation naming vocabulary (tracked separately), individual domain-specific notification redesign beyond label substitution
|
||||
- **Affected workflow families / surfaces**: Operations (run detail, summary, notifications), Provider (connection health, blocked notifications, next-steps banners), Baselines (compare skip reasons, capture precondition reasons), Restore (run result messages, check severity explanations), Verification (check status explanations), RBAC (health check reasons), Onboarding (lifecycle denial reasons), Findings (diff unavailable messages, governance validity labels), System Console (triage, failure details)
|
||||
- **Why this should be one coherent candidate rather than fragmented per-domain fixes**: The 6 reason-code artifacts share the same structural gap (no consistent humanization contract), and per-domain fixes would each invent a different translation pattern. The contract must be defined once so that all domains implement it consistently. A per-domain approach would produce 6 different label formats, 6 different next-step patterns, and no shared resolution envelope — exactly the inconsistency this candidate eliminates. The implementation touches each domain's reason-code artifact, but the contract that governs all of them must be a single decision.
|
||||
- **Dependencies**: Operator Outcome Taxonomy (provides the target vocabulary that reason code labels translate into). Soft dependency — translation work can begin with pragmatic labels before the full taxonomy is ratified, but labels should converge with the taxonomy once available.
|
||||
- **Related specs / candidates**: Operator Outcome Taxonomy and Cross-Domain State Separation (provides vocabulary target), Operator Presentation & Lifecycle Action Hardening (provides rendering enforcement), Baseline Capture Truthful Outcomes (consumes baseline-specific reason code translations), Provider Connection Resolution Normalization (provides backend connection plumbing that gate results reference), Operations Naming Harmonization (complementary — operation type labels vs. reason code labels)
|
||||
- **Strategic sequencing**: Recommended SECOND in the operator-truth initiative sequence, after the Outcome Taxonomy. Can begin in parallel if pragmatic interim labels are acceptable, but final label convergence depends on the taxonomy.
|
||||
- **Priority**: high
|
||||
|
||||
### Provider-Backed Action Preflight and Dispatch Gate Unification
|
||||
- **Type**: hardening
|
||||
- **Source**: prerequisite-handling architecture analysis, provider dispatch gate architecture review, semantic clarity audit 2026-03-21
|
||||
- **Problem**: TenantPilot has two generations of provider-backed action dispatch patterns that produce inconsistent operator experiences for the same class of problem (missing prerequisites, blocked execution, concurrency collision):
|
||||
- **Gen 2 pattern** (correct, ~3 job types): `ProviderInventorySyncJob`, `ProviderConnectionHealthCheckJob`, `ProviderComplianceSnapshotJob` receive an explicit `providerConnectionId`, pass through `ProviderOperationStartGate` at dispatch time, and produce structured `ProviderOperationStartResult` envelopes with 4 clear states (`started`, `deduped`, `scope_busy`, `blocked`) plus structured reason codes and next-steps. Operators learn about blocked conditions **before** the job is queued.
|
||||
- **Gen 1 pattern** (inconsistent, ~20 services + their jobs): `ExecuteRestoreRunJob`, `EntraGroupSyncJob`, `SyncRoleDefinitionsJob`, policy sync jobs, and approximately 17 other services resolve connections implicitly at runtime via `MicrosoftGraphOptionsResolver::resolveForTenant()` or internal `resolveProviderConnection()` methods. These services bypass the gate entirely — prerequisites are not checked before dispatch. Blocked conditions are discovered asynchronously during job execution, producing runtime exceptions (`ProviderConfigurationRequiredException`, `RuntimeException`, `InvalidArgumentException`) that surface to operators as after-the-fact failed runs rather than preventable preflight blocks.
|
||||
- **Operator impact**: the same class of problem (missing provider connection, expired consent, invalid credentials, scope busy) produces two different operator experiences depending on which action triggered it. Gen 2 actions produce a clear "blocked" result with reason code and next-step guidance at the moment the operator clicks the button. Gen 1 actions silently queue, then fail asynchronously — the operator discovers the problem only when checking the operation run later, with a raw error message instead of structured guidance.
|
||||
- **Concurrency and deduplication gaps**: the `ProviderOperationStartGate` handles scope_busy / deduplication for Gen 2 operations, but Gen 1 operations have no equivalent deduplication — multiple restore or sync jobs for the same tenant/scope can be queued simultaneously, competing for the same provider connection without coordination.
|
||||
- **Notification inconsistency**: Gen 2 blocked results produce immediate toast/notification via `ProviderOperationStartResult` rendering in Filament actions. Gen 1 failures produce terminal `OperationRunCompleted` notifications with sanitized but still technical failure messages. The operator receives different feedback patterns for equivalent problems.
|
||||
- **Why it matters now**: As TenantPilot adds more provider domains (Entra roles, enterprise apps, SharePoint sharing), more operation types (baseline capture, drift detection, evidence generation), and more governance workflows (restore, review, compliance snapshot), every new provider-backed action that follows the Gen 1 implicit pattern reproduces the same operator experience gap. The Gen 2 pattern is proven, architecturally correct, and already handles the hard problems (connection locking, stale run detection, structured reason codes). The gap is not design — it is incomplete adoption. Additionally, the "Provider Connection Resolution Normalization" candidate addresses the backend plumbing problem (explicit connection ID passing), but does not address the operator-facing preflight/dispatch gate UX pattern. This candidate addresses the operator experience layer: ensuring that all provider-backed actions follow one canonical start path and that operators receive consistent, structured, before-dispatch feedback about prerequisites.
|
||||
- **Proposed direction**:
|
||||
- **Canonical dispatch entry point for all provider-backed actions**: all operator-triggered provider-backed actions (sync, backup, restore, health check, compliance snapshot, baseline capture, evidence generation, and future provider operations) must pass through a canonical preflight/dispatch gate before queuing. The existing `ProviderOperationStartGate` is the reference implementation; this candidate extends its scope to cover all provider-backed operation types, not just the current 3.
|
||||
- **Structured preflight result presentation contract**: define a shared Filament action result-rendering pattern for `ProviderOperationStartResult` states (`started`, `deduped`, `scope_busy`, `blocked`) so that every provider-backed action button produces the same UX feedback pattern. Currently, each Gen 2 consumer renders gate results with local if/else blocks — this should be a shared presenter or action mixin.
|
||||
- **Pre-queue prerequisite detection**: blocked conditions (missing connection, expired consent, invalid credentials, tenant not operable, scope busy, missing required permissions) must be detected and surfaced to the operator **before** the job is dispatched to the queue. Operators should never discover a preventable prerequisite failure only after checking a terminal `OperationRun` record.
|
||||
- **Dispatch-time connection locking for all operation types**: extend the `FOR UPDATE` row-locking pattern from Gen 2 to all provider-backed operations, preventing concurrent conflicting operations on the same provider connection.
|
||||
- **Deduplication/scope-busy enforcement for all operation types**: extend scope_busy/dedup detection to Gen 1 operations (restore, group sync, role sync, etc.) that currently lack it. Operators should receive "An operation of this type is already running for this tenant" feedback at click time, not discover it through a failed run.
|
||||
- **Unified next-steps for all blocked states**: extend the `ProviderNextStepsRegistry` pattern (or its successor from the Reason Code Translation candidate) to cover all provider-backed operation blocked states, not just provider connection codes. Every "blocked" gate result includes cause-specific next-action guidance.
|
||||
- **Operator notification alignment**: terminal notifications for provider-backed operations must follow the same structured pattern regardless of which generation of plumbing dispatched them. The notification should include: translated reason code (per Reason Code Translation contract), structured next-action guidance, and a link to the relevant resolution surface.
|
||||
- **Key decisions to encode**:
|
||||
- `ProviderOperationStartGate` (or its evolved successor) is the single canonical dispatch entry point — no provider-backed action bypasses it
|
||||
- Pre-queue prerequisite detection is a product guarantee for all provider operations — async-only failure discovery is an anti-pattern
|
||||
- Scope-busy / deduplication is mandatory for all provider operations, not just Gen 2
|
||||
- The gate result presentation is a shared UI contract, not a per-action local rendering decision
|
||||
- **Scope boundaries**:
|
||||
- **In scope**: extending `ProviderOperationStartGate` scope to all provider-backed operation types, shared gate result presenter for Filament actions, pre-queue prerequisite detection for Gen 1 operations, scope-busy/dedup extension, next-steps enrichment for all gate blocked states, notification alignment for gate results, dispatch-time connection locking extension
|
||||
- **Out of scope**: backend connection resolution refactoring (tracked separately as "Provider Connection Resolution Normalization" — that candidate handles explicit `providerConnectionId` passing; this candidate handles the operator-facing gate/preflight layer), provider connection UX label changes (tracked as "Provider Connection UX Clarity"), legacy credential cleanup (tracked as "Provider Connection Legacy Cleanup"), adding new provider domains (domain expansion specs own that), operation naming vocabulary (tracked separately), reason code translation contract definition (tracked as "Operator Reason Code Translation" — this candidate consumes translated labels)
|
||||
- **Affected workflow families / surfaces**: All provider-backed Filament actions across TenantResource, ProviderConnectionResource, onboarding wizard, and future governance action surfaces. Approximately 20 services currently using Gen 1 implicit resolution. Notification templates for provider-backed operation terminal states. System console triage views for provider-related failures.
|
||||
- **Why this should be one coherent candidate rather than per-action fixes**: The Gen 1 pattern is used by ~20 services. Fixing each service independently would produce 20 local preflight implementations with inconsistent result rendering, different dedup logic, and incompatible notification patterns. The value is the unified contract: one gate, one result envelope, one presenter, one notification pattern. Per-action fixes cannot deliver this convergence.
|
||||
- **Dependencies**: Provider Connection Resolution Normalization (soft dependency — gate unification is more coherent when all services receive explicit connection IDs, but the gate itself can be extended before full backend normalization is complete since `ProviderConnectionResolver::resolveDefault()` already exists). Operator Reason Code Translation (for translated blocked-reason labels in gate results). Operator Outcome Taxonomy (for consistent outcome vocabulary in gate result presentation).
|
||||
- **Related specs / candidates**: Provider Connection Resolution Normalization (backend plumbing), Provider Connection UX Clarity (UX labels), Provider Connection Legacy Cleanup (post-normalization cleanup), Queued Execution Reauthorization (execution-time revalidation — complementary to dispatch-time gating), Baseline Capture Truthful Outcomes (consumes gate results for baseline-specific preconditions), Operator Presentation & Lifecycle Action Hardening (rendering conventions for gate results)
|
||||
- **Strategic sequencing**: Recommended THIRD in the operator-truth initiative sequence. Benefits from the vocabulary defined by the Outcome Taxonomy and the humanized labels from Reason Code Translation. However, the backend extension of `ProviderOperationStartGate` scope can proceed independently and in parallel with the other two candidates.
|
||||
- **Priority**: high
|
||||
|
||||
> **Operator Truth Initiative — Sequencing Note**
|
||||
>
|
||||
> The three candidates above (Operator Outcome Taxonomy, Reason Code Translation, Provider Dispatch Gate Unification) form a coherent cross-product initiative addressing the systemic gap between backend truth richness and operator-facing truth quality. They are sequenced as a dependency chain with parallelization opportunities:
|
||||
>
|
||||
> **Recommended order:**
|
||||
> 1. **Operator Outcome Taxonomy and Cross-Domain State Separation** — defines the shared vocabulary, state-axis separation rules, and color-severity conventions that all other operator-facing work references. This is the smallest deliverable (a reference document + restructuring guidelines) but the highest-leverage decision. Without it, the other two candidates will invent local vocabularies that diverge.
|
||||
> 2. **Operator Reason Code Translation and Humanization Contract** — defines the translation bridge from internal codes to operator-facing labels using the Outcome Taxonomy's vocabulary. Can begin in parallel with the taxonomy using pragmatic interim labels, but final convergence depends on the taxonomy.
|
||||
> 3. **Provider-Backed Action Preflight and Dispatch Gate Unification** — extends the proven Gen 2 gate pattern to all provider-backed operations and establishes a shared result presenter. Benefits from both upstream candidates but has significant backend scope (gate extension + scope-busy enforcement) that can proceed independently.
|
||||
>
|
||||
> **Why this order rather than the inverse:** The semantic-clarity audit classified the taxonomy problem as P0 (60% of warning badges are false alarms — actively damages operator trust). Reason code translation and gate unification are both P1-level (strongly confusing, should be fixed soon). The taxonomy is also the smallest and most decisive deliverable — it produces a reference that all other candidates consume. Shipping the taxonomy first prevents the other two candidates from making locally correct but globally inconsistent vocabulary choices. The gate unification has the largest implementation surface (~20 services) but much of its backend work (extending `ProviderOperationStartGate` scope, adding connection locking, dedup enforcement) can proceed in parallel once the taxonomy establishes the shared vocabulary for gate result presentation.
|
||||
>
|
||||
> **Why these are not one spec:** Each candidate has a different implementation surface, different stakeholders, and different shippability boundaries. The taxonomy is a cross-cutting decision document. Reason code translation touches 6+ reason-code artifacts and notification builders. Gate unification touches ~20 services, the gate class, Filament action handlers, and notification templates. Merging them would create an unshippable monolith. Keeping them as a sequenced initiative preserves independent delivery while ensuring vocabulary convergence.
|
||||
|
||||
### Baseline Snapshot Fidelity Semantics
|
||||
- **Type**: hardening
|
||||
- **Source**: semantic clarity & operator-language audit 2026-03-21
|
||||
- **Vehicle**: new standalone candidate
|
||||
- **Problem**: Baseline snapshots currently conflate renderer maturity, capture depth, metadata-only capture modes, and real API/capture failures into operator-facing fidelity and gap badges. `FidelityState`, `GapSummary`, and related baseline snapshot presentation surfaces make standard-renderer or summary-only situations read like governance warnings. The result is a high-volume false-warning pattern on one of the product's most frequently scanned detail surfaces.
|
||||
- **Why it matters**: This is the single highest-volume false-warning source identified in the audit. If baseline surfaces keep teaching operators that yellow means "the product used a standard renderer" instead of "you should investigate a governance problem," every later baseline warning becomes less credible.
|
||||
- **Proposed direction**: Create a bounded baseline-specific follow-up that consumes the Operator Outcome Taxonomy and separates snapshot-internal fidelity semantics from diagnostic renderer metadata. Split gap causes into product limitation vs capture-mode choice vs actual capture problem, move product-support facts to diagnostics, and replace vague snapshot labels such as "Captured with gaps" with truthful operator language tied to the correct axis.
|
||||
- **Dependencies**: Operator Outcome Taxonomy and Cross-Domain State Separation, Structured Snapshot Rendering (Spec 130)
|
||||
- **Priority**: high
|
||||
|
||||
### Restore Lifecycle Semantic Clarity
|
||||
- **Type**: hardening
|
||||
- **Source**: semantic clarity & operator-language audit 2026-03-21
|
||||
- **Vehicle**: new standalone candidate
|
||||
- **Problem**: Restore flows currently overload run-level status, item-level result status, preview decision state, and manual follow-up guidance into a vocabulary that is too ambiguous for a safety-critical workflow. "Partial" means different things at different levels, preview and apply states are visually too close, and blocked/manual-required states do not consistently tell operators what happened or what to do next.
|
||||
- **Why it matters**: Restore is one of the few product areas where semantic ambiguity can directly lead to the wrong remediation action. Operators need to know whether a tenant is in a consistent state, which items were skipped intentionally versus prevented by prerequisites, and whether the next step is retry, manual completion, or no action.
|
||||
- **Proposed direction**: Define a restore-specific semantic cleanup that consumes the taxonomy and rationalizes run lifecycle, item results, preview decisions, and next-action language without changing restore execution mechanics. The candidate should reduce ambiguous states, quantify mixed outcomes, and move raw technical detail behind secondary diagnostics.
|
||||
- **Dependencies**: Operator Outcome Taxonomy and Cross-Domain State Separation, restore-run and restore-preview surfaces already delivered through the restore spec family
|
||||
- **Priority**: high
|
||||
|
||||
### Inventory, Provider & Operability Semantics
|
||||
- **Type**: hardening
|
||||
- **Source**: semantic clarity & operator-language audit 2026-03-21
|
||||
- **Vehicle**: new standalone candidate
|
||||
- **Problem**: Inventory KPI badges, provider connection status labels, sync/prerequisite messages, verification outcomes, and onboarding helper messages all reuse warning language for product/platform readiness facts that are not the same as governance problems. "Metadata only," "Needs attention," "Blocked," degraded provider health, and onboarding prerequisite messages currently collapse prerequisite state, stale verification state, and product support tier into one operator-facing severity bucket.
|
||||
- **Why it matters**: These surfaces shape daily operator trust and first-run product impression. If inventory and onboarding surfaces treat every prerequisite or product-tier distinction like a warning, operators either ignore the guidance or cannot tell what actually needs intervention.
|
||||
- **Proposed direction**: Create a single operability-semantic candidate that consumes the taxonomy and normalizes how inventory capture mode, provider health/prerequisites, verification results, and onboarding next-step messaging are presented. The candidate should make prerequisite state and recovery guidance explicit while demoting pure product-maturity facts to neutral/diagnostic treatment.
|
||||
- **Dependencies**: Operator Outcome Taxonomy and Cross-Domain State Separation, provider connection vocabulary/cutover work, onboarding and verification spec family
|
||||
- **Priority**: medium
|
||||
|
||||
### Exception / Risk-Acceptance Workflow for Findings
|
||||
- **Type**: feature
|
||||
- **Source**: HANDOVER gap analysis, Spec 111 follow-up
|
||||
@ -84,6 +272,7 @@ ### Exception / Risk-Acceptance Workflow for Findings
|
||||
### Evidence Domain Foundation
|
||||
- **Type**: feature
|
||||
- **Source**: HANDOVER gap, R2 theme completion
|
||||
- **Vehicle note**: Promoted into existing Spec 153. Do not create a second evidence-domain candidate for semantic cleanup; extend Spec 153 when evidence completeness / freshness semantics need to be corrected.
|
||||
- **Problem**: Review pack export (Spec 109) and permission posture reports (104/105) exist as separate output artifacts. There is no first-class evidence domain model that curates, bundles, and tracks these artifacts as a coherent compliance deliverable for external audit submission.
|
||||
- **Why it matters**: Enterprise customers need a single, versioned, auditor-ready package — not a collection of separate exports assembled manually. The gap is not export packaging (Spec 109 handles that); it is the absence of an evidence domain layer that owns curation, completeness tracking, and audit-trail linkage.
|
||||
- **Proposed direction**: Evidence domain model with curated artifact references (review packs, posture reports, findings summaries, baseline governance snapshots). Completeness metadata. Immutable snapshots with generation timestamp and actor. Not a re-implementation of export — a higher-order assembly layer.
|
||||
@ -95,6 +284,7 @@ ### Evidence Domain Foundation
|
||||
### Compliance Readiness & Executive Review Packs
|
||||
- **Type**: feature
|
||||
- **Source**: roadmap-to-spec coverage audit 2026-03-18, R2 theme completion, product positioning for German midmarket / MSP governance
|
||||
- **Vehicle note**: Tenant review and publication-readiness semantics should extend existing Spec 155 (`tenant-review-layer`), not become a separate candidate. This candidate remains about broader management/stakeholder-facing readiness outputs beyond the current review-layer spec.
|
||||
- **Problem**: TenantPilot is building a strong evidence/data foundation (Evidence Domain Foundation candidate, StoredReports, review pack export via Spec 109, findings, baselines), but there is no product-level capability that assembles this data into management-ready, customer-facing, or auditor-oriented readiness views. Enterprise customers, MSP account managers, and CISOs need structured governance outputs for recurring tenant reviews, audit preparation, and compliance conversations — not raw artifact collections or manual export assembly. The gap is not data availability; it is the absence of a dedicated readiness presentation and packaging layer that turns existing governance evidence into actionable, consumable deliverables.
|
||||
- **Why it matters**: This is a core product differentiator and revenue-relevant capability for the MSP and German midmarket audience. Without it, TenantPilot remains an operator tool — powerful but invisible to the stakeholders who sign off on governance, approve budgets, and evaluate vendor value. Structured readiness outputs (lightweight BSI/NIS2/CIS-oriented views, executive summaries, customer review packs) make TenantPilot sellable as a governance review platform, not just a backup and configuration tool. This directly strengthens the MSP sales story for quarterly reviews, security health checks, and audit preparation.
|
||||
- **Proposed direction**:
|
||||
@ -333,6 +523,25 @@ ### Operator Presentation & Lifecycle Action Hardening
|
||||
- **Related candidates**: Operations Naming Harmonization (naming vocabulary — complementary but distinct), Admin Visual Language Canon (visual conventions — broader scope), Action Surface Contract v1.1 (interaction-level action rules — complementary)
|
||||
- **Priority**: medium
|
||||
|
||||
### Operations Presence & Non-Blocking Status UX
|
||||
- **Type**: hardening
|
||||
- **Source**: UI/UX audit — operations presence and background activity patterns
|
||||
- **Problem**: `BulkOperationProgress` is a fixed bottom-right overlay (`z-[999999]`) with adaptive polling, mounted globally via render hooks in both panels. This creates several operator-UX problems: (1) completed and stale operations remain visible without a dismiss or minimize affordance, occupying screen real estate on every page; (2) the widget polls on all pages including those with no operational relevance, creating unnecessary network activity and visual noise; (3) there is no transition from "active progress" to "completed notification" — the overlay persists until polling naturally decays, with no intermediate state that acknowledges completion; (4) there is no semantic distinction between "operations are actively running" (progress feedback) and "operations recently finished" (notification/history feedback) — both are served by the same fixed overlay; (5) operators have no way to acknowledge, dismiss, or minimize operation progress once seen, meaning batch operations can dominate the viewport throughout execution.
|
||||
- **Why it matters**: Background operations (sync, backup, restore, compliance snapshot, inventory) are a core product mechanic. How they surface to operators — during and after execution — directly affects perceived responsiveness, trust in operation completion, and cognitive load during multi-step governance workflows. An overlay that cannot be dismissed, polls everywhere, and makes no distinction between "in progress" and "recently completed" scales poorly as operation types multiply and concurrent operations become normal. Enterprise operators performing audit reviews, findings triage, or policy inspection should not be distracted by persistent progress overlays for unrelated background operations.
|
||||
- **Proposed direction**:
|
||||
- **Dismiss and minimize semantics**: operators can dismiss completed operations from the progress overlay, and optionally minimize the overlay to a compact indicator during active operations. Dismissed state is session-scoped (not persisted across page loads).
|
||||
- **Page-scoped vs global polling context**: distinguish pages where operations are contextually relevant (operations list, tenant detail, monitoring hub) from pages where they are not (findings detail, policy inspector, RBAC settings). Non-relevant pages receive either suppressed polling or a minimal "N operations running" indicator instead of the full overlay.
|
||||
- **Completion transition pattern**: define a clear transition from "progress" to "notification" when operations complete — e.g. transient toast notification on completion + overlay removal, compact "completed" badge that auto-dismisses after a timeout, or handoff to the notification panel. Indefinite overlay persistence after completion is the anti-pattern to resolve.
|
||||
- **Stale operation visibility rules**: define when a completed or failed operation is too old to merit overlay presence. Completed operations older than a configurable threshold should not appear in the progress overlay — they belong in the operations list/history, not in a persistent viewport overlay.
|
||||
- **Non-blocking pattern formalization**: establish the product-level convention for how background operations communicate status without blocking or dominating the operator's current context. This convention applies to `BulkOperationProgress` and should be extensible to future background activities (scheduled checks, async exports, evidence generation).
|
||||
- **In scope**: `BulkOperationProgress` widget behavior redesign, dismiss/minimize UX, polling scope refinement, completion transition pattern, stale visibility rules, non-blocking convention documentation
|
||||
- **Out of scope**: Operation label vocabulary (tracked as "Operations Naming Harmonization"), badge/status rendering conventions (tracked as "Operator Presentation & Lifecycle Action Hardening"), operations domain architecture changes, new operation types, notification panel infrastructure, monitoring hub redesign, BulkOperationProgress internal data model or domain-layer changes beyond the UX behavior layer
|
||||
- **Boundary with Operator Presentation & Lifecycle Action Hardening**: That candidate owns shared conventions for how operation labels, status badges, and lifecycle-aware actions render consistently across surfaces. This candidate owns the widget-level UX behavior of how background operations surface, persist, dismiss, and transition in the operator's viewport. Presentation conventions define *how it looks*; operations presence defines *when and where it appears*.
|
||||
- **Boundary with Operations Naming Harmonization**: Naming harmonization owns the vocabulary (internal type identifiers vs operator-facing labels). This candidate owns the UX behavior layer that consumes those labels.
|
||||
- **Dependencies**: BulkOperationProgress current implementation, render hook registration, Filament v5 panel infrastructure, operations domain model
|
||||
- **Related candidates**: Operator Presentation & Lifecycle Action Hardening (complementary — rendering conventions), Operations Naming Harmonization (complementary — terminology), Admin Visual Language Canon (visual weight rules may inform overlay styling)
|
||||
- **Priority**: medium
|
||||
|
||||
### Provider Connection Resolution Normalization
|
||||
- **Type**: hardening
|
||||
- **Source**: architecture audit – provider connection resolution analysis
|
||||
@ -838,6 +1047,26 @@ ### Admin Visual Language Canon — First-Party UI Convention Codification and D
|
||||
- **Risks if ignored**: Slow visual drift across surfaces, increasing review friction for new surfaces, divergent local conventions that become expensive to reconcile, weakened enterprise UX credibility as surface count grows, and higher cost of eventual systematic alignment.
|
||||
- **Priority**: medium
|
||||
|
||||
### Surface Signal-to-Noise Optimization — Metadata Hierarchy and Information Density
|
||||
- **Type**: hardening
|
||||
- **Source**: UI/UX audit — consistency and noise reduction analysis
|
||||
- **Problem**: Across TenantPilot's operator-facing list pages, detail views, and table surfaces, secondary metadata (timestamps, technical identifiers, raw provider keys, policy family labels, scope tag counts) often renders with the same visual prominence as primary content (policy name, status, outcome, tenant name). This creates a "wall of equal-weight information" where operators must mentally parse every row to find the signal that matters. Specific patterns: (1) date/time format inconsistency — some surfaces use `since()` (relative time), others use absolute datetime, with no clear rule for when each is appropriate; (2) technical identifiers (Graph API entity IDs, internal run IDs, provider-specific keys) surface in columns or info entries where they add no operator value; (3) badge/indicator density — some table rows have 3–4 badges (status, outcome, type, provider) where a simpler hierarchy would communicate the same information with less cognitive load; (4) column label and truncation inconsistency — overlong policy names, setting paths, or assignment descriptions push column layouts into horizontal scroll or wrap awkwardly without consistent truncation conventions; (5) metadata-to-action ratio — some detail/view pages dedicate more viewport space to metadata fields than to the actionable governance information the page exists to serve.
|
||||
- **Why it matters**: Enterprise governance UX credibility depends on perceived information quality, not just data completeness. A surface that shows everything with equal visual weight communicates "we don't know what's important" to operators processing dozens of policies, hundreds of operation runs, or fleet-level tenant summaries daily. Noise reduction is not aesthetics — it is usability: faster scanning, fewer misreadings, lower cognitive fatigue, and higher confidence that the visible information is the information that matters. This is particularly important as TenantPilot adds more governance domains (Entra roles, enterprise apps, compliance baselines, evidence surfaces) — each new domain adds columns, badges, and metadata fields, and without a noise-reduction pass, information density will compound rather than degrade gracefully.
|
||||
- **Proposed direction**:
|
||||
- **Date/time format decision rules**: codify when to use relative time (`since()`, "2 hours ago"), when to use absolute datetime, and when to use contextual format (date only for old entries, time only for today). Apply consistently across all list and detail surfaces. Relative time is appropriate for recency-sensitive contexts (last sync, last backup, operation age); absolute datetime is appropriate for audit/evidence contexts (created_at, snapshot timestamp). Both should render with appropriate visual weight (secondary text style for timestamps in list columns, not primary text weight).
|
||||
- **Technical identifier suppression**: audit list and detail surfaces for raw IDs, Graph API entity IDs, and internal identifiers that serve no operator purpose. Suppress or move to expandable/copyable detail panels. Operators should see human-readable labels, not internal keys, unless they explicitly request technical detail.
|
||||
- **Badge density reduction**: establish a per-row badge budget or hierarchy rule — primary status badge, optional outcome badge, context labels where needed, but not every possible dimension as a visible badge. Secondary indicators can be tooltips, expandable detail, or column values instead of badges.
|
||||
- **Truncation and column conventions**: define consistent truncation rules for long text fields in table columns (policy names, setting paths, assignment descriptions). Prefer tooltip-on-truncation over horizontal scroll. Define maximum comfortable column count for primary list surfaces.
|
||||
- **Metadata visual weight hierarchy**: establish a 3-tier visual weight system for metadata: primary (name, status — full weight), secondary (type, updated_at, tenant — reduced weight, secondary text color), tertiary (ID, raw key, scope tag count — hidden by default, available on demand). Apply across list and detail surfaces.
|
||||
- **In scope**: metadata visual weight hierarchy rules, date/time format conventions, technical identifier suppression audit, badge density guidelines, truncation conventions, affected surface inventory, implementation of conventions on highest-traffic surfaces
|
||||
- **Out of scope**: visual redesign or aesthetic refresh (this is hierarchy and noise reduction, not a design overhaul), Admin Visual Language Canon codification (which writes down the full visual convention set — this candidate solves one specific problem within that space), empty states (covered by Spec 122), action hierarchy (covered by Action Surface Contract), new component development, third-party theme integration
|
||||
- **Boundary with Admin Visual Language Canon**: The Canon is a codification and drift-prevention effort — it documents all visual conventions to prevent future divergence. This candidate identifies and resolves a specific, measurable UX problem (excess metadata noise) that exists today. The solutions from this candidate should feed into the Canon's documented conventions, but this candidate produces concrete UX improvements, not just documentation. The Canon defines the rules; this candidate fixes the violations that currently hurt operator experience.
|
||||
- **Boundary with Operator Presentation & Lifecycle Action Hardening**: That candidate owns shared rendering conventions for operation labels and status badges via centralized abstractions (OperationCatalog, BadgeRenderer). This candidate owns the broader question of how much metadata should be visible and at what visual weight — a problem that extends beyond operations to all governance surfaces.
|
||||
- **Boundary with Spec 122 (Empty State Consistency)**: Empty states address surfaces with no data. This candidate addresses surfaces with data that presents too much secondary information with too little hierarchy. Complementary problems at opposite ends of the information-density spectrum.
|
||||
- **Dependencies**: Admin Visual Language Canon (soft dependency — conventions established here should align with or feed into the Canon), existing Filament table/infolist infrastructure, BadgeRenderer/BadgeCatalog (for badge rendering conventions)
|
||||
- **Related candidates**: Admin Visual Language Canon (complementary — visual convention codification), Operator Presentation & Lifecycle Action Hardening (complementary — rendering conventions), Spec 122 (complementary — empty state consistency)
|
||||
- **Priority**: medium
|
||||
|
||||
### Infrastructure & Platform Debt — CI, Static Analysis, Test Parity, Release Process
|
||||
- **Type**: hardening
|
||||
- **Source**: roadmap-to-spec coverage audit 2026-03-18, Infrastructure & Platform Debt table in `docs/product/roadmap.md`
|
||||
@ -868,6 +1097,18 @@ ### Governance Architecture Hardening Wave (umbrella — dissolved)
|
||||
- **Status**: Dissolved into individual candidates. The four children are now tracked separately in Qualified: Queued Execution Reauthorization, Tenant-Owned Query Canon, Livewire Context Locking. The fourth child (Findings Workflow Enforcement) is absorbed below.
|
||||
- **Reference**: [../audits/tenantpilot-architecture-audit-constitution.md](../audits/tenantpilot-architecture-audit-constitution.md), [../audits/2026-03-15-audit-spec-candidates.md](../audits/2026-03-15-audit-spec-candidates.md)
|
||||
|
||||
### Evidence Completeness Reclassification
|
||||
- **Original source**: semantic clarity & operator-language audit 2026-03-21
|
||||
- **Status**: Do not promote as a separate candidate. This follow-up is absorbed into existing Spec 153 (Evidence Domain Foundation), which should carry the semantic split between coverage, freshness, valid-empty states, and operator-facing completeness language.
|
||||
|
||||
### Operation Outcome & Notification Language
|
||||
- **Original source**: semantic clarity & operator-language audit 2026-03-21
|
||||
- **Status**: Prefer extending existing Spec 055 (Ops-UX Constitution Rollout) rather than creating a standalone semantic candidate, as long as Spec 055 is still the active vehicle for operation outcome presentation, partial-success messaging, blocked-cause guidance, and terminal notification language.
|
||||
|
||||
### Tenant Review & Publication Readiness Semantics
|
||||
- **Original source**: semantic clarity & operator-language audit 2026-03-21
|
||||
- **Status**: Do not create a standalone candidate. This follow-up is absorbed into existing Spec 155 (Tenant Review Layer), which should own review completeness vocabulary, publication-readiness blocker wording, freshness semantics, and review-layer next-action language.
|
||||
|
||||
### Findings Workflow Enforcement and Audit Backstop
|
||||
- **Original source**: architecture audit 2026-03-15, candidate C
|
||||
- **Status**: Largely absorbed by Spec 111 (findings workflow v2) which defines transition enforcement, timestamp tracking, reason validation, and audit logging. The remaining architectural enforcement gap (model-level bypass prevention) is a hardening follow-up to Spec 111, not a standalone spec-sized problem. Re-qualify only if enforcement softness surfaces as a concrete regression or audit finding.
|
||||
@ -880,6 +1121,16 @@ ### Dashboard Polish (Enterprise-grade)
|
||||
- **Original source**: Product review 2026-03-08
|
||||
- **Status**: Core tenant dashboard is covered by Spec 058 (drift-first KPIs, needs attention, recent lists). Workspace-level landing is in progress via Spec 129. The remaining polish items (sparklines, compliance gauge, progressive disclosure) are tracked in Inbox. This was demoted because the candidate lacked a bounded spec scope — it read as a wish list rather than a specifiable problem.
|
||||
|
||||
### Scope & Navigation Semantics (UI/UX Audit)
|
||||
- **Original source**: UI/UX audit — scope and navigation semantics analysis
|
||||
- **Status**: Comprehensively covered by existing spec constellation. Spec 077 (implemented) established workspace-first navigation, monitoring hub IA, header context bar, and tenant-context default filters. Spec 103 (draft) addresses IA scope-vs-filter-vs-targeting semantics on monitoring pages. Spec 121 (draft) fixes workspace switch routing semantics. Spec 106 (draft) corrects sidebar navigation context visibility. Spec 107 (draft) covers workspace chooser v1. Spec 129 (draft) addresses workspace home and admin landing pages. Spec 143 (draft) covers tenant lifecycle, operability, and context semantics. Spec 144 (draft) addresses canonical operation viewer context decoupling. Spec 131 (draft) covers cross-resource navigation and drill-down cohesion. The audit's navigation/scope concerns are distributed across these specs as precisely bounded, spec-level problems — no new umbrella candidate is needed.
|
||||
- **Reference specs**: 077, 103, 106, 107, 121, 129, 131, 143, 144
|
||||
|
||||
### Detail Page Hierarchy & Progressive Disclosure (UI/UX Audit)
|
||||
- **Original source**: UI/UX audit — detail page hierarchy and progressive disclosure analysis
|
||||
- **Status**: Directly covered by Spec 133 (View Page Template Standard for Enterprise Detail Screens). Spec 133 defines the shared enterprise detail-page composition standard including summary-first header, main-and-supporting layout, dedicated related-context section, secondary technical detail separation, optional section support, and degraded-state resilience. Spec.md, plan.md, research.md, data-model.md, and tasks.md (all tasks complete) exist for 4 initial target pages (BaselineSnapshot, BackupSet, EntraGroup, OperationRun). If additional pages require alignment beyond the initial 4 targets, that is a Spec 133 follow-up scope extension, not a new candidate.
|
||||
- **Reference specs**: 133
|
||||
|
||||
---
|
||||
|
||||
## Planned
|
||||
|
||||
@ -0,0 +1,110 @@
|
||||
@php
|
||||
$state = $getState();
|
||||
$state = is_array($state) ? $state : [];
|
||||
|
||||
$summary = is_array($state['summary'] ?? null) ? $state['summary'] : [];
|
||||
$highlights = is_array($state['highlights'] ?? null) ? $state['highlights'] : [];
|
||||
$entries = is_array($state['entries'] ?? null) ? $state['entries'] : [];
|
||||
$nextActions = is_array($state['next_actions'] ?? null) ? $state['next_actions'] : [];
|
||||
$links = is_array($state['links'] ?? null) ? $state['links'] : [];
|
||||
$disclosure = is_string($state['disclosure'] ?? null) ? $state['disclosure'] : null;
|
||||
$emptyState = is_string($state['empty_state'] ?? null) ? $state['empty_state'] : null;
|
||||
@endphp
|
||||
|
||||
<div class="space-y-3">
|
||||
@if ($summary !== [])
|
||||
<dl class="grid grid-cols-1 gap-3 sm:grid-cols-2 xl:grid-cols-3">
|
||||
@foreach ($summary as $item)
|
||||
@php
|
||||
$label = is_string($item['label'] ?? null) ? $item['label'] : null;
|
||||
$value = is_string($item['value'] ?? null) ? $item['value'] : null;
|
||||
@endphp
|
||||
|
||||
@continue($label === null || $value === null)
|
||||
|
||||
<div class="rounded-md border border-gray-100 bg-gray-50 px-3 py-2 dark:border-gray-800 dark:bg-gray-950/60">
|
||||
<dt class="text-[11px] font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">{{ $label }}</dt>
|
||||
<dd class="mt-1 text-sm text-gray-900 dark:text-gray-100">{{ $value }}</dd>
|
||||
</div>
|
||||
@endforeach
|
||||
</dl>
|
||||
@endif
|
||||
|
||||
@if ($highlights !== [])
|
||||
<ul class="space-y-1 text-sm text-gray-700 dark:text-gray-300">
|
||||
@foreach ($highlights as $highlight)
|
||||
@continue(! is_string($highlight) || trim($highlight) === '')
|
||||
|
||||
<li class="rounded-md border border-gray-100 bg-gray-50 px-3 py-2 dark:border-gray-800 dark:bg-gray-950/60">{{ $highlight }}</li>
|
||||
@endforeach
|
||||
</ul>
|
||||
@endif
|
||||
|
||||
@if ($entries !== [])
|
||||
<div class="space-y-2">
|
||||
<div class="text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">Key entries</div>
|
||||
<div class="space-y-2">
|
||||
@foreach ($entries as $entry)
|
||||
@continue(! is_array($entry))
|
||||
|
||||
<div class="rounded-md border border-gray-100 bg-gray-50 px-3 py-3 text-sm dark:border-gray-800 dark:bg-gray-950/60">
|
||||
<div class="font-medium text-gray-900 dark:text-gray-100">
|
||||
{{ $entry['title'] ?? $entry['displayName'] ?? $entry['type'] ?? 'Entry' }}
|
||||
</div>
|
||||
|
||||
@php
|
||||
$detailParts = collect([
|
||||
$entry['severity'] ?? null,
|
||||
$entry['status'] ?? null,
|
||||
$entry['governance_state'] ?? null,
|
||||
$entry['outcome'] ?? null,
|
||||
])->filter(fn ($value) => is_string($value) && trim($value) !== '')->map(fn (string $value) => \Illuminate\Support\Str::headline($value))->all();
|
||||
@endphp
|
||||
|
||||
@if ($detailParts !== [])
|
||||
<div class="mt-1 text-xs text-gray-500 dark:text-gray-400">{{ implode(' · ', $detailParts) }}</div>
|
||||
@endif
|
||||
</div>
|
||||
@endforeach
|
||||
</div>
|
||||
</div>
|
||||
@elseif ($emptyState)
|
||||
<div class="rounded-md border border-gray-100 bg-gray-50 px-3 py-2 text-sm text-gray-700 dark:border-gray-800 dark:bg-gray-950/60 dark:text-gray-300">
|
||||
{{ $emptyState }}
|
||||
</div>
|
||||
@endif
|
||||
|
||||
@if ($disclosure)
|
||||
<div class="rounded-md border border-amber-100 bg-amber-50 px-3 py-2 text-sm text-amber-800 dark:border-amber-900/40 dark:bg-amber-950/30 dark:text-amber-200">
|
||||
{{ $disclosure }}
|
||||
</div>
|
||||
@endif
|
||||
|
||||
@if ($nextActions !== [])
|
||||
<div class="space-y-2">
|
||||
<div class="text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">Follow-up</div>
|
||||
<ul class="space-y-1 text-sm text-gray-700 dark:text-gray-300">
|
||||
@foreach ($nextActions as $action)
|
||||
@continue(! is_string($action) || trim($action) === '')
|
||||
|
||||
<li class="rounded-md border border-primary-100 bg-primary-50 px-3 py-2 dark:border-primary-900/40 dark:bg-primary-950/30">{{ $action }}</li>
|
||||
@endforeach
|
||||
</ul>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
@if ($links !== [])
|
||||
<div class="flex flex-wrap gap-2">
|
||||
@foreach ($links as $link)
|
||||
@continue(! is_array($link) || ! is_string($link['label'] ?? null) || ! is_string($link['url'] ?? null))
|
||||
|
||||
<a
|
||||
class="inline-flex items-center rounded-lg border border-gray-300 px-3 py-2 text-sm font-medium text-gray-700 transition hover:bg-gray-50 dark:border-gray-700 dark:text-gray-200 dark:hover:bg-gray-900"
|
||||
href="{{ $link['url'] }}"
|
||||
>
|
||||
{{ $link['label'] }}
|
||||
</a>
|
||||
@endforeach
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
@ -0,0 +1,71 @@
|
||||
@php
|
||||
$state = $getState();
|
||||
$state = is_array($state) ? $state : [];
|
||||
|
||||
$metrics = is_array($state['metrics'] ?? null) ? $state['metrics'] : [];
|
||||
$highlights = is_array($state['highlights'] ?? null) ? $state['highlights'] : [];
|
||||
$nextActions = is_array($state['next_actions'] ?? null) ? $state['next_actions'] : [];
|
||||
$publishBlockers = is_array($state['publish_blockers'] ?? null) ? $state['publish_blockers'] : [];
|
||||
@endphp
|
||||
|
||||
<div class="space-y-4">
|
||||
<dl class="grid grid-cols-2 gap-3 xl:grid-cols-4">
|
||||
@foreach ($metrics as $metric)
|
||||
@php
|
||||
$label = is_string($metric['label'] ?? null) ? $metric['label'] : null;
|
||||
$value = is_string($metric['value'] ?? null) ? $metric['value'] : null;
|
||||
@endphp
|
||||
|
||||
@continue($label === null || $value === null)
|
||||
|
||||
<div class="rounded-md border border-gray-100 bg-gray-50 px-3 py-2 dark:border-gray-800 dark:bg-gray-950/60">
|
||||
<dt class="text-[11px] font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">{{ $label }}</dt>
|
||||
<dd class="mt-1 text-sm text-gray-900 dark:text-gray-100">{{ $value }}</dd>
|
||||
</div>
|
||||
@endforeach
|
||||
</dl>
|
||||
|
||||
@if ($highlights !== [])
|
||||
<div class="space-y-2">
|
||||
<div class="text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">Highlights</div>
|
||||
<ul class="space-y-1 text-sm text-gray-700 dark:text-gray-300">
|
||||
@foreach ($highlights as $highlight)
|
||||
@continue(! is_string($highlight) || trim($highlight) === '')
|
||||
|
||||
<li class="rounded-md border border-gray-100 bg-gray-50 px-3 py-2 dark:border-gray-800 dark:bg-gray-950/60">{{ $highlight }}</li>
|
||||
@endforeach
|
||||
</ul>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
@if ($nextActions !== [])
|
||||
<div class="space-y-2">
|
||||
<div class="text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">Next actions</div>
|
||||
<ul class="space-y-1 text-sm text-gray-700 dark:text-gray-300">
|
||||
@foreach ($nextActions as $action)
|
||||
@continue(! is_string($action) || trim($action) === '')
|
||||
|
||||
<li class="rounded-md border border-primary-100 bg-primary-50 px-3 py-2 dark:border-primary-900/40 dark:bg-primary-950/30">{{ $action }}</li>
|
||||
@endforeach
|
||||
</ul>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
<div class="space-y-2">
|
||||
<div class="text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">Publication readiness</div>
|
||||
|
||||
@if ($publishBlockers === [])
|
||||
<div class="rounded-md border border-emerald-100 bg-emerald-50 px-3 py-2 text-sm text-emerald-800 dark:border-emerald-900/40 dark:bg-emerald-950/30 dark:text-emerald-200">
|
||||
This review is ready for publication and executive-pack export.
|
||||
</div>
|
||||
@else
|
||||
<ul class="space-y-1 text-sm text-amber-800 dark:text-amber-200">
|
||||
@foreach ($publishBlockers as $blocker)
|
||||
@continue(! is_string($blocker) || trim($blocker) === '')
|
||||
|
||||
<li class="rounded-md border border-amber-100 bg-amber-50 px-3 py-2 dark:border-amber-900/40 dark:bg-amber-950/30">{{ $blocker }}</li>
|
||||
@endforeach
|
||||
</ul>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
@ -0,0 +1,19 @@
|
||||
<x-filament-panels::page>
|
||||
<x-filament::section>
|
||||
<div class="flex flex-col gap-3">
|
||||
<div class="text-lg font-semibold text-gray-900 dark:text-gray-100">
|
||||
Recurring review register
|
||||
</div>
|
||||
|
||||
<div class="text-sm text-gray-600 dark:text-gray-300">
|
||||
Review draft, published, archived, and superseded tenant reviews across the tenants you are entitled to manage.
|
||||
</div>
|
||||
|
||||
<div class="text-sm text-gray-600 dark:text-gray-300">
|
||||
Opening a row returns to the tenant-scoped detail surface so executive review history stays tenant-safe and audit-friendly.
|
||||
</div>
|
||||
</div>
|
||||
</x-filament::section>
|
||||
|
||||
{{ $this->table }}
|
||||
</x-filament-panels::page>
|
||||
@ -11,6 +11,7 @@
|
||||
/** @var bool $canManage */
|
||||
/** @var ?string $downloadUrl */
|
||||
/** @var ?string $failedReason */
|
||||
/** @var ?string $reviewUrl */
|
||||
|
||||
$badgeSpec = $statusEnum ? BadgeCatalog::spec(BadgeDomain::ReviewPackStatus, $statusEnum->value) : null;
|
||||
@endphp
|
||||
@ -96,6 +97,17 @@
|
||||
</x-filament::button>
|
||||
@endif
|
||||
|
||||
@if ($canView && $reviewUrl)
|
||||
<x-filament::button
|
||||
size="sm"
|
||||
color="gray"
|
||||
tag="a"
|
||||
:href="$reviewUrl"
|
||||
>
|
||||
View review
|
||||
</x-filament::button>
|
||||
@endif
|
||||
|
||||
@if ($canManage)
|
||||
<x-filament::button
|
||||
size="sm"
|
||||
|
||||
170
specs/001-tenant-review-layer/spec.md
Normal file
170
specs/001-tenant-review-layer/spec.md
Normal file
@ -0,0 +1,170 @@
|
||||
# Feature Specification: Tenant Review Layer
|
||||
|
||||
**Feature Branch**: `001-tenant-review-layer`
|
||||
**Created**: 2026-03-20
|
||||
**Status**: Draft
|
||||
**Input**: User description: "Executive Review Packs / Tenant Review Layer"
|
||||
|
||||
## Spec Scope Fields *(mandatory)*
|
||||
|
||||
- **Scope**: workspace + tenant + canonical-view
|
||||
- **Primary Routes**:
|
||||
- `/admin/t/{tenant}/reviews` as the tenant-scoped review library and entry point for recurring review preparation
|
||||
- `/admin/t/{tenant}/reviews/{review}` as the canonical tenant review inspection surface
|
||||
- `/admin/reviews` as the workspace-scoped canonical review register for entitled operators who manage recurring reviews across multiple tenants
|
||||
- Existing evidence, findings, baseline, and permissions surfaces remain linked drill-down destinations from the review detail when the operator is entitled to inspect them
|
||||
- **Data Ownership**:
|
||||
- Tenant-owned: tenant review records, review composition metadata, review lifecycle state, executive summary content, and stakeholder-facing review-pack references for one tenant
|
||||
- Tenant-owned inputs: evidence snapshots, accepted-risk summaries, findings summaries, baseline or drift posture, permission posture, and operational health summaries that are consumed but not re-owned by the review layer
|
||||
- Workspace-owned but tenant-filtered: canonical review library filters, review schedule summaries, and cross-tenant list presentation state without changing tenant ownership of the review itself
|
||||
- Compliance or framework readiness interpretations remain outside this feature and are not stored as first-class review truth in this slice
|
||||
- **RBAC**:
|
||||
- Workspace membership remains required for every review surface
|
||||
- Tenant entitlement remains required to inspect or mutate tenant-scoped review records
|
||||
- `tenant_review.view` permits listing and inspecting reviews within authorized scope
|
||||
- `tenant_review.manage` permits creating, refreshing, publishing, archiving, and exporting review packs within authorized scope
|
||||
- Non-members or users outside the relevant workspace or tenant scope remain deny-as-not-found, while in-scope members lacking the required capability remain forbidden
|
||||
|
||||
For canonical-view specs, the spec MUST define:
|
||||
|
||||
- **Default filter behavior when tenant-context is active**: When an operator navigates from a tenant into the shared review register, the canonical workspace view opens prefiltered to that tenant. The operator may clear or change the filter only within their authorized tenant set.
|
||||
- **Explicit entitlement checks preventing cross-tenant leakage**: Review queries, counts, tenant labels, filter options, executive summaries, and exported review-pack references must be assembled only after workspace and tenant entitlement checks. Unauthorized users must not learn whether another tenant has review history, stakeholder packs, or upcoming review work.
|
||||
|
||||
## User Scenarios & Testing *(mandatory)*
|
||||
|
||||
### User Story 1 - Prepare one tenant review from curated evidence (Priority: P1)
|
||||
|
||||
As a governance operator, I want to create a tenant review from an evidence snapshot and related governance signals, so that quarterly or ad hoc tenant reviews start from one stable, curated review record instead of manual page-by-page assembly.
|
||||
|
||||
**Why this priority**: This is the core product workflow. Without a first-class tenant review record, executive review packs are still ad hoc exports rather than a repeatable review motion.
|
||||
|
||||
**Independent Test**: Can be fully tested by selecting an eligible tenant evidence snapshot, creating a tenant review, and verifying that the resulting review preserves the chosen evidence basis, key governance sections, and summary state even if live source data changes later.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** a tenant has at least one eligible evidence snapshot, findings summary, and posture inputs, **When** an authorized operator creates a tenant review, **Then** the system creates one review record that captures the selected evidence basis and generated review sections for that tenant.
|
||||
2. **Given** a tenant review has been created from a specific evidence snapshot, **When** live findings or posture data later change, **Then** the existing review remains tied to its original evidence basis until the operator explicitly refreshes or creates a new review.
|
||||
3. **Given** the chosen evidence basis is partial, **When** the operator creates the review, **Then** the review clearly records which sections are complete, partial, or unavailable rather than implying a fully complete review.
|
||||
|
||||
---
|
||||
|
||||
### User Story 2 - Present an executive-ready tenant review pack (Priority: P1)
|
||||
|
||||
As an MSP account manager or governance lead, I want a concise executive review surface and exportable review pack for one tenant, so that I can lead customer or management conversations with a stakeholder-ready output rather than raw operational artifacts.
|
||||
|
||||
**Why this priority**: This is the commercial value layer. The product stops being only an operator console when it can produce a readable, stakeholder-facing review output.
|
||||
|
||||
**Independent Test**: Can be fully tested by opening a prepared tenant review, confirming that it presents executive summary sections and drill-down links coherently, and generating a stakeholder-ready review pack from that review without rebuilding the evidence manually.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** an authorized operator opens a prepared tenant review, **When** the review detail loads, **Then** it shows an executive summary, key risks, accepted-risk summary, posture highlights, and recommended next actions in one coherent inspection surface.
|
||||
2. **Given** a tenant review is ready for stakeholder delivery, **When** the operator publishes or exports the executive review pack, **Then** the pack is generated from that review record and reflects the same section ordering and summary truth shown in the product.
|
||||
3. **Given** a stakeholder-facing review pack omits one or more dimensions because the underlying evidence was partial, **When** the operator inspects or exports it, **Then** the omission is explained clearly instead of being silently hidden.
|
||||
|
||||
---
|
||||
|
||||
### User Story 3 - Manage recurring tenant reviews over time (Priority: P2)
|
||||
|
||||
As a workspace operator, I want a canonical review library across the tenants I manage, so that I can see which tenants were reviewed, which reviews are draft or published, and which tenants need the next review cycle.
|
||||
|
||||
**Why this priority**: Once the first tenant review exists, the product needs a repeatable operating model rather than one-off packs. This enables recurring review discipline and prepares the ground for the later portfolio dashboard.
|
||||
|
||||
**Independent Test**: Can be fully tested by creating reviews for multiple tenants, opening the workspace review register, and verifying that the register shows only entitled tenants with correct lifecycle, publish status, and recency signals.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** an operator is entitled to multiple tenants with review history, **When** they open the workspace review register, **Then** they can filter by tenant, review state, publish status, and review date without seeing unauthorized tenant rows.
|
||||
2. **Given** a tenant already has a published review, **When** the operator starts the next review cycle, **Then** the system creates a new draft review instead of mutating the historical published review.
|
||||
3. **Given** no review matches the current filters, **When** the operator opens the canonical review register, **Then** the empty state explains that no review records match and offers exactly one clear next action.
|
||||
|
||||
### Edge Cases
|
||||
|
||||
- A tenant has eligible evidence snapshots but no valid accepted-risk records; the review must still generate and explicitly show that no governed accepted risks are currently active.
|
||||
- A previously published review pack is revisited after the underlying evidence snapshot expires or is superseded; the historical review must remain intelligible from stored review metadata.
|
||||
- A tenant has multiple evidence snapshots available; the operator must choose which one anchors the review rather than the system silently picking a different basis.
|
||||
- An operator tries to publish or export a review that is still missing required summary sections; the product must fail with a clear readiness reason instead of producing a misleading finished pack.
|
||||
- A workspace operator is entitled to some, but not all, tenants in a workspace; the canonical review register must suppress unauthorized tenant labels, counts, and filter values.
|
||||
- A tenant review is created twice from the same evidence basis without meaningful changes; the system must prevent accidental duplicate published reviews while still allowing a deliberate new draft when needed.
|
||||
|
||||
## Requirements *(mandatory)*
|
||||
|
||||
**Constitution alignment (required):** This feature introduces a new review-domain data model, user-driven write behavior, and optional long-running generation work for stakeholder-facing review packs, but it does not introduce new Microsoft Graph collection. It must describe the review contract with evidence snapshots, explicit publish/export safety gates, tenant isolation, run observability for any generated pack artifact, and tests. Security-relevant DB-only review lifecycle changes such as publish, archive, and unpublish equivalents must always emit audit history.
|
||||
|
||||
**Constitution alignment (OPS-UX):** If review-pack generation is asynchronous, this feature creates or reuses a dedicated `OperationRun` family for tenant review pack generation and must comply with the Ops-UX 3-surface feedback contract. Start actions may show intent-only feedback. Progress belongs only in the active-ops widget and Monitoring run detail. Review detail may link to the canonical run detail but must not create a parallel progress tracker. `OperationRun.status` and `OperationRun.outcome` remain service-owned through `OperationRunService`. Any `summary_counts` must use allowed numeric-only keys and values. Scheduled or system-initiated review generation must not create initiator-only terminal DB notifications.
|
||||
|
||||
**Constitution alignment (RBAC-UX):** This feature operates in the tenant/admin plane for tenant review detail and mutation surfaces and in the workspace-admin canonical view for the shared review register. Cross-plane access remains deny-as-not-found. Non-members or users outside workspace or tenant scope receive `404`. In-scope users lacking `tenant_review.view` or `tenant_review.manage` receive `403` according to the attempted action. Authorization must be enforced server-side for create, refresh, publish, archive, and export actions. The canonical capability registry remains the only capability source. Destructive-like actions such as archive require confirmation.
|
||||
|
||||
**Constitution alignment (OPS-EX-AUTH-001):** Not applicable. No authentication handshake behavior is changed.
|
||||
|
||||
**Constitution alignment (BADGE-001):** Review lifecycle state, publication state, completeness state, and export readiness are status-like values and must use centralized badge semantics rather than local page-specific mappings. Tests must cover all newly introduced values.
|
||||
|
||||
**Constitution alignment (UI-NAMING-001):** The target object is the tenant review. Operator-facing verbs are `Create review`, `Refresh review`, `Publish review`, `Export executive pack`, and `Archive review`. Source/domain disambiguation is needed only where the review references evidence dimensions such as findings, baseline posture, permissions, or operations health. The same review vocabulary must be preserved across action labels, modal titles, run titles, notifications, and audit prose. Implementation-first terms such as `render package`, `materialize review`, or `hydrate sections` must not become primary operator-facing labels.
|
||||
|
||||
**Constitution alignment (Filament Action Surfaces):** This feature adds or modifies Filament pages/resources for tenant review list and detail plus workspace canonical review register. The Action Surface Contract is satisfied if list inspection uses a canonical inspect affordance, pack generation remains an explicit action, destructive lifecycle actions require confirmation, and all mutations are capability-gated and auditable.
|
||||
|
||||
**Constitution alignment (UX-001 — Layout & Information Architecture):** Review list screens must provide search, sort, and filters for tenant, review state, publication state, evidence basis, and review date. Review detail must use an Infolist-style inspection surface rather than a disabled edit form. Any review-creation form or action must keep inputs inside sections. Empty states must include a specific title, explanation, and exactly one CTA.
|
||||
|
||||
### Functional Requirements
|
||||
|
||||
- **FR-001**: The system MUST provide a first-class tenant review record that represents one curated governance review for one tenant and one chosen evidence basis.
|
||||
- **FR-002**: A tenant review MUST reference exactly one anchored evidence basis at creation time, with enough stored metadata to remain intelligible if downstream source artifacts later change, expire, or are superseded.
|
||||
- **FR-003**: The first implementation slice MUST support review sections for executive summary, open-risk highlights, accepted-risk summary, permission posture summary, baseline or drift posture summary, and operational health summary.
|
||||
- **FR-004**: The system MUST allow an authorized operator to create a tenant review from an eligible evidence snapshot without manually rebuilding each section from live source pages.
|
||||
- **FR-005**: The system MUST preserve review immutability for published reviews. Refreshing a published review MUST create a new draft review or explicit successor review instead of mutating the published historical record.
|
||||
- **FR-006**: The system MUST distinguish at least draft, ready, published, archived, and superseded review lifecycle states.
|
||||
- **FR-007**: The system MUST record review completeness and section availability explicitly, including when a review is based on partial evidence.
|
||||
- **FR-008**: The system MUST make it clear which evidence dimensions were included, omitted, partial, or stale in each review.
|
||||
- **FR-009**: The system MUST provide one tenant-scoped review library where authorized operators can list, inspect, refresh, publish, archive, and export review records for the active tenant.
|
||||
- **FR-010**: The system MUST provide one workspace-scoped canonical review register where authorized operators can review tenant review history across entitled tenants without leaking unauthorized tenant detail.
|
||||
- **FR-011**: The system MUST provide one stakeholder-facing executive review surface for a prepared tenant review that presents summary content and recommended next steps without forcing the operator into raw source artifacts.
|
||||
- **FR-012**: The system MUST support an exportable executive review pack derived from one prepared tenant review record rather than from ad hoc live assembly.
|
||||
- **FR-013**: Exporting an executive review pack MUST use the selected tenant review as the source of truth for section ordering, summary content, and included dimensions.
|
||||
- **FR-014**: The system MUST block publish or export actions when the review lacks required summary sections or required completeness thresholds for this slice, and it MUST explain the blocking reason clearly.
|
||||
- **FR-015**: The system MUST define duplicate-prevention semantics so that accidental repeated publish or export attempts from the same unchanged review do not create duplicate final artifacts unintentionally.
|
||||
- **FR-016**: The system MUST preserve historical published review records and exported pack references so prior reviews remain auditable and comparable over time.
|
||||
- **FR-017**: Creating, refreshing, publishing, archiving, and exporting a review MUST be recorded in audit history with workspace scope, tenant scope, actor, action, and outcome.
|
||||
- **FR-018**: The feature MUST explicitly exclude framework-oriented compliance scoring, certification claims, and BSI, NIS2, or CIS mapping from the first slice. Those remain a downstream Compliance Readiness feature.
|
||||
- **FR-019**: The feature MUST introduce at least one positive and one negative authorization test for tenant-scoped review management and workspace-scoped canonical review visibility.
|
||||
- **FR-020**: The feature MUST introduce regression tests proving evidence-basis anchoring, published-review immutability, executive-pack consistency, and cross-tenant isolation.
|
||||
|
||||
## 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 |
|
||||
|---|---|---|---|---|---|---|---|---|---|---|
|
||||
| Tenant Review Library | Tenant-context review list under `/admin/t/{tenant}/reviews` | `Create review` (`tenant_review.manage`) | Clickable row to review detail | `View review`, `Export executive pack` when ready | None in v1 | `Create first review` | None | N/A | Yes | Create may use an action modal because it selects an evidence basis and starts a review composition workflow |
|
||||
| Tenant Review Detail | Canonical detail route under `/admin/t/{tenant}/reviews/{review}` | None | N/A | None | None | N/A | `Refresh review`, `Publish review`, `Export executive pack`, `Archive review` | N/A | Yes | Inspection surface only; no disabled edit form |
|
||||
| Workspace Review Register | Workspace canonical view at `/admin/reviews` | `Clear filters` | Clickable row to review detail | `View review`, `Export executive pack` when authorized | None in v1 | `Clear filters` | None | N/A | Export yes | Must suppress unauthorized tenant rows and filter values |
|
||||
|
||||
### Key Entities *(include if feature involves data)*
|
||||
|
||||
- **Tenant Review**: A curated review record for one tenant anchored to a chosen evidence basis and used for recurring governance conversations.
|
||||
- **Review Section**: One named portion of the tenant review, such as executive summary, risk highlights, posture summary, or operational health summary, including its completeness and source references.
|
||||
- **Executive Review Pack**: A stakeholder-facing deliverable derived from one tenant review and preserving that review's section ordering, summary truth, and completeness disclosures.
|
||||
- **Review Lifecycle State**: The normalized state of a tenant review, including draft, ready, published, archived, and superseded.
|
||||
|
||||
## Success Criteria *(mandatory)*
|
||||
|
||||
### Measurable Outcomes
|
||||
|
||||
- **SC-001**: An authorized operator can create a tenant review from an eligible evidence basis and open its executive summary in under 3 minutes without leaving the product.
|
||||
- **SC-002**: Published tenant reviews remain unchanged in 100% of automated immutability tests after underlying live source records are modified.
|
||||
- **SC-003**: In manual review flow validation, an operator can answer the tenant's top risks, current posture highlights, and next actions from one review detail surface without opening more than one optional drill-down page.
|
||||
- **SC-004**: Exported executive review packs match their source tenant review's included dimensions and summary ordering in 100% of automated integration tests for the covered first-slice review sections.
|
||||
- **SC-005**: Negative authorization tests prove that non-members or wrong-tenant users receive deny-as-not-found behavior and in-scope users without the required capability cannot create, publish, archive, or export tenant reviews.
|
||||
- **SC-006**: Operators can distinguish draft, ready, published, archived, and superseded review states in one inspection step from list or detail surfaces.
|
||||
|
||||
## Assumptions
|
||||
|
||||
- Evidence snapshots are the primary source-of-truth input for review creation in the first slice.
|
||||
- Findings summaries, accepted-risk lifecycle data, permission posture, and baseline or drift posture are mature enough to populate first-slice review sections.
|
||||
- The first slice optimizes for tenant-by-tenant recurring reviews and executive packs, not for framework-oriented compliance mapping.
|
||||
- Workspace-level review visibility is a register and management surface, not yet a portfolio dashboard with SLA analytics.
|
||||
|
||||
## Non-Goals
|
||||
|
||||
- Building a framework-oriented Compliance Readiness layer with BSI, NIS2, or CIS mapping
|
||||
- Creating tenant portfolio rollups, SLA health dashboards, or fleet ranking views across tenants
|
||||
- Implementing cross-tenant compare or promotion workflows
|
||||
- Turning the tenant review layer into a generic BI reporting system
|
||||
- Triggering new Microsoft Graph collection during review preparation
|
||||
35
specs/155-tenant-review-layer/checklists/requirements.md
Normal file
35
specs/155-tenant-review-layer/checklists/requirements.md
Normal file
@ -0,0 +1,35 @@
|
||||
# Specification Quality Checklist: Tenant Review Layer
|
||||
|
||||
**Purpose**: Validate specification completeness and quality before proceeding to planning
|
||||
**Created**: 2026-03-20
|
||||
**Feature**: [spec.md](../spec.md)
|
||||
|
||||
## Content Quality
|
||||
|
||||
- [x] No implementation details (languages, frameworks, APIs)
|
||||
- [x] Focused on user value and business needs
|
||||
- [x] Written for non-technical stakeholders
|
||||
- [x] All mandatory sections completed
|
||||
|
||||
## Requirement Completeness
|
||||
|
||||
- [x] No [NEEDS CLARIFICATION] markers remain
|
||||
- [x] Requirements are testable and unambiguous
|
||||
- [x] Success criteria are measurable
|
||||
- [x] Success criteria are technology-agnostic (no implementation details)
|
||||
- [x] All acceptance scenarios are defined
|
||||
- [x] Edge cases are identified
|
||||
- [x] Scope is clearly bounded
|
||||
- [x] Dependencies and assumptions identified
|
||||
|
||||
## Feature Readiness
|
||||
|
||||
- [x] All functional requirements have clear acceptance criteria
|
||||
- [x] User scenarios cover primary flows
|
||||
- [x] Feature meets measurable outcomes defined in Success Criteria
|
||||
- [x] No implementation details leak into specification
|
||||
|
||||
## Notes
|
||||
|
||||
- This spec intentionally separates tenant-scoped recurring review workflows and executive packs from the later framework-oriented Compliance Readiness layer.
|
||||
- Workspace scope is limited to canonical review register behavior in this slice; portfolio dashboards and SLA reporting remain separate future work.
|
||||
@ -0,0 +1,342 @@
|
||||
openapi: 3.1.0
|
||||
info:
|
||||
title: Tenant Review Layer Internal Contract
|
||||
version: 0.1.0
|
||||
description: |
|
||||
Internal admin-plane contract for tenant review library, review lifecycle actions,
|
||||
and executive-pack export based on the Tenant Review Layer spec.
|
||||
servers:
|
||||
- url: /admin
|
||||
paths:
|
||||
/t/{tenant}/reviews:
|
||||
get:
|
||||
summary: List tenant reviews
|
||||
operationId: listTenantReviews
|
||||
parameters:
|
||||
- $ref: '#/components/parameters/TenantPath'
|
||||
- $ref: '#/components/parameters/ReviewStatusFilter'
|
||||
- $ref: '#/components/parameters/PublicationStateFilter'
|
||||
responses:
|
||||
'200':
|
||||
description: Tenant review library
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
data:
|
||||
type: array
|
||||
items:
|
||||
$ref: '#/components/schemas/TenantReviewSummary'
|
||||
'403':
|
||||
$ref: '#/components/responses/Forbidden'
|
||||
'404':
|
||||
$ref: '#/components/responses/NotFound'
|
||||
post:
|
||||
summary: Create tenant review from evidence basis
|
||||
operationId: createTenantReview
|
||||
parameters:
|
||||
- $ref: '#/components/parameters/TenantPath'
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
required: [evidence_snapshot_id]
|
||||
properties:
|
||||
evidence_snapshot_id:
|
||||
type: integer
|
||||
include_operations_summary:
|
||||
type: boolean
|
||||
default: true
|
||||
responses:
|
||||
'202':
|
||||
description: Review composition accepted or queued
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/TenantReviewMutationResult'
|
||||
'403':
|
||||
$ref: '#/components/responses/Forbidden'
|
||||
'404':
|
||||
$ref: '#/components/responses/NotFound'
|
||||
'422':
|
||||
$ref: '#/components/responses/ValidationError'
|
||||
/t/{tenant}/reviews/{review}:
|
||||
get:
|
||||
summary: View tenant review detail
|
||||
operationId: viewTenantReview
|
||||
parameters:
|
||||
- $ref: '#/components/parameters/TenantPath'
|
||||
- $ref: '#/components/parameters/ReviewPath'
|
||||
responses:
|
||||
'200':
|
||||
description: Tenant review detail
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/TenantReviewDetail'
|
||||
'403':
|
||||
$ref: '#/components/responses/Forbidden'
|
||||
'404':
|
||||
$ref: '#/components/responses/NotFound'
|
||||
/t/{tenant}/reviews/{review}/refresh:
|
||||
post:
|
||||
summary: Refresh review into a successor draft
|
||||
operationId: refreshTenantReview
|
||||
parameters:
|
||||
- $ref: '#/components/parameters/TenantPath'
|
||||
- $ref: '#/components/parameters/ReviewPath'
|
||||
responses:
|
||||
'202':
|
||||
description: Review refresh accepted or queued
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/TenantReviewMutationResult'
|
||||
'403':
|
||||
$ref: '#/components/responses/Forbidden'
|
||||
'404':
|
||||
$ref: '#/components/responses/NotFound'
|
||||
'409':
|
||||
description: Review cannot be refreshed in current state
|
||||
/t/{tenant}/reviews/{review}/publish:
|
||||
post:
|
||||
summary: Publish review
|
||||
operationId: publishTenantReview
|
||||
parameters:
|
||||
- $ref: '#/components/parameters/TenantPath'
|
||||
- $ref: '#/components/parameters/ReviewPath'
|
||||
responses:
|
||||
'200':
|
||||
description: Review published
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/TenantReviewMutationResult'
|
||||
'403':
|
||||
$ref: '#/components/responses/Forbidden'
|
||||
'404':
|
||||
$ref: '#/components/responses/NotFound'
|
||||
'409':
|
||||
description: Review not publication-ready
|
||||
/t/{tenant}/reviews/{review}/archive:
|
||||
post:
|
||||
summary: Archive review
|
||||
operationId: archiveTenantReview
|
||||
parameters:
|
||||
- $ref: '#/components/parameters/TenantPath'
|
||||
- $ref: '#/components/parameters/ReviewPath'
|
||||
responses:
|
||||
'200':
|
||||
description: Review archived
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/TenantReviewMutationResult'
|
||||
'403':
|
||||
$ref: '#/components/responses/Forbidden'
|
||||
'404':
|
||||
$ref: '#/components/responses/NotFound'
|
||||
'409':
|
||||
description: Review cannot be archived in current state
|
||||
/t/{tenant}/reviews/{review}/export:
|
||||
post:
|
||||
summary: Generate executive review pack
|
||||
operationId: exportExecutiveReviewPack
|
||||
parameters:
|
||||
- $ref: '#/components/parameters/TenantPath'
|
||||
- $ref: '#/components/parameters/ReviewPath'
|
||||
requestBody:
|
||||
required: false
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
include_pii:
|
||||
type: boolean
|
||||
default: true
|
||||
responses:
|
||||
'202':
|
||||
description: Executive pack generation accepted or queued
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ReviewPackMutationResult'
|
||||
'403':
|
||||
$ref: '#/components/responses/Forbidden'
|
||||
'404':
|
||||
$ref: '#/components/responses/NotFound'
|
||||
'409':
|
||||
description: Review export blocked by readiness rules
|
||||
/reviews:
|
||||
get:
|
||||
summary: List canonical workspace review register
|
||||
operationId: listWorkspaceReviews
|
||||
parameters:
|
||||
- $ref: '#/components/parameters/TenantFilter'
|
||||
- $ref: '#/components/parameters/ReviewStatusFilter'
|
||||
- $ref: '#/components/parameters/PublicationStateFilter'
|
||||
responses:
|
||||
'200':
|
||||
description: Canonical workspace review register
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
data:
|
||||
type: array
|
||||
items:
|
||||
$ref: '#/components/schemas/TenantReviewRegisterRow'
|
||||
'403':
|
||||
$ref: '#/components/responses/Forbidden'
|
||||
'404':
|
||||
$ref: '#/components/responses/NotFound'
|
||||
components:
|
||||
parameters:
|
||||
TenantPath:
|
||||
name: tenant
|
||||
in: path
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
ReviewPath:
|
||||
name: review
|
||||
in: path
|
||||
required: true
|
||||
schema:
|
||||
type: integer
|
||||
TenantFilter:
|
||||
name: tenant_id
|
||||
in: query
|
||||
required: false
|
||||
schema:
|
||||
type: integer
|
||||
ReviewStatusFilter:
|
||||
name: status
|
||||
in: query
|
||||
required: false
|
||||
schema:
|
||||
type: string
|
||||
enum: [draft, ready, published, archived, superseded, failed]
|
||||
PublicationStateFilter:
|
||||
name: publication_state
|
||||
in: query
|
||||
required: false
|
||||
schema:
|
||||
type: string
|
||||
enum: [unpublished, published]
|
||||
responses:
|
||||
Forbidden:
|
||||
description: Member lacks required capability
|
||||
NotFound:
|
||||
description: Workspace or tenant scope not visible to caller
|
||||
ValidationError:
|
||||
description: Request validation failed
|
||||
schemas:
|
||||
TenantReviewSummary:
|
||||
type: object
|
||||
required: [id, tenant_id, status, completeness_state]
|
||||
properties:
|
||||
id:
|
||||
type: integer
|
||||
tenant_id:
|
||||
type: integer
|
||||
evidence_snapshot_id:
|
||||
type: integer
|
||||
status:
|
||||
type: string
|
||||
completeness_state:
|
||||
type: string
|
||||
generated_at:
|
||||
type: string
|
||||
format: date-time
|
||||
nullable: true
|
||||
published_at:
|
||||
type: string
|
||||
format: date-time
|
||||
nullable: true
|
||||
TenantReviewRegisterRow:
|
||||
allOf:
|
||||
- $ref: '#/components/schemas/TenantReviewSummary'
|
||||
- type: object
|
||||
properties:
|
||||
tenant_name:
|
||||
type: string
|
||||
has_ready_export:
|
||||
type: boolean
|
||||
publish_blockers_count:
|
||||
type: integer
|
||||
TenantReviewDetail:
|
||||
allOf:
|
||||
- $ref: '#/components/schemas/TenantReviewSummary'
|
||||
- type: object
|
||||
properties:
|
||||
executive_summary:
|
||||
type: object
|
||||
additionalProperties: true
|
||||
sections:
|
||||
type: array
|
||||
items:
|
||||
$ref: '#/components/schemas/TenantReviewSection'
|
||||
latest_export:
|
||||
$ref: '#/components/schemas/ReviewPackSummary'
|
||||
TenantReviewSection:
|
||||
type: object
|
||||
required: [section_key, title, sort_order, completeness_state]
|
||||
properties:
|
||||
section_key:
|
||||
type: string
|
||||
title:
|
||||
type: string
|
||||
sort_order:
|
||||
type: integer
|
||||
completeness_state:
|
||||
type: string
|
||||
summary_payload:
|
||||
type: object
|
||||
additionalProperties: true
|
||||
ReviewPackSummary:
|
||||
type: object
|
||||
properties:
|
||||
id:
|
||||
type: integer
|
||||
status:
|
||||
type: string
|
||||
generated_at:
|
||||
type: string
|
||||
format: date-time
|
||||
nullable: true
|
||||
expires_at:
|
||||
type: string
|
||||
format: date-time
|
||||
nullable: true
|
||||
TenantReviewMutationResult:
|
||||
type: object
|
||||
required: [review_id, status]
|
||||
properties:
|
||||
review_id:
|
||||
type: integer
|
||||
status:
|
||||
type: string
|
||||
operation_run_id:
|
||||
type: integer
|
||||
nullable: true
|
||||
message:
|
||||
type: string
|
||||
ReviewPackMutationResult:
|
||||
type: object
|
||||
required: [review_pack_id, status]
|
||||
properties:
|
||||
review_pack_id:
|
||||
type: integer
|
||||
status:
|
||||
type: string
|
||||
operation_run_id:
|
||||
type: integer
|
||||
nullable: true
|
||||
message:
|
||||
type: string
|
||||
134
specs/155-tenant-review-layer/data-model.md
Normal file
134
specs/155-tenant-review-layer/data-model.md
Normal file
@ -0,0 +1,134 @@
|
||||
# Data Model: Tenant Review Layer
|
||||
|
||||
## Overview
|
||||
|
||||
The first slice introduces a new tenant-owned review aggregate that consumes existing evidence-domain and governance outputs without replacing them.
|
||||
|
||||
## Entities
|
||||
|
||||
### TenantReview
|
||||
|
||||
- **Purpose**: The primary recurring governance review record for one tenant, anchored to one chosen evidence basis.
|
||||
- **Ownership**: Tenant-owned (`workspace_id`, `tenant_id` required).
|
||||
- **Core fields**:
|
||||
- `id`
|
||||
- `workspace_id`
|
||||
- `tenant_id`
|
||||
- `evidence_snapshot_id`
|
||||
- `current_export_review_pack_id` nullable
|
||||
- `operation_run_id` nullable for async composition/refresh
|
||||
- `initiated_by_user_id`
|
||||
- `published_by_user_id` nullable
|
||||
- `superseded_by_review_id` nullable self-reference
|
||||
- `fingerprint` nullable, deterministic for dedupe of unchanged draft composition
|
||||
- `status` enum: `draft`, `ready`, `published`, `archived`, `superseded`, `failed`
|
||||
- `completeness_state` enum: `complete`, `partial`, `missing`, `stale`
|
||||
- `summary` JSONB for aggregate executive metadata and publish blockers
|
||||
- `published_at` nullable timestamp
|
||||
- `archived_at` nullable timestamp
|
||||
- `generated_at` nullable timestamp
|
||||
- `created_at`, `updated_at`
|
||||
- **Derived presentation states**:
|
||||
- publication state is derived from `status` plus `published_at`; no separate persisted publication enum is introduced in this slice
|
||||
- export readiness is derived from `current_export_review_pack_id`, review completeness/readiness rules, and the latest related review-pack state; no separate persisted export-readiness enum is introduced in this slice
|
||||
- **Relationships**:
|
||||
- belongs to `Tenant`
|
||||
- belongs to `Workspace`
|
||||
- belongs to `EvidenceSnapshot`
|
||||
- belongs to `User` as initiator
|
||||
- belongs to `User` as publisher
|
||||
- belongs to `OperationRun` for async generation/refresh only
|
||||
- has many `TenantReviewSection`
|
||||
- has many `ReviewPack` export artifacts if export history is retained per review
|
||||
- **Validation rules**:
|
||||
- `workspace_id` and `tenant_id` must match the anchored evidence snapshot
|
||||
- exactly one active evidence basis per review
|
||||
- published reviews cannot be mutated in place
|
||||
- archived reviews cannot return to draft/ready
|
||||
- **State transitions**:
|
||||
- `draft -> ready`
|
||||
- `draft -> failed`
|
||||
- `ready -> published`
|
||||
- `ready -> failed`
|
||||
- `published -> superseded`
|
||||
- `published -> archived`
|
||||
- `ready -> archived`
|
||||
- terminal: `archived`, `superseded`
|
||||
|
||||
### TenantReviewSection
|
||||
|
||||
- **Purpose**: Ordered section-level composition for one tenant review.
|
||||
- **Ownership**: Tenant-owned through `tenant_review_id`; redundantly stores `workspace_id` and `tenant_id` for isolation and query simplicity.
|
||||
- **Core fields**:
|
||||
- `id`
|
||||
- `tenant_review_id`
|
||||
- `workspace_id`
|
||||
- `tenant_id`
|
||||
- `section_key` enum-like string such as `executive_summary`, `open_risks`, `accepted_risks`, `permission_posture`, `baseline_drift_posture`, `operations_health`
|
||||
- `title`
|
||||
- `sort_order`
|
||||
- `required` boolean
|
||||
- `completeness_state`
|
||||
- `source_snapshot_fingerprint` nullable
|
||||
- `summary_payload` JSONB
|
||||
- `render_payload` JSONB
|
||||
- `measured_at` nullable timestamp
|
||||
- `created_at`, `updated_at`
|
||||
- **Relationships**:
|
||||
- belongs to `TenantReview`
|
||||
- **Validation rules**:
|
||||
- unique per `tenant_review_id + section_key`
|
||||
- `sort_order` must be stable and non-negative
|
||||
- `summary_payload` and `render_payload` must be sanitized, summary-first, and secret-free
|
||||
|
||||
### ReviewPack (existing, extended)
|
||||
|
||||
- **Purpose**: Existing export artifact reused as the stakeholder-facing executive pack output.
|
||||
- **Change in this feature**:
|
||||
- add `tenant_review_id` nullable foreign key
|
||||
- treat executive-pack exports as review-derived artifacts when `tenant_review_id` is present
|
||||
- **Important existing fields reused**:
|
||||
- `workspace_id`, `tenant_id`
|
||||
- `operation_run_id`
|
||||
- `evidence_snapshot_id`
|
||||
- `status`
|
||||
- `fingerprint`
|
||||
- `options`
|
||||
- `file_disk`, `file_path`, `file_size`, `sha256`
|
||||
- `generated_at`, `expires_at`
|
||||
- **State transitions**:
|
||||
- existing `queued -> generating -> ready|failed -> expired`
|
||||
- derived from review export pipeline, not from review publication state itself
|
||||
|
||||
## Derived Views
|
||||
|
||||
### Workspace Review Register Row
|
||||
|
||||
- **Purpose**: Canonical `/admin/reviews` row projection for entitled tenants only.
|
||||
- **Fields**:
|
||||
- `tenant_id`
|
||||
- `tenant_name`
|
||||
- `review_id`
|
||||
- `review_status`
|
||||
- `completeness_state`
|
||||
- `published_at`
|
||||
- `generated_at`
|
||||
- `evidence_snapshot_id`
|
||||
- `has_ready_export`
|
||||
- `publish_blockers_count`
|
||||
|
||||
## Indexing / Constraints
|
||||
|
||||
- `tenant_reviews(workspace_id, tenant_id, created_at desc)`
|
||||
- `tenant_reviews(tenant_id, status, published_at desc)`
|
||||
- partial unique index for one current mutable review per `tenant_id` if desired: `status in ('draft','ready')`
|
||||
- unique `tenant_review_sections(tenant_review_id, section_key)`
|
||||
- `review_packs(tenant_review_id, generated_at desc)`
|
||||
|
||||
## Invariants
|
||||
|
||||
- Tenant-owned review tables always include both `workspace_id` and `tenant_id`.
|
||||
- A published review is immutable.
|
||||
- A review always points to exactly one anchored evidence snapshot.
|
||||
- Review completeness is explicit and never inferred from pack readiness alone.
|
||||
- Export artifacts never become the source of truth for review content; they remain derived from `TenantReview`.
|
||||
107
specs/155-tenant-review-layer/plan.md
Normal file
107
specs/155-tenant-review-layer/plan.md
Normal file
@ -0,0 +1,107 @@
|
||||
# Implementation Plan: Tenant Review Layer
|
||||
|
||||
**Branch**: `155-tenant-review-layer` | **Date**: 2026-03-20 | **Spec**: [spec.md](./spec.md)
|
||||
**Input**: Feature specification from `/specs/155-tenant-review-layer/spec.md`
|
||||
|
||||
**Note**: This plan covers the first delivery slice for Executive Review Packs / Tenant Review Layer and intentionally excludes later Compliance Readiness, MSP portfolio rollups, and cross-tenant compare.
|
||||
|
||||
## Summary
|
||||
|
||||
Introduce a first-class tenant-owned review aggregate that anchors one recurring tenant review to a chosen evidence snapshot, renders ordered executive-facing review sections, and reuses the existing review-pack export pipeline for stakeholder delivery. The implementation keeps review preparation and review inspection DB-backed and tenant-safe, uses `OperationRun` only for long-running composition/export work, preserves immutable published review history, and exposes a workspace-scoped canonical review register without introducing framework-oriented readiness scoring.
|
||||
|
||||
## Technical Context
|
||||
|
||||
**Language/Version**: PHP 8.4, Laravel 12, Livewire 4, Filament 5
|
||||
**Primary Dependencies**: Filament resources/pages/actions, Eloquent models, queued Laravel jobs, existing `EvidenceSnapshotService`, existing `ReviewPackService`, capability registry, `OperationRunService`
|
||||
**Storage**: PostgreSQL with JSONB-backed summary payloads and tenant/workspace ownership columns
|
||||
**Testing**: Pest feature tests, Livewire/Filament page tests, policy/authorization tests, operation-run regression coverage
|
||||
**Target Platform**: Laravel Sail web application with tenant/admin Filament panel and workspace-admin canonical routes
|
||||
**Project Type**: Laravel monolith with Filament admin UI and queued background jobs
|
||||
**Performance Goals**: Tenant review create/refresh/export actions enqueue or complete intent feedback in under 2 seconds; review list/detail render from DB-backed precomputed data with no live Graph calls; stakeholder pack generation remains asynchronous
|
||||
**Constraints**: No live Microsoft Graph collection during review preparation or export; published reviews remain immutable; tenant/workspace isolation stays strict with 404/403 semantics; long-running work must use `OperationRun`; destructive lifecycle actions require confirmation
|
||||
**Scale/Scope**: One new tenant-owned aggregate (`TenantReview`) with ordered section data, one workspace-scoped canonical review register, one tenant-scoped review library/detail flow, and reuse of the existing export artifact pipeline for executive packs
|
||||
|
||||
## Constitution Check
|
||||
|
||||
*GATE: Must pass before Phase 0 research. Re-check after Phase 1 design.*
|
||||
|
||||
- **Inventory-first, Snapshots-second**: PASS. Tenant reviews consume evidence snapshots and stored governance summaries as the last observed substrate. They do not replace inventory or evidence snapshots and do not create a second mutable evidence truth.
|
||||
- **Read/write separation**: PASS. Review inspection is read-only. Review creation/refresh/publish/archive/export are explicit mutations with audit logging; archive is destructive-like and requires confirmation. No silent mutation of published reviews is allowed.
|
||||
- **Graph contract path**: PASS. The feature does not add Microsoft Graph calls. Review generation reads existing DB-backed evidence and review data only.
|
||||
- **Deterministic capabilities**: PASS. New review capabilities will be added to the central capability registry and tested through policies/UI enforcement helpers.
|
||||
- **RBAC-UX**: PASS. Tenant review library/detail live in the tenant/admin plane; workspace review register remains canonical `/admin` and tenant-safe. Non-member access is 404, member-but-missing-capability is 403. No `/system` plane overlap is introduced.
|
||||
- **Workspace isolation**: PASS. All review records are tenant-owned with `workspace_id` and `tenant_id`; canonical register only lists reviews for tenants the actor is entitled to in the selected workspace context.
|
||||
- **Tenant isolation**: PASS. Every review read/write resolves through tenant-owned scoping. No tenantless direct ID shortcut is introduced for review detail.
|
||||
- **Run observability**: PASS. Review create/refresh/export may create or reuse `OperationRun` for long-running composition/export. Publish and archive remain synchronous DB-backed actions with audit logs, not runs.
|
||||
- **Ops-UX 3-surface feedback**: PASS. Asynchronous review composition/export will use the existing pattern: intent-only toast, progress in active ops and Monitoring run detail, one terminal completion notification via `OperationRunCompleted`.
|
||||
- **Ops-UX lifecycle ownership**: PASS. Any `OperationRun` transitions remain in `OperationRunService`; the review feature will not update status/outcome directly.
|
||||
- **Ops-UX summary counts**: PASS. New review/export runs will use allowed numeric keys only, likely limited to section count, completed sections, and exported artifact count if needed.
|
||||
- **Ops-UX guards**: PASS. Existing regression guard patterns remain applicable; new operation types must be covered where added.
|
||||
- **Automation/idempotency**: PASS. Create/refresh/export flows will reuse fingerprint/dedup semantics to prevent duplicate active work or duplicate artifacts from unchanged review state.
|
||||
- **Data minimization**: PASS. Review summaries store stakeholder-ready summaries and source references, not raw evidence payload dumps or secrets.
|
||||
- **Badge semantics (BADGE-001)**: PASS. New review lifecycle/publication/readiness badges will extend centralized badge domains instead of adding page-local color mappings.
|
||||
- **UI naming (UI-NAMING-001)**: PASS. Primary operator verbs remain `Create review`, `Refresh review`, `Publish review`, `Export executive pack`, `Archive review`.
|
||||
- **Filament Action Surface Contract**: PASS with explicit design choice. Tenant review list/detail and workspace register will use clickable-row inspection, no lone view action, no bulk actions in v1, and confirmation for archive.
|
||||
- **Filament UX-001**: PASS. Detail uses Infolist-style inspection, lists include scoped filters/search/sort, and empty states have one CTA.
|
||||
|
||||
## Project Structure
|
||||
|
||||
### Documentation (this feature)
|
||||
|
||||
```text
|
||||
specs/155-tenant-review-layer/
|
||||
├── plan.md
|
||||
├── research.md
|
||||
├── data-model.md
|
||||
├── quickstart.md
|
||||
├── contracts/
|
||||
│ └── review-layer.openapi.yaml
|
||||
└── tasks.md
|
||||
```
|
||||
|
||||
### Source Code (repository root)
|
||||
|
||||
```text
|
||||
app/
|
||||
├── Filament/
|
||||
│ ├── Pages/
|
||||
│ ├── Resources/
|
||||
│ └── Widgets/
|
||||
├── Http/Controllers/
|
||||
├── Jobs/
|
||||
├── Models/
|
||||
├── Policies/
|
||||
├── Services/
|
||||
│ └── Evidence/
|
||||
└── Support/
|
||||
├── Auth/
|
||||
├── Badges/
|
||||
└── WorkspaceIsolation/
|
||||
|
||||
database/
|
||||
├── factories/
|
||||
└── migrations/
|
||||
|
||||
routes/
|
||||
└── web.php
|
||||
|
||||
tests/
|
||||
├── Feature/
|
||||
└── Unit/
|
||||
```
|
||||
|
||||
**Structure Decision**: Use the existing Laravel monolith structure. Add the new review aggregate under `app/Models`, review orchestration under `app/Services`, review/export jobs under `app/Jobs`, tenant/admin and canonical review surfaces under `app/Filament`, new migrations under `database/migrations`, route glue under `routes/web.php` only where signed downloads or canonical register endpoints are needed, and Pest coverage under `tests/Feature` plus targeted unit tests for composition/fingerprint logic.
|
||||
|
||||
## Complexity Tracking
|
||||
|
||||
| Violation | Why Needed | Simpler Alternative Rejected Because |
|
||||
|-----------|------------|-------------------------------------|
|
||||
| None | N/A | N/A |
|
||||
|
||||
## Post-Design Constitution Re-check
|
||||
|
||||
- **Ownership model**: PASS. The design keeps `TenantReview` tenant-owned and avoids persisting tenant data in workspace-owned templates or registers.
|
||||
- **Evidence foundation boundary**: PASS. Review records consume `EvidenceSnapshot` and related summaries but do not replace evidence-domain storage.
|
||||
- **Compliance readiness boundary**: PASS. Framework mapping and compliance scoring remain deferred; this slice only defines recurring tenant review and executive pack composition.
|
||||
- **Operations UX boundary**: PASS. Only composition/export use `OperationRun`; review lifecycle DB mutations remain audited synchronous actions.
|
||||
- **RBAC / tenant safety**: PASS. Canonical register remains list-only and tenant-safe; detail inspection stays tenant-scoped.
|
||||
56
specs/155-tenant-review-layer/quickstart.md
Normal file
56
specs/155-tenant-review-layer/quickstart.md
Normal file
@ -0,0 +1,56 @@
|
||||
# Quickstart: Tenant Review Layer
|
||||
|
||||
## Goal
|
||||
|
||||
Validate the first slice of the Tenant Review Layer locally in Sail using existing evidence snapshots and review-pack infrastructure.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
1. Start Sail and ensure the application database is migrated.
|
||||
2. Seed or create:
|
||||
- one workspace
|
||||
- one tenant in that workspace
|
||||
- one authorized user with tenant access
|
||||
- one active evidence snapshot for the tenant
|
||||
- findings, permission posture, Entra admin-role report, baseline/drift posture, and operations summary inputs sufficient for review composition
|
||||
|
||||
## Happy-path walkthrough
|
||||
|
||||
1. Open the tenant admin surface for the target tenant.
|
||||
2. Navigate to the tenant review library at `/admin/t/{tenant}/reviews`.
|
||||
3. Create a review from the latest eligible evidence snapshot.
|
||||
4. Confirm the created review shows:
|
||||
- executive summary
|
||||
- open-risk highlights
|
||||
- accepted-risk summary
|
||||
- permission posture summary
|
||||
- baseline/drift posture summary
|
||||
- operations health summary
|
||||
5. Publish the review once required sections are complete.
|
||||
6. Export an executive pack from the published review.
|
||||
7. Download the resulting artifact and confirm it matches the review detail summary ordering.
|
||||
|
||||
## Authorization checks
|
||||
|
||||
1. As a non-member or wrong-tenant user, open the tenant review library URL.
|
||||
- Expected: `404`
|
||||
2. As a tenant member without `tenant_review.manage`, attempt create/publish/archive/export.
|
||||
- Expected: UI disabled where applicable, server returns `403` on execution.
|
||||
3. As a workspace-scoped operator entitled to multiple tenants, open `/admin/reviews`.
|
||||
- Expected: only entitled tenant rows and filter values are visible.
|
||||
|
||||
## Immutability checks
|
||||
|
||||
1. Publish a tenant review.
|
||||
2. Change the underlying findings or posture data.
|
||||
3. Re-open the published review.
|
||||
- Expected: published review remains unchanged.
|
||||
4. Create a refreshed successor review.
|
||||
- Expected: the successor uses the updated evidence while the published review remains historical.
|
||||
|
||||
## Export checks
|
||||
|
||||
1. Export the executive pack twice from the same unchanged published review.
|
||||
- Expected: duplicate-prevention semantics avoid creating accidental duplicate final artifacts.
|
||||
2. Attempt export from a review missing required sections.
|
||||
- Expected: export is blocked with a clear readiness reason.
|
||||
49
specs/155-tenant-review-layer/research.md
Normal file
49
specs/155-tenant-review-layer/research.md
Normal file
@ -0,0 +1,49 @@
|
||||
# Research: Tenant Review Layer
|
||||
|
||||
## Decision 1: Model the review layer as a new tenant-owned aggregate
|
||||
|
||||
- **Decision**: Introduce a dedicated `TenantReview` aggregate as the primary recurring review record, separate from `EvidenceSnapshot` and separate from `ReviewPack` export artifacts.
|
||||
- **Rationale**: The codebase already distinguishes evidence curation from stakeholder export. `EvidenceSnapshot` is a data-curation substrate with completeness/freshness semantics, while `ReviewPack` is an export artifact with file retention and download behavior. The missing concept is the recurring governance review itself: a curated, inspectable, publishable tenant review that can remain intelligible and historically stable over time.
|
||||
- **Alternatives considered**:
|
||||
- Reuse `EvidenceSnapshot` directly as the review record: rejected because evidence snapshots intentionally stop at curated inputs and do not own executive summary composition or review lifecycle.
|
||||
- Reuse `ReviewPack` directly as the review record: rejected because `ReviewPack` is file/export-oriented and does not provide a durable, inspectable in-product review aggregate.
|
||||
|
||||
## Decision 2: Reuse the existing review-pack export pipeline instead of inventing a second export model
|
||||
|
||||
- **Decision**: Keep `ReviewPack` as the export/download artifact pattern and link it to `TenantReview` in the first slice instead of creating a second export model.
|
||||
- **Rationale**: The current code already has deterministic fingerprinting, `OperationRun` integration, retention/expiry, signed downloads, and Filament surfaces for review-pack artifacts. Reusing that pipeline minimizes churn and lets the new review layer focus on composition, publication, and executive inspection rather than rebuilding artifact storage from scratch.
|
||||
- **Alternatives considered**:
|
||||
- New `ExecutiveReviewPack` table and controller: rejected because it duplicates status, file, retention, and async-export concerns that `ReviewPack` already solves.
|
||||
- Inline synchronous file generation from review detail: rejected because it violates current Ops-UX and export-artifact patterns.
|
||||
|
||||
## Decision 3: Store ordered review sections as child records, not one opaque JSON blob
|
||||
|
||||
- **Decision**: Represent section-level review composition as `TenantReviewSection` child rows under `TenantReview`, each with a section key, ordering, completeness state, and summary payload.
|
||||
- **Rationale**: Existing evidence uses `EvidenceSnapshotItem` child rows for ordered dimensions with their own metadata. The review layer has similar needs: ordered sections, per-section completeness, selective rendering, and future section-level evolution. Child rows keep section logic explicit and reduce coupling to one large JSON document.
|
||||
- **Alternatives considered**:
|
||||
- Single JSONB payload on `TenantReview`: rejected because it obscures section ordering and completeness as first-class data and makes future section-level evolution more brittle.
|
||||
- One table per section type: rejected as over-modeled for the first slice.
|
||||
|
||||
## Decision 4: Keep publish and archive synchronous, but make composition and export asynchronous
|
||||
|
||||
- **Decision**: Review composition and executive-pack export may use `OperationRun`; publish and archive remain synchronous audited DB mutations.
|
||||
- **Rationale**: Composition and export can involve section assembly, pack rendering, and file persistence, which fit the existing async pattern. Publish and archive are governance-state transitions and should behave like explicit DB-backed lifecycle mutations with immediate auditability.
|
||||
- **Alternatives considered**:
|
||||
- Make every lifecycle action asynchronous: rejected because publish/archive do not need background processing and would add unnecessary Monitoring noise.
|
||||
- Make export synchronous: rejected because the repo already treats pack generation as an observable background operation.
|
||||
|
||||
## Decision 5: Canonical workspace review surface is a register, not a tenantless detail viewer
|
||||
|
||||
- **Decision**: In v1, `/admin/reviews` is a workspace-scoped canonical register with tenant-safe filtering and row-level drill-down back into tenant-scoped review detail.
|
||||
- **Rationale**: This matches the current boundary used by other tenant-owned domains: canonical workspace context can safely summarize and list entitled tenant records, while authoritative inspection remains tenant-scoped. This lowers tenant-leak risk and aligns with the existing middleware and tenant-owned query-scoping model.
|
||||
- **Alternatives considered**:
|
||||
- Add a fully tenantless canonical review detail viewer under `/admin/reviews/{review}`: rejected for the first slice because it introduces extra tenant-resolution and wrong-tenant guard complexity before the domain proves its value.
|
||||
- Keep everything tenant-only with no canonical register: rejected because recurring review management across multiple tenants is a core requirement.
|
||||
|
||||
## Decision 6: Treat review publication readiness as review completeness, not compliance readiness
|
||||
|
||||
- **Decision**: Publication/export gating is based on review completeness and required section presence, not on BSI, NIS2, or CIS mapping.
|
||||
- **Rationale**: The spec explicitly defers Compliance Readiness. The first slice needs an internal readiness rule for “is this review good enough to publish/export?” without collapsing into framework-oriented compliance scoring.
|
||||
- **Alternatives considered**:
|
||||
- Add lightweight framework labels now: rejected because even “lightweight” framework mapping would blur the product boundary the spec intentionally separates.
|
||||
- Allow publish/export regardless of completeness: rejected because it would undermine stakeholder trust in the executive pack.
|
||||
170
specs/155-tenant-review-layer/spec.md
Normal file
170
specs/155-tenant-review-layer/spec.md
Normal file
@ -0,0 +1,170 @@
|
||||
# Feature Specification: Tenant Review Layer
|
||||
|
||||
**Feature Branch**: `155-tenant-review-layer`
|
||||
**Created**: 2026-03-20
|
||||
**Status**: Draft
|
||||
**Input**: User description: "Executive Review Packs / Tenant Review Layer"
|
||||
|
||||
## Spec Scope Fields *(mandatory)*
|
||||
|
||||
- **Scope**: workspace + tenant + canonical-view
|
||||
- **Primary Routes**:
|
||||
- `/admin/t/{tenant}/reviews` as the tenant-scoped review library and entry point for recurring review preparation
|
||||
- `/admin/t/{tenant}/reviews/{review}` as the canonical tenant review inspection surface
|
||||
- `/admin/reviews` as the workspace-scoped canonical review register for entitled operators who manage recurring reviews across multiple tenants
|
||||
- Existing evidence, findings, baseline, and permissions surfaces remain linked drill-down destinations from the review detail when the operator is entitled to inspect them
|
||||
- **Data Ownership**:
|
||||
- Tenant-owned: tenant review records, review composition metadata, review lifecycle state, executive summary content, and stakeholder-facing review-pack references for one tenant
|
||||
- Tenant-owned inputs: evidence snapshots, accepted-risk summaries, findings summaries, baseline or drift posture, permission posture, and operational health summaries that are consumed but not re-owned by the review layer
|
||||
- Workspace-owned but tenant-filtered: canonical review library filters, review schedule summaries, and cross-tenant list presentation state without changing tenant ownership of the review itself
|
||||
- Compliance or framework readiness interpretations remain outside this feature and are not stored as first-class review truth in this slice
|
||||
- **RBAC**:
|
||||
- Workspace membership remains required for every review surface
|
||||
- Tenant entitlement remains required to inspect or mutate tenant-scoped review records
|
||||
- `tenant_review.view` permits listing and inspecting reviews within authorized scope
|
||||
- `tenant_review.manage` permits creating, refreshing, publishing, archiving, and exporting review packs within authorized scope
|
||||
- Non-members or users outside the relevant workspace or tenant scope remain deny-as-not-found, while in-scope members lacking the required capability remain forbidden
|
||||
|
||||
For canonical-view specs, the spec MUST define:
|
||||
|
||||
- **Default filter behavior when tenant-context is active**: When an operator navigates from a tenant into the shared review register, the canonical workspace view opens prefiltered to that tenant. The operator may clear or change the filter only within their authorized tenant set.
|
||||
- **Explicit entitlement checks preventing cross-tenant leakage**: Review queries, counts, tenant labels, filter options, executive summaries, and exported review-pack references must be assembled only after workspace and tenant entitlement checks. Unauthorized users must not learn whether another tenant has review history, stakeholder packs, or upcoming review work.
|
||||
|
||||
## User Scenarios & Testing *(mandatory)*
|
||||
|
||||
### User Story 1 - Prepare one tenant review from curated evidence (Priority: P1)
|
||||
|
||||
As a governance operator, I want to create a tenant review from an evidence snapshot and related governance signals, so that quarterly or ad hoc tenant reviews start from one stable, curated review record instead of manual page-by-page assembly.
|
||||
|
||||
**Why this priority**: This is the core product workflow. Without a first-class tenant review record, executive review packs are still ad hoc exports rather than a repeatable review motion.
|
||||
|
||||
**Independent Test**: Can be fully tested by selecting an eligible tenant evidence snapshot, creating a tenant review, and verifying that the resulting review preserves the chosen evidence basis, key governance sections, and summary state even if live source data changes later.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** a tenant has at least one eligible evidence snapshot, findings summary, and posture inputs, **When** an authorized operator creates a tenant review, **Then** the system creates one review record that captures the selected evidence basis and generated review sections for that tenant.
|
||||
2. **Given** a tenant review has been created from a specific evidence snapshot, **When** live findings or posture data later change, **Then** the existing review remains tied to its original evidence basis until the operator explicitly refreshes or creates a new review.
|
||||
3. **Given** the chosen evidence basis is partial, **When** the operator creates the review, **Then** the review clearly records which sections are complete, partial, or unavailable rather than implying a fully complete review.
|
||||
|
||||
---
|
||||
|
||||
### User Story 2 - Present an executive-ready tenant review pack (Priority: P1)
|
||||
|
||||
As an MSP account manager or governance lead, I want a concise executive review surface and exportable review pack for one tenant, so that I can lead customer or management conversations with a stakeholder-ready output rather than raw operational artifacts.
|
||||
|
||||
**Why this priority**: This is the commercial value layer. The product stops being only an operator console when it can produce a readable, stakeholder-facing review output.
|
||||
|
||||
**Independent Test**: Can be fully tested by opening a prepared tenant review, confirming that it presents executive summary sections and drill-down links coherently, and generating a stakeholder-ready review pack from that review without rebuilding the evidence manually.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** an authorized operator opens a prepared tenant review, **When** the review detail loads, **Then** it shows an executive summary, key risks, accepted-risk summary, posture highlights, and recommended next actions in one coherent inspection surface.
|
||||
2. **Given** a tenant review is ready for stakeholder delivery, **When** the operator publishes or exports the executive review pack, **Then** the pack is generated from that review record and reflects the same section ordering and summary truth shown in the product.
|
||||
3. **Given** a stakeholder-facing review pack omits one or more dimensions because the underlying evidence was partial, **When** the operator inspects or exports it, **Then** the omission is explained clearly instead of being silently hidden.
|
||||
|
||||
---
|
||||
|
||||
### User Story 3 - Manage recurring tenant reviews over time (Priority: P2)
|
||||
|
||||
As a workspace operator, I want a canonical review library across the tenants I manage, so that I can see which tenants were reviewed, which reviews are draft or published, and which tenants need the next review cycle.
|
||||
|
||||
**Why this priority**: Once the first tenant review exists, the product needs a repeatable operating model rather than one-off packs. This enables recurring review discipline and prepares the ground for the later portfolio dashboard.
|
||||
|
||||
**Independent Test**: Can be fully tested by creating reviews for multiple tenants, opening the workspace review register, and verifying that the register shows only entitled tenants with correct lifecycle, publish status, and recency signals.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** an operator is entitled to multiple tenants with review history, **When** they open the workspace review register, **Then** they can filter by tenant, review state, publish status, and review date without seeing unauthorized tenant rows.
|
||||
2. **Given** a tenant already has a published review, **When** the operator starts the next review cycle, **Then** the system creates a new draft review instead of mutating the historical published review.
|
||||
3. **Given** no review matches the current filters, **When** the operator opens the canonical review register, **Then** the empty state explains that no review records match and offers exactly one clear next action.
|
||||
|
||||
### Edge Cases
|
||||
|
||||
- A tenant has eligible evidence snapshots but no valid accepted-risk records; the review must still generate and explicitly show that no governed accepted risks are currently active.
|
||||
- A previously published review pack is revisited after the underlying evidence snapshot expires or is superseded; the historical review must remain intelligible from stored review metadata.
|
||||
- A tenant has multiple evidence snapshots available; the operator must choose which one anchors the review rather than the system silently picking a different basis.
|
||||
- An operator tries to publish or export a review that is still missing required summary sections; the product must fail with a clear readiness reason instead of producing a misleading finished pack.
|
||||
- A workspace operator is entitled to some, but not all, tenants in a workspace; the canonical review register must suppress unauthorized tenant labels, counts, and filter values.
|
||||
- A tenant review is created twice from the same evidence basis without meaningful changes; the system must prevent accidental duplicate published reviews while still allowing a deliberate new draft when needed.
|
||||
|
||||
## Requirements *(mandatory)*
|
||||
|
||||
**Constitution alignment (required):** This feature introduces a new review-domain data model, user-driven write behavior, and optional long-running generation work for stakeholder-facing review packs, but it does not introduce new Microsoft Graph collection. It must describe the review contract with evidence snapshots, explicit publish/export safety gates, tenant isolation, run observability for any generated pack artifact, and tests. Security-relevant DB-only review lifecycle changes such as publish, archive, and unpublish equivalents must always emit audit history.
|
||||
|
||||
**Constitution alignment (OPS-UX):** If review-pack generation is asynchronous, this feature creates or reuses a dedicated `OperationRun` family for tenant review pack generation and must comply with the Ops-UX 3-surface feedback contract. Start actions may show intent-only feedback. Progress belongs only in the active-ops widget and Monitoring run detail. Review detail may link to the canonical run detail but must not create a parallel progress tracker. `OperationRun.status` and `OperationRun.outcome` remain service-owned through `OperationRunService`. Any `summary_counts` must use allowed numeric-only keys and values. Scheduled or system-initiated review generation must not create initiator-only terminal DB notifications.
|
||||
|
||||
**Constitution alignment (RBAC-UX):** This feature operates in the tenant/admin plane for tenant review detail and mutation surfaces and in the workspace-admin canonical view for the shared review register. Cross-plane access remains deny-as-not-found. Non-members or users outside workspace or tenant scope receive `404`. In-scope users lacking `tenant_review.view` or `tenant_review.manage` receive `403` according to the attempted action. Authorization must be enforced server-side for create, refresh, publish, archive, and export actions. The canonical capability registry remains the only capability source. Destructive-like actions such as archive require confirmation.
|
||||
|
||||
**Constitution alignment (OPS-EX-AUTH-001):** Not applicable. No authentication handshake behavior is changed.
|
||||
|
||||
**Constitution alignment (BADGE-001):** Review lifecycle state, publication state, completeness state, and export readiness are status-like values and must use centralized badge semantics rather than local page-specific mappings. Tests must cover all newly introduced values.
|
||||
|
||||
**Constitution alignment (UI-NAMING-001):** The target object is the tenant review. Operator-facing verbs are `Create review`, `Refresh review`, `Publish review`, `Export executive pack`, and `Archive review`. Source/domain disambiguation is needed only where the review references evidence dimensions such as findings, baseline posture, permissions, or operations health. The same review vocabulary must be preserved across action labels, modal titles, run titles, notifications, and audit prose. Implementation-first terms such as `render package`, `materialize review`, or `hydrate sections` must not become primary operator-facing labels.
|
||||
|
||||
**Constitution alignment (Filament Action Surfaces):** This feature adds or modifies Filament pages/resources for tenant review list and detail plus workspace canonical review register. The Action Surface Contract is satisfied if list inspection uses a canonical inspect affordance, pack generation remains an explicit action, destructive lifecycle actions require confirmation, and all mutations are capability-gated and auditable.
|
||||
|
||||
**Constitution alignment (UX-001 — Layout & Information Architecture):** Review list screens must provide search, sort, and filters for tenant, review state, publication state, evidence basis, and review date. Review detail must use an Infolist-style inspection surface rather than a disabled edit form. Any review-creation form or action must keep inputs inside sections. Empty states must include a specific title, explanation, and exactly one CTA.
|
||||
|
||||
### Functional Requirements
|
||||
|
||||
- **FR-001**: The system MUST provide a first-class tenant review record that represents one curated governance review for one tenant and one chosen evidence basis.
|
||||
- **FR-002**: A tenant review MUST reference exactly one anchored evidence basis at creation time, with enough stored metadata to remain intelligible if downstream source artifacts later change, expire, or are superseded.
|
||||
- **FR-003**: The first implementation slice MUST support review sections for executive summary, open-risk highlights, accepted-risk summary, permission posture summary, baseline or drift posture summary, and operational health summary.
|
||||
- **FR-004**: The system MUST allow an authorized operator to create a tenant review from an eligible evidence snapshot without manually rebuilding each section from live source pages.
|
||||
- **FR-005**: The system MUST preserve review immutability for published reviews. Refreshing a published review MUST create a new draft review or explicit successor review instead of mutating the published historical record.
|
||||
- **FR-006**: The system MUST distinguish at least draft, ready, published, archived, and superseded review lifecycle states.
|
||||
- **FR-007**: The system MUST record review completeness and section availability explicitly, including when a review is based on partial evidence.
|
||||
- **FR-008**: The system MUST make it clear which evidence dimensions were included, omitted, partial, or stale in each review.
|
||||
- **FR-009**: The system MUST provide one tenant-scoped review library where authorized operators can list, inspect, refresh, publish, archive, and export review records for the active tenant.
|
||||
- **FR-010**: The system MUST provide one workspace-scoped canonical review register where authorized operators can review tenant review history across entitled tenants without leaking unauthorized tenant detail.
|
||||
- **FR-011**: The system MUST provide one stakeholder-facing executive review surface for a prepared tenant review that presents summary content and recommended next steps without forcing the operator into raw source artifacts.
|
||||
- **FR-012**: The system MUST support an exportable executive review pack derived from one prepared tenant review record rather than from ad hoc live assembly.
|
||||
- **FR-013**: Exporting an executive review pack MUST use the selected tenant review as the source of truth for section ordering, summary content, and included dimensions.
|
||||
- **FR-014**: The system MUST block publish or export actions when the review lacks required summary sections or required completeness thresholds for this slice, and it MUST explain the blocking reason clearly.
|
||||
- **FR-015**: The system MUST define duplicate-prevention semantics so that accidental repeated publish or export attempts from the same unchanged review do not create duplicate final artifacts unintentionally.
|
||||
- **FR-016**: The system MUST preserve historical published review records and exported pack references so prior reviews remain auditable and comparable over time.
|
||||
- **FR-017**: Creating, refreshing, publishing, archiving, and exporting a review MUST be recorded in audit history with workspace scope, tenant scope, actor, action, and outcome.
|
||||
- **FR-018**: The feature MUST explicitly exclude framework-oriented compliance scoring, certification claims, and BSI, NIS2, or CIS mapping from the first slice. Those remain a downstream Compliance Readiness feature.
|
||||
- **FR-019**: The feature MUST introduce at least one positive and one negative authorization test for tenant-scoped review management and workspace-scoped canonical review visibility.
|
||||
- **FR-020**: The feature MUST introduce regression tests proving evidence-basis anchoring, published-review immutability, executive-pack consistency, and cross-tenant isolation.
|
||||
|
||||
## 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 |
|
||||
|---|---|---|---|---|---|---|---|---|---|---|
|
||||
| Tenant Review Library | Tenant-context review list under `/admin/t/{tenant}/reviews` | `Create review` (`tenant_review.manage`) | Clickable row to review detail | `View review`, `Export executive pack` when ready | None in v1 | `Create first review` | None | N/A | Yes | Create may use an action modal because it selects an evidence basis and starts a review composition workflow |
|
||||
| Tenant Review Detail | Canonical detail route under `/admin/t/{tenant}/reviews/{review}` | None | N/A | None | None | N/A | `Refresh review`, `Publish review`, `Export executive pack`, `Archive review` | N/A | Yes | Inspection surface only; no disabled edit form |
|
||||
| Workspace Review Register | Workspace canonical view at `/admin/reviews` | `Clear filters` | Clickable row to review detail | `View review`, `Export executive pack` when authorized | None in v1 | `Clear filters` | None | N/A | Export yes | Must suppress unauthorized tenant rows and filter values |
|
||||
|
||||
### Key Entities *(include if feature involves data)*
|
||||
|
||||
- **Tenant Review**: A curated review record for one tenant anchored to a chosen evidence basis and used for recurring governance conversations.
|
||||
- **Review Section**: One named portion of the tenant review, such as executive summary, risk highlights, posture summary, or operational health summary, including its completeness and source references.
|
||||
- **Executive Review Pack**: A stakeholder-facing deliverable derived from one tenant review and preserving that review's section ordering, summary truth, and completeness disclosures.
|
||||
- **Review Lifecycle State**: The normalized state of a tenant review, including draft, ready, published, archived, and superseded.
|
||||
|
||||
## Success Criteria *(mandatory)*
|
||||
|
||||
### Measurable Outcomes
|
||||
|
||||
- **SC-001**: An authorized operator can create a tenant review from an eligible evidence basis and open its executive summary in under 3 minutes without leaving the product.
|
||||
- **SC-002**: Published tenant reviews remain unchanged in 100% of automated immutability tests after underlying live source records are modified.
|
||||
- **SC-003**: In manual review flow validation, an operator can answer the tenant's top risks, current posture highlights, and next actions from one review detail surface without opening more than one optional drill-down page.
|
||||
- **SC-004**: Exported executive review packs match their source tenant review's included dimensions and summary ordering in 100% of automated integration tests for the covered first-slice review sections.
|
||||
- **SC-005**: Negative authorization tests prove that non-members or wrong-tenant users receive deny-as-not-found behavior and in-scope users without the required capability cannot create, publish, archive, or export tenant reviews.
|
||||
- **SC-006**: Operators can distinguish draft, ready, published, archived, and superseded review states in one inspection step from list or detail surfaces.
|
||||
|
||||
## Assumptions
|
||||
|
||||
- Evidence snapshots are the primary source-of-truth input for review creation in the first slice.
|
||||
- Findings summaries, accepted-risk lifecycle data, permission posture, and baseline or drift posture are mature enough to populate first-slice review sections.
|
||||
- The first slice optimizes for tenant-by-tenant recurring reviews and executive packs, not for framework-oriented compliance mapping.
|
||||
- Workspace-level review visibility is a register and management surface, not yet a portfolio dashboard with SLA analytics.
|
||||
|
||||
## Non-Goals
|
||||
|
||||
- Building a framework-oriented Compliance Readiness layer with BSI, NIS2, or CIS mapping
|
||||
- Creating tenant portfolio rollups, SLA health dashboards, or fleet ranking views across tenants
|
||||
- Implementing cross-tenant compare or promotion workflows
|
||||
- Turning the tenant review layer into a generic BI reporting system
|
||||
- Triggering new Microsoft Graph collection during review preparation
|
||||
244
specs/155-tenant-review-layer/tasks.md
Normal file
244
specs/155-tenant-review-layer/tasks.md
Normal file
@ -0,0 +1,244 @@
|
||||
# Tasks: Tenant Review Layer
|
||||
|
||||
**Input**: Design documents from `/specs/155-tenant-review-layer/`
|
||||
**Prerequisites**: plan.md (required), spec.md (required for user stories), research.md, data-model.md, contracts/
|
||||
|
||||
**Tests**: For runtime behavior changes in this repo, tests are REQUIRED (Pest). Only docs-only changes may omit tests.
|
||||
**Operations**: Review composition and executive-pack export reuse the canonical `OperationRun` flow. Publish and archive remain synchronous DB-backed mutations and must emit audit history.
|
||||
**RBAC**: Tenant review detail/mutations run in the tenant/admin plane; the workspace review register runs in the workspace-admin canonical plane. Non-members or wrong-scope users must receive `404`; in-scope users lacking capability must receive `403`.
|
||||
**UI Naming**: Primary operator-facing verbs remain `Create review`, `Refresh review`, `Publish review`, `Export executive pack`, and `Archive review`.
|
||||
**Filament UI Action Surfaces**: Tenant review list/detail and workspace register must honor the spec action matrix, clickable inspection affordances, confirmation for destructive actions, and audit coverage for relevant mutations.
|
||||
**Filament UI UX-001**: Create flows must keep inputs inside sections, detail must use an Infolist-style inspection surface, and empty states must provide exactly one CTA.
|
||||
**Badges**: Review lifecycle state and completeness state must use `BadgeCatalog` / `BadgeRenderer` with mapping tests.
|
||||
|
||||
**Organization**: Tasks are grouped by user story to enable independent implementation and testing of each story.
|
||||
|
||||
## Phase 1: Setup (Shared Infrastructure)
|
||||
|
||||
**Purpose**: Introduce the shared enums, capability vocabulary, and operation metadata that every review flow depends on.
|
||||
|
||||
- [X] T001 Add tenant review state enums and derived publication/export-readiness badge mappings in `app/Support/TenantReviewStatus.php`, `app/Support/TenantReviewCompletenessState.php`, `app/Support/Badges/Domains/TenantReviewStatusBadge.php`, `app/Support/Badges/Domains/TenantReviewCompletenessStateBadge.php`, and `app/Support/Badges/BadgeCatalog.php`
|
||||
- [X] T002 [P] Register `tenant_review.view` and `tenant_review.manage` in `app/Support/Auth/Capabilities.php` and `app/Services/Auth/RoleCapabilityMap.php`
|
||||
- [X] T003 [P] Reserve tenant-review operation metadata in `app/Support/OperationRunType.php`, `app/Support/OperationCatalog.php`, and `app/Services/SystemConsole/OperationRunTriageService.php`
|
||||
|
||||
---
|
||||
|
||||
## Phase 2: Foundational (Blocking Prerequisites)
|
||||
|
||||
**Purpose**: Build the data model, policy enforcement, and composition services that block all user stories.
|
||||
|
||||
**⚠️ CRITICAL**: No user story work can begin until this phase is complete.
|
||||
|
||||
- [X] T004 Create tenant review persistence schema in `database/migrations/2026_03_20_000000_create_tenant_reviews_table.php`, `database/migrations/2026_03_20_000100_create_tenant_review_sections_table.php`, and `database/migrations/2026_03_20_000200_add_tenant_review_id_to_review_packs_table.php`
|
||||
- [X] T005 [P] Add review aggregate models and relationships in `app/Models/TenantReview.php`, `app/Models/TenantReviewSection.php`, `app/Models/ReviewPack.php`, `app/Models/EvidenceSnapshot.php`, and `app/Models/Tenant.php`
|
||||
- [X] T006 [P] Enforce tenant review authorization in `app/Policies/TenantReviewPolicy.php` and `app/Providers/AuthServiceProvider.php`
|
||||
- [X] T007 [P] Register tenant review ownership with workspace-isolation helpers in `app/Support/WorkspaceIsolation/TenantOwnedModelFamilies.php`
|
||||
- [X] T008 Implement core review composition services in `app/Services/TenantReviews/TenantReviewService.php`, `app/Services/TenantReviews/TenantReviewComposer.php`, and `app/Services/TenantReviews/TenantReviewSectionFactory.php`
|
||||
- [X] T009 Implement fingerprinting and readiness rules in `app/Services/TenantReviews/TenantReviewFingerprint.php` and `app/Services/TenantReviews/TenantReviewReadinessGate.php`
|
||||
|
||||
**Checkpoint**: Foundation ready. User story work can now proceed.
|
||||
|
||||
---
|
||||
|
||||
## Phase 3: User Story 1 - Prepare one tenant review from curated evidence (Priority: P1)
|
||||
|
||||
**Goal**: Allow an entitled operator to create and inspect a tenant review anchored to one chosen evidence snapshot, with explicit completeness and immutable evidence-basis semantics.
|
||||
|
||||
**Independent Test**: Create a review from an eligible evidence snapshot, verify the review stores the anchored evidence basis and section completeness, then change live source data and confirm the review remains tied to its original basis until explicitly refreshed.
|
||||
|
||||
### Tests for User Story 1
|
||||
|
||||
- [X] T010 [P] [US1] Add anchored-review creation coverage in `tests/Feature/TenantReview/TenantReviewCreationTest.php`
|
||||
- [X] T011 [P] [US1] Add review composition and badge mapping coverage in `tests/Unit/TenantReview/TenantReviewComposerTest.php` and `tests/Unit/TenantReview/TenantReviewBadgeTest.php`
|
||||
- [X] T012 [P] [US1] Add compose-run Ops-UX regression coverage in `tests/Feature/TenantReview/TenantReviewOperationsUxTest.php`
|
||||
- [X] T013 [P] [US1] Add tenant-scope authorization coverage for create, view, and refresh in `tests/Feature/TenantReview/TenantReviewRbacTest.php`
|
||||
|
||||
### Implementation for User Story 1
|
||||
|
||||
- [X] T014 [US1] Implement create and refresh orchestration with immutable evidence anchoring in `app/Services/TenantReviews/TenantReviewService.php`, `app/Services/TenantReviews/TenantReviewComposer.php`, and `app/Jobs/ComposeTenantReviewJob.php`
|
||||
- [X] T015 [US1] Create the tenant-scoped Filament resource and list/detail pages in `app/Filament/Resources/TenantReviewResource.php`, `app/Filament/Resources/TenantReviewResource/Pages/ListTenantReviews.php`, and `app/Filament/Resources/TenantReviewResource/Pages/ViewTenantReview.php`
|
||||
- [X] T016 [US1] Build the review detail infolist, section completeness rendering, and evidence drill-down links in `app/Filament/Resources/TenantReviewResource.php`
|
||||
- [X] T017 [US1] Implement the `Create review` modal, `Refresh review` action, row inspection affordance, and tenant-library empty state in `app/Filament/Resources/TenantReviewResource/Pages/ListTenantReviews.php` and `app/Filament/Resources/TenantReviewResource/Pages/ViewTenantReview.php`
|
||||
- [X] T018 [US1] Wire review composition runs to canonical operations UX in `app/Jobs/ComposeTenantReviewJob.php`, `app/Services/OperationRunService.php`, `app/Support/OpsUx/OperationUxPresenter.php`, and `app/Notifications/OperationRunCompleted.php`
|
||||
- [X] T019 [US1] Record create and refresh audit events plus stored evidence-basis metadata in `app/Services/TenantReviews/TenantReviewService.php` and `app/Models/TenantReview.php`
|
||||
|
||||
**Checkpoint**: User Story 1 is independently functional when a tenant review can be created, inspected, refreshed, and audited without leaking live-source changes into the anchored review.
|
||||
|
||||
---
|
||||
|
||||
## Phase 4: User Story 2 - Present an executive-ready tenant review pack (Priority: P1)
|
||||
|
||||
**Goal**: Present a stakeholder-ready review detail and exportable executive pack derived from the prepared tenant review, with clear readiness gates and immutable published history.
|
||||
|
||||
**Independent Test**: Open a prepared tenant review, verify the executive sections and disclosures, publish the review, export the executive pack, and confirm the exported artifact matches the same section ordering and summary truth shown in the product.
|
||||
|
||||
### Tests for User Story 2
|
||||
|
||||
- [X] T020 [P] [US2] Add executive detail and pack-consistency coverage in `tests/Feature/TenantReview/TenantReviewExecutivePackTest.php`
|
||||
- [X] T021 [P] [US2] Add publish, archive, and readiness-gate coverage in `tests/Feature/TenantReview/TenantReviewLifecycleTest.php`
|
||||
- [X] T022 [P] [US2] Add review-derived export integration coverage in `tests/Feature/ReviewPack/TenantReviewDerivedReviewPackTest.php`
|
||||
- [X] T023 [P] [US2] Add export-run Ops-UX guard coverage for lifecycle ownership, summary counts, and terminal notifications in `tests/Feature/TenantReview/TenantReviewExportOperationsUxTest.php`
|
||||
|
||||
### Implementation for User Story 2
|
||||
|
||||
- [X] T024 [US2] Implement publish, archive, successor, and duplicate-prevention lifecycle rules in `app/Services/TenantReviews/TenantReviewLifecycleService.php`, `app/Services/TenantReviews/TenantReviewReadinessGate.php`, and `app/Models/TenantReview.php`
|
||||
- [X] T025 [P] [US2] Extend review-derived review-pack generation and download flow in `app/Services/ReviewPackService.php`, `app/Jobs/GenerateReviewPackJob.php`, `app/Http/Controllers/ReviewPackDownloadController.php`, and `app/Models/ReviewPack.php`
|
||||
- [X] T026 [P] [US2] Add executive summary, disclosure sections, and detail-page header actions in `app/Filament/Resources/TenantReviewResource.php` and `app/Filament/Resources/TenantReviewResource/Pages/ViewTenantReview.php`
|
||||
- [X] T027 [US2] Reuse canonical export-run messaging, monitoring links, and summary-count keys for `Export executive pack` in `app/Support/OperationCatalog.php`, `app/Support/OpsUx/OperationUxPresenter.php`, `app/Support/OpsUx/OperationSummaryKeys.php`, and `app/Services/SystemConsole/OperationRunTriageService.php`
|
||||
- [X] T028 [US2] Record publish, archive, and export audit history with aligned operator-facing copy in `app/Services/TenantReviews/TenantReviewLifecycleService.php` and `app/Services/ReviewPackService.php`
|
||||
- [X] T029 [US2] Surface review-derived export metadata and navigation in `app/Filament/Widgets/Tenant/TenantReviewPackCard.php` and `app/Filament/Resources/ReviewPackResource.php`
|
||||
|
||||
**Checkpoint**: User Story 2 is independently functional when an operator can inspect a stakeholder-ready review, publish it safely, and export a matching executive pack with readiness failures explained clearly.
|
||||
|
||||
---
|
||||
|
||||
## Phase 5: User Story 3 - Manage recurring tenant reviews over time (Priority: P2)
|
||||
|
||||
**Goal**: Provide a canonical workspace review register and recurring-cycle workflow that shows only entitled tenants while preserving published review history.
|
||||
|
||||
**Independent Test**: Create reviews for multiple tenants, open the workspace register, confirm only entitled tenants appear with correct lifecycle and recency signals, then start a new cycle from a published review and verify a successor draft is created instead of mutating history.
|
||||
|
||||
### Tests for User Story 3
|
||||
|
||||
- [X] T030 [P] [US3] Add workspace review register filtering, positive visibility, and empty-state coverage in `tests/Feature/TenantReview/TenantReviewRegisterTest.php`
|
||||
- [X] T031 [P] [US3] Add tenant-context prefilter and authorized filter-option scoping coverage in `tests/Feature/TenantReview/TenantReviewRegisterPrefilterTest.php`
|
||||
- [X] T032 [P] [US3] Add canonical register deny-as-not-found and capability coverage in `tests/Feature/TenantReview/TenantReviewRegisterRbacTest.php`
|
||||
- [X] T033 [P] [US3] Add successor-cycle history coverage in `tests/Feature/TenantReview/TenantReviewCycleTest.php`
|
||||
|
||||
### Implementation for User Story 3
|
||||
|
||||
- [X] T034 [US3] Implement workspace-scoped register queries, entitled-tenant filtering, and tenant-context prefilter initialization in `app/Services/TenantReviews/TenantReviewRegisterService.php` and `app/Models/TenantReview.php`
|
||||
- [X] T035 [P] [US3] Create the canonical workspace review register page in `app/Filament/Pages/Reviews/ReviewRegister.php`
|
||||
- [X] T036 [P] [US3] Register tenant-review navigation and page discovery in `app/Providers/Filament/TenantPanelProvider.php` and `app/Providers/Filament/AdminPanelProvider.php`
|
||||
- [X] T037 [US3] Implement register table filters, authorized filter-option scoping, row navigation, and one-CTA empty-state behavior in `app/Filament/Pages/Reviews/ReviewRegister.php`
|
||||
- [X] T038 [US3] Add `Create next review` successor flow on published reviews in `app/Services/TenantReviews/TenantReviewLifecycleService.php` and `app/Filament/Resources/TenantReviewResource/Pages/ViewTenantReview.php`
|
||||
|
||||
**Checkpoint**: User Story 3 is independently functional when the workspace register safely lists entitled tenant reviews and operators can start the next cycle without mutating published history.
|
||||
|
||||
---
|
||||
|
||||
## Phase 6: Polish & Cross-Cutting Concerns
|
||||
|
||||
**Purpose**: Final hardening, performance, and verification across all stories.
|
||||
|
||||
- [X] T039 [P] Add global-search and record-title decisions for tenant reviews in `app/Filament/Resources/TenantReviewResource.php` and `app/Models/TenantReview.php`
|
||||
- [X] T040 [P] Add cross-story audit-log and UI-contract regression coverage in `tests/Feature/TenantReview/TenantReviewAuditLogTest.php` and `tests/Feature/TenantReview/TenantReviewUiContractTest.php`
|
||||
- [X] T041 Harden eager loading, list performance, and review-pack query paths in `app/Services/TenantReviews/TenantReviewRegisterService.php`, `app/Filament/Resources/TenantReviewResource.php`, and `app/Jobs/GenerateReviewPackJob.php`
|
||||
- [X] T042 Run the feature validation scenarios in `specs/155-tenant-review-layer/quickstart.md`
|
||||
|
||||
---
|
||||
|
||||
## Dependencies & Execution Order
|
||||
|
||||
### Phase Dependencies
|
||||
|
||||
- **Setup (Phase 1)**: No dependencies; can start immediately.
|
||||
- **Foundational (Phase 2)**: Depends on Phase 1; blocks all user stories.
|
||||
- **User Story 1 (Phase 3)**: Depends on Phase 2 only.
|
||||
- **User Story 2 (Phase 4)**: Depends on Phase 2 and consumes the review aggregate delivered in User Story 1.
|
||||
- **User Story 3 (Phase 5)**: Depends on Phase 2 and should land after User Story 1 because it surfaces recurring-cycle state from real review records.
|
||||
- **Polish (Phase 6)**: Depends on all desired stories being complete.
|
||||
|
||||
### User Story Dependencies
|
||||
|
||||
- **US1**: Starts after Foundational; no dependency on other stories.
|
||||
- **US2**: Starts after Foundational but is most valuable once US1 review creation/detail is working.
|
||||
- **US3**: Starts after Foundational but depends on existing review records from US1 for meaningful validation.
|
||||
|
||||
### Within Each User Story
|
||||
|
||||
- Tests must be written first and fail before implementation.
|
||||
- Models/services before Filament surfaces where practical.
|
||||
- Operation-run wiring before exposing async actions broadly.
|
||||
- Audit and authorization coverage must ship with each mutation workflow.
|
||||
|
||||
### Parallel Opportunities
|
||||
|
||||
- `T002` and `T003` can run in parallel after `T001`.
|
||||
- `T005`, `T006`, and `T007` can run in parallel after `T004`.
|
||||
- In US1, `T010` through `T013` can run in parallel.
|
||||
- In US2, `T020` through `T023` can run in parallel, and `T025` plus `T026` can run in parallel after `T024`.
|
||||
- In US3, `T030` through `T033` can run in parallel, and `T035` plus `T036` can run in parallel after `T034`.
|
||||
|
||||
---
|
||||
|
||||
## Parallel Example: User Story 1
|
||||
|
||||
```bash
|
||||
# Launch the US1 tests together:
|
||||
Task: "Add anchored-review creation coverage in tests/Feature/TenantReview/TenantReviewCreationTest.php"
|
||||
Task: "Add review composition and badge mapping coverage in tests/Unit/TenantReview/TenantReviewComposerTest.php and tests/Unit/TenantReview/TenantReviewBadgeTest.php"
|
||||
Task: "Add compose-run Ops-UX regression coverage in tests/Feature/TenantReview/TenantReviewOperationsUxTest.php"
|
||||
Task: "Add tenant-scope authorization coverage for create, view, and refresh in tests/Feature/TenantReview/TenantReviewRbacTest.php"
|
||||
|
||||
# Build the tenant review Filament surface in parallel after orchestration exists:
|
||||
Task: "Create the tenant-scoped Filament resource and list/detail pages in app/Filament/Resources/TenantReviewResource.php, app/Filament/Resources/TenantReviewResource/Pages/ListTenantReviews.php, and app/Filament/Resources/TenantReviewResource/Pages/ViewTenantReview.php"
|
||||
Task: "Build the review detail infolist, section completeness rendering, and evidence drill-down links in app/Filament/Resources/TenantReviewResource.php"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Parallel Example: User Story 2
|
||||
|
||||
```bash
|
||||
# Launch the US2 tests together:
|
||||
Task: "Add executive detail and pack-consistency coverage in tests/Feature/TenantReview/TenantReviewExecutivePackTest.php"
|
||||
Task: "Add publish, archive, and readiness-gate coverage in tests/Feature/TenantReview/TenantReviewLifecycleTest.php"
|
||||
Task: "Add review-derived export integration coverage in tests/Feature/ReviewPack/TenantReviewDerivedReviewPackTest.php"
|
||||
Task: "Add export-run Ops-UX guard coverage for lifecycle ownership, summary counts, and terminal notifications in tests/Feature/TenantReview/TenantReviewExportOperationsUxTest.php"
|
||||
|
||||
# Implement export surfaces in parallel after lifecycle rules exist:
|
||||
Task: "Extend review-derived review-pack generation and download flow in app/Services/ReviewPackService.php, app/Jobs/GenerateReviewPackJob.php, app/Http/Controllers/ReviewPackDownloadController.php, and app/Models/ReviewPack.php"
|
||||
Task: "Add executive summary, disclosure sections, and detail-page header actions in app/Filament/Resources/TenantReviewResource.php and app/Filament/Resources/TenantReviewResource/Pages/ViewTenantReview.php"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Parallel Example: User Story 3
|
||||
|
||||
```bash
|
||||
# Launch the US3 tests together:
|
||||
Task: "Add workspace review register filtering, positive visibility, and empty-state coverage in tests/Feature/TenantReview/TenantReviewRegisterTest.php"
|
||||
Task: "Add tenant-context prefilter and authorized filter-option scoping coverage in tests/Feature/TenantReview/TenantReviewRegisterPrefilterTest.php"
|
||||
Task: "Add canonical register deny-as-not-found and capability coverage in tests/Feature/TenantReview/TenantReviewRegisterRbacTest.php"
|
||||
Task: "Add successor-cycle history coverage in tests/Feature/TenantReview/TenantReviewCycleTest.php"
|
||||
|
||||
# Build the canonical register in parallel after register queries exist:
|
||||
Task: "Create the canonical workspace review register page in app/Filament/Pages/Reviews/ReviewRegister.php"
|
||||
Task: "Register tenant-review navigation and page discovery in app/Providers/Filament/TenantPanelProvider.php and app/Providers/Filament/AdminPanelProvider.php"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Implementation Strategy
|
||||
|
||||
### MVP First (User Story 1 Only)
|
||||
|
||||
1. Complete Phase 1: Setup.
|
||||
2. Complete Phase 2: Foundational.
|
||||
3. Complete Phase 3: User Story 1.
|
||||
4. Validate anchored review creation, completeness rendering, RBAC, and audit history.
|
||||
5. Demo tenant review creation/detail before layering exports or workspace register views.
|
||||
|
||||
### Incremental Delivery
|
||||
|
||||
1. Finish Setup + Foundational to establish the review aggregate.
|
||||
2. Deliver US1 for review creation and anchored inspection.
|
||||
3. Deliver US2 for publication and executive-pack export.
|
||||
4. Deliver US3 for recurring-cycle management and canonical register visibility.
|
||||
5. Finish with polish, performance, and regression hardening.
|
||||
|
||||
### Parallel Team Strategy
|
||||
|
||||
1. One developer handles persistence/policies/services in Phases 1-2.
|
||||
2. After Phase 2, one developer can take US1 Filament surfaces while another prepares US2 export integration tests.
|
||||
3. Once US1 data flows exist, a third developer can build US3 register surfaces and RBAC coverage.
|
||||
|
||||
---
|
||||
|
||||
## Notes
|
||||
|
||||
- `[P]` tasks touch different files and can run in parallel once their dependencies are complete.
|
||||
- `[US1]`, `[US2]`, and `[US3]` map directly to the user stories in `spec.md`.
|
||||
- Global search should only remain enabled if `TenantReviewResource` keeps a `View` page; otherwise disable it explicitly.
|
||||
- Filament v5 work here remains compatible with Livewire v4, and panel-provider changes belong in `bootstrap/providers.php` only if a new provider is introduced. This feature reuses the existing panel providers.
|
||||
@ -11,7 +11,6 @@
|
||||
use App\Models\Tenant;
|
||||
use App\Services\Evidence\EvidenceSnapshotService;
|
||||
use App\Support\Evidence\EvidenceSnapshotStatus;
|
||||
use Filament\Facades\Filament;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Livewire\Livewire;
|
||||
@ -90,8 +89,7 @@ function seedWidgetReviewPackSnapshot(Tenant $tenant): EvidenceSnapshot
|
||||
$tenant = Tenant::factory()->create();
|
||||
[$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'owner');
|
||||
|
||||
$tenant->makeCurrent();
|
||||
Filament::setTenant($tenant, true);
|
||||
setTenantPanelContext($tenant);
|
||||
|
||||
Livewire::actingAs($user)
|
||||
->test(TenantReviewPackCard::class, ['record' => $tenant])
|
||||
@ -117,8 +115,7 @@ function seedWidgetReviewPackSnapshot(Tenant $tenant): EvidenceSnapshot
|
||||
'file_disk' => 'exports',
|
||||
]);
|
||||
|
||||
$tenant->makeCurrent();
|
||||
Filament::setTenant($tenant, true);
|
||||
setTenantPanelContext($tenant);
|
||||
|
||||
Livewire::actingAs($user)
|
||||
->test(TenantReviewPackCard::class, ['record' => $tenant])
|
||||
@ -139,8 +136,7 @@ function seedWidgetReviewPackSnapshot(Tenant $tenant): EvidenceSnapshot
|
||||
'initiated_by_user_id' => (int) $user->getKey(),
|
||||
]);
|
||||
|
||||
$tenant->makeCurrent();
|
||||
Filament::setTenant($tenant, true);
|
||||
setTenantPanelContext($tenant);
|
||||
|
||||
Livewire::actingAs($user)
|
||||
->test(TenantReviewPackCard::class, ['record' => $tenant])
|
||||
@ -160,8 +156,7 @@ function seedWidgetReviewPackSnapshot(Tenant $tenant): EvidenceSnapshot
|
||||
'initiated_by_user_id' => (int) $user->getKey(),
|
||||
]);
|
||||
|
||||
$tenant->makeCurrent();
|
||||
Filament::setTenant($tenant, true);
|
||||
setTenantPanelContext($tenant);
|
||||
|
||||
Livewire::actingAs($user)
|
||||
->test(TenantReviewPackCard::class, ['record' => $tenant])
|
||||
@ -181,8 +176,7 @@ function seedWidgetReviewPackSnapshot(Tenant $tenant): EvidenceSnapshot
|
||||
'initiated_by_user_id' => (int) $user->getKey(),
|
||||
]);
|
||||
|
||||
$tenant->makeCurrent();
|
||||
Filament::setTenant($tenant, true);
|
||||
setTenantPanelContext($tenant);
|
||||
|
||||
Livewire::actingAs($user)
|
||||
->test(TenantReviewPackCard::class, ['record' => $tenant])
|
||||
@ -202,8 +196,7 @@ function seedWidgetReviewPackSnapshot(Tenant $tenant): EvidenceSnapshot
|
||||
'initiated_by_user_id' => (int) $user->getKey(),
|
||||
]);
|
||||
|
||||
$tenant->makeCurrent();
|
||||
Filament::setTenant($tenant, true);
|
||||
setTenantPanelContext($tenant);
|
||||
|
||||
Livewire::actingAs($user)
|
||||
->test(TenantReviewPackCard::class, ['record' => $tenant])
|
||||
@ -218,8 +211,7 @@ function seedWidgetReviewPackSnapshot(Tenant $tenant): EvidenceSnapshot
|
||||
[$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'owner');
|
||||
seedWidgetReviewPackSnapshot($tenant);
|
||||
|
||||
$tenant->makeCurrent();
|
||||
Filament::setTenant($tenant, true);
|
||||
setTenantPanelContext($tenant);
|
||||
|
||||
Livewire::actingAs($user)
|
||||
->test(TenantReviewPackCard::class, ['record' => $tenant])
|
||||
|
||||
@ -0,0 +1,64 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Filament\Resources\ReviewPackResource;
|
||||
use App\Jobs\GenerateReviewPackJob;
|
||||
use App\Services\ReviewPackService;
|
||||
use App\Support\ReviewPackStatus;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
|
||||
beforeEach(function (): void {
|
||||
Storage::fake('exports');
|
||||
});
|
||||
|
||||
it('generates a review-derived executive pack with tenant-review metadata and filtered sections', function (): void {
|
||||
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||
$review = composeTenantReviewForTest($tenant, $user);
|
||||
|
||||
$pack = app(ReviewPackService::class)->generateFromReview($review, $user, [
|
||||
'include_pii' => false,
|
||||
'include_operations' => false,
|
||||
]);
|
||||
|
||||
$job = new GenerateReviewPackJob(
|
||||
reviewPackId: (int) $pack->getKey(),
|
||||
operationRunId: (int) $pack->operation_run_id,
|
||||
);
|
||||
app()->call([$job, 'handle']);
|
||||
|
||||
$pack->refresh();
|
||||
$review->refresh();
|
||||
|
||||
expect($pack->tenant_review_id)->toBe((int) $review->getKey())
|
||||
->and($pack->status)->toBe(ReviewPackStatus::Ready->value)
|
||||
->and($pack->summary['tenant_review_id'] ?? null)->toBe((int) $review->getKey())
|
||||
->and($pack->summary['review_status'] ?? null)->toBe((string) $review->status)
|
||||
->and($review->current_export_review_pack_id)->toBe((int) $pack->getKey())
|
||||
->and(data_get($review->summary, 'has_ready_export'))->toBeTrue();
|
||||
|
||||
$zipContent = Storage::disk('exports')->get((string) $pack->file_path);
|
||||
$tempFile = tempnam(sys_get_temp_dir(), 'review-derived-pack-');
|
||||
file_put_contents($tempFile, $zipContent);
|
||||
|
||||
$zip = new ZipArchive;
|
||||
$zip->open($tempFile);
|
||||
|
||||
$metadata = json_decode((string) $zip->getFromName('metadata.json'), true, 512, JSON_THROW_ON_ERROR);
|
||||
$summary = json_decode((string) $zip->getFromName('summary.json'), true, 512, JSON_THROW_ON_ERROR);
|
||||
$sections = json_decode((string) $zip->getFromName('sections.json'), true, 512, JSON_THROW_ON_ERROR);
|
||||
|
||||
expect(data_get($metadata, 'tenant_name'))->toBe('[REDACTED]')
|
||||
->and(data_get($metadata, 'options.include_operations'))->toBeFalse()
|
||||
->and(data_get($summary, 'tenant_review_id'))->toBe((int) $review->getKey())
|
||||
->and(collect($sections)->pluck('section_key')->all())->not->toContain('operations_health');
|
||||
|
||||
$zip->close();
|
||||
unlink($tempFile);
|
||||
|
||||
$this->actingAs($user)
|
||||
->get(ReviewPackResource::getUrl('view', ['record' => $pack], tenant: $tenant))
|
||||
->assertOk()
|
||||
->assertSee('#'.$review->getKey())
|
||||
->assertSee('Review status');
|
||||
});
|
||||
90
tests/Feature/TenantReview/TenantReviewAuditLogTest.php
Normal file
90
tests/Feature/TenantReview/TenantReviewAuditLogTest.php
Normal file
@ -0,0 +1,90 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Jobs\GenerateReviewPackJob;
|
||||
use App\Models\AuditLog;
|
||||
use App\Models\EvidenceSnapshot;
|
||||
use App\Services\ReviewPackService;
|
||||
use App\Services\TenantReviews\TenantReviewLifecycleService;
|
||||
use App\Services\TenantReviews\TenantReviewService;
|
||||
use App\Support\Audit\AuditActionId;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
|
||||
beforeEach(function (): void {
|
||||
Storage::fake('exports');
|
||||
});
|
||||
|
||||
it('records tenant-review audit history across create, refresh, publish, export, successor, and archive flows', function (): void {
|
||||
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||
|
||||
$reviewService = app(TenantReviewService::class);
|
||||
$lifecycle = app(TenantReviewLifecycleService::class);
|
||||
|
||||
$initialSnapshot = seedTenantReviewEvidence($tenant);
|
||||
$review = $reviewService->create($tenant, $initialSnapshot, $user);
|
||||
$review = $reviewService->compose($review);
|
||||
|
||||
EvidenceSnapshot::query()
|
||||
->where('tenant_id', (int) $tenant->getKey())
|
||||
->where('status', 'active')
|
||||
->update([
|
||||
'status' => 'expired',
|
||||
'expires_at' => now(),
|
||||
]);
|
||||
|
||||
$refreshSnapshot = seedTenantReviewEvidence(
|
||||
tenant: $tenant,
|
||||
findingCount: 6,
|
||||
driftCount: 2,
|
||||
operationRunCount: 2,
|
||||
);
|
||||
$review = $reviewService->refresh($review, $user, $refreshSnapshot);
|
||||
$review = $reviewService->compose($review->fresh());
|
||||
|
||||
$published = $lifecycle->publish($review, $user);
|
||||
|
||||
EvidenceSnapshot::query()
|
||||
->where('tenant_id', (int) $tenant->getKey())
|
||||
->where('status', 'active')
|
||||
->update([
|
||||
'status' => 'expired',
|
||||
'expires_at' => now(),
|
||||
]);
|
||||
|
||||
$pack = app(ReviewPackService::class)->generateFromReview($published, $user, [
|
||||
'include_pii' => true,
|
||||
'include_operations' => true,
|
||||
]);
|
||||
|
||||
$job = new GenerateReviewPackJob(
|
||||
reviewPackId: (int) $pack->getKey(),
|
||||
operationRunId: (int) $pack->operation_run_id,
|
||||
);
|
||||
app()->call([$job, 'handle']);
|
||||
|
||||
$nextReview = $lifecycle->createNextReview($published->fresh(), $user, seedTenantReviewEvidence(
|
||||
tenant: $tenant,
|
||||
findingCount: 7,
|
||||
driftCount: 1,
|
||||
operationRunCount: 3,
|
||||
));
|
||||
|
||||
$lifecycle->archive($nextReview, $user);
|
||||
|
||||
expect(AuditLog::query()->where('action', AuditActionId::TenantReviewCreated->value)->exists())->toBeTrue()
|
||||
->and(AuditLog::query()->where('action', AuditActionId::TenantReviewRefreshed->value)->exists())->toBeTrue()
|
||||
->and(AuditLog::query()->where('action', AuditActionId::TenantReviewPublished->value)->exists())->toBeTrue()
|
||||
->and(AuditLog::query()->where('action', AuditActionId::TenantReviewExported->value)->exists())->toBeTrue()
|
||||
->and(AuditLog::query()->where('action', AuditActionId::TenantReviewSuccessorCreated->value)->exists())->toBeTrue()
|
||||
->and(AuditLog::query()->where('action', AuditActionId::TenantReviewArchived->value)->exists())->toBeTrue();
|
||||
|
||||
$exportAudit = AuditLog::query()
|
||||
->where('action', AuditActionId::TenantReviewExported->value)
|
||||
->latest('id')
|
||||
->first();
|
||||
|
||||
expect($exportAudit)->not->toBeNull()
|
||||
->and($exportAudit?->resource_type)->toBe('tenant_review')
|
||||
->and(data_get($exportAudit?->metadata, 'review_pack_id'))->toBe((int) $pack->getKey());
|
||||
});
|
||||
50
tests/Feature/TenantReview/TenantReviewCreationTest.php
Normal file
50
tests/Feature/TenantReview/TenantReviewCreationTest.php
Normal file
@ -0,0 +1,50 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Models\Finding;
|
||||
use App\Services\Evidence\EvidenceSnapshotService;
|
||||
use App\Support\TenantReviewCompletenessState;
|
||||
use App\Support\TenantReviewStatus;
|
||||
|
||||
it('creates an anchored tenant review from a chosen evidence snapshot and keeps that basis stable after live data changes', function (): void {
|
||||
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||
$snapshot = seedTenantReviewEvidence($tenant);
|
||||
|
||||
$review = composeTenantReviewForTest($tenant, $user, $snapshot);
|
||||
|
||||
expect($review->evidence_snapshot_id)->toBe((int) $snapshot->getKey())
|
||||
->and($review->sections)->toHaveCount(6)
|
||||
->and($review->summary['evidence_basis']['snapshot_id'])->toBe((int) $snapshot->getKey());
|
||||
|
||||
Finding::factory()->create([
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'severity' => Finding::SEVERITY_CRITICAL,
|
||||
]);
|
||||
|
||||
$newSnapshotPayload = app(EvidenceSnapshotService::class)->buildSnapshotPayload($tenant);
|
||||
|
||||
expect($review->fresh()->evidence_snapshot_id)->toBe((int) $snapshot->getKey())
|
||||
->and($review->fresh()->summary['evidence_basis']['snapshot_fingerprint'])->toBe((string) $snapshot->fingerprint)
|
||||
->and($newSnapshotPayload['fingerprint'])->not->toBe((string) $snapshot->fingerprint);
|
||||
});
|
||||
|
||||
it('records completeness and publish blockers when the evidence basis is partial or missing', function (): void {
|
||||
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||
|
||||
$snapshot = seedTenantReviewEvidence(
|
||||
tenant: $tenant,
|
||||
permissionPayload: [
|
||||
'required_count' => 10,
|
||||
'granted_count' => 7,
|
||||
],
|
||||
operationRunCount: 0,
|
||||
);
|
||||
|
||||
$review = composeTenantReviewForTest($tenant, $user, $snapshot);
|
||||
|
||||
expect($review->completeness_state)->toBe(TenantReviewCompletenessState::Missing->value)
|
||||
->and($review->status)->toBe(TenantReviewStatus::Draft->value)
|
||||
->and($review->publishBlockers())->not->toBeEmpty();
|
||||
});
|
||||
41
tests/Feature/TenantReview/TenantReviewCycleTest.php
Normal file
41
tests/Feature/TenantReview/TenantReviewCycleTest.php
Normal file
@ -0,0 +1,41 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Models\EvidenceSnapshot;
|
||||
use App\Services\TenantReviews\TenantReviewLifecycleService;
|
||||
use App\Support\TenantReviewStatus;
|
||||
|
||||
it('creates a successor draft from a published tenant review without mutating the published review body', function (): void {
|
||||
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||
$publishedReview = app(TenantReviewLifecycleService::class)->publish(
|
||||
composeTenantReviewForTest($tenant, $user),
|
||||
$user,
|
||||
);
|
||||
|
||||
EvidenceSnapshot::query()
|
||||
->where('tenant_id', (int) $tenant->getKey())
|
||||
->where('status', 'active')
|
||||
->update([
|
||||
'status' => 'expired',
|
||||
'expires_at' => now(),
|
||||
]);
|
||||
|
||||
$nextSnapshot = seedTenantReviewEvidence(
|
||||
tenant: $tenant,
|
||||
findingCount: 5,
|
||||
driftCount: 2,
|
||||
operationRunCount: 2,
|
||||
);
|
||||
|
||||
$nextReview = app(TenantReviewLifecycleService::class)->createNextReview($publishedReview, $user, $nextSnapshot);
|
||||
$publishedReview->refresh();
|
||||
|
||||
expect((int) $nextReview->getKey())->not->toBe((int) $publishedReview->getKey())
|
||||
->and($nextReview->isMutable())->toBeTrue()
|
||||
->and($nextReview->evidence_snapshot_id)->toBe((int) $nextSnapshot->getKey());
|
||||
|
||||
expect($publishedReview->status)->toBe(TenantReviewStatus::Superseded->value)
|
||||
->and($publishedReview->superseded_by_review_id)->toBe((int) $nextReview->getKey())
|
||||
->and($publishedReview->published_at)->not->toBeNull();
|
||||
});
|
||||
66
tests/Feature/TenantReview/TenantReviewExecutivePackTest.php
Normal file
66
tests/Feature/TenantReview/TenantReviewExecutivePackTest.php
Normal file
@ -0,0 +1,66 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Filament\Resources\TenantReviewResource;
|
||||
use App\Filament\Widgets\Tenant\TenantReviewPackCard;
|
||||
use App\Jobs\GenerateReviewPackJob;
|
||||
use App\Services\ReviewPackService;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Livewire\Livewire;
|
||||
|
||||
beforeEach(function (): void {
|
||||
Storage::fake('exports');
|
||||
});
|
||||
|
||||
it('renders an executive-ready tenant review and exports a pack with matching section order and summary truth', function (): void {
|
||||
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||
$review = composeTenantReviewForTest($tenant, $user);
|
||||
|
||||
$this->actingAs($user)
|
||||
->get(TenantReviewResource::tenantScopedUrl('view', ['record' => $review], $tenant))
|
||||
->assertOk()
|
||||
->assertSee('Executive posture')
|
||||
->assertSee('Executive summary')
|
||||
->assertSee('Open risk highlights')
|
||||
->assertSee('Permission posture')
|
||||
->assertSee('Publication readiness');
|
||||
|
||||
$pack = app(ReviewPackService::class)->generateFromReview($review, $user, [
|
||||
'include_pii' => true,
|
||||
'include_operations' => true,
|
||||
]);
|
||||
|
||||
$job = new GenerateReviewPackJob(
|
||||
reviewPackId: (int) $pack->getKey(),
|
||||
operationRunId: (int) $pack->operation_run_id,
|
||||
);
|
||||
app()->call([$job, 'handle']);
|
||||
|
||||
$pack->refresh();
|
||||
$review->refresh()->load('sections');
|
||||
|
||||
$zipContent = Storage::disk('exports')->get((string) $pack->file_path);
|
||||
$tempFile = tempnam(sys_get_temp_dir(), 'tenant-review-pack-');
|
||||
file_put_contents($tempFile, $zipContent);
|
||||
|
||||
$zip = new ZipArchive;
|
||||
$zip->open($tempFile);
|
||||
|
||||
$summary = json_decode((string) $zip->getFromName('summary.json'), true, 512, JSON_THROW_ON_ERROR);
|
||||
$sections = json_decode((string) $zip->getFromName('sections.json'), true, 512, JSON_THROW_ON_ERROR);
|
||||
|
||||
expect(array_column($sections, 'section_key'))
|
||||
->toBe($review->sections->pluck('section_key')->values()->all())
|
||||
->and($summary['highlights'] ?? null)->toBe($review->summary['highlights'] ?? [])
|
||||
->and($summary['recommended_next_actions'] ?? null)->toBe($review->summary['recommended_next_actions'] ?? []);
|
||||
|
||||
$zip->close();
|
||||
unlink($tempFile);
|
||||
|
||||
setTenantPanelContext($tenant);
|
||||
|
||||
Livewire::actingAs($user)
|
||||
->test(TenantReviewPackCard::class, ['record' => $tenant])
|
||||
->assertSee('View review');
|
||||
});
|
||||
@ -0,0 +1,83 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Filament\Resources\TenantReviewResource\Pages\ViewTenantReview;
|
||||
use App\Jobs\GenerateReviewPackJob;
|
||||
use App\Models\OperationRun;
|
||||
use App\Models\ReviewPack;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
use App\Notifications\OperationRunCompleted;
|
||||
use App\Support\OperationRunOutcome;
|
||||
use App\Support\OperationRunStatus;
|
||||
use App\Support\OperationRunType;
|
||||
use Illuminate\Support\Facades\Notification;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Livewire\Livewire;
|
||||
|
||||
beforeEach(function (): void {
|
||||
Storage::fake('exports');
|
||||
});
|
||||
|
||||
it('preserves executive-pack export visibility but disables it for readonly operators', function (): void {
|
||||
$tenant = Tenant::factory()->create();
|
||||
[$owner, $tenant] = createUserWithTenant(tenant: $tenant, role: 'owner');
|
||||
[$readonly] = createUserWithTenant(tenant: $tenant, user: User::factory()->create(), role: 'readonly');
|
||||
$review = composeTenantReviewForTest($tenant, $owner);
|
||||
|
||||
setTenantPanelContext($tenant);
|
||||
|
||||
Livewire::actingAs($readonly)
|
||||
->test(ViewTenantReview::class, ['record' => $review->getKey()])
|
||||
->assertActionVisible('export_executive_pack')
|
||||
->assertActionDisabled('export_executive_pack');
|
||||
});
|
||||
|
||||
it('queues the canonical executive-pack export once and records terminal summary counts on completion', function (): void {
|
||||
Notification::fake();
|
||||
|
||||
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||
$review = composeTenantReviewForTest($tenant, $user);
|
||||
|
||||
setTenantPanelContext($tenant);
|
||||
|
||||
$component = Livewire::actingAs($user)
|
||||
->test(ViewTenantReview::class, ['record' => $review->getKey()])
|
||||
->callAction('export_executive_pack')
|
||||
->assertNotified();
|
||||
|
||||
$pack = ReviewPack::query()
|
||||
->where('tenant_review_id', (int) $review->getKey())
|
||||
->latest('id')
|
||||
->firstOrFail();
|
||||
|
||||
$run = OperationRun::query()->findOrFail($pack->operation_run_id);
|
||||
|
||||
expect($run->type)->toBe(OperationRunType::ReviewPackGenerate->value);
|
||||
|
||||
$component
|
||||
->callAction('export_executive_pack')
|
||||
->assertNotified();
|
||||
|
||||
expect(ReviewPack::query()->where('tenant_review_id', (int) $review->getKey())->count())->toBe(1);
|
||||
|
||||
$job = new GenerateReviewPackJob(
|
||||
reviewPackId: (int) $pack->getKey(),
|
||||
operationRunId: (int) $pack->operation_run_id,
|
||||
);
|
||||
app()->call([$job, 'handle']);
|
||||
|
||||
$run->refresh();
|
||||
|
||||
expect($run->status)->toBe(OperationRunStatus::Completed->value)
|
||||
->and($run->outcome)->toBe(OperationRunOutcome::Succeeded->value)
|
||||
->and($run->summary_counts)->toMatchArray([
|
||||
'created' => 1,
|
||||
'finding_count' => (int) ($review->summary['finding_count'] ?? 0),
|
||||
'report_count' => (int) ($review->summary['report_count'] ?? 0),
|
||||
'operation_count' => (int) ($review->summary['operation_count'] ?? 0),
|
||||
]);
|
||||
|
||||
Notification::assertSentTo($user, OperationRunCompleted::class);
|
||||
});
|
||||
44
tests/Feature/TenantReview/TenantReviewLifecycleTest.php
Normal file
44
tests/Feature/TenantReview/TenantReviewLifecycleTest.php
Normal file
@ -0,0 +1,44 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Services\TenantReviews\TenantReviewLifecycleService;
|
||||
use App\Services\TenantReviews\TenantReviewReadinessGate;
|
||||
use App\Support\TenantReviewStatus;
|
||||
|
||||
it('blocks publication when required review sections are missing from the anchored evidence basis', function (): void {
|
||||
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||
|
||||
$review = composeTenantReviewForTest($tenant, $user, seedTenantReviewEvidence(
|
||||
tenant: $tenant,
|
||||
permissionPayload: [
|
||||
'required_count' => 11,
|
||||
'granted_count' => 7,
|
||||
],
|
||||
operationRunCount: 0,
|
||||
));
|
||||
|
||||
expect(app(TenantReviewReadinessGate::class)->canPublish($review))->toBeFalse()
|
||||
->and($review->publishBlockers())->not->toBeEmpty();
|
||||
|
||||
expect(fn () => app(TenantReviewLifecycleService::class)->publish($review, $user))
|
||||
->toThrow(\InvalidArgumentException::class);
|
||||
});
|
||||
|
||||
it('publishes ready tenant reviews and archives them without mutating the published evidence history', function (): void {
|
||||
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||
$review = composeTenantReviewForTest($tenant, $user);
|
||||
|
||||
$published = app(TenantReviewLifecycleService::class)->publish($review, $user);
|
||||
$publishedAt = $published->published_at?->toIso8601String();
|
||||
|
||||
expect($published->status)->toBe(TenantReviewStatus::Published->value)
|
||||
->and($published->published_by_user_id)->toBe((int) $user->getKey())
|
||||
->and($publishedAt)->not->toBeNull();
|
||||
|
||||
$archived = app(TenantReviewLifecycleService::class)->archive($published, $user);
|
||||
|
||||
expect($archived->status)->toBe(TenantReviewStatus::Archived->value)
|
||||
->and($archived->archived_at)->not->toBeNull()
|
||||
->and($archived->published_at?->toIso8601String())->toBe($publishedAt);
|
||||
});
|
||||
39
tests/Feature/TenantReview/TenantReviewOperationsUxTest.php
Normal file
39
tests/Feature/TenantReview/TenantReviewOperationsUxTest.php
Normal file
@ -0,0 +1,39 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Jobs\ComposeTenantReviewJob;
|
||||
use App\Models\OperationRun;
|
||||
use App\Notifications\OperationRunCompleted;
|
||||
use App\Support\OperationCatalog;
|
||||
use App\Support\OperationRunOutcome;
|
||||
use App\Support\OperationRunStatus;
|
||||
use App\Support\OperationRunType;
|
||||
use Illuminate\Support\Facades\Notification;
|
||||
use Illuminate\Support\Facades\Queue;
|
||||
|
||||
it('queues review composition on the canonical operation-run type and sends the terminal notification after completion', function (): void {
|
||||
Queue::fake();
|
||||
Notification::fake();
|
||||
|
||||
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||
$snapshot = seedTenantReviewEvidence($tenant);
|
||||
|
||||
$review = app(\App\Services\TenantReviews\TenantReviewService::class)->create($tenant, $snapshot, $user);
|
||||
$run = OperationRun::query()->findOrFail($review->operation_run_id);
|
||||
|
||||
expect($run->type)->toBe(OperationRunType::TenantReviewCompose->value)
|
||||
->and(OperationCatalog::label((string) $run->type))->toBe('Review composition');
|
||||
|
||||
Queue::assertPushed(ComposeTenantReviewJob::class);
|
||||
|
||||
$job = new ComposeTenantReviewJob((int) $review->getKey(), (int) $run->getKey());
|
||||
app()->call([$job, 'handle']);
|
||||
|
||||
$run->refresh();
|
||||
|
||||
expect($run->status)->toBe(OperationRunStatus::Completed->value)
|
||||
->and($run->outcome)->toBe(OperationRunOutcome::Succeeded->value);
|
||||
|
||||
Notification::assertSentTo($user, OperationRunCompleted::class);
|
||||
});
|
||||
53
tests/Feature/TenantReview/TenantReviewRbacTest.php
Normal file
53
tests/Feature/TenantReview/TenantReviewRbacTest.php
Normal file
@ -0,0 +1,53 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Filament\Resources\TenantReviewResource;
|
||||
use App\Filament\Resources\TenantReviewResource\Pages\ListTenantReviews;
|
||||
use App\Filament\Resources\TenantReviewResource\Pages\ViewTenantReview;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
use App\Support\Auth\UiTooltips;
|
||||
use Livewire\Livewire;
|
||||
|
||||
it('returns not found for non-members on the tenant review library and detail routes', function (): void {
|
||||
$targetTenant = Tenant::factory()->create();
|
||||
[$member] = createUserWithTenant(role: 'owner');
|
||||
$reviewOwner = User::factory()->create();
|
||||
createUserWithTenant(tenant: $targetTenant, user: $reviewOwner, role: 'owner');
|
||||
$review = composeTenantReviewForTest($targetTenant, $reviewOwner);
|
||||
|
||||
$this->actingAs($member)
|
||||
->get(TenantReviewResource::tenantScopedUrl('index', tenant: $targetTenant))
|
||||
->assertNotFound();
|
||||
|
||||
$this->actingAs($member)
|
||||
->get(TenantReviewResource::tenantScopedUrl('view', ['record' => $review], $targetTenant))
|
||||
->assertNotFound();
|
||||
});
|
||||
|
||||
it('allows readonly members to inspect reviews but keeps create actions disabled', function (): void {
|
||||
$tenant = Tenant::factory()->create();
|
||||
[$owner, $tenant] = createUserWithTenant(tenant: $tenant, role: 'owner');
|
||||
[$readonly] = createUserWithTenant(tenant: $tenant, user: User::factory()->create(), role: 'readonly');
|
||||
$review = composeTenantReviewForTest($tenant, $owner);
|
||||
|
||||
$this->actingAs($readonly)
|
||||
->get(TenantReviewResource::tenantScopedUrl('view', ['record' => $review], $tenant))
|
||||
->assertOk();
|
||||
|
||||
setTenantPanelContext($tenant);
|
||||
|
||||
Livewire::actingAs($readonly)
|
||||
->test(ListTenantReviews::class)
|
||||
->assertActionVisible('create_review')
|
||||
->assertActionDisabled('create_review')
|
||||
->assertActionExists('create_review', fn ($action): bool => $action->getTooltip() === UiTooltips::insufficientPermission());
|
||||
|
||||
Livewire::actingAs($readonly)
|
||||
->test(ViewTenantReview::class, ['record' => $review->getKey()])
|
||||
->assertActionVisible('publish_review')
|
||||
->assertActionDisabled('publish_review')
|
||||
->assertActionVisible('archive_review')
|
||||
->assertActionDisabled('archive_review');
|
||||
});
|
||||
@ -0,0 +1,89 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Filament\Pages\Reviews\ReviewRegister;
|
||||
use App\Models\Tenant;
|
||||
use App\Support\Workspaces\WorkspaceContext;
|
||||
use Livewire\Livewire;
|
||||
|
||||
it('defaults the canonical review register to the remembered tenant when tenant context is available', function (): void {
|
||||
$tenantA = Tenant::factory()->create(['name' => 'Alpha Tenant']);
|
||||
[$user, $tenantA] = createUserWithTenant(tenant: $tenantA, role: 'owner');
|
||||
|
||||
$tenantB = Tenant::factory()->create([
|
||||
'workspace_id' => (int) $tenantA->workspace_id,
|
||||
'name' => 'Beta Tenant',
|
||||
]);
|
||||
createUserWithTenant(tenant: $tenantB, user: $user, role: 'readonly');
|
||||
|
||||
$reviewA = composeTenantReviewForTest($tenantA, $user);
|
||||
$reviewB = composeTenantReviewForTest($tenantB, $user);
|
||||
|
||||
$this->actingAs($user);
|
||||
setAdminPanelContext();
|
||||
session()->put(WorkspaceContext::SESSION_KEY, (int) $tenantA->workspace_id);
|
||||
session()->put(WorkspaceContext::LAST_TENANT_IDS_SESSION_KEY, [
|
||||
(string) $tenantA->workspace_id => (int) $tenantB->getKey(),
|
||||
]);
|
||||
|
||||
Livewire::actingAs($user)
|
||||
->test(ReviewRegister::class)
|
||||
->assertSet('tableFilters.tenant_id.value', (string) $tenantB->getKey())
|
||||
->assertCanSeeTableRecords([$reviewB])
|
||||
->assertCanNotSeeTableRecords([$reviewA]);
|
||||
});
|
||||
|
||||
it('prefilters the review register from a tenant query parameter and accepts external tenant identifiers', function (): void {
|
||||
$tenantA = Tenant::factory()->create(['name' => 'Alpha Tenant']);
|
||||
[$user, $tenantA] = createUserWithTenant(tenant: $tenantA, role: 'owner');
|
||||
|
||||
$tenantB = Tenant::factory()->create([
|
||||
'workspace_id' => (int) $tenantA->workspace_id,
|
||||
'name' => 'Beta Tenant',
|
||||
]);
|
||||
createUserWithTenant(tenant: $tenantB, user: $user, role: 'readonly');
|
||||
|
||||
$reviewA = composeTenantReviewForTest($tenantA, $user);
|
||||
$reviewB = composeTenantReviewForTest($tenantB, $user);
|
||||
|
||||
$this->actingAs($user);
|
||||
setAdminPanelContext();
|
||||
session()->put(WorkspaceContext::SESSION_KEY, (int) $tenantA->workspace_id);
|
||||
|
||||
Livewire::withQueryParams(['tenant' => (string) $tenantA->external_id])
|
||||
->test(ReviewRegister::class)
|
||||
->assertSet('tableFilters.tenant_id.value', (string) $tenantA->getKey())
|
||||
->assertCanSeeTableRecords([$reviewA])
|
||||
->assertCanNotSeeTableRecords([$reviewB]);
|
||||
});
|
||||
|
||||
it('scopes canonical tenant filter options to entitled tenants only', function (): void {
|
||||
$tenantA = Tenant::factory()->create(['name' => 'Alpha Tenant']);
|
||||
[$user, $tenantA] = createUserWithTenant(tenant: $tenantA, role: 'owner');
|
||||
|
||||
$tenantB = Tenant::factory()->create([
|
||||
'workspace_id' => (int) $tenantA->workspace_id,
|
||||
'name' => 'Beta Tenant',
|
||||
]);
|
||||
createUserWithTenant(tenant: $tenantB, user: $user, role: 'readonly');
|
||||
|
||||
$tenantC = Tenant::factory()->create([
|
||||
'workspace_id' => (int) $tenantA->workspace_id,
|
||||
'name' => 'Gamma Tenant',
|
||||
]);
|
||||
|
||||
$this->actingAs($user);
|
||||
setAdminPanelContext();
|
||||
session()->put(WorkspaceContext::SESSION_KEY, (int) $tenantA->workspace_id);
|
||||
|
||||
$component = Livewire::actingAs($user)->test(ReviewRegister::class);
|
||||
$tenantFilter = $component->instance()->getTable()->getFilters()['tenant_id'] ?? null;
|
||||
|
||||
expect($tenantFilter)->not->toBeNull()
|
||||
->and($tenantFilter?->getOptions())->toBe([
|
||||
(string) $tenantA->getKey() => $tenantA->name,
|
||||
(string) $tenantB->getKey() => $tenantB->name,
|
||||
])
|
||||
->and($tenantFilter?->getOptions())->not->toHaveKey((string) $tenantC->getKey());
|
||||
});
|
||||
47
tests/Feature/TenantReview/TenantReviewRegisterRbacTest.php
Normal file
47
tests/Feature/TenantReview/TenantReviewRegisterRbacTest.php
Normal file
@ -0,0 +1,47 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Filament\Pages\Reviews\ReviewRegister;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
use App\Models\Workspace;
|
||||
use App\Models\WorkspaceMembership;
|
||||
use App\Support\Workspaces\WorkspaceContext;
|
||||
|
||||
it('returns 404 for users outside the active workspace on the canonical review register', function (): void {
|
||||
$workspace = Workspace::factory()->create();
|
||||
$user = User::factory()->create();
|
||||
|
||||
$this->actingAs($user)
|
||||
->withSession([WorkspaceContext::SESSION_KEY => (int) $workspace->getKey()])
|
||||
->get(ReviewRegister::getUrl(panel: 'admin'))
|
||||
->assertNotFound();
|
||||
});
|
||||
|
||||
it('returns 404 for workspace members that have no tenant-review visibility in the active workspace', function (): void {
|
||||
$workspace = Workspace::factory()->create();
|
||||
$user = User::factory()->create();
|
||||
|
||||
WorkspaceMembership::factory()->create([
|
||||
'workspace_id' => (int) $workspace->getKey(),
|
||||
'user_id' => (int) $user->getKey(),
|
||||
'role' => 'owner',
|
||||
]);
|
||||
|
||||
$this->actingAs($user)
|
||||
->withSession([WorkspaceContext::SESSION_KEY => (int) $workspace->getKey()])
|
||||
->get(ReviewRegister::getUrl(panel: 'admin'))
|
||||
->assertNotFound();
|
||||
});
|
||||
|
||||
it('allows entitled workspace members to access the canonical review register', function (): void {
|
||||
$tenant = Tenant::factory()->create();
|
||||
[$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'readonly');
|
||||
composeTenantReviewForTest($tenant, $user);
|
||||
|
||||
$this->actingAs($user)
|
||||
->withSession([WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id])
|
||||
->get(ReviewRegister::getUrl(panel: 'admin'))
|
||||
->assertOk();
|
||||
});
|
||||
60
tests/Feature/TenantReview/TenantReviewRegisterTest.php
Normal file
60
tests/Feature/TenantReview/TenantReviewRegisterTest.php
Normal file
@ -0,0 +1,60 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Filament\Pages\Reviews\ReviewRegister;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
use App\Support\Workspaces\WorkspaceContext;
|
||||
use Livewire\Livewire;
|
||||
|
||||
it('lists only entitled tenant reviews in the canonical review register and filters by tenant', function (): void {
|
||||
$tenantA = Tenant::factory()->create(['name' => 'Alpha Tenant']);
|
||||
[$user, $tenantA] = createUserWithTenant(tenant: $tenantA, role: 'owner');
|
||||
|
||||
$tenantB = Tenant::factory()->create([
|
||||
'workspace_id' => (int) $tenantA->workspace_id,
|
||||
'name' => 'Beta Tenant',
|
||||
]);
|
||||
createUserWithTenant(tenant: $tenantB, user: $user, role: 'readonly');
|
||||
|
||||
$tenantC = Tenant::factory()->create([
|
||||
'workspace_id' => (int) $tenantA->workspace_id,
|
||||
'name' => 'Gamma Tenant',
|
||||
]);
|
||||
$foreignOwner = User::factory()->create();
|
||||
createUserWithTenant(tenant: $tenantC, user: $foreignOwner, role: 'owner');
|
||||
|
||||
$reviewA = composeTenantReviewForTest($tenantA, $user);
|
||||
$reviewB = composeTenantReviewForTest($tenantB, $user);
|
||||
$reviewC = composeTenantReviewForTest($tenantC, $foreignOwner);
|
||||
|
||||
$this->actingAs($user);
|
||||
setAdminPanelContext();
|
||||
session()->put(WorkspaceContext::SESSION_KEY, (int) $tenantA->workspace_id);
|
||||
|
||||
Livewire::actingAs($user)
|
||||
->test(ReviewRegister::class)
|
||||
->assertCanSeeTableRecords([$reviewA, $reviewB])
|
||||
->assertCanNotSeeTableRecords([$reviewC])
|
||||
->filterTable('tenant_id', (string) $tenantB->getKey())
|
||||
->assertCanSeeTableRecords([$reviewB])
|
||||
->assertCanNotSeeTableRecords([$reviewA, $reviewC]);
|
||||
});
|
||||
|
||||
it('shows a single clear-filters empty-state action when no review rows match the current register view', function (): void {
|
||||
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||
$review = composeTenantReviewForTest($tenant, $user);
|
||||
|
||||
$this->actingAs($user);
|
||||
setAdminPanelContext();
|
||||
session()->put(WorkspaceContext::SESSION_KEY, (int) $tenant->workspace_id);
|
||||
|
||||
Livewire::actingAs($user)
|
||||
->test(ReviewRegister::class)
|
||||
->searchTable('no-such-review-row')
|
||||
->assertCanNotSeeTableRecords([$review])
|
||||
->assertTableEmptyStateActionsExistInOrder(['clear_filters_empty'])
|
||||
->assertSee('No review records match this view')
|
||||
->assertSee('Clear filters');
|
||||
});
|
||||
80
tests/Feature/TenantReview/TenantReviewUiContractTest.php
Normal file
80
tests/Feature/TenantReview/TenantReviewUiContractTest.php
Normal file
@ -0,0 +1,80 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Filament\Pages\Reviews\ReviewRegister;
|
||||
use App\Filament\Resources\TenantReviewResource;
|
||||
use App\Filament\Resources\TenantReviewResource\Pages\ListTenantReviews;
|
||||
use App\Filament\Resources\TenantReviewResource\Pages\ViewTenantReview;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
use App\Services\TenantReviews\TenantReviewLifecycleService;
|
||||
use App\Support\Workspaces\WorkspaceContext;
|
||||
use Livewire\Livewire;
|
||||
|
||||
it('disables tenant-review global search while keeping the view page available for resource inspection', function (): void {
|
||||
$reflection = new ReflectionClass(TenantReviewResource::class);
|
||||
|
||||
expect($reflection->getStaticPropertyValue('isGloballySearchable'))->toBeFalse()
|
||||
->and(array_keys(TenantReviewResource::getPages()))->toContain('view');
|
||||
});
|
||||
|
||||
it('keeps tenant review list and canonical register empty states to a single CTA', function (): void {
|
||||
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||
|
||||
$this->actingAs($user);
|
||||
setTenantPanelContext($tenant);
|
||||
|
||||
Livewire::actingAs($user)
|
||||
->test(ListTenantReviews::class)
|
||||
->assertTableEmptyStateActionsExistInOrder(['create_first_review'])
|
||||
->assertSee('No tenant reviews yet')
|
||||
->mountAction('create_review')
|
||||
->assertActionMounted('create_review');
|
||||
|
||||
setAdminPanelContext();
|
||||
session()->put(WorkspaceContext::SESSION_KEY, (int) $tenant->workspace_id);
|
||||
|
||||
Livewire::actingAs($user)
|
||||
->test(ReviewRegister::class)
|
||||
->searchTable('no-such-review')
|
||||
->assertTableEmptyStateActionsExistInOrder(['clear_filters_empty'])
|
||||
->assertSee('No review records match this view');
|
||||
});
|
||||
|
||||
it('requires confirmation for destructive tenant-review actions and preserves disabled management visibility for readonly users', function (): void {
|
||||
$tenant = Tenant::factory()->create();
|
||||
[$owner, $tenant] = createUserWithTenant(tenant: $tenant, role: 'owner');
|
||||
[$readonly] = createUserWithTenant(tenant: $tenant, user: User::factory()->create(), role: 'readonly');
|
||||
$review = composeTenantReviewForTest($tenant, $owner);
|
||||
|
||||
setTenantPanelContext($tenant);
|
||||
|
||||
Livewire::actingAs($readonly)
|
||||
->test(ViewTenantReview::class, ['record' => $review->getKey()])
|
||||
->assertActionVisible('refresh_review')
|
||||
->assertActionDisabled('refresh_review')
|
||||
->assertActionVisible('publish_review')
|
||||
->assertActionDisabled('publish_review')
|
||||
->assertActionVisible('export_executive_pack')
|
||||
->assertActionDisabled('export_executive_pack')
|
||||
->assertActionVisible('archive_review')
|
||||
->assertActionDisabled('archive_review');
|
||||
|
||||
Livewire::actingAs($owner)
|
||||
->test(ViewTenantReview::class, ['record' => $review->getKey()])
|
||||
->mountAction('refresh_review')
|
||||
->assertActionMounted('refresh_review');
|
||||
|
||||
Livewire::actingAs($owner)
|
||||
->test(ViewTenantReview::class, ['record' => $review->getKey()])
|
||||
->mountAction('publish_review')
|
||||
->assertActionMounted('publish_review');
|
||||
|
||||
$published = app(TenantReviewLifecycleService::class)->publish($review, $owner);
|
||||
|
||||
Livewire::actingAs($owner)
|
||||
->test(ViewTenantReview::class, ['record' => $published->getKey()])
|
||||
->mountAction('archive_review')
|
||||
->assertActionMounted('archive_review');
|
||||
});
|
||||
134
tests/Pest.php
134
tests/Pest.php
@ -1,14 +1,22 @@
|
||||
<?php
|
||||
|
||||
use App\Models\EvidenceSnapshot;
|
||||
use App\Models\Finding;
|
||||
use App\Models\OperationRun;
|
||||
use App\Models\ProviderConnection;
|
||||
use App\Models\ProviderCredential;
|
||||
use App\Models\StoredReport;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\TenantOnboardingSession;
|
||||
use App\Models\TenantReview;
|
||||
use App\Models\User;
|
||||
use App\Models\Workspace;
|
||||
use App\Models\WorkspaceMembership;
|
||||
use App\Services\Evidence\EvidenceSnapshotService;
|
||||
use App\Services\Graph\GraphClientInterface;
|
||||
use App\Services\TenantReviews\TenantReviewService;
|
||||
use App\Services\Tenants\TenantActionPolicySurface;
|
||||
use App\Support\Evidence\EvidenceSnapshotStatus;
|
||||
use App\Support\Providers\ProviderConnectionType;
|
||||
use App\Support\Providers\ProviderConsentStatus;
|
||||
use App\Support\Providers\ProviderCredentialKind;
|
||||
@ -17,6 +25,7 @@
|
||||
use App\Support\Tenants\TenantActionDescriptor;
|
||||
use App\Support\Tenants\TenantActionSurface;
|
||||
use App\Support\Workspaces\WorkspaceContext;
|
||||
use Filament\Facades\Filament;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Pest\PendingCalls\TestCall;
|
||||
use Tests\Feature\Findings\Concerns\InteractsWithFindingsWorkflow;
|
||||
@ -464,6 +473,131 @@ function ensureDefaultProviderConnection(
|
||||
return $connection;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $permissionPayload
|
||||
* @param array<string, mixed> $rolePayload
|
||||
*/
|
||||
function seedTenantReviewEvidence(
|
||||
Tenant $tenant,
|
||||
array $permissionPayload = [],
|
||||
array $rolePayload = [],
|
||||
int $findingCount = 3,
|
||||
int $driftCount = 1,
|
||||
int $operationRunCount = 1,
|
||||
): EvidenceSnapshot {
|
||||
StoredReport::factory()->create([
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'report_type' => StoredReport::REPORT_TYPE_PERMISSION_POSTURE,
|
||||
'payload' => array_replace_recursive([
|
||||
'posture_score' => 86,
|
||||
'required_count' => 14,
|
||||
'granted_count' => 12,
|
||||
'permissions' => [
|
||||
['key' => 'DeviceManagementConfiguration.ReadWrite.All', 'status' => 'granted'],
|
||||
],
|
||||
], $permissionPayload),
|
||||
]);
|
||||
|
||||
StoredReport::factory()->create([
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'report_type' => StoredReport::REPORT_TYPE_ENTRA_ADMIN_ROLES,
|
||||
'payload' => array_replace_recursive([
|
||||
'roles' => [
|
||||
[
|
||||
'displayName' => 'Global Administrator',
|
||||
'userPrincipalName' => 'admin@contoso.com',
|
||||
],
|
||||
],
|
||||
], $rolePayload),
|
||||
]);
|
||||
|
||||
Finding::factory()
|
||||
->count($findingCount)
|
||||
->create([
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
]);
|
||||
|
||||
Finding::factory()
|
||||
->count($driftCount)
|
||||
->create([
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'finding_type' => Finding::FINDING_TYPE_DRIFT,
|
||||
]);
|
||||
|
||||
OperationRun::factory()
|
||||
->count($operationRunCount)
|
||||
->forTenant($tenant)
|
||||
->create();
|
||||
|
||||
/** @var EvidenceSnapshotService $service */
|
||||
$service = app(EvidenceSnapshotService::class);
|
||||
$payload = $service->buildSnapshotPayload($tenant);
|
||||
|
||||
$snapshot = EvidenceSnapshot::query()->create([
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'status' => EvidenceSnapshotStatus::Active->value,
|
||||
'fingerprint' => $payload['fingerprint'],
|
||||
'completeness_state' => $payload['completeness'],
|
||||
'summary' => $payload['summary'],
|
||||
'generated_at' => now(),
|
||||
]);
|
||||
|
||||
foreach ($payload['items'] as $item) {
|
||||
$snapshot->items()->create([
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'dimension_key' => $item['dimension_key'],
|
||||
'state' => $item['state'],
|
||||
'required' => $item['required'],
|
||||
'source_kind' => $item['source_kind'],
|
||||
'source_record_type' => $item['source_record_type'],
|
||||
'source_record_id' => $item['source_record_id'],
|
||||
'source_fingerprint' => $item['source_fingerprint'],
|
||||
'measured_at' => $item['measured_at'],
|
||||
'freshness_at' => $item['freshness_at'],
|
||||
'summary_payload' => $item['summary_payload'],
|
||||
'sort_order' => $item['sort_order'],
|
||||
]);
|
||||
}
|
||||
|
||||
return $snapshot->load('items');
|
||||
}
|
||||
|
||||
function composeTenantReviewForTest(Tenant $tenant, User $user, ?EvidenceSnapshot $snapshot = null): TenantReview
|
||||
{
|
||||
$snapshot ??= seedTenantReviewEvidence($tenant);
|
||||
|
||||
/** @var TenantReviewService $service */
|
||||
$service = app(TenantReviewService::class);
|
||||
$review = $service->create($tenant, $snapshot, $user);
|
||||
|
||||
$review = $review->refresh();
|
||||
|
||||
if ($review->generated_at === null || ! $review->sections()->exists()) {
|
||||
$review = $service->compose($review);
|
||||
}
|
||||
|
||||
return $review->refresh();
|
||||
}
|
||||
|
||||
function setTenantPanelContext(Tenant $tenant): void
|
||||
{
|
||||
$tenant->makeCurrent();
|
||||
Filament::setCurrentPanel('tenant');
|
||||
Filament::setTenant($tenant, true);
|
||||
Filament::bootCurrentPanel();
|
||||
}
|
||||
|
||||
function setAdminPanelContext(): void
|
||||
{
|
||||
Filament::setCurrentPanel('admin');
|
||||
Filament::setTenant(null, true);
|
||||
Filament::bootCurrentPanel();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $attributes
|
||||
*/
|
||||
|
||||
21
tests/Unit/TenantReview/TenantReviewBadgeTest.php
Normal file
21
tests/Unit/TenantReview/TenantReviewBadgeTest.php
Normal file
@ -0,0 +1,21 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Support\Badges\BadgeCatalog;
|
||||
use App\Support\Badges\BadgeDomain;
|
||||
|
||||
it('maps tenant review lifecycle values to canonical badge semantics', function (): void {
|
||||
expect(BadgeCatalog::spec(BadgeDomain::TenantReviewStatus, 'draft')->label)->toBe('Draft')
|
||||
->and(BadgeCatalog::spec(BadgeDomain::TenantReviewStatus, 'ready')->color)->toBe('info')
|
||||
->and(BadgeCatalog::spec(BadgeDomain::TenantReviewStatus, 'published')->color)->toBe('success')
|
||||
->and(BadgeCatalog::spec(BadgeDomain::TenantReviewStatus, 'archived')->color)->toBe('gray')
|
||||
->and(BadgeCatalog::spec(BadgeDomain::TenantReviewStatus, 'failed')->color)->toBe('danger');
|
||||
});
|
||||
|
||||
it('maps tenant review completeness values to canonical badge semantics', function (): void {
|
||||
expect(BadgeCatalog::spec(BadgeDomain::TenantReviewCompleteness, 'complete')->label)->toBe('Complete')
|
||||
->and(BadgeCatalog::spec(BadgeDomain::TenantReviewCompleteness, 'partial')->color)->toBe('warning')
|
||||
->and(BadgeCatalog::spec(BadgeDomain::TenantReviewCompleteness, 'missing')->color)->toBe('danger')
|
||||
->and(BadgeCatalog::spec(BadgeDomain::TenantReviewCompleteness, 'stale')->label)->toBe('Stale');
|
||||
});
|
||||
41
tests/Unit/TenantReview/TenantReviewComposerTest.php
Normal file
41
tests/Unit/TenantReview/TenantReviewComposerTest.php
Normal file
@ -0,0 +1,41 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Services\TenantReviews\TenantReviewComposer;
|
||||
use App\Support\TenantReviewCompletenessState;
|
||||
use App\Support\TenantReviewStatus;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
it('builds the first-slice tenant review sections in a stable order', function (): void {
|
||||
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||
$snapshot = seedTenantReviewEvidence($tenant);
|
||||
|
||||
$payload = app(TenantReviewComposer::class)->compose($snapshot);
|
||||
|
||||
expect(array_column($payload['sections'], 'section_key'))->toBe([
|
||||
'executive_summary',
|
||||
'open_risks',
|
||||
'accepted_risks',
|
||||
'permission_posture',
|
||||
'baseline_drift_posture',
|
||||
'operations_health',
|
||||
])->and($payload['status'])->toBe(TenantReviewStatus::Ready->value);
|
||||
});
|
||||
|
||||
it('marks reviews as ready when evidence is partial but required sections are still present', function (): void {
|
||||
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||
|
||||
$snapshot = seedTenantReviewEvidence($tenant, permissionPayload: [
|
||||
'required_count' => 12,
|
||||
'granted_count' => 9,
|
||||
]);
|
||||
|
||||
$payload = app(TenantReviewComposer::class)->compose($snapshot);
|
||||
|
||||
expect($payload['completeness_state'])->toBe(TenantReviewCompletenessState::Partial->value)
|
||||
->and($payload['status'])->toBe(TenantReviewStatus::Ready->value)
|
||||
->and($payload['summary']['publish_blockers'])->toBe([]);
|
||||
});
|
||||
Loading…
Reference in New Issue
Block a user