Compare commits

..

6 Commits

Author SHA1 Message Date
Ahmed Darrazi
6383f205a1 chore: commit all changes (automated) 2026-04-27T21:17:40Z
Some checks failed
PR Fast Feedback / fast-feedback (pull_request) Failing after 1m3s
2026-04-27 23:17:40 +02:00
Ahmed Darrazi
0739018ee5 Merge remote-tracking branch 'origin/dev' into platform-dev 2026-04-27 19:36:43 +02:00
Ahmed Darrazi
9a02261f5c Merge remote-tracking branch 'origin/dev' into platform-dev 2026-04-27 15:03:58 +02:00
Ahmed Darrazi
65ec1d5904 Merge remote-tracking branch 'origin/dev' into platform-dev 2026-04-27 10:33:23 +02:00
Ahmed Darrazi
f05857c276 Merge remote-tracking branch 'origin/dev' into platform-dev 2026-04-27 02:13:30 +02:00
Ahmed Darrazi
9f5d3293c5 Merge remote-tracking branch 'origin/dev' into platform-dev 2026-04-26 22:53:42 +02:00
77 changed files with 64 additions and 9718 deletions

View File

@ -260,10 +260,6 @@ ## Active Technologies
- PostgreSQL via existing `managed_tenant_onboarding_sessions`, `provider_connections`, `operation_runs`, and stored permission-posture data; no new persistence planned (240-tenant-onboarding-readiness)
- PHP 8.4 (Laravel 12) + Laravel 12 + Filament v5 + Livewire v4 + Pest; existing `OperationRunLinks`, `GovernanceRunDiagnosticSummaryBuilder`, `ProviderReasonTranslator`, `RelatedNavigationResolver`, `RedactionIntegrity`, `WorkspaceAuditLogger` (241-support-diagnostic-pack)
- PostgreSQL via existing `operation_runs`, `provider_connections`, `findings`, `stored_reports`, `tenant_reviews`, `review_packs`, and `audit_logs`; no new persistence planned (241-support-diagnostic-pack)
- PHP 8.4, Laravel 12 + Filament v5, Livewire v4, Pest v4, existing review/evidence/review-pack/audit/RBAC support services (249-customer-review-workspace)
- PostgreSQL via existing `tenant_reviews`, `review_packs`, `evidence_snapshots`, findings / finding-exception truth, workspace memberships, and `audit_logs`; no new persistence planned (249-customer-review-workspace)
- PHP 8.4 (Laravel 12) + Filament v5 + Livewire v4, existing workspace settings stack (`SettingsRegistry`, `SettingsResolver`, `SettingsWriter`), `WorkspaceEntitlementResolver`, `ReviewPackService`, system directory detail page (251-commercial-entitlements-billing-state)
- PostgreSQL via existing `workspace_settings` rows plus existing audit log records; no new table or billing/account model (251-commercial-entitlements-billing-state)
- PHP 8.4.15 (feat/005-bulk-operations)
@ -298,9 +294,9 @@ ## Code Style
PHP 8.4.15: Follow standard conventions
## Recent Changes
- 251-commercial-entitlements-billing-state: Added PHP 8.4 (Laravel 12) + Filament v5 + Livewire v4, existing workspace settings stack (`SettingsRegistry`, `SettingsResolver`, `SettingsWriter`), `WorkspaceEntitlementResolver`, `ReviewPackService`, system directory detail page
- 249-customer-review-workspace: Added PHP 8.4, Laravel 12 + Filament v5, Livewire v4, Pest v4, existing review/evidence/review-pack/audit/RBAC support services
- 241-support-diagnostic-pack: Added PHP 8.4 (Laravel 12) + Laravel 12 + Filament v5 + Livewire v4 + Pest; existing `OperationRunLinks`, `GovernanceRunDiagnosticSummaryBuilder`, `ProviderReasonTranslator`, `RelatedNavigationResolver`, `RedactionIntegrity`, `WorkspaceAuditLogger`
- 240-tenant-onboarding-readiness: Added PHP 8.4 (Laravel 12) + Laravel 12 + Filament v5 + Livewire v4 + Pest; existing onboarding services (`OnboardingLifecycleService`, `OnboardingDraftStageResolver`), provider connection summary, verification assist, and Ops-UX helpers
- 239-canonical-operation-type-source-of-truth: Added PHP 8.4.15, Laravel 12, Filament v5, Livewire v4 + existing `App\Support\OperationCatalog`, `App\Support\OperationRunType`, `App\Services\OperationRunService`, `App\Services\Providers\ProviderOperationRegistry`, `App\Services\Providers\ProviderOperationStartGate`, `App\Filament\Resources\OperationRunResource`, `App\Filament\Pages\Workspaces\ManagedTenantOnboardingWizard`, `App\Support\Filament\FilterOptionCatalog`, `App\Support\OpsUx\OperationUxPresenter`, `App\Support\References\Resolvers\OperationRunReferenceResolver`, `App\Services\Audit\AuditEventBuilder`, Pest v4
<!-- MANUAL ADDITIONS START -->
### Pre-production compatibility check

View File

@ -1,494 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Filament\Pages\Governance;
use App\Filament\Pages\Findings\FindingsIntakeQueue;
use App\Filament\Pages\Findings\MyFindingsInbox;
use App\Models\Tenant;
use App\Models\User;
use App\Models\Workspace;
use App\Services\Auth\CapabilityResolver;
use App\Services\Auth\WorkspaceCapabilityResolver;
use App\Services\TenantReviews\TenantReviewRegisterService;
use App\Support\Auth\Capabilities;
use App\Support\GovernanceInbox\GovernanceInboxSectionBuilder;
use App\Support\Navigation\CanonicalNavigationContext;
use App\Support\Ui\ActionSurface\ActionSurfaceDeclaration;
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceInspectAffordance;
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceProfile;
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceSlot;
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceType;
use App\Support\Workspaces\WorkspaceContext;
use BackedEnum;
use Filament\Facades\Filament;
use Filament\Pages\Page;
use Illuminate\Support\Str;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
use UnitEnum;
class GovernanceInbox extends Page
{
protected static bool $isDiscovered = false;
protected static string|BackedEnum|null $navigationIcon = 'heroicon-o-inbox-stack';
protected static string|UnitEnum|null $navigationGroup = 'Governance';
protected static ?string $navigationLabel = 'Governance inbox';
protected static ?int $navigationSort = 5;
protected static ?string $title = 'Governance inbox';
protected static ?string $slug = 'governance/inbox';
protected string $view = 'filament.pages.governance.governance-inbox';
/**
* @var array<int, Tenant>|null
*/
private ?array $authorizedTenants = null;
/**
* @var array<int, Tenant>|null
*/
private ?array $visibleFindingTenants = null;
/**
* @var array<int, Tenant>|null
*/
private ?array $reviewTenants = null;
/**
* @var array<string, mixed>|null
*/
private ?array $inboxPayload = null;
/**
* @var array<string, mixed>|null
*/
private ?array $unfilteredInboxPayload = null;
private ?Workspace $workspace = null;
private ?bool $visibleAlertsFamily = null;
public ?int $tenantId = null;
public ?string $family = null;
public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration
{
return ActionSurfaceDeclaration::forPage(ActionSurfaceProfile::ListOnlyReadOnly, ActionSurfaceType::ReadOnlyRegistryReport)
->satisfy(ActionSurfaceSlot::ListHeader, 'Header actions keep the workspace decision surface calm and read-only.')
->satisfy(ActionSurfaceSlot::InspectAffordance, ActionSurfaceInspectAffordance::ClickableRow->value)
->exempt(ActionSurfaceSlot::ListRowMoreMenu, 'The governance inbox routes into existing source surfaces instead of exposing row-level secondary actions.')
->exempt(ActionSurfaceSlot::ListBulkMoreGroup, 'The governance inbox does not expose bulk actions.')
->satisfy(ActionSurfaceSlot::ListEmptyState, 'Empty states stay calm and capability-safe when no visible attention exists.')
->exempt(ActionSurfaceSlot::DetailHeader, 'The governance inbox owns no local detail surface in v1.');
}
public function mount(): void
{
$this->authorizeWorkspaceMembership();
$this->applyRequestedTenantPrefilter();
$this->family = $this->resolveRequestedFamily();
$this->ensureAtLeastOneVisibleFamily();
$this->ensureRequestedFamilyIsVisible();
}
/**
* @return array<string, mixed>
*/
public function appliedScope(): array
{
$selectedTenant = $this->selectedTenant();
$availableFamilies = collect($this->availableFamilies())
->keyBy('key');
return [
'workspace_label' => $this->workspace()?->name,
'tenant_label' => $selectedTenant?->name,
'tenant_prefilter_source' => $selectedTenant instanceof Tenant ? 'explicit_filter' : 'none',
'family_key' => $this->family,
'family_label' => $this->family !== null
? ($availableFamilies->get($this->family)['label'] ?? Str::headline($this->family))
: 'All attention',
'total_count' => (int) ($this->inboxPayload()['total_count'] ?? 0),
];
}
/**
* @return list<array{key: string, label: string, count: int}>
*/
public function availableFamilies(): array
{
return $this->inboxPayload()['available_families'] ?? [];
}
/**
* @return list<array<string, mixed>>
*/
public function sections(): array
{
return $this->inboxPayload()['sections'] ?? [];
}
/**
* @return array<string, mixed>
*/
public function calmEmptyState(): array
{
if ($this->tenantFilterAloneExcludesRows()) {
return [
'title' => 'This tenant filter is hiding other visible attention',
'body' => 'The current tenant scope is calm, but other visible tenants in this workspace still have governance attention.',
'action_label' => 'Clear tenant filter',
'action_url' => $this->pageUrl(['tenant' => null]),
];
}
return [
'title' => 'No visible governance attention right now',
'body' => 'The current workspace scope is calm across the visible governance families.',
'action_label' => null,
'action_url' => null,
];
}
public function hasTenantPrefilter(): bool
{
return $this->selectedTenant() instanceof Tenant;
}
public function isActiveFamily(?string $familyKey): bool
{
return $this->family === $familyKey;
}
public function pageUrl(array $overrides = []): string
{
$selectedTenant = $this->selectedTenant();
$resolvedTenant = array_key_exists('tenant', $overrides)
? $overrides['tenant']
: ($selectedTenant?->getKey() !== null ? (string) $selectedTenant->getKey() : null);
$resolvedFamily = array_key_exists('family', $overrides)
? $overrides['family']
: $this->family;
return static::getUrl(
panel: 'admin',
parameters: array_filter([
'tenant_id' => is_string($resolvedTenant) && $resolvedTenant !== '' ? $resolvedTenant : null,
'family' => is_string($resolvedFamily) && $resolvedFamily !== '' ? $resolvedFamily : null,
], static fn (mixed $value): bool => $value !== null && $value !== ''),
);
}
public function navigationContext(): CanonicalNavigationContext
{
return new CanonicalNavigationContext(
sourceSurface: 'governance.inbox',
canonicalRouteName: static::getRouteName(Filament::getPanel('admin')),
tenantId: $this->tenantId,
backLinkLabel: 'Back to governance inbox',
backLinkUrl: $this->pageUrl(),
);
}
private function authorizeWorkspaceMembership(): void
{
$user = auth()->user();
$workspace = $this->workspace();
if (! $user instanceof User) {
abort(403);
}
if (! $workspace instanceof Workspace) {
throw new NotFoundHttpException;
}
$resolver = app(WorkspaceCapabilityResolver::class);
if (! $resolver->isMember($user, $workspace)) {
throw new NotFoundHttpException;
}
}
private function ensureAtLeastOneVisibleFamily(): void
{
if (
$this->hasVisibleOperationsFamily()
|| $this->visibleFindingTenants() !== []
|| $this->reviewTenants() !== []
|| $this->hasVisibleAlertsFamily()
) {
return;
}
abort(403);
}
private function ensureRequestedFamilyIsVisible(): void
{
if ($this->family === null) {
return;
}
if (in_array($this->family, collect($this->availableFamilies())->pluck('key')->all(), true)) {
return;
}
throw new NotFoundHttpException;
}
private function hasVisibleOperationsFamily(): bool
{
return $this->authorizedTenants() !== [];
}
private function hasVisibleAlertsFamily(): bool
{
if (is_bool($this->visibleAlertsFamily)) {
return $this->visibleAlertsFamily;
}
$user = auth()->user();
$workspace = $this->workspace();
if (! $user instanceof User || ! $workspace instanceof Workspace) {
return $this->visibleAlertsFamily = false;
}
return $this->visibleAlertsFamily = app(WorkspaceCapabilityResolver::class)->can($user, $workspace, Capabilities::ALERTS_VIEW);
}
/**
* @return array<int, Tenant>
*/
private function visibleFindingTenants(): array
{
if ($this->visibleFindingTenants !== null) {
return $this->visibleFindingTenants;
}
$user = auth()->user();
$tenants = $this->authorizedTenants();
if (! $user instanceof User || $tenants === []) {
return $this->visibleFindingTenants = [];
}
$resolver = app(CapabilityResolver::class);
$resolver->primeMemberships(
$user,
array_map(static fn (Tenant $tenant): int => (int) $tenant->getKey(), $tenants),
);
return $this->visibleFindingTenants = array_values(array_filter(
$tenants,
fn (Tenant $tenant): bool => $resolver->can($user, $tenant, Capabilities::TENANT_FINDINGS_VIEW),
));
}
/**
* @return array<int, Tenant>
*/
private function reviewTenants(): array
{
if ($this->reviewTenants !== null) {
return $this->reviewTenants;
}
$user = auth()->user();
$workspace = $this->workspace();
if (! $user instanceof User || ! $workspace instanceof Workspace) {
return $this->reviewTenants = [];
}
$service = app(TenantReviewRegisterService::class);
if (! $service->canAccessWorkspace($user, $workspace)) {
return $this->reviewTenants = [];
}
return $this->reviewTenants = $service->authorizedTenants($user, $workspace);
}
/**
* @return array<int, Tenant>
*/
private 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 = $user->tenants()
->where('tenants.workspace_id', (int) $workspace->getKey())
->where('tenants.status', 'active')
->orderBy('tenants.name')
->get(['tenants.id', 'tenants.name', 'tenants.external_id', 'tenants.workspace_id'])
->all();
}
private function applyRequestedTenantPrefilter(): void
{
$requestedTenant = request()->query('tenant_id', 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->tenantId = (int) $tenant->getKey();
return;
}
throw new NotFoundHttpException;
}
private function resolveRequestedFamily(): ?string
{
$family = request()->query('family');
if (! is_string($family)) {
return null;
}
return in_array($family, [
'assigned_findings',
'intake_findings',
'stale_operations',
'alert_delivery_failures',
'review_follow_up',
], true) ? $family : null;
}
private function workspace(): ?Workspace
{
if ($this->workspace instanceof Workspace) {
return $this->workspace;
}
$workspaceId = app(WorkspaceContext::class)->currentWorkspaceId(request());
if (! is_int($workspaceId)) {
return null;
}
return $this->workspace = Workspace::query()->whereKey($workspaceId)->first();
}
/**
* @return array<string, mixed>
*/
private function inboxPayload(): array
{
if (is_array($this->inboxPayload)) {
return $this->inboxPayload;
}
$user = auth()->user();
$workspace = $this->workspace();
if (! $user instanceof User || ! $workspace instanceof Workspace) {
return $this->inboxPayload = [
'sections' => [],
'available_families' => [],
'family_counts' => [],
'total_count' => 0,
];
}
return $this->inboxPayload = app(GovernanceInboxSectionBuilder::class)->build(
user: $user,
workspace: $workspace,
authorizedTenants: $this->authorizedTenants(),
visibleFindingTenants: $this->visibleFindingTenants(),
reviewTenants: $this->reviewTenants(),
canViewAlerts: $this->hasVisibleAlertsFamily(),
selectedTenant: $this->selectedTenant(),
selectedFamily: $this->family,
navigationContext: $this->navigationContext(),
);
}
/**
* @return array<string, mixed>
*/
private function unfilteredInboxPayload(): array
{
if (is_array($this->unfilteredInboxPayload)) {
return $this->unfilteredInboxPayload;
}
$user = auth()->user();
$workspace = $this->workspace();
if (! $user instanceof User || ! $workspace instanceof Workspace) {
return $this->unfilteredInboxPayload = [
'sections' => [],
'available_families' => [],
'family_counts' => [],
'total_count' => 0,
];
}
return $this->unfilteredInboxPayload = app(GovernanceInboxSectionBuilder::class)->build(
user: $user,
workspace: $workspace,
authorizedTenants: $this->authorizedTenants(),
visibleFindingTenants: $this->visibleFindingTenants(),
reviewTenants: $this->reviewTenants(),
canViewAlerts: $this->hasVisibleAlertsFamily(),
selectedTenant: null,
selectedFamily: null,
navigationContext: $this->navigationContext(),
);
}
private function selectedTenant(): ?Tenant
{
if (! is_int($this->tenantId)) {
return null;
}
foreach ($this->authorizedTenants() as $tenant) {
if ((int) $tenant->getKey() === $this->tenantId) {
return $tenant;
}
}
return null;
}
private function tenantFilterAloneExcludesRows(): bool
{
if (! is_int($this->tenantId) || $this->family !== null) {
return false;
}
if ($this->sections() !== []) {
return false;
}
return (int) ($this->unfilteredInboxPayload()['total_count'] ?? 0) > 0;
}
}

View File

@ -1,497 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Filament\Pages\Reviews;
use App\Filament\Resources\TenantReviewResource;
use App\Models\Tenant;
use App\Models\ReviewPack;
use App\Models\TenantReview;
use App\Models\User;
use App\Models\Workspace;
use App\Services\ReviewPackService;
use App\Services\TenantReviews\TenantReviewRegisterService;
use App\Support\Auth\Capabilities;
use App\Support\Findings\FindingOutcomeSemantics;
use App\Support\Filament\TablePaginationProfiles;
use App\Support\ReviewPackStatus;
use App\Support\Ui\GovernanceArtifactTruth\ArtifactTruthEnvelope;
use App\Support\Ui\GovernanceArtifactTruth\ArtifactTruthPresenter;
use App\Support\Ui\GovernanceArtifactTruth\CompressedGovernanceOutcome;
use App\Support\Ui\GovernanceArtifactTruth\SurfaceCompressionContext;
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 CustomerReviewWorkspace extends Page implements HasTable
{
use InteractsWithTable;
public const string DETAIL_CONTEXT_QUERY_KEY = 'customer_workspace';
private const string SOURCE_SURFACE = 'customer_review_workspace';
protected static bool $isDiscovered = false;
protected static string|BackedEnum|null $navigationIcon = 'heroicon-o-document-text';
protected static string|UnitEnum|null $navigationGroup = 'Reporting';
protected static ?string $navigationLabel = 'Customer reviews';
protected static ?int $navigationSort = 44;
protected static ?string $title = 'Customer Review Workspace';
protected static ?string $slug = 'reviews/workspace';
protected string $view = 'filament.pages.reviews.customer-review-workspace';
public static function tenantPrefilterUrl(Tenant $tenant): string
{
$tenantIdentifier = filled($tenant->external_id)
? (string) $tenant->external_id
: (string) $tenant->getKey();
return static::getUrl(panel: 'admin').'?'.http_build_query([
'tenant' => $tenantIdentifier,
]);
}
/**
* @var array<int, Tenant>|null
*/
private ?array $authorizedTenants = null;
public function mount(): void
{
$this->authorizePageAccess();
$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->clearWorkspaceFilters();
}),
];
}
public function table(Table $table): Table
{
return $table
->query(fn (): Builder => $this->workspaceQuery())
->defaultSort('name')
->paginated(TablePaginationProfiles::customPage())
->persistFiltersInSession()
->persistSearchInSession()
->persistSortInSession()
->recordUrl(fn (Tenant $record): ?string => $this->latestReviewUrl($record))
->columns([
TextColumn::make('name')->label('Tenant')->searchable()->sortable(),
TextColumn::make('latest_review')
->label('Latest review')
->badge()
->getStateUsing(fn (Tenant $record): string => $this->latestReviewStateLabel($record))
->color(fn (Tenant $record): string => $this->latestReviewStateColor($record))
->icon(fn (Tenant $record): ?string => $this->latestReviewStateIcon($record))
->iconColor(fn (Tenant $record): ?string => $this->latestReviewStateIconColor($record))
->description(fn (Tenant $record): ?string => $this->reviewOutcomeDescription($record))
->wrap(),
TextColumn::make('finding_summary')
->label('Key findings')
->getStateUsing(fn (Tenant $record): string => $this->findingSummary($record))
->wrap(),
TextColumn::make('accepted_risk_summary')
->label('Accepted risks')
->getStateUsing(fn (Tenant $record): string => $this->acceptedRiskSummary($record))
->wrap(),
TextColumn::make('published_at')
->label('Published')
->getStateUsing(fn (Tenant $record): ?string => $this->latestPublishedAt($record)?->toDateTimeString())
->dateTime()
->placeholder('—'),
TextColumn::make('review_pack_state')
->label('Review pack')
->getStateUsing(fn (Tenant $record): string => $this->reviewPackAvailability($record)),
])
->filters([
SelectFilter::make('tenant_id')
->label('Tenant')
->options(fn (): array => $this->tenantFilterOptions())
->default(fn (): ?string => $this->defaultTenantFilter())
->query(function (Builder $query, array $data): Builder {
$tenantId = $data['value'] ?? null;
return is_numeric($tenantId)
? $query->whereKey((int) $tenantId)
: $query;
})
->searchable(),
])
->actions([
Action::make('open_latest_review')
->label('Open latest review')
->icon('heroicon-o-arrow-top-right-on-square')
->url(fn (Tenant $record): ?string => $this->latestReviewUrl($record))
->visible(fn (Tenant $record): bool => $this->latestPublishedReview($record) instanceof TenantReview),
Action::make('download_review_pack')
->label('Download review pack')
->icon('heroicon-o-arrow-down-tray')
->url(fn (Tenant $record): ?string => $this->latestReviewPackDownloadUrl($record))
->openUrlInNewTab()
->visible(fn (Tenant $record): bool => is_string($this->latestReviewPackDownloadUrl($record))),
])
->bulkActions([])
->emptyStateHeading('No entitled tenants match this view')
->emptyStateDescription(fn (): string => $this->hasActiveFilters()
? 'Clear the current filters to return to the full customer review workspace for your entitled tenants.'
: 'Adjust filters to return to the full customer review workspace for your entitled tenants.')
->emptyStateActions([
Action::make('clear_filters_empty')
->label('Clear filters')
->icon('heroicon-o-x-mark')
->color('gray')
->visible(fn (): bool => $this->hasActiveFilters())
->action(fn (): mixed => $this->clearWorkspaceFilters()),
]);
}
/**
* @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 workspaceQuery(): Builder
{
$user = auth()->user();
$workspace = $this->workspace();
if (! $user instanceof User || ! $workspace instanceof Workspace) {
return Tenant::query()->whereRaw('1 = 0');
}
return app(TenantReviewRegisterService::class)->customerWorkspaceTenantQuery($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', request()->query('tenant_id'));
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;
}
throw new NotFoundHttpException;
}
private function hasActiveFilters(): bool
{
return $this->currentTenantFilterId() !== null;
}
private function clearWorkspaceFilters(): void
{
app(WorkspaceContext::class)->clearLastTenantId(request());
$this->removeTableFilters();
}
private function currentTenantFilterId(): ?int
{
$tenantFilter = data_get($this->tableFilters, 'tenant_id.value');
if (! is_numeric($tenantFilter)) {
$tenantFilter = data_get(session()->get($this->getTableFiltersSessionKey(), []), 'tenant_id.value');
}
return is_numeric($tenantFilter) ? (int) $tenantFilter : null;
}
private function workspace(): ?Workspace
{
$workspaceId = app(WorkspaceContext::class)->currentWorkspaceId(request());
return is_numeric($workspaceId)
? Workspace::query()->whereKey((int) $workspaceId)->first()
: null;
}
private function latestPublishedReview(Tenant $tenant): ?TenantReview
{
$review = $tenant->tenantReviews->first();
return $review instanceof TenantReview ? $review : null;
}
private function latestReviewUrl(Tenant $tenant): ?string
{
$review = $this->latestPublishedReview($tenant);
if (! $review instanceof TenantReview) {
return null;
}
return TenantReviewResource::tenantScopedUrl('view', ['record' => $review], $tenant, 'tenant').'?'.http_build_query([
self::DETAIL_CONTEXT_QUERY_KEY => 1,
]);
}
private function latestReviewPack(Tenant $tenant): ?ReviewPack
{
$review = $this->latestPublishedReview($tenant);
$pack = $review?->currentExportReviewPack;
return $pack instanceof ReviewPack ? $pack : null;
}
private function latestReviewPackDownloadUrl(Tenant $tenant): ?string
{
$user = auth()->user();
$pack = $this->latestReviewPack($tenant);
if (! $user instanceof User || ! $pack instanceof ReviewPack) {
return null;
}
if (! $user->can(Capabilities::REVIEW_PACK_VIEW, $tenant)) {
return null;
}
if ($pack->status !== ReviewPackStatus::Ready->value) {
return null;
}
if ($pack->expires_at !== null && $pack->expires_at->isPast()) {
return null;
}
return app(ReviewPackService::class)->generateDownloadUrl($pack, [
'source_surface' => self::SOURCE_SURFACE,
]);
}
private function latestPublishedAt(Tenant $tenant): ?\Illuminate\Support\Carbon
{
return $this->latestPublishedReview($tenant)?->published_at;
}
private function reviewTruth(Tenant $tenant): ?ArtifactTruthEnvelope
{
$review = $this->latestPublishedReview($tenant);
return $review instanceof TenantReview
? app(ArtifactTruthPresenter::class)->forTenantReview($review)
: null;
}
private function reviewOutcome(Tenant $tenant): ?CompressedGovernanceOutcome
{
$presenter = app(ArtifactTruthPresenter::class);
$review = $this->latestPublishedReview($tenant);
$truth = $this->reviewTruth($tenant);
if (! $review instanceof TenantReview || ! $truth instanceof ArtifactTruthEnvelope) {
return null;
}
return $presenter->compressedOutcomeFor($review, SurfaceCompressionContext::reviewRegister())
?? $presenter->compressedOutcomeFromEnvelope($truth, SurfaceCompressionContext::reviewRegister());
}
private function latestReviewStateLabel(Tenant $tenant): string
{
return $this->reviewOutcome($tenant)?->primaryLabel ?? 'No published review';
}
private function latestReviewStateColor(Tenant $tenant): string
{
return $this->reviewOutcome($tenant)?->primaryBadge->color ?? 'gray';
}
private function latestReviewStateIcon(Tenant $tenant): ?string
{
return $this->reviewOutcome($tenant)?->primaryBadge->icon;
}
private function latestReviewStateIconColor(Tenant $tenant): ?string
{
return $this->reviewOutcome($tenant)?->primaryBadge->iconColor;
}
private function reviewOutcomeDescription(Tenant $tenant): ?string
{
$review = $this->latestPublishedReview($tenant);
if (! $review instanceof TenantReview) {
return 'No published review available yet';
}
$primaryReason = $this->reviewOutcome($tenant)?->primaryReason;
$summary = is_array($review->summary) ? $review->summary : [];
$findingOutcomes = $summary['finding_outcomes'] ?? null;
if (! is_array($findingOutcomes)) {
return $primaryReason;
}
$findingOutcomeSummary = app(FindingOutcomeSemantics::class)->compactOutcomeSummary($findingOutcomes);
if ($findingOutcomeSummary === null) {
return $primaryReason;
}
return trim($primaryReason.' Terminal outcomes: '.$findingOutcomeSummary.'.');
}
private function findingSummary(Tenant $tenant): string
{
$review = $this->latestPublishedReview($tenant);
if (! $review instanceof TenantReview) {
return 'No published review available yet';
}
$summary = is_array($review->summary) ? $review->summary : [];
$findingCount = (int) ($summary['finding_count'] ?? 0);
$findingOutcomes = is_array($summary['finding_outcomes'] ?? null) ? $summary['finding_outcomes'] : [];
$terminalOutcomes = app(FindingOutcomeSemantics::class)->compactOutcomeSummary($findingOutcomes);
if ($findingCount === 0) {
return 'No findings recorded in the published review.';
}
if ($terminalOutcomes === null) {
return sprintf('%d findings summarized in the published review.', $findingCount);
}
return sprintf('%d findings. Terminal outcomes: %s.', $findingCount, $terminalOutcomes);
}
private function acceptedRiskSummary(Tenant $tenant): string
{
$review = $this->latestPublishedReview($tenant);
if (! $review instanceof TenantReview) {
return 'No published review available yet';
}
$summary = is_array($review->summary) ? $review->summary : [];
$riskAcceptance = is_array($summary['risk_acceptance'] ?? null) ? $summary['risk_acceptance'] : [];
$statusMarkedCount = (int) ($riskAcceptance['status_marked_count'] ?? 0);
$validGovernedCount = (int) ($riskAcceptance['valid_governed_count'] ?? 0);
$warningCount = (int) ($riskAcceptance['warning_count'] ?? 0);
return match (true) {
$statusMarkedCount === 0 => 'No accepted risks recorded.',
$warningCount > 0 => sprintf('%d accepted risks need governance follow-up (%d total).', $warningCount, $statusMarkedCount),
$validGovernedCount > 0 => sprintf('%d accepted risks are governed.', $validGovernedCount),
default => sprintf('%d accepted risks are on record.', $statusMarkedCount),
};
}
private function reviewPackAvailability(Tenant $tenant): string
{
$pack = $this->latestReviewPack($tenant);
if (! $pack instanceof ReviewPack) {
return 'Unavailable';
}
if ($pack->status !== ReviewPackStatus::Ready->value) {
return 'Unavailable';
}
if ($pack->expires_at !== null && $pack->expires_at->isPast()) {
return 'Unavailable';
}
return 'Available';
}
}

View File

@ -178,23 +178,9 @@ public function table(Table $table): Table
&& auth()->user()->can(Capabilities::TENANT_REVIEW_MANAGE, $record->tenant)
&& in_array($record->status, ['ready', 'published'], true))
->disabled(fn (TenantReview $record): bool => (bool) (app(ReviewPackService::class)->reviewPackGenerationDecisionForTenant($record->tenant)['is_blocked'] ?? false))
->tooltip(function (TenantReview $record): ?string {
$decision = app(ReviewPackService::class)->reviewPackGenerationDecisionForTenant($record->tenant);
if ((bool) ($decision['is_blocked'] ?? false)) {
$reason = $decision['block_reason'] ?? null;
return is_string($reason) && $reason !== '' ? $reason : null;
}
if ((bool) ($decision['is_warning'] ?? false)) {
$reason = $decision['warning_reason'] ?? null;
return is_string($reason) && $reason !== '' ? $reason : null;
}
return null;
})
->tooltip(fn (TenantReview $record): ?string => (bool) (app(ReviewPackService::class)->reviewPackGenerationDecisionForTenant($record->tenant)['is_blocked'] ?? false)
? (string) (app(ReviewPackService::class)->reviewPackGenerationDecisionForTenant($record->tenant)['block_reason'] ?? '')
: null)
->action(fn (TenantReview $record): mixed => TenantReviewResource::executeExport($record)),
])
->bulkActions([])

View File

@ -30,7 +30,6 @@
use App\Services\Onboarding\OnboardingDraftResolver;
use App\Services\Onboarding\OnboardingDraftStageResolver;
use App\Services\Onboarding\OnboardingLifecycleService;
use App\Services\Entitlements\WorkspaceCommercialLifecycleResolver;
use App\Services\Entitlements\WorkspaceEntitlementResolver;
use App\Services\OperationRunService;
use App\Services\Providers\ProviderConnectionMutationService;
@ -4552,30 +4551,27 @@ private function completionSummaryEntitlementDecision(): array
return [];
}
return app(WorkspaceCommercialLifecycleResolver::class)->actionDecision(
return app(WorkspaceEntitlementResolver::class)->resolve(
$this->workspace,
WorkspaceCommercialLifecycleResolver::ACTION_MANAGED_TENANT_ACTIVATION,
WorkspaceEntitlementResolver::KEY_MANAGED_TENANT_ACTIVATION_LIMIT,
);
}
private function completionSummaryEntitlementBlocked(): bool
{
return ($this->completionSummaryEntitlementDecision()['outcome'] ?? null) === WorkspaceCommercialLifecycleResolver::OUTCOME_BLOCK;
return (bool) ($this->completionSummaryEntitlementDecision()['is_blocked'] ?? false);
}
private function completionSummaryEntitlementSummary(): string
{
$decision = $this->completionSummaryEntitlementDecision();
$entitlementDecision = is_array($decision['entitlement_decision'] ?? null) ? $decision['entitlement_decision'] : [];
$currentUsage = (int) ($entitlementDecision['current_usage'] ?? 0);
$effectiveValue = (int) ($entitlementDecision['effective_value'] ?? 0);
$sourceLabel = $this->completionSummaryEntitlementSourceLabel($entitlementDecision);
$stateLabel = is_string($decision['state_label'] ?? null) ? $decision['state_label'] : 'Active paid';
$currentUsage = (int) ($decision['current_usage'] ?? 0);
$effectiveValue = (int) ($decision['effective_value'] ?? 0);
$sourceLabel = $this->completionSummaryEntitlementSourceLabel($decision);
return sprintf(
'%s - %s - %d active of %d allowed (%s)',
'%s - %d active of %d allowed (%s)',
$this->completionSummaryEntitlementBlocked() ? 'Blocked' : 'Allowed',
$stateLabel,
$currentUsage,
$effectiveValue,
$sourceLabel,
@ -4585,15 +4581,13 @@ private function completionSummaryEntitlementSummary(): string
private function completionSummaryEntitlementDetail(): string
{
$decision = $this->completionSummaryEntitlementDecision();
$entitlementDecision = is_array($decision['entitlement_decision'] ?? null) ? $decision['entitlement_decision'] : [];
$currentUsage = (int) ($entitlementDecision['current_usage'] ?? 0);
$effectiveValue = (int) ($entitlementDecision['effective_value'] ?? 0);
$remainingCapacity = (int) ($entitlementDecision['remaining_capacity'] ?? 0);
$sourceLabel = $this->completionSummaryEntitlementSourceLabel($entitlementDecision);
$currentUsage = (int) ($decision['current_usage'] ?? 0);
$effectiveValue = (int) ($decision['effective_value'] ?? 0);
$remainingCapacity = (int) ($decision['remaining_capacity'] ?? 0);
$sourceLabel = $this->completionSummaryEntitlementSourceLabel($decision);
$rationale = is_string($decision['rationale'] ?? null) ? $decision['rationale'] : null;
$message = sprintf(
'%s Current usage is %d active managed tenant%s out of %d allowed. Source: %s.',
(string) ($decision['message'] ?? 'Managed-tenant activation is available for this workspace commercial state.'),
'Current usage is %d active managed tenant%s out of %d allowed. Source: %s.',
$currentUsage,
$currentUsage === 1 ? '' : 's',
$effectiveValue,
@ -4612,7 +4606,7 @@ private function completionSummaryEntitlementDetail(): string
}
}
if ($rationale !== null && $rationale !== '' && ($decision['source'] ?? null) === WorkspaceCommercialLifecycleResolver::SOURCE_WORKSPACE_SETTING) {
if ($rationale !== null && $rationale !== '' && ($decision['source'] ?? null) === 'workspace_override') {
$message .= ' Rationale: '.$rationale;
}
@ -4988,7 +4982,7 @@ public function completeOnboarding(): void
if ($this->completionSummaryEntitlementBlocked()) {
Notification::make()
->title('Activation unavailable')
->title('Activation limit reached')
->body($this->completionSummaryEntitlementDetail())
->warning()
->send();

View File

@ -6,7 +6,6 @@
use App\Filament\Concerns\InteractsWithTenantOwnedRecords;
use App\Filament\Concerns\ResolvesPanelTenantContext;
use App\Filament\Pages\Reviews\CustomerReviewWorkspace;
use App\Filament\Resources\EvidenceSnapshotResource\Pages;
use App\Filament\Resources\ReviewPackResource;
use App\Models\EvidenceSnapshot;
@ -268,20 +267,6 @@ public static function relatedContextEntries(EvidenceSnapshot $record): array
)->toArray();
}
if ($record->tenant instanceof Tenant) {
$entries[] = RelatedContextEntry::available(
key: 'customer_review_workspace',
label: 'Customer workspace',
value: $record->tenant->name,
secondaryValue: 'Open the customer-safe review workspace prefiltered to this tenant.',
targetUrl: CustomerReviewWorkspace::tenantPrefilterUrl($record->tenant),
targetKind: 'canonical_page',
priority: 30,
actionLabel: 'Open customer workspace',
contextBadge: 'Reporting',
)->toArray();
}
return $entries;
}

View File

@ -5,7 +5,6 @@
use App\Filament\Concerns\ResolvesPanelTenantContext;
use App\Exceptions\Entitlements\WorkspaceEntitlementBlockedException;
use App\Exceptions\ReviewPackEvidenceResolutionException;
use App\Filament\Pages\Reviews\CustomerReviewWorkspace;
use App\Filament\Resources\EvidenceSnapshotResource as TenantEvidenceSnapshotResource;
use App\Filament\Resources\ReviewPackResource\Pages;
use App\Models\ReviewPack;
@ -196,13 +195,6 @@ public static function infolist(Schema $schema): Schema
? TenantReviewResource::tenantScopedUrl('view', ['record' => $record->tenantReview], $record->tenant)
: null)
->placeholder('—'),
TextEntry::make('customer_workspace')
->label('Customer workspace')
->state(fn (): string => 'Open workspace')
->url(fn (ReviewPack $record): ?string => $record->tenant instanceof Tenant
? CustomerReviewWorkspace::tenantPrefilterUrl($record->tenant)
: null)
->placeholder('—'),
TextEntry::make('summary.review_status')
->label('Review status')
->badge()
@ -575,19 +567,6 @@ public static function reviewPackGenerationBlockReason(?Tenant $tenant = null):
return is_string($reason) && $reason !== '' ? $reason : null;
}
public static function reviewPackGenerationWarningReason(?Tenant $tenant = null): ?string
{
$decision = static::reviewPackGenerationDecision($tenant);
if (! (bool) ($decision['is_warning'] ?? false)) {
return null;
}
$reason = $decision['warning_reason'] ?? null;
return is_string($reason) && $reason !== '' ? $reason : null;
}
public static function reviewPackGenerationActionTooltip(?Tenant $tenant = null): ?string
{
$tenant ??= static::currentTenantContext();
@ -597,7 +576,6 @@ public static function reviewPackGenerationActionTooltip(?Tenant $tenant = null)
return AuthUiTooltips::insufficientPermission();
}
return static::reviewPackGenerationBlockReason($tenant)
?? static::reviewPackGenerationWarningReason($tenant);
return static::reviewPackGenerationBlockReason($tenant);
}
}

View File

@ -6,7 +6,6 @@
use App\Filament\Concerns\InteractsWithTenantOwnedRecords;
use App\Filament\Concerns\ResolvesPanelTenantContext;
use App\Filament\Pages\Reviews\CustomerReviewWorkspace;
use App\Filament\Resources\TenantReviewResource\Pages;
use App\Exceptions\Entitlements\WorkspaceEntitlementBlockedException;
use App\Models\EvidenceSnapshot;
@ -464,19 +463,6 @@ public static function reviewPackGenerationBlockReason(?Tenant $tenant = null):
return is_string($reason) && $reason !== '' ? $reason : null;
}
public static function reviewPackGenerationWarningReason(?Tenant $tenant = null): ?string
{
$decision = static::reviewPackGenerationDecision($tenant);
if (! (bool) ($decision['is_warning'] ?? false)) {
return null;
}
$reason = $decision['warning_reason'] ?? null;
return is_string($reason) && $reason !== '' ? $reason : null;
}
public static function reviewPackGenerationActionTooltip(?Tenant $tenant = null): ?string
{
$tenant ??= static::panelTenantContext();
@ -486,8 +472,7 @@ public static function reviewPackGenerationActionTooltip(?Tenant $tenant = null)
return AuthUiTooltips::insufficientPermission();
}
return static::reviewPackGenerationBlockReason($tenant)
?? static::reviewPackGenerationWarningReason($tenant);
return static::reviewPackGenerationBlockReason($tenant);
}
public static function executeExport(TenantReview $review): void
@ -664,15 +649,6 @@ private static function summaryContextLinks(TenantReview $record): array
];
}
if ($record->tenant) {
$links[] = [
'title' => 'Customer workspace',
'label' => 'Open customer workspace',
'url' => CustomerReviewWorkspace::tenantPrefilterUrl($record->tenant),
'description' => 'Open the customer-safe review workspace prefiltered to this tenant.',
];
}
if ($record->evidenceSnapshot && $record->tenant) {
$links[] = [
'title' => 'Evidence snapshot',

View File

@ -4,15 +4,12 @@
namespace App\Filament\Resources\TenantReviewResource\Pages;
use App\Filament\Pages\Reviews\CustomerReviewWorkspace;
use App\Filament\Resources\TenantReviewResource;
use App\Models\Tenant;
use App\Models\TenantReview;
use App\Models\User;
use App\Services\Audit\WorkspaceAuditLogger;
use App\Services\TenantReviews\TenantReviewLifecycleService;
use App\Services\TenantReviews\TenantReviewService;
use App\Support\Audit\AuditActionId;
use App\Support\Auth\Capabilities;
use App\Support\Rbac\UiEnforcement;
use App\Support\TenantReviewStatus;
@ -27,13 +24,6 @@ class ViewTenantReview extends ViewRecord
{
protected static string $resource = TenantReviewResource::class;
public function mount(int|string $record): void
{
parent::mount($record);
$this->auditCustomerWorkspaceOpen();
}
protected function resolveRecord(int|string $key): Model
{
return TenantReviewResource::resolveScopedRecordOrFail($key);
@ -79,7 +69,7 @@ protected function getHeaderActions(): array
->label('Danger')
->icon('heroicon-o-archive-box')
->color('danger')
->visible(fn (): bool => ! $this->isCustomerWorkspaceView() && ! $this->record->statusEnum()->isTerminal()),
->visible(fn (): bool => ! $this->record->statusEnum()->isTerminal()),
]));
}
@ -95,10 +85,6 @@ private function primaryLifecycleAction(): ?Actions\Action
private function primaryLifecycleActionName(): ?string
{
if ($this->isCustomerWorkspaceView()) {
return null;
}
if ((string) $this->record->status === TenantReviewStatus::Published->value) {
return 'export_executive_pack';
}
@ -136,10 +122,6 @@ private function secondaryLifecycleActions(): array
*/
private function secondaryLifecycleActionNames(): array
{
if ($this->isCustomerWorkspaceView()) {
return [];
}
$names = [];
if ($this->record->isMutable()) {
@ -196,6 +178,7 @@ private function refreshReviewAction(): Actions\Action
}),
)
->requireCapability(Capabilities::TENANT_REVIEW_MANAGE)
->preserveVisibility()
->apply();
}
@ -342,39 +325,4 @@ private function archiveReviewAction(): Actions\Action
->preserveVisibility()
->apply();
}
private function isCustomerWorkspaceView(): bool
{
return request()->boolean(CustomerReviewWorkspace::DETAIL_CONTEXT_QUERY_KEY);
}
private function auditCustomerWorkspaceOpen(): void
{
if (! $this->isCustomerWorkspaceView()) {
return;
}
$user = auth()->user();
$tenant = $this->record->tenant;
if (! $user instanceof User || ! $tenant instanceof Tenant) {
return;
}
app(WorkspaceAuditLogger::class)->log(
workspace: $tenant->workspace,
action: AuditActionId::TenantReviewOpened,
context: [
'metadata' => [
'review_id' => (int) $this->record->getKey(),
'source_surface' => 'customer_review_workspace',
],
],
actor: $user,
resourceType: 'tenant_review',
resourceId: (string) $this->record->getKey(),
targetLabel: sprintf('Tenant review #%d', (int) $this->record->getKey()),
tenant: $tenant,
);
}
}

View File

@ -9,19 +9,13 @@
use App\Models\PlatformUser;
use App\Models\Tenant;
use App\Models\Workspace;
use App\Services\Entitlements\WorkspaceCommercialLifecycleResolver;
use App\Services\Entitlements\WorkspaceEntitlementResolver;
use App\Services\Settings\SettingsWriter;
use App\Support\Auth\PlatformCapabilities;
use App\Support\CustomerHealth\WorkspaceHealthSummaryQuery;
use App\Support\OperationCatalog;
use App\Support\System\SystemDirectoryLinks;
use App\Support\System\SystemOperationRunLinks;
use App\Support\SystemConsole\SystemConsoleWindow;
use Filament\Actions\Action;
use Filament\Forms\Components\Select;
use Filament\Forms\Components\Textarea;
use Filament\Notifications\Notification;
use Filament\Pages\Page;
use Illuminate\Support\Collection;
@ -100,77 +94,6 @@ public function workspaceEntitlementSummary(): array
return app(WorkspaceEntitlementResolver::class)->summary($this->workspace);
}
/**
* @return array<string, mixed>
*/
public function workspaceCommercialLifecycleSummary(): array
{
return app(WorkspaceCommercialLifecycleResolver::class)->summary($this->workspace);
}
/**
* @return array<Action>
*/
protected function getHeaderActions(): array
{
return [
Action::make('change_commercial_state')
->label('Change commercial state')
->icon('heroicon-o-adjustments-horizontal')
->color('warning')
->visible(fn (): bool => $this->canManageCommercialLifecycle())
->requiresConfirmation()
->modalHeading('Change commercial state')
->modalDescription('This changes the workspace commercial lifecycle overlay. The rationale is audited and affects onboarding activation and review-pack starts.')
->form([
Select::make('state')
->label('Commercial state')
->options(WorkspaceCommercialLifecycleResolver::stateLabels())
->required()
->default(fn (): string => (string) ($this->workspaceCommercialLifecycleSummary()['state'] ?? WorkspaceCommercialLifecycleResolver::STATE_ACTIVE_PAID)),
Textarea::make('reason')
->label('Rationale')
->required()
->minLength(5)
->maxLength(500)
->rows(4),
])
->action(function (array $data, SettingsWriter $settingsWriter): void {
$actor = auth('platform')->user();
if (! $actor instanceof PlatformUser) {
abort(403);
}
if (! $actor->hasCapability(PlatformCapabilities::COMMERCIAL_LIFECYCLE_MANAGE)) {
abort(403);
}
$settingsWriter->updateWorkspaceCommercialLifecycle(
actor: $actor,
workspace: $this->workspace,
state: (string) ($data['state'] ?? ''),
reason: (string) ($data['reason'] ?? ''),
);
$this->workspace->refresh();
Notification::make()
->title('Commercial state updated')
->success()
->send();
}),
];
}
private function canManageCommercialLifecycle(): bool
{
$user = auth('platform')->user();
return $user instanceof PlatformUser
&& $user->hasCapability(PlatformCapabilities::COMMERCIAL_LIFECYCLE_MANAGE);
}
/**
* @return array{
* overall: array{label: string, color: string, icon: string|null},

View File

@ -5,7 +5,6 @@
namespace App\Filament\Widgets\Tenant;
use App\Exceptions\Entitlements\WorkspaceEntitlementBlockedException;
use App\Filament\Pages\Reviews\CustomerReviewWorkspace;
use App\Models\OperationRun;
use App\Models\ReviewPack;
use App\Models\Tenant;
@ -81,14 +80,6 @@ public function generatePack(bool $includePii = true, bool $includeOperations =
return;
}
if ((bool) ($decision['is_warning'] ?? false) && is_string($decision['warning_reason'] ?? null)) {
Notification::make()
->title('Review pack generation allowed with warning')
->body($decision['warning_reason'])
->warning()
->send();
}
$activeRun = $service->checkActiveRun($tenant)
? OperationRun::query()
->where('tenant_id', (int) $tenant->getKey())
@ -171,9 +162,6 @@ protected function getViewData(): array
$generationBlockReason = is_string($generationEntitlement['block_reason'] ?? null)
? $generationEntitlement['block_reason']
: null;
$generationWarningReason = is_string($generationEntitlement['warning_reason'] ?? null)
? $generationEntitlement['warning_reason']
: null;
$latestPack = ReviewPack::query()
->with(['tenantReview', 'operationRun'])
@ -192,8 +180,6 @@ protected function getViewData(): array
'canManage' => $canManage,
'generationBlocked' => $generationBlocked,
'generationBlockReason' => $generationBlockReason,
'generationWarningReason' => $generationWarningReason,
'customerWorkspaceUrl' => $canView ? CustomerReviewWorkspace::tenantPrefilterUrl($tenant) : null,
'downloadUrl' => null,
'failedReason' => null,
'reviewUrl' => null,
@ -244,8 +230,6 @@ protected function getViewData(): array
'canManage' => $canManage,
'generationBlocked' => $generationBlocked,
'generationBlockReason' => $generationBlockReason,
'generationWarningReason' => $generationWarningReason,
'customerWorkspaceUrl' => $canView ? CustomerReviewWorkspace::tenantPrefilterUrl($tenant) : null,
'downloadUrl' => $downloadUrl,
'failedReason' => $failedReason,
'failedReasonDetail' => $failedReasonDetail,
@ -278,8 +262,6 @@ private function emptyState(): array
'canManage' => false,
'generationBlocked' => false,
'generationBlockReason' => null,
'generationWarningReason' => null,
'customerWorkspaceUrl' => null,
'downloadUrl' => null,
'failedReason' => null,
'failedReasonDetail' => null,

View File

@ -4,12 +4,7 @@
namespace App\Http\Controllers;
use App\Models\Tenant;
use App\Models\ReviewPack;
use App\Models\User;
use App\Services\Audit\WorkspaceAuditLogger;
use App\Support\Audit\AuditActionId;
use App\Support\Auth\Capabilities;
use App\Support\ReviewPackStatus;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Storage;
@ -20,21 +15,6 @@ class ReviewPackDownloadController extends Controller
{
public function __invoke(Request $request, ReviewPack $reviewPack): StreamedResponse
{
$user = $request->user();
$tenant = $reviewPack->tenant;
if (! $user instanceof User || ! $tenant instanceof Tenant) {
throw new NotFoundHttpException;
}
if (! $user->canAccessTenant($tenant)) {
throw new NotFoundHttpException;
}
if (! $user->can(Capabilities::REVIEW_PACK_VIEW, $tenant)) {
abort(403);
}
if ($reviewPack->status !== ReviewPackStatus::Ready->value) {
throw new NotFoundHttpException;
}
@ -49,26 +29,7 @@ public function __invoke(Request $request, ReviewPack $reviewPack): StreamedResp
throw new NotFoundHttpException;
}
app(WorkspaceAuditLogger::class)->log(
workspace: $tenant->workspace,
action: AuditActionId::ReviewPackDownloaded,
context: [
'metadata' => [
'review_pack_id' => (int) $reviewPack->getKey(),
'tenant_review_id' => $reviewPack->tenant_review_id !== null
? (int) $reviewPack->tenant_review_id
: null,
'source_surface' => (string) $request->query('source_surface', 'review_pack'),
],
],
actor: $user,
resourceType: 'review_pack',
resourceId: (string) $reviewPack->getKey(),
targetLabel: sprintf('Review pack #%d', (int) $reviewPack->getKey()),
tenant: $tenant,
operationRunId: $reviewPack->operation_run_id,
);
$tenant = $reviewPack->tenant;
$filename = sprintf(
'review-pack-%s-%s.zip',
$tenant?->external_id ?? 'unknown',

View File

@ -7,13 +7,11 @@
use App\Filament\Pages\ChooseWorkspace;
use App\Filament\Pages\Findings\FindingsHygieneReport;
use App\Filament\Pages\Findings\FindingsIntakeQueue;
use App\Filament\Pages\Governance\GovernanceInbox;
use App\Filament\Pages\Findings\MyFindingsInbox;
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\Reviews\CustomerReviewWorkspace;
use App\Filament\Pages\Settings\WorkspaceSettings;
use App\Filament\Pages\TenantRequiredPermissions;
use App\Filament\Pages\WorkspaceOverview;
@ -181,12 +179,10 @@ public function panel(Panel $panel): Panel
InventoryCoverage::class,
TenantRequiredPermissions::class,
WorkspaceSettings::class,
GovernanceInbox::class,
FindingsHygieneReport::class,
FindingsIntakeQueue::class,
MyFindingsInbox::class,
FindingExceptionsQueue::class,
CustomerReviewWorkspace::class,
ReviewRegister::class,
])
->widgets([

View File

@ -1,410 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Services\Entitlements;
use App\Models\AuditLog;
use App\Models\Tenant;
use App\Models\Workspace;
use App\Models\WorkspaceSetting;
use App\Services\Settings\SettingsResolver;
use App\Support\Audit\AuditActionId;
use Carbon\CarbonInterface;
final class WorkspaceCommercialLifecycleResolver
{
public const SETTING_DOMAIN = WorkspaceEntitlementResolver::SETTING_DOMAIN;
public const SETTING_COMMERCIAL_LIFECYCLE_STATE = 'commercial_lifecycle_state';
public const SETTING_COMMERCIAL_LIFECYCLE_REASON = 'commercial_lifecycle_reason';
public const STATE_TRIAL = 'trial';
public const STATE_GRACE = 'grace';
public const STATE_ACTIVE_PAID = 'active_paid';
public const STATE_SUSPENDED_READ_ONLY = 'suspended_read_only';
public const SOURCE_DEFAULT_ACTIVE_PAID = 'default_active_paid';
public const SOURCE_WORKSPACE_SETTING = 'workspace_setting';
public const ACTION_MANAGED_TENANT_ACTIVATION = 'managed_tenant_activation';
public const ACTION_REVIEW_PACK_START = 'review_pack_start';
public const ACTION_REVIEW_HISTORY_READ = 'review_history_read';
public const ACTION_EVIDENCE_READ = 'evidence_read';
public const ACTION_GENERATED_PACK_READ = 'generated_pack_read';
public const OUTCOME_ALLOW = 'allow';
public const OUTCOME_WARN = 'warn';
public const OUTCOME_BLOCK = 'block';
public const OUTCOME_ALLOW_READ_ONLY = 'allow_read_only';
public const REASON_FAMILY_ENTITLEMENT_SUBSTRATE = 'entitlement_substrate';
public const REASON_FAMILY_COMMERCIAL_LIFECYCLE = 'commercial_lifecycle';
public function __construct(
private readonly SettingsResolver $settingsResolver,
private readonly WorkspaceEntitlementResolver $workspaceEntitlementResolver,
) {}
/**
* @return list<string>
*/
public static function stateIds(): array
{
return [
self::STATE_TRIAL,
self::STATE_GRACE,
self::STATE_ACTIVE_PAID,
self::STATE_SUSPENDED_READ_ONLY,
];
}
/**
* @return array<string, string>
*/
public static function stateLabels(): array
{
return [
self::STATE_TRIAL => 'Trial',
self::STATE_GRACE => 'Grace',
self::STATE_ACTIVE_PAID => 'Active paid',
self::STATE_SUSPENDED_READ_ONLY => 'Suspended / read-only',
];
}
/**
* @return array<string, string>
*/
public static function stateDescriptions(): array
{
return [
self::STATE_TRIAL => 'Expansion and review-pack starts are available while the workspace is in trial.',
self::STATE_GRACE => 'New managed-tenant activation is frozen, but review-pack starts remain available with a warning.',
self::STATE_ACTIVE_PAID => 'Expansion and review-pack starts are available for the active paid workspace.',
self::STATE_SUSPENDED_READ_ONLY => 'New activation and review-pack starts are blocked while existing review, evidence, and generated-pack access remains read-only.',
];
}
/**
* @return array<string, mixed>
*/
public function summary(Workspace $workspace): array
{
$lifecycle = $this->resolve($workspace);
return $lifecycle + [
'entitlement_summary' => $this->workspaceEntitlementResolver->summary($workspace),
'action_decisions' => [
self::ACTION_MANAGED_TENANT_ACTIVATION => $this->actionDecision($workspace, self::ACTION_MANAGED_TENANT_ACTIVATION, $lifecycle),
self::ACTION_REVIEW_PACK_START => $this->actionDecision($workspace, self::ACTION_REVIEW_PACK_START, $lifecycle),
self::ACTION_REVIEW_HISTORY_READ => $this->actionDecision($workspace, self::ACTION_REVIEW_HISTORY_READ, $lifecycle),
self::ACTION_EVIDENCE_READ => $this->actionDecision($workspace, self::ACTION_EVIDENCE_READ, $lifecycle),
self::ACTION_GENERATED_PACK_READ => $this->actionDecision($workspace, self::ACTION_GENERATED_PACK_READ, $lifecycle),
],
];
}
/**
* @return array{
* workspace_id: int,
* state: string,
* state_label: string,
* source: string,
* source_label: string,
* rationale: string|null,
* description: string,
* last_changed_at: CarbonInterface|null,
* last_changed_by: string|null
* }
*/
public function resolve(Workspace $workspace): array
{
$stateSetting = $this->settingsResolver->resolveDetailed(
workspace: $workspace,
domain: self::SETTING_DOMAIN,
key: self::SETTING_COMMERCIAL_LIFECYCLE_STATE,
);
$rawState = is_string($stateSetting['value'] ?? null)
? strtolower(trim((string) $stateSetting['value']))
: null;
$state = in_array($rawState, self::stateIds(), true)
? $rawState
: self::STATE_ACTIVE_PAID;
$source = ($stateSetting['source'] ?? null) === 'workspace_override' && $rawState !== null
? self::SOURCE_WORKSPACE_SETTING
: self::SOURCE_DEFAULT_ACTIVE_PAID;
$rationale = $this->settingsResolver->resolveValue(
workspace: $workspace,
domain: self::SETTING_DOMAIN,
key: self::SETTING_COMMERCIAL_LIFECYCLE_REASON,
);
$labels = self::stateLabels();
$descriptions = self::stateDescriptions();
$lastChanged = $this->lastChangedMetadata($workspace);
return [
'workspace_id' => (int) $workspace->getKey(),
'state' => $state,
'state_label' => $labels[$state],
'source' => $source,
'source_label' => $source === self::SOURCE_WORKSPACE_SETTING
? 'workspace setting'
: 'default active paid',
'rationale' => is_string($rationale) && trim($rationale) !== '' ? trim($rationale) : null,
'description' => $descriptions[$state],
'last_changed_at' => $lastChanged['last_changed_at'],
'last_changed_by' => $lastChanged['last_changed_by'],
];
}
/**
* @param array<string, mixed>|null $lifecycle
* @return array<string, mixed>
*/
public function actionDecision(Workspace $workspace, string $actionKey, ?array $lifecycle = null): array
{
$lifecycle ??= $this->resolve($workspace);
return match ($actionKey) {
self::ACTION_MANAGED_TENANT_ACTIVATION => $this->managedTenantActivationDecision($workspace, $lifecycle),
self::ACTION_REVIEW_PACK_START => $this->reviewPackStartDecision($workspace, $lifecycle),
self::ACTION_REVIEW_HISTORY_READ,
self::ACTION_EVIDENCE_READ,
self::ACTION_GENERATED_PACK_READ => $this->readOnlyDecision($actionKey, $lifecycle),
default => throw new \InvalidArgumentException(sprintf('Unknown commercial lifecycle action key: %s', $actionKey)),
};
}
/**
* @return array<string, mixed>
*/
public function reviewPackStartDecisionForTenant(Tenant $tenant): array
{
$tenant->loadMissing('workspace');
return $this->actionDecision($tenant->workspace, self::ACTION_REVIEW_PACK_START);
}
/**
* @param array<string, mixed> $lifecycle
* @return array<string, mixed>
*/
private function managedTenantActivationDecision(Workspace $workspace, array $lifecycle): array
{
$substrateDecision = $this->workspaceEntitlementResolver->resolve(
$workspace,
WorkspaceEntitlementResolver::KEY_MANAGED_TENANT_ACTIVATION_LIMIT,
);
if ((bool) ($substrateDecision['is_blocked'] ?? false)) {
return $this->decision(
lifecycle: $lifecycle,
actionKey: self::ACTION_MANAGED_TENANT_ACTIVATION,
outcome: self::OUTCOME_BLOCK,
reasonFamily: self::REASON_FAMILY_ENTITLEMENT_SUBSTRATE,
message: (string) ($substrateDecision['block_reason'] ?? 'Workspace entitlement currently blocks managed tenant activation.'),
substrateDecision: $substrateDecision,
);
}
return match ($lifecycle['state']) {
self::STATE_GRACE => $this->decision(
lifecycle: $lifecycle,
actionKey: self::ACTION_MANAGED_TENANT_ACTIVATION,
outcome: self::OUTCOME_BLOCK,
reasonFamily: self::REASON_FAMILY_COMMERCIAL_LIFECYCLE,
message: 'New managed-tenant activation is frozen while this workspace is in grace.',
substrateDecision: $substrateDecision,
),
self::STATE_SUSPENDED_READ_ONLY => $this->decision(
lifecycle: $lifecycle,
actionKey: self::ACTION_MANAGED_TENANT_ACTIVATION,
outcome: self::OUTCOME_BLOCK,
reasonFamily: self::REASON_FAMILY_COMMERCIAL_LIFECYCLE,
message: 'This workspace is suspended / read-only. New managed-tenant activation is blocked, but existing review and evidence history remains available.',
substrateDecision: $substrateDecision,
),
default => $this->decision(
lifecycle: $lifecycle,
actionKey: self::ACTION_MANAGED_TENANT_ACTIVATION,
outcome: self::OUTCOME_ALLOW,
reasonFamily: null,
message: 'Managed-tenant activation is available for this workspace commercial state.',
substrateDecision: $substrateDecision,
),
};
}
/**
* @param array<string, mixed> $lifecycle
* @return array<string, mixed>
*/
private function reviewPackStartDecision(Workspace $workspace, array $lifecycle): array
{
$substrateDecision = $this->workspaceEntitlementResolver->resolve(
$workspace,
WorkspaceEntitlementResolver::KEY_REVIEW_PACK_GENERATION_ENABLED,
);
if ((bool) ($substrateDecision['is_blocked'] ?? false)) {
return $this->decision(
lifecycle: $lifecycle,
actionKey: self::ACTION_REVIEW_PACK_START,
outcome: self::OUTCOME_BLOCK,
reasonFamily: self::REASON_FAMILY_ENTITLEMENT_SUBSTRATE,
message: (string) ($substrateDecision['block_reason'] ?? 'Workspace entitlement currently blocks review pack generation.'),
substrateDecision: $substrateDecision,
);
}
return match ($lifecycle['state']) {
self::STATE_GRACE => $this->decision(
lifecycle: $lifecycle,
actionKey: self::ACTION_REVIEW_PACK_START,
outcome: self::OUTCOME_WARN,
reasonFamily: self::REASON_FAMILY_COMMERCIAL_LIFECYCLE,
message: 'Workspace is in grace. Review-pack starts remain available, but managed-tenant expansion is frozen.',
substrateDecision: $substrateDecision,
),
self::STATE_SUSPENDED_READ_ONLY => $this->decision(
lifecycle: $lifecycle,
actionKey: self::ACTION_REVIEW_PACK_START,
outcome: self::OUTCOME_BLOCK,
reasonFamily: self::REASON_FAMILY_COMMERCIAL_LIFECYCLE,
message: 'This workspace is suspended / read-only. New review-pack starts are blocked, but existing review packs, evidence, and review history remain available.',
substrateDecision: $substrateDecision,
),
default => $this->decision(
lifecycle: $lifecycle,
actionKey: self::ACTION_REVIEW_PACK_START,
outcome: self::OUTCOME_ALLOW,
reasonFamily: null,
message: 'Review-pack starts are available for this workspace commercial state.',
substrateDecision: $substrateDecision,
),
};
}
/**
* @param array<string, mixed> $lifecycle
* @return array<string, mixed>
*/
private function readOnlyDecision(string $actionKey, array $lifecycle): array
{
if (($lifecycle['state'] ?? null) === self::STATE_SUSPENDED_READ_ONLY) {
return $this->decision(
lifecycle: $lifecycle,
actionKey: $actionKey,
outcome: self::OUTCOME_ALLOW_READ_ONLY,
reasonFamily: self::REASON_FAMILY_COMMERCIAL_LIFECYCLE,
message: 'Suspended / read-only workspaces keep existing review, evidence, and generated-pack consumption available under current RBAC.',
substrateDecision: null,
);
}
return $this->decision(
lifecycle: $lifecycle,
actionKey: $actionKey,
outcome: self::OUTCOME_ALLOW,
reasonFamily: null,
message: 'Read-only history remains available under current RBAC.',
substrateDecision: null,
);
}
/**
* @param array<string, mixed> $lifecycle
* @param array<string, mixed>|null $substrateDecision
* @return array<string, mixed>
*/
private function decision(
array $lifecycle,
string $actionKey,
string $outcome,
?string $reasonFamily,
string $message,
?array $substrateDecision,
): array {
return [
'workspace_id' => (int) ($lifecycle['workspace_id'] ?? 0),
'action_key' => $actionKey,
'outcome' => $outcome,
'is_blocked' => $outcome === self::OUTCOME_BLOCK,
'is_warning' => $outcome === self::OUTCOME_WARN,
'block_reason' => $outcome === self::OUTCOME_BLOCK ? $message : null,
'warning_reason' => $outcome === self::OUTCOME_WARN ? $message : null,
'message' => $message,
'reason_family' => $reasonFamily,
'state' => (string) $lifecycle['state'],
'state_label' => (string) $lifecycle['state_label'],
'source' => (string) $lifecycle['source'],
'source_label' => (string) $lifecycle['source_label'],
'rationale' => $lifecycle['rationale'] ?? null,
'entitlement_decision' => $substrateDecision,
];
}
/**
* @return array{last_changed_at: CarbonInterface|null, last_changed_by: string|null}
*/
private function lastChangedMetadata(Workspace $workspace): array
{
$audit = AuditLog::query()
->where('workspace_id', (int) $workspace->getKey())
->where('action', AuditActionId::WorkspaceSettingUpdated->value)
->where('resource_type', 'workspace_setting')
->where('resource_id', self::SETTING_DOMAIN.'.'.self::SETTING_COMMERCIAL_LIFECYCLE_STATE)
->latest('recorded_at')
->latest('id')
->first();
if ($audit instanceof AuditLog) {
return [
'last_changed_at' => $audit->recorded_at,
'last_changed_by' => $audit->actorDisplayLabel(),
];
}
$record = WorkspaceSetting::query()
->where('workspace_id', (int) $workspace->getKey())
->where('domain', self::SETTING_DOMAIN)
->whereIn('key', [
self::SETTING_COMMERCIAL_LIFECYCLE_STATE,
self::SETTING_COMMERCIAL_LIFECYCLE_REASON,
])
->with('updatedByUser:id,name')
->latest('updated_at')
->latest('id')
->first();
if (! $record instanceof WorkspaceSetting) {
return [
'last_changed_at' => null,
'last_changed_by' => null,
];
}
return [
'last_changed_at' => $record->updated_at,
'last_changed_by' => $record->updatedByUser?->name,
];
}
}

View File

@ -14,7 +14,7 @@
use App\Models\TenantReview;
use App\Models\User;
use App\Services\Audit\WorkspaceAuditLogger;
use App\Services\Entitlements\WorkspaceCommercialLifecycleResolver;
use App\Services\Entitlements\WorkspaceEntitlementResolver;
use App\Services\Evidence\EvidenceResolutionRequest;
use App\Services\Evidence\EvidenceSnapshotResolver;
use App\Support\Audit\AuditActionId;
@ -30,7 +30,7 @@ public function __construct(
private OperationRunService $operationRunService,
private EvidenceSnapshotResolver $snapshotResolver,
private WorkspaceAuditLogger $auditLogger,
private WorkspaceCommercialLifecycleResolver $workspaceCommercialLifecycleResolver,
private WorkspaceEntitlementResolver $workspaceEntitlementResolver,
private ProductTelemetryRecorder $productTelemetryRecorder,
) {}
@ -234,16 +234,14 @@ public function computeFingerprint(Tenant $tenant, array $options): string
/**
* Generate a signed download URL for a review pack.
*
* @param array<string, scalar|null> $parameters
*/
public function generateDownloadUrl(ReviewPack $pack, array $parameters = []): string
public function generateDownloadUrl(ReviewPack $pack): string
{
$ttlMinutes = (int) config('tenantpilot.review_pack.download_url_ttl_minutes', 60);
return URL::signedRoute(
'admin.review-packs.download',
array_merge(['reviewPack' => $pack->getKey()], $parameters),
['reviewPack' => $pack->getKey()],
now()->addMinutes($ttlMinutes),
);
}
@ -253,22 +251,10 @@ public function generateDownloadUrl(ReviewPack $pack, array $parameters = []): s
*/
public function reviewPackGenerationDecisionForTenant(Tenant $tenant): array
{
$tenant->loadMissing('workspace');
$decision = $this->workspaceCommercialLifecycleResolver->actionDecision(
return $this->workspaceEntitlementResolver->resolve(
$tenant->workspace,
WorkspaceCommercialLifecycleResolver::ACTION_REVIEW_PACK_START,
WorkspaceEntitlementResolver::KEY_REVIEW_PACK_GENERATION_ENABLED,
);
$entitlementDecision = is_array($decision['entitlement_decision'] ?? null)
? $decision['entitlement_decision']
: [];
return $decision + [
'effective_value' => $entitlementDecision['effective_value'] ?? null,
'source' => $decision['source'] ?? null,
'current_usage' => $entitlementDecision['current_usage'] ?? null,
'remaining_capacity' => $entitlementDecision['remaining_capacity'] ?? null,
];
}
private function recordReviewPackRequestTelemetry(ReviewPack $reviewPack, User $user, string $sourceSurface): void

View File

@ -4,7 +4,6 @@
namespace App\Services\Settings;
use App\Models\PlatformUser;
use App\Models\Tenant;
use App\Models\TenantSetting;
use App\Models\User;
@ -12,14 +11,11 @@
use App\Models\WorkspaceSetting;
use App\Services\Audit\WorkspaceAuditLogger;
use App\Services\Auth\WorkspaceCapabilityResolver;
use App\Services\Entitlements\WorkspaceCommercialLifecycleResolver;
use App\Support\Audit\AuditActionId;
use App\Support\Auth\Capabilities;
use App\Support\Auth\PlatformCapabilities;
use App\Support\Settings\SettingDefinition;
use App\Support\Settings\SettingsRegistry;
use Illuminate\Auth\Access\AuthorizationException;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Validator;
use Illuminate\Validation\ValidationException;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
@ -37,7 +33,27 @@ public function updateWorkspaceSetting(User $actor, Workspace $workspace, string
{
$this->authorizeManage($actor, $workspace);
$result = $this->persistWorkspaceSetting($workspace, $domain, $key, $value, (int) $actor->getKey());
$definition = $this->requireDefinition($domain, $key);
$normalizedValue = $this->validatedValue($definition, $value);
$existing = WorkspaceSetting::query()
->where('workspace_id', (int) $workspace->getKey())
->where('domain', $domain)
->where('key', $key)
->first();
$beforeValue = $existing instanceof WorkspaceSetting
? $this->decodeStoredValue($existing->getAttribute('value'))
: null;
$setting = WorkspaceSetting::query()->updateOrCreate([
'workspace_id' => (int) $workspace->getKey(),
'domain' => $domain,
'key' => $key,
], [
'value' => $normalizedValue,
'updated_by_user_id' => (int) $actor->getKey(),
]);
$this->resolver->clearCache();
@ -51,7 +67,7 @@ public function updateWorkspaceSetting(User $actor, Workspace $workspace, string
'scope' => 'workspace',
'domain' => $domain,
'key' => $key,
'before_value' => $result['before_value'],
'before_value' => $beforeValue,
'after_value' => $afterValue,
],
],
@ -60,79 +76,7 @@ public function updateWorkspaceSetting(User $actor, Workspace $workspace, string
resourceId: $domain.'.'.$key,
);
return $result['setting'];
}
public function updateWorkspaceCommercialLifecycle(
PlatformUser $actor,
Workspace $workspace,
string $state,
string $reason,
): void {
$state = strtolower(trim($state));
$reason = trim($reason);
if (! $actor->hasCapability(PlatformCapabilities::COMMERCIAL_LIFECYCLE_MANAGE)) {
throw new AuthorizationException('Missing commercial lifecycle manage capability.');
}
if ($reason === '') {
throw ValidationException::withMessages([
'reason' => ['A rationale is required when changing commercial lifecycle state.'],
]);
}
DB::transaction(function () use ($actor, $workspace, $state, $reason): void {
$stateResult = $this->persistWorkspaceSetting(
workspace: $workspace,
domain: WorkspaceCommercialLifecycleResolver::SETTING_DOMAIN,
key: WorkspaceCommercialLifecycleResolver::SETTING_COMMERCIAL_LIFECYCLE_STATE,
value: $state,
updatedByUserId: null,
);
$reasonResult = $this->persistWorkspaceSetting(
workspace: $workspace,
domain: WorkspaceCommercialLifecycleResolver::SETTING_DOMAIN,
key: WorkspaceCommercialLifecycleResolver::SETTING_COMMERCIAL_LIFECYCLE_REASON,
value: $reason,
updatedByUserId: null,
);
$this->resolver->clearCache();
$afterState = $this->resolver->resolveValue(
$workspace,
WorkspaceCommercialLifecycleResolver::SETTING_DOMAIN,
WorkspaceCommercialLifecycleResolver::SETTING_COMMERCIAL_LIFECYCLE_STATE,
);
$afterReason = $this->resolver->resolveValue(
$workspace,
WorkspaceCommercialLifecycleResolver::SETTING_DOMAIN,
WorkspaceCommercialLifecycleResolver::SETTING_COMMERCIAL_LIFECYCLE_REASON,
);
$this->auditLogger->log(
workspace: $workspace,
action: AuditActionId::WorkspaceSettingUpdated->value,
context: [
'metadata' => [
'scope' => 'workspace',
'domain' => WorkspaceCommercialLifecycleResolver::SETTING_DOMAIN,
'key' => WorkspaceCommercialLifecycleResolver::SETTING_COMMERCIAL_LIFECYCLE_STATE,
'before_state' => $stateResult['before_value'],
'after_state' => $afterState,
'before_reason' => $reasonResult['before_value'],
'after_reason' => $afterReason,
],
],
actor: $actor,
resourceType: 'workspace_setting',
resourceId: WorkspaceCommercialLifecycleResolver::SETTING_DOMAIN.'.'.WorkspaceCommercialLifecycleResolver::SETTING_COMMERCIAL_LIFECYCLE_STATE,
targetLabel: 'Commercial lifecycle state',
);
});
return $setting;
}
public function resetWorkspaceSetting(User $actor, Workspace $workspace, string $domain, string $key): void
@ -230,39 +174,6 @@ private function requireDefinition(string $domain, string $key): SettingDefiniti
]);
}
/**
* @return array{setting: WorkspaceSetting, before_value: mixed}
*/
private function persistWorkspaceSetting(Workspace $workspace, string $domain, string $key, mixed $value, ?int $updatedByUserId): array
{
$definition = $this->requireDefinition($domain, $key);
$normalizedValue = $this->validatedValue($definition, $value);
$existing = WorkspaceSetting::query()
->where('workspace_id', (int) $workspace->getKey())
->where('domain', $domain)
->where('key', $key)
->first();
$beforeValue = $existing instanceof WorkspaceSetting
? $this->decodeStoredValue($existing->getAttribute('value'))
: null;
$setting = WorkspaceSetting::query()->updateOrCreate([
'workspace_id' => (int) $workspace->getKey(),
'domain' => $domain,
'key' => $key,
], [
'value' => $normalizedValue,
'updated_by_user_id' => $updatedByUserId,
]);
return [
'setting' => $setting,
'before_value' => $beforeValue,
];
}
private function validatedValue(SettingDefinition $definition, mixed $value): mixed
{
$validator = Validator::make(

View File

@ -12,7 +12,6 @@
use App\Services\Auth\RoleCapabilityMap;
use App\Support\Auth\Capabilities;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Facades\DB;
final class TenantReviewRegisterService
{
@ -44,55 +43,6 @@ public function query(User $user, Workspace $workspace): Builder
->latest('id');
}
public function latestPublishedQuery(User $user, Workspace $workspace): Builder
{
$tenantIds = array_keys($this->authorizedTenants($user, $workspace));
$rankedReviews = TenantReview::query()
->select([
'tenant_reviews.id',
'tenant_reviews.tenant_id',
'tenant_reviews.published_at',
'tenant_reviews.generated_at',
])
->selectRaw('ROW_NUMBER() OVER (PARTITION BY tenant_id ORDER BY published_at DESC, generated_at DESC, id DESC) as rn')
->forWorkspace((int) $workspace->getKey())
->whereIn('tenant_id', $tenantIds === [] ? [-1] : $tenantIds)
->published();
$latestPublishedIds = DB::query()
->fromSub($rankedReviews, 'ranked_tenant_reviews')
->where('rn', 1)
->select('id');
return TenantReview::query()
->with(['tenant', 'evidenceSnapshot', 'currentExportReviewPack'])
->forWorkspace((int) $workspace->getKey())
->whereIn('tenant_reviews.id', $latestPublishedIds)
->orderByDesc('published_at')
->orderByDesc('generated_at')
->orderByDesc('id');
}
public function customerWorkspaceTenantQuery(User $user, Workspace $workspace): Builder
{
$tenantIds = array_keys($this->authorizedTenants($user, $workspace));
return Tenant::query()
->where('workspace_id', (int) $workspace->getKey())
->whereIn('id', $tenantIds === [] ? [-1] : $tenantIds)
->with([
'tenantReviews' => fn ($query) => $query
->with(['tenant', 'evidenceSnapshot', 'currentExportReviewPack'])
->published()
->orderByDesc('published_at')
->orderByDesc('generated_at')
->orderByDesc('id')
->limit(1),
])
->orderBy('name');
}
public function canAccessWorkspace(User $user, Workspace $workspace): bool
{
return WorkspaceMembership::query()

View File

@ -94,10 +94,8 @@ enum AuditActionId: string
case TenantReviewRefreshed = 'tenant_review.refreshed';
case TenantReviewPublished = 'tenant_review.published';
case TenantReviewArchived = 'tenant_review.archived';
case TenantReviewOpened = 'tenant_review.opened';
case TenantReviewExported = 'tenant_review.exported';
case TenantReviewSuccessorCreated = 'tenant_review.successor_created';
case ReviewPackDownloaded = 'review_pack.downloaded';
case TenantTriageReviewMarkedReviewed = 'tenant_triage_review.marked_reviewed';
case TenantTriageReviewMarkedFollowUpNeeded = 'tenant_triage_review.marked_follow_up_needed';
@ -240,10 +238,8 @@ private static function labels(): array
self::TenantReviewRefreshed->value => 'Tenant review refreshed',
self::TenantReviewPublished->value => 'Tenant review published',
self::TenantReviewArchived->value => 'Tenant review archived',
self::TenantReviewOpened->value => 'Tenant review opened',
self::TenantReviewExported->value => 'Tenant review exported',
self::TenantReviewSuccessorCreated->value => 'Tenant review next cycle created',
self::ReviewPackDownloaded->value => 'Review pack downloaded',
self::TenantTriageReviewMarkedReviewed->value => 'Triage review marked reviewed',
self::TenantTriageReviewMarkedFollowUpNeeded->value => 'Triage review marked follow-up needed',
self::SupportDiagnosticsOpened->value => 'Support diagnostics opened',
@ -332,10 +328,8 @@ private static function summaries(): array
self::TenantReviewRefreshed->value => 'Tenant review refreshed',
self::TenantReviewPublished->value => 'Tenant review published',
self::TenantReviewArchived->value => 'Tenant review archived',
self::TenantReviewOpened->value => 'Tenant review opened',
self::TenantReviewExported->value => 'Tenant review exported',
self::TenantReviewSuccessorCreated->value => 'Tenant review next cycle created',
self::ReviewPackDownloaded->value => 'Review pack downloaded',
self::SupportDiagnosticsOpened->value => 'Support diagnostics opened',
self::SupportRequestCreated->value => 'Support request created',
self::AiExecutionDecisionEvaluated->value => 'AI execution decision evaluated',

View File

@ -18,8 +18,6 @@ class PlatformCapabilities
public const DIRECTORY_VIEW = 'platform.directory.view';
public const COMMERCIAL_LIFECYCLE_MANAGE = 'platform.commercial_lifecycle.manage';
public const OPERATIONS_VIEW = 'platform.operations.view';
public const OPERATIONS_MANAGE = 'platform.operations.manage';

View File

@ -57,7 +57,6 @@ final class BadgeCatalog
BadgeDomain::BaselineProfileStatus->value => Domains\BaselineProfileStatusBadge::class,
BadgeDomain::FindingType->value => Domains\FindingTypeBadge::class,
BadgeDomain::ReviewPackStatus->value => Domains\ReviewPackStatusBadge::class,
BadgeDomain::CommercialLifecycleState->value => Domains\CommercialLifecycleStateBadge::class,
BadgeDomain::EvidenceSnapshotStatus->value => Domains\EvidenceSnapshotStatusBadge::class,
BadgeDomain::EvidenceCompleteness->value => Domains\EvidenceCompletenessBadge::class,
BadgeDomain::TenantReviewStatus->value => Domains\TenantReviewStatusBadge::class,

View File

@ -48,7 +48,6 @@ enum BadgeDomain: string
case BaselineProfileStatus = 'baseline_profile_status';
case FindingType = 'finding_type';
case ReviewPackStatus = 'review_pack_status';
case CommercialLifecycleState = 'commercial_lifecycle_state';
case EvidenceSnapshotStatus = 'evidence_snapshot_status';
case EvidenceCompleteness = 'evidence_completeness';
case TenantReviewStatus = 'tenant_review_status';

View File

@ -1,26 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Support\Badges\Domains;
use App\Services\Entitlements\WorkspaceCommercialLifecycleResolver;
use App\Support\Badges\BadgeCatalog;
use App\Support\Badges\BadgeMapper;
use App\Support\Badges\BadgeSpec;
final class CommercialLifecycleStateBadge implements BadgeMapper
{
public function spec(mixed $value): BadgeSpec
{
$state = BadgeCatalog::normalizeState($value);
return match ($state) {
WorkspaceCommercialLifecycleResolver::STATE_TRIAL => new BadgeSpec('Trial', 'info', 'heroicon-m-clock'),
WorkspaceCommercialLifecycleResolver::STATE_GRACE => new BadgeSpec('Grace', 'warning', 'heroicon-m-exclamation-triangle'),
WorkspaceCommercialLifecycleResolver::STATE_ACTIVE_PAID => new BadgeSpec('Active paid', 'success', 'heroicon-m-check-circle'),
WorkspaceCommercialLifecycleResolver::STATE_SUSPENDED_READ_ONLY => new BadgeSpec('Suspended / read-only', 'danger', 'heroicon-m-lock-closed'),
default => BadgeSpec::unknown(),
};
}
}

View File

@ -1,888 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Support\GovernanceInbox;
use App\Filament\Pages\Findings\FindingsIntakeQueue;
use App\Filament\Pages\Findings\MyFindingsInbox;
use App\Filament\Pages\Reviews\CustomerReviewWorkspace;
use App\Filament\Resources\AlertDeliveryResource;
use App\Filament\Resources\FindingExceptionResource;
use App\Filament\Resources\FindingResource;
use App\Filament\Resources\TenantResource;
use App\Filament\Resources\TenantReviewResource;
use App\Models\AlertDelivery;
use App\Models\Finding;
use App\Models\OperationRun;
use App\Models\Tenant;
use App\Models\TenantTriageReview;
use App\Models\User;
use App\Models\Workspace;
use App\Services\TenantReviews\TenantReviewRegisterService;
use App\Support\Auth\Capabilities;
use App\Support\BackupHealth\TenantBackupHealthAssessment;
use App\Support\BackupHealth\TenantBackupHealthResolver;
use App\Support\Navigation\CanonicalNavigationContext;
use App\Support\OperationRunLinks;
use App\Support\PortfolioTriage\PortfolioArrivalContextToken;
use App\Support\PortfolioTriage\TenantTriageReviewStateResolver;
use App\Support\RestoreSafety\RestoreSafetyResolver;
use App\Support\Tenants\TenantRecoveryTriagePresentation;
use Illuminate\Support\Str;
final readonly class GovernanceInboxSectionBuilder
{
private const PREVIEW_LIMIT = 3;
/**
* @var list<string>
*/
private const FAMILY_ORDER = [
'assigned_findings',
'intake_findings',
'stale_operations',
'alert_delivery_failures',
'review_follow_up',
];
public function __construct(
private TenantBackupHealthResolver $backupHealthResolver,
private RestoreSafetyResolver $restoreSafetyResolver,
private TenantTriageReviewStateResolver $tenantTriageReviewStateResolver,
private TenantReviewRegisterService $tenantReviewRegisterService,
) {}
/**
* @param array<int, Tenant> $authorizedTenants
* @param array<int, Tenant> $visibleFindingTenants
* @param array<int, Tenant> $reviewTenants
* @return array{
* sections: list<array<string, mixed>>,
* available_families: list<array{key: string, label: string, count: int}>,
* family_counts: array<string, int>,
* total_count: int,
* }
*/
public function build(
User $user,
Workspace $workspace,
array $authorizedTenants,
array $visibleFindingTenants,
array $reviewTenants,
bool $canViewAlerts,
?Tenant $selectedTenant = null,
?string $selectedFamily = null,
?CanonicalNavigationContext $navigationContext = null,
): array {
$authorizedTenantsById = $this->indexTenants($authorizedTenants);
$visibleFindingTenantsById = $this->indexTenants($visibleFindingTenants);
$reviewTenantsById = $this->indexTenants($reviewTenants);
$allSections = [];
$availableFamilies = [];
$familyCounts = [];
if ($visibleFindingTenantsById !== []) {
$assignedSection = $this->assignedFindingsSection(
user: $user,
visibleFindingTenants: $visibleFindingTenantsById,
selectedTenant: $selectedTenant,
navigationContext: $navigationContext,
);
$allSections[$assignedSection['key']] = $assignedSection;
$availableFamilies[] = [
'key' => $assignedSection['key'],
'label' => $assignedSection['label'],
'count' => $assignedSection['count'],
];
$familyCounts[$assignedSection['key']] = $assignedSection['count'];
$intakeSection = $this->intakeFindingsSection(
visibleFindingTenants: $visibleFindingTenantsById,
selectedTenant: $selectedTenant,
navigationContext: $navigationContext,
);
$allSections[$intakeSection['key']] = $intakeSection;
$availableFamilies[] = [
'key' => $intakeSection['key'],
'label' => $intakeSection['label'],
'count' => $intakeSection['count'],
];
$familyCounts[$intakeSection['key']] = $intakeSection['count'];
}
if ($authorizedTenantsById !== []) {
$operationsSection = $this->operationsSection(
workspace: $workspace,
authorizedTenants: $authorizedTenantsById,
selectedTenant: $selectedTenant,
navigationContext: $navigationContext,
);
$allSections[$operationsSection['key']] = $operationsSection;
$availableFamilies[] = [
'key' => $operationsSection['key'],
'label' => $operationsSection['label'],
'count' => $operationsSection['count'],
];
$familyCounts[$operationsSection['key']] = $operationsSection['count'];
}
if ($canViewAlerts) {
$alertsSection = $this->alertsSection(
workspace: $workspace,
authorizedTenants: $authorizedTenantsById,
selectedTenant: $selectedTenant,
navigationContext: $navigationContext,
);
$allSections[$alertsSection['key']] = $alertsSection;
$availableFamilies[] = [
'key' => $alertsSection['key'],
'label' => $alertsSection['label'],
'count' => $alertsSection['count'],
];
$familyCounts[$alertsSection['key']] = $alertsSection['count'];
}
if ($reviewTenantsById !== []) {
$reviewSection = $this->reviewFollowUpSection(
user: $user,
workspace: $workspace,
reviewTenants: $reviewTenantsById,
selectedTenant: $selectedTenant,
navigationContext: $navigationContext,
);
$allSections[$reviewSection['key']] = $reviewSection;
$availableFamilies[] = [
'key' => $reviewSection['key'],
'label' => $reviewSection['label'],
'count' => $reviewSection['count'],
];
$familyCounts[$reviewSection['key']] = $reviewSection['count'];
}
$sections = [];
foreach (self::FAMILY_ORDER as $familyKey) {
$section = $allSections[$familyKey] ?? null;
if (! is_array($section)) {
continue;
}
if ($selectedFamily !== null) {
if ($familyKey === $selectedFamily) {
$sections[] = $section;
}
continue;
}
if ((int) ($section['count'] ?? 0) > 0) {
$sections[] = $section;
}
}
return [
'sections' => $sections,
'available_families' => $availableFamilies,
'family_counts' => $familyCounts,
'total_count' => array_sum($familyCounts),
];
}
/**
* @param array<int, Tenant> $tenants
* @return array<int, Tenant>
*/
private function indexTenants(array $tenants): array
{
$indexed = [];
foreach ($tenants as $tenant) {
$indexed[(int) $tenant->getKey()] = $tenant;
}
return $indexed;
}
/**
* @param array<int, Tenant> $visibleFindingTenants
* @return array<string, mixed>
*/
private function assignedFindingsSection(
User $user,
array $visibleFindingTenants,
?Tenant $selectedTenant,
?CanonicalNavigationContext $navigationContext,
): array {
$baseQuery = $this->assignedFindingsQuery($user, $visibleFindingTenants, $selectedTenant);
$count = (clone $baseQuery)->count();
$overdueCount = (clone $baseQuery)
->whereNotNull('due_at')
->where('due_at', '<', now())
->count();
$entries = $this->orderedAssignedFindingsQuery(clone $baseQuery)
->limit(self::PREVIEW_LIMIT)
->get()
->map(fn (Finding $finding): array => $this->findingEntry($finding, 'assigned_findings', $navigationContext, 10))
->all();
return [
'key' => 'assigned_findings',
'label' => 'Assigned findings',
'count' => $count,
'summary' => $this->assignedFindingsSummary($count, $overdueCount),
'dominant_action_label' => 'Open my findings',
'dominant_action_url' => $this->appendQuery(
MyFindingsInbox::getUrl(
panel: 'admin',
parameters: array_filter([
'tenant' => $selectedTenant?->external_id,
], static fn (mixed $value): bool => is_string($value) && $value !== ''),
),
$navigationContext?->toQuery() ?? [],
),
'entries' => $entries,
'empty_state' => $selectedTenant instanceof Tenant
? 'No assigned findings match this tenant filter right now.'
: 'No assigned findings are visible right now.',
];
}
/**
* @param array<int, Tenant> $visibleFindingTenants
* @return array<string, mixed>
*/
private function intakeFindingsSection(
array $visibleFindingTenants,
?Tenant $selectedTenant,
?CanonicalNavigationContext $navigationContext,
): array {
$baseQuery = $this->intakeFindingsQuery($visibleFindingTenants, $selectedTenant);
$count = (clone $baseQuery)->count();
$needsTriageCount = (clone $baseQuery)
->whereIn('status', [Finding::STATUS_NEW, Finding::STATUS_REOPENED])
->count();
$entries = $this->orderedIntakeFindingsQuery(clone $baseQuery)
->limit(self::PREVIEW_LIMIT)
->get()
->map(fn (Finding $finding): array => $this->findingEntry($finding, 'intake_findings', $navigationContext, 20))
->all();
return [
'key' => 'intake_findings',
'label' => 'Findings intake',
'count' => $count,
'summary' => $this->intakeFindingsSummary($count, $needsTriageCount),
'dominant_action_label' => 'Open findings intake',
'dominant_action_url' => $this->appendQuery(
FindingsIntakeQueue::getUrl(
panel: 'admin',
parameters: array_filter([
'tenant' => $selectedTenant?->external_id,
'view' => $needsTriageCount > 0 ? 'needs_triage' : null,
], static fn (mixed $value): bool => $value !== null && $value !== ''),
),
$navigationContext?->toQuery() ?? [],
),
'entries' => $entries,
'empty_state' => $selectedTenant instanceof Tenant
? 'No intake findings match this tenant filter right now.'
: 'No intake findings are visible right now.',
];
}
/**
* @param array<int, Tenant> $authorizedTenants
* @return array<string, mixed>
*/
private function operationsSection(
Workspace $workspace,
array $authorizedTenants,
?Tenant $selectedTenant,
?CanonicalNavigationContext $navigationContext,
): array {
$terminalQuery = $this->terminalOperationsQuery($workspace, $authorizedTenants, $selectedTenant);
$staleQuery = $this->staleOperationsQuery($workspace, $authorizedTenants, $selectedTenant);
$terminalCount = (clone $terminalQuery)->count();
$staleCount = (clone $staleQuery)->count();
$entries = array_merge(
(clone $terminalQuery)->latest('completed_at')->latest('id')->limit(self::PREVIEW_LIMIT)->get()->all(),
(clone $staleQuery)->latest('created_at')->latest('id')->limit(self::PREVIEW_LIMIT)->get()->all(),
);
$entries = collect($entries)
->unique(fn (OperationRun $run): int => (int) $run->getKey())
->sortBy([
fn (OperationRun $run): int => $run->problemClass() === OperationRun::PROBLEM_CLASS_TERMINAL_FOLLOW_UP ? 0 : 1,
fn (OperationRun $run): int => -1 * (int) $run->getKey(),
])
->take(self::PREVIEW_LIMIT)
->map(fn (OperationRun $run): array => $this->operationEntry($run, $navigationContext))
->values()
->all();
$dominantProblemClass = $terminalCount > 0
? OperationRun::PROBLEM_CLASS_TERMINAL_FOLLOW_UP
: OperationRun::PROBLEM_CLASS_ACTIVE_STALE_ATTENTION;
return [
'key' => 'stale_operations',
'label' => 'Operations follow-up',
'count' => $terminalCount + $staleCount,
'summary' => $this->operationsSummary($terminalCount, $staleCount),
'dominant_action_label' => $terminalCount > 0 ? 'Open terminal follow-up' : 'Open stale operations',
'dominant_action_url' => OperationRunLinks::index(
tenant: $selectedTenant,
context: $navigationContext,
problemClass: $dominantProblemClass,
),
'entries' => $entries,
'empty_state' => $selectedTenant instanceof Tenant
? 'No stale or terminal follow-up operations match this tenant filter right now.'
: 'No stale or terminal follow-up operations are visible right now.',
];
}
/**
* @param array<int, Tenant> $authorizedTenants
* @return array<string, mixed>
*/
private function alertsSection(
Workspace $workspace,
array $authorizedTenants,
?Tenant $selectedTenant,
?CanonicalNavigationContext $navigationContext,
): array {
$baseQuery = $this->alertsQuery($workspace, $authorizedTenants, $selectedTenant);
$count = (clone $baseQuery)->count();
$entries = (clone $baseQuery)
->latest('created_at')
->latest('id')
->limit(self::PREVIEW_LIMIT)
->get()
->map(fn (AlertDelivery $delivery): array => $this->alertEntry($delivery, $navigationContext))
->all();
return [
'key' => 'alert_delivery_failures',
'label' => 'Alert delivery failures',
'count' => $count,
'summary' => $this->alertsSummary($count),
'dominant_action_label' => 'Open alert deliveries',
'dominant_action_url' => $this->appendQuery(
AlertDeliveryResource::getUrl(panel: 'admin'),
array_replace_recursive(
$navigationContext?->toQuery() ?? [],
[
'tableFilters' => array_filter([
'status' => ['value' => AlertDelivery::STATUS_FAILED],
'tenant_id' => $selectedTenant instanceof Tenant
? ['value' => (string) $selectedTenant->getKey()]
: null,
], static fn (mixed $value): bool => $value !== null),
],
),
),
'entries' => $entries,
'empty_state' => $selectedTenant instanceof Tenant
? 'No failed alert deliveries match this tenant filter right now.'
: 'No failed alert deliveries are visible right now.',
];
}
/**
* @param array<int, Tenant> $reviewTenants
* @return array<string, mixed>
*/
private function reviewFollowUpSection(
User $user,
Workspace $workspace,
array $reviewTenants,
?Tenant $selectedTenant,
?CanonicalNavigationContext $navigationContext,
): array {
$tenantIds = $selectedTenant instanceof Tenant
? [(int) $selectedTenant->getKey()]
: array_keys($reviewTenants);
$backupHealthByTenant = $this->backupHealthResolver->assessMany($tenantIds);
$recoveryEvidenceByTenant = $this->restoreSafetyResolver->dashboardRecoveryEvidenceForTenants($tenantIds, $backupHealthByTenant);
$resolved = $this->tenantTriageReviewStateResolver->resolveMany(
workspaceId: (int) $workspace->getKey(),
tenantIds: $tenantIds,
backupHealthByTenant: $backupHealthByTenant,
recoveryEvidenceByTenant: $recoveryEvidenceByTenant,
);
$latestPublishedReviews = $this->tenantReviewRegisterService
->latestPublishedQuery($user, $workspace)
->get()
->keyBy('tenant_id')
->all();
$rawEntries = [];
foreach ($tenantIds as $tenantId) {
$tenant = $reviewTenants[$tenantId] ?? null;
$rows = $resolved['rows'][$tenantId] ?? null;
if (! $tenant instanceof Tenant || ! is_array($rows)) {
continue;
}
foreach ([PortfolioArrivalContextToken::FAMILY_BACKUP_HEALTH, PortfolioArrivalContextToken::FAMILY_RECOVERY_EVIDENCE] as $family) {
$row = $rows[$family] ?? null;
if (! is_array($row) || ($row['current_concern_present'] ?? false) !== true) {
continue;
}
$derivedState = $row['derived_state'] ?? null;
if (! in_array($derivedState, [
TenantTriageReview::STATE_FOLLOW_UP_NEEDED,
TenantTriageReview::DERIVED_STATE_CHANGED_SINCE_REVIEW,
], true)) {
continue;
}
$rawEntries[] = $this->reviewEntry(
tenant: $tenant,
family: $family,
row: $row,
latestPublishedReview: $latestPublishedReviews[$tenantId] ?? null,
navigationContext: $navigationContext,
);
}
}
usort($rawEntries, function (array $left, array $right): int {
$leftRank = (int) ($left['urgency_rank'] ?? 0);
$rightRank = (int) ($right['urgency_rank'] ?? 0);
if ($leftRank !== $rightRank) {
return $leftRank <=> $rightRank;
}
return strcmp((string) ($left['headline'] ?? ''), (string) ($right['headline'] ?? ''));
});
$followUpCount = collect($rawEntries)
->where('status_label', 'Follow-up needed')
->count();
$changedCount = collect($rawEntries)
->where('status_label', 'Changed since review')
->count();
return [
'key' => 'review_follow_up',
'label' => 'Review follow-up',
'count' => count($rawEntries),
'summary' => $this->reviewSummary($followUpCount, $changedCount),
'dominant_action_label' => 'Open review follow-up',
'dominant_action_url' => $selectedTenant instanceof Tenant
? $this->appendQuery(CustomerReviewWorkspace::tenantPrefilterUrl($selectedTenant), $navigationContext?->toQuery() ?? [])
: $this->appendQuery(TenantResource::getUrl(panel: 'admin'), array_replace_recursive(
$navigationContext?->toQuery() ?? [],
[
'backup_posture' => [
TenantBackupHealthAssessment::POSTURE_ABSENT,
TenantBackupHealthAssessment::POSTURE_STALE,
TenantBackupHealthAssessment::POSTURE_DEGRADED,
],
'recovery_evidence' => [
TenantRecoveryTriagePresentation::RECOVERY_EVIDENCE_WEAKENED,
TenantRecoveryTriagePresentation::RECOVERY_EVIDENCE_UNVALIDATED,
],
'review_state' => [
TenantTriageReview::STATE_FOLLOW_UP_NEEDED,
TenantTriageReview::DERIVED_STATE_CHANGED_SINCE_REVIEW,
],
'triage_sort' => TenantRecoveryTriagePresentation::TRIAGE_SORT_WORST_FIRST,
],
)),
'entries' => array_slice($rawEntries, 0, self::PREVIEW_LIMIT),
'empty_state' => $selectedTenant instanceof Tenant
? 'No review follow-up is visible for this tenant filter right now.'
: 'No review follow-up is visible right now.',
];
}
/**
* @param array<int, Tenant> $visibleFindingTenants
*/
private function assignedFindingsQuery(User $user, array $visibleFindingTenants, ?Tenant $selectedTenant): \Illuminate\Database\Eloquent\Builder
{
$tenantIds = $selectedTenant instanceof Tenant
? [(int) $selectedTenant->getKey()]
: array_keys($visibleFindingTenants);
return Finding::query()
->with(['tenant', 'ownerUser:id,name', 'assigneeUser:id,name'])
->withSubjectDisplayName()
->whereIn('tenant_id', $tenantIds === [] ? [-1] : $tenantIds)
->where('assignee_user_id', (int) $user->getKey())
->whereIn('status', Finding::openStatusesForQuery());
}
private function orderedAssignedFindingsQuery(\Illuminate\Database\Eloquent\Builder $query): \Illuminate\Database\Eloquent\Builder
{
return $query
->orderByRaw(
'case when due_at is not null and due_at < ? then 0 when reopened_at is not null then 1 else 2 end asc',
[now()],
)
->orderByRaw('case when due_at is null then 1 else 0 end asc')
->orderBy('due_at')
->orderByDesc('id');
}
/**
* @param array<int, Tenant> $visibleFindingTenants
*/
private function intakeFindingsQuery(array $visibleFindingTenants, ?Tenant $selectedTenant): \Illuminate\Database\Eloquent\Builder
{
$tenantIds = $selectedTenant instanceof Tenant
? [(int) $selectedTenant->getKey()]
: array_keys($visibleFindingTenants);
return Finding::query()
->with(['tenant', 'ownerUser:id,name', 'assigneeUser:id,name'])
->withSubjectDisplayName()
->whereIn('tenant_id', $tenantIds === [] ? [-1] : $tenantIds)
->whereNull('assignee_user_id')
->whereIn('status', Finding::openStatusesForQuery());
}
private function orderedIntakeFindingsQuery(\Illuminate\Database\Eloquent\Builder $query): \Illuminate\Database\Eloquent\Builder
{
return $query
->orderByRaw(
"case
when due_at is not null and due_at < ? then 0
when status = ? then 1
when status = ? then 2
else 3
end asc",
[now(), Finding::STATUS_REOPENED, Finding::STATUS_NEW],
)
->orderByRaw('case when due_at is null then 1 else 0 end asc')
->orderBy('due_at')
->orderByDesc('id');
}
/**
* @param array<int, Tenant> $authorizedTenants
*/
private function terminalOperationsQuery(Workspace $workspace, array $authorizedTenants, ?Tenant $selectedTenant): \Illuminate\Database\Eloquent\Builder
{
return $this->operationsBaseQuery($workspace, $authorizedTenants, $selectedTenant)
->terminalFollowUp();
}
/**
* @param array<int, Tenant> $authorizedTenants
*/
private function staleOperationsQuery(Workspace $workspace, array $authorizedTenants, ?Tenant $selectedTenant): \Illuminate\Database\Eloquent\Builder
{
return $this->operationsBaseQuery($workspace, $authorizedTenants, $selectedTenant)
->activeStaleAttention();
}
/**
* @param array<int, Tenant> $authorizedTenants
*/
private function operationsBaseQuery(Workspace $workspace, array $authorizedTenants, ?Tenant $selectedTenant): \Illuminate\Database\Eloquent\Builder
{
$tenantIds = array_keys($authorizedTenants);
return OperationRun::query()
->with('tenant')
->where('workspace_id', (int) $workspace->getKey())
->where(function ($query) use ($selectedTenant, $tenantIds): void {
if ($selectedTenant instanceof Tenant) {
$query->where('tenant_id', (int) $selectedTenant->getKey());
return;
}
$query
->whereIn('tenant_id', $tenantIds === [] ? [-1] : $tenantIds)
->orWhereNull('tenant_id');
});
}
/**
* @param array<int, Tenant> $authorizedTenants
*/
private function alertsQuery(Workspace $workspace, array $authorizedTenants, ?Tenant $selectedTenant): \Illuminate\Database\Eloquent\Builder
{
$tenantIds = array_keys($authorizedTenants);
return AlertDelivery::query()
->with('tenant')
->where('workspace_id', (int) $workspace->getKey())
->where('status', AlertDelivery::STATUS_FAILED)
->where(function ($query) use ($selectedTenant, $tenantIds): void {
if ($selectedTenant instanceof Tenant) {
$query->where('tenant_id', (int) $selectedTenant->getKey());
return;
}
$query
->whereIn('tenant_id', $tenantIds === [] ? [-1] : $tenantIds)
->orWhereNull('tenant_id');
});
}
/**
* @return array<string, mixed>
*/
private function findingEntry(Finding $finding, string $familyKey, ?CanonicalNavigationContext $navigationContext, int $baseUrgencyRank): array
{
$sublineParts = array_values(array_filter([
$finding->owner_user_id !== null ? 'Owner: '.FindingResource::accountableOwnerDisplayFor($finding) : null,
FindingExceptionResource::relativeTimeDescription($finding->due_at) ?? FindingResource::dueAttentionLabelFor($finding),
$finding->reopened_at !== null ? 'Reopened' : null,
]));
return [
'family_key' => $familyKey,
'source_model' => Finding::class,
'source_key' => (string) $finding->getKey(),
'tenant_id' => $finding->tenant ? (int) $finding->tenant->getKey() : null,
'tenant_label' => $finding->tenant?->name,
'headline' => $finding->resolvedSubjectDisplayName() ?? 'Finding #'.$finding->getKey(),
'subline' => $sublineParts === [] ? null : implode(' • ', $sublineParts),
'urgency_rank' => $baseUrgencyRank
+ ($finding->due_at?->isPast() === true ? 0 : 1)
+ ($finding->reopened_at !== null ? 0 : 1),
'status_label' => Str::of((string) $finding->status)->replace('_', ' ')->title()->value(),
'destination_url' => $this->appendQuery(
FindingResource::getUrl('view', ['record' => $finding], panel: 'tenant', tenant: $finding->tenant),
$navigationContext?->toQuery() ?? [],
),
'back_label' => $navigationContext?->backLinkLabel ?? 'Back to governance inbox',
];
}
/**
* @return array<string, mixed>
*/
private function operationEntry(OperationRun $run, ?CanonicalNavigationContext $navigationContext): array
{
$problemClass = $run->problemClass();
return [
'family_key' => 'stale_operations',
'source_model' => OperationRun::class,
'source_key' => (string) $run->getKey(),
'tenant_id' => $run->tenant ? (int) $run->tenant->getKey() : null,
'tenant_label' => $run->tenant?->name,
'headline' => $problemClass === OperationRun::PROBLEM_CLASS_TERMINAL_FOLLOW_UP
? 'Terminal follow-up operation'
: 'Stale active operation',
'subline' => OperationRunLinks::identifier($run),
'urgency_rank' => $problemClass === OperationRun::PROBLEM_CLASS_TERMINAL_FOLLOW_UP ? 0 : 1,
'status_label' => $problemClass === OperationRun::PROBLEM_CLASS_TERMINAL_FOLLOW_UP
? 'Terminal follow-up'
: 'Stale',
'destination_url' => OperationRunLinks::tenantlessView($run, $navigationContext),
'back_label' => $navigationContext?->backLinkLabel ?? 'Back to governance inbox',
];
}
/**
* @return array<string, mixed>
*/
private function alertEntry(AlertDelivery $delivery, ?CanonicalNavigationContext $navigationContext): array
{
$payload = is_array($delivery->payload) ? $delivery->payload : [];
$headline = is_string($payload['title'] ?? null) && $payload['title'] !== ''
? (string) $payload['title']
: 'Failed alert delivery';
$sublineParts = array_values(array_filter([
is_string($delivery->last_error_message) && $delivery->last_error_message !== ''
? $delivery->last_error_message
: null,
is_string($delivery->event_type) && $delivery->event_type !== ''
? $delivery->event_type
: null,
]));
return [
'family_key' => 'alert_delivery_failures',
'source_model' => AlertDelivery::class,
'source_key' => (string) $delivery->getKey(),
'tenant_id' => $delivery->tenant ? (int) $delivery->tenant->getKey() : null,
'tenant_label' => $delivery->tenant?->name,
'headline' => $headline,
'subline' => $sublineParts === [] ? null : implode(' • ', $sublineParts),
'urgency_rank' => 0,
'status_label' => 'Failed',
'destination_url' => $this->appendQuery(
AlertDeliveryResource::getUrl('view', ['record' => $delivery], panel: 'admin'),
$navigationContext?->toQuery() ?? [],
),
'back_label' => $navigationContext?->backLinkLabel ?? 'Back to governance inbox',
];
}
/**
* @param array<string, mixed> $row
* @return array<string, mixed>
*/
private function reviewEntry(
Tenant $tenant,
string $family,
array $row,
mixed $latestPublishedReview,
?CanonicalNavigationContext $navigationContext,
): array {
$state = (string) ($row['derived_state'] ?? TenantTriageReview::DERIVED_STATE_NOT_REVIEWED);
$familyLabel = $family === PortfolioArrivalContextToken::FAMILY_BACKUP_HEALTH
? 'Backup health'
: 'Recovery evidence';
$headline = $state === TenantTriageReview::STATE_FOLLOW_UP_NEEDED
? $familyLabel.' needs review follow-up'
: $familyLabel.' changed since review';
$sublineParts = array_values(array_filter([
is_string($row['reviewed_by_user_name'] ?? null) && $row['reviewed_by_user_name'] !== ''
? 'Last review: '.$row['reviewed_by_user_name']
: null,
isset($row['reviewed_at']) && $row['reviewed_at'] !== null
? 'Reviewed '.optional($row['reviewed_at'])->toDateTimeString()
: null,
]));
$destinationUrl = $latestPublishedReview !== null
? TenantReviewResource::tenantScopedUrl('view', ['record' => $latestPublishedReview], $tenant, 'tenant')
: CustomerReviewWorkspace::tenantPrefilterUrl($tenant);
return [
'family_key' => 'review_follow_up',
'source_model' => TenantTriageReview::class,
'source_key' => (string) $tenant->getKey().':'.$family,
'tenant_id' => (int) $tenant->getKey(),
'tenant_label' => $tenant->name,
'headline' => $headline,
'subline' => $sublineParts === [] ? null : implode(' • ', $sublineParts),
'urgency_rank' => $state === TenantTriageReview::STATE_FOLLOW_UP_NEEDED ? 0 : 1,
'status_label' => $state === TenantTriageReview::STATE_FOLLOW_UP_NEEDED
? 'Follow-up needed'
: 'Changed since review',
'destination_url' => $this->appendQuery($destinationUrl, $navigationContext?->toQuery() ?? []),
'back_label' => $navigationContext?->backLinkLabel ?? 'Back to governance inbox',
];
}
private function assignedFindingsSummary(int $count, int $overdueCount): string
{
if ($count === 0) {
return 'No assigned findings are visible in the current scope.';
}
if ($overdueCount > 0) {
return sprintf(
'%d assigned finding%s remain open. %d %s overdue.',
$count,
$count === 1 ? '' : 's',
$overdueCount,
$overdueCount === 1 ? 'is' : 'are',
);
}
return sprintf(
'%d assigned finding%s remain open in the visible scope.',
$count,
$count === 1 ? '' : 's',
);
}
private function intakeFindingsSummary(int $count, int $needsTriageCount): string
{
if ($count === 0) {
return 'No intake findings are visible in the current scope.';
}
return sprintf(
'%d unassigned finding%s remain in intake. %d still need first triage.',
$count,
$count === 1 ? '' : 's',
$needsTriageCount,
);
}
private function operationsSummary(int $terminalCount, int $staleCount): string
{
if ($terminalCount + $staleCount === 0) {
return 'No stale or terminal follow-up operations are visible in the current scope.';
}
if ($terminalCount > 0 && $staleCount > 0) {
return sprintf(
'%d terminal follow-up operation%s and %d stale active run%s need monitoring attention.',
$terminalCount,
$terminalCount === 1 ? '' : 's',
$staleCount,
$staleCount === 1 ? '' : 's',
);
}
if ($terminalCount > 0) {
return sprintf(
'%d terminal follow-up operation%s need monitoring attention.',
$terminalCount,
$terminalCount === 1 ? '' : 's',
);
}
return sprintf(
'%d stale active run%s need monitoring attention.',
$staleCount,
$staleCount === 1 ? '' : 's',
);
}
private function alertsSummary(int $count): string
{
if ($count === 0) {
return 'No failed alert deliveries are visible in the current scope.';
}
return sprintf(
'%d failed alert delivery attempt%s remain visible in this workspace.',
$count,
$count === 1 ? '' : 's',
);
}
private function reviewSummary(int $followUpCount, int $changedCount): string
{
$total = $followUpCount + $changedCount;
if ($total === 0) {
return 'No review follow-up is visible in the current scope.';
}
return sprintf(
'%d review concern%s need attention. %d marked follow-up needed and %d changed since review.',
$total,
$total === 1 ? '' : 's',
$followUpCount,
$changedCount,
);
}
/**
* @param array<string, mixed> $query
*/
private function appendQuery(string $url, array $query): string
{
if ($query === []) {
return $url;
}
$separator = str_contains($url, '?') ? '&' : '?';
return $url.$separator.http_build_query($query);
}
}

View File

@ -6,7 +6,6 @@
use App\Support\Ai\AiPolicyMode;
use App\Models\Finding;
use App\Services\Entitlements\WorkspaceCommercialLifecycleResolver;
use App\Services\Entitlements\WorkspacePlanProfileCatalog;
final class SettingsRegistry
@ -315,44 +314,6 @@ static function (string $attribute, mixed $value, \Closure $fail): void {
return $normalized === '' ? null : $normalized;
},
));
$this->register(new SettingDefinition(
domain: 'entitlements',
key: WorkspaceCommercialLifecycleResolver::SETTING_COMMERCIAL_LIFECYCLE_STATE,
type: 'string',
systemDefault: null,
rules: [
'nullable',
'string',
'in:'.implode(',', WorkspaceCommercialLifecycleResolver::stateIds()),
],
normalizer: static function (mixed $value): ?string {
if ($value === null) {
return null;
}
$normalized = strtolower(trim((string) $value));
return $normalized === '' ? null : $normalized;
},
));
$this->register(new SettingDefinition(
domain: 'entitlements',
key: WorkspaceCommercialLifecycleResolver::SETTING_COMMERCIAL_LIFECYCLE_REASON,
type: 'string',
systemDefault: null,
rules: ['nullable', 'string', 'max:500'],
normalizer: static function (mixed $value): ?string {
if ($value === null) {
return null;
}
$normalized = trim((string) $value);
return $normalized === '' ? null : $normalized;
},
));
}
/**

View File

@ -749,17 +749,12 @@ public static function spec195ResidualSurfaceInventory(): array
'discoveryState' => 'outside_primary_discovery',
'closureDecision' => 'harmless_special_case',
'reasonCategory' => 'read_mostly_context_detail',
'explicitReason' => 'The workspace directory detail page is a read-mostly drilldown with one bounded, capability-gated commercial lifecycle mutation added by spec 251; it is still not a declaration-backed mutable system workbench.',
'explicitReason' => 'The workspace directory detail page is a read-mostly drilldown that exposes context and links, not a declaration-backed mutable system workbench.',
'evidence' => [
[
'kind' => 'feature_livewire_test',
'reference' => 'tests/Feature/System/Spec195/SystemDirectoryResidualSurfaceTest.php',
'proves' => 'The workspace detail page stays capability-gated and renders contextual tenant and run links while remaining outside the primary declaration-backed table contract.',
],
[
'kind' => 'feature_livewire_test',
'reference' => 'tests/Feature/System/ViewWorkspaceEntitlementsTest.php',
'proves' => 'The commercial lifecycle mutation is separately capability-gated, confirmation-protected, rationale-required, and audited.',
'proves' => 'The workspace detail page stays capability-gated and renders contextual tenant and run links without mutating actions.',
],
[
'kind' => 'authorization_test',

View File

@ -8,8 +8,6 @@ final class WorkspaceResolver
{
public function resolve(string $value): ?Workspace
{
$value = $this->normalizeRouteValue($value);
$workspace = Workspace::query()
->where('slug', $value)
->first();
@ -24,37 +22,4 @@ public function resolve(string $value): ?Workspace
return Workspace::query()->whereKey((int) $value)->first();
}
private function normalizeRouteValue(string $value): string
{
$value = trim($value);
if (! str_starts_with($value, '{')) {
return $value;
}
$decoded = json_decode($value, true);
if (! is_array($decoded)) {
return $value;
}
$slug = $decoded['slug'] ?? null;
if (is_string($slug) && $slug !== '') {
return $slug;
}
$id = $decoded['id'] ?? null;
if (is_int($id)) {
return (string) $id;
}
if (is_string($id) && ctype_digit($id)) {
return $id;
}
return $value;
}
}

View File

@ -1,164 +0,0 @@
<x-filament-panels::page>
@php
$scope = $this->appliedScope();
$sections = $this->sections();
$emptyState = $this->calmEmptyState();
@endphp
<x-filament::section>
<div class="flex flex-col gap-3">
<div class="inline-flex w-fit items-center gap-2 rounded-full border border-primary-200 bg-primary-50 px-3 py-1 text-xs font-medium text-primary-700 dark:border-primary-700/60 dark:bg-primary-950/40 dark:text-primary-300">
<x-filament::icon icon="heroicon-o-inbox-stack" class="h-3.5 w-3.5" />
Governance inbox
</div>
<div class="space-y-1">
<h1 class="text-2xl font-semibold tracking-tight text-gray-950 dark:text-white">
Governance inbox
</h1>
<p class="max-w-3xl text-sm leading-6 text-gray-600 dark:text-gray-300">
This workspace decision surface routes you into the existing findings, operations, alerts, and review surfaces without introducing a second workflow state.
</p>
</div>
<div class="flex flex-wrap gap-2 text-sm text-gray-600 dark:text-gray-300">
@if (filled($scope['workspace_label'] ?? null))
<span class="inline-flex items-center rounded-full bg-gray-100 px-3 py-1 text-xs font-medium text-gray-700 dark:bg-gray-800 dark:text-gray-200">
Workspace: {{ $scope['workspace_label'] }}
</span>
@endif
<span class="inline-flex items-center rounded-full bg-gray-100 px-3 py-1 text-xs font-medium text-gray-700 dark:bg-gray-800 dark:text-gray-200">
Scope: {{ $scope['family_label'] ?? 'All attention' }}
</span>
<span class="inline-flex items-center rounded-full bg-gray-100 px-3 py-1 text-xs font-medium text-gray-700 dark:bg-gray-800 dark:text-gray-200">
Visible items: {{ $scope['total_count'] ?? 0 }}
</span>
@if (filled($scope['tenant_label'] ?? null))
<span class="inline-flex items-center rounded-full bg-warning-50 px-3 py-1 text-xs font-medium text-warning-700 dark:bg-warning-500/10 dark:text-warning-300">
Tenant: {{ $scope['tenant_label'] }}
</span>
@endif
</div>
<div class="flex flex-wrap gap-2">
<a
href="{{ $this->pageUrl(['family' => null]) }}"
class="inline-flex items-center gap-2 rounded-full border px-3 py-1.5 text-sm font-medium transition {{ $this->family === null ? 'border-primary-300 bg-primary-50 text-primary-700 dark:border-primary-700/60 dark:bg-primary-950/40 dark:text-primary-300' : 'border-gray-200 text-gray-700 hover:border-gray-300 dark:border-gray-700 dark:text-gray-200 dark:hover:border-gray-600' }}"
>
All attention
<span class="rounded-full bg-black/5 px-2 py-0.5 text-xs dark:bg-white/10">{{ $scope['total_count'] ?? 0 }}</span>
</a>
@foreach ($this->availableFamilies() as $family)
<a
href="{{ $this->pageUrl(['family' => $family['key']]) }}"
class="inline-flex items-center gap-2 rounded-full border px-3 py-1.5 text-sm font-medium transition {{ $this->isActiveFamily($family['key']) ? 'border-primary-300 bg-primary-50 text-primary-700 dark:border-primary-700/60 dark:bg-primary-950/40 dark:text-primary-300' : 'border-gray-200 text-gray-700 hover:border-gray-300 dark:border-gray-700 dark:text-gray-200 dark:hover:border-gray-600' }}"
>
{{ $family['label'] }}
<span class="rounded-full bg-black/5 px-2 py-0.5 text-xs dark:bg-white/10">{{ $family['count'] }}</span>
</a>
@endforeach
</div>
@if ($this->hasTenantPrefilter())
<div class="flex flex-wrap items-center gap-3 text-sm text-gray-600 dark:text-gray-300">
<span>The inbox is currently filtered to one tenant.</span>
<a href="{{ $this->pageUrl(['tenant' => null]) }}" class="font-medium text-primary-600 hover:text-primary-500 dark:text-primary-400 dark:hover:text-primary-300">
Clear tenant filter
</a>
</div>
@endif
</div>
</x-filament::section>
@if ($sections === [])
<x-filament::section>
<div class="flex flex-col gap-4 rounded-2xl border border-dashed border-gray-300 bg-gray-50/60 p-6 dark:border-gray-700 dark:bg-gray-900/40">
<div class="space-y-1">
<h2 class="text-base font-semibold text-gray-950 dark:text-white">{{ $emptyState['title'] }}</h2>
<p class="max-w-2xl text-sm leading-6 text-gray-600 dark:text-gray-300">{{ $emptyState['body'] }}</p>
</div>
@if (filled($emptyState['action_label'] ?? null) && filled($emptyState['action_url'] ?? null))
<div>
<x-filament::button tag="a" color="gray" href="{{ $emptyState['action_url'] }}">
{{ $emptyState['action_label'] }}
</x-filament::button>
</div>
@endif
</div>
</x-filament::section>
@else
@foreach ($sections as $section)
<x-filament::section>
<div class="flex flex-col gap-4">
<div class="flex flex-col gap-3 lg:flex-row lg:items-start lg:justify-between">
<div class="space-y-2">
<div class="flex flex-wrap items-center gap-2">
<h2 class="text-base font-semibold text-gray-950 dark:text-white">{{ $section['label'] }}</h2>
<span class="inline-flex items-center rounded-full bg-gray-100 px-2.5 py-1 text-xs font-medium text-gray-700 dark:bg-gray-800 dark:text-gray-200">
{{ $section['count'] }}
</span>
</div>
<p class="max-w-3xl text-sm leading-6 text-gray-600 dark:text-gray-300">{{ $section['summary'] }}</p>
</div>
<div>
<x-filament::button tag="a" color="gray" href="{{ $section['dominant_action_url'] }}">
{{ $section['dominant_action_label'] }}
</x-filament::button>
</div>
</div>
@if ($section['count'] === 0)
<div class="rounded-2xl border border-dashed border-gray-300 bg-gray-50/60 p-5 text-sm leading-6 text-gray-600 dark:border-gray-700 dark:bg-gray-900/40 dark:text-gray-300">
{{ $section['empty_state'] }}
</div>
@else
<ul class="grid gap-3">
@foreach ($section['entries'] as $entry)
<li class="rounded-2xl border border-gray-200 bg-white p-4 shadow-sm dark:border-gray-800 dark:bg-gray-900/60">
<div class="flex flex-col gap-3 lg:flex-row lg:items-start lg:justify-between">
<div class="space-y-1.5">
@if (filled($entry['tenant_label'] ?? null))
<div class="text-xs font-medium uppercase tracking-[0.16em] text-gray-500 dark:text-gray-400">
{{ $entry['tenant_label'] }}
</div>
@endif
<div class="flex flex-wrap items-center gap-2">
<a href="{{ $entry['destination_url'] }}" class="text-sm font-semibold text-gray-950 hover:text-primary-600 dark:text-white dark:hover:text-primary-300">
{{ $entry['headline'] }}
</a>
<span class="inline-flex items-center rounded-full bg-gray-100 px-2.5 py-1 text-xs font-medium text-gray-700 dark:bg-gray-800 dark:text-gray-200">
{{ $entry['status_label'] }}
</span>
</div>
@if (filled($entry['subline'] ?? null))
<p class="text-sm leading-6 text-gray-600 dark:text-gray-300">{{ $entry['subline'] }}</p>
@endif
</div>
<div>
<x-filament::button tag="a" color="gray" size="sm" href="{{ $entry['destination_url'] }}">
Open source
</x-filament::button>
</div>
</div>
</li>
@endforeach
</ul>
@endif
</div>
</x-filament::section>
@endforeach
@endif
</x-filament-panels::page>

View File

@ -1,19 +0,0 @@
<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">
Customer-safe review workspace
</div>
<div class="text-sm text-gray-600 dark:text-gray-300">
Review the latest published customer-safe posture for each entitled tenant without leaving the current workspace context.
</div>
<div class="text-sm text-gray-600 dark:text-gray-300">
Opening a row returns to the existing tenant review detail so evidence, review packs, and audit-aware proof remain on their canonical tenant-scoped surfaces.
</div>
</div>
</x-filament::section>
{{ $this->table }}
</x-filament-panels::page>

View File

@ -1,18 +1,9 @@
@php
use App\Support\Badges\BadgeCatalog;
use App\Support\Badges\BadgeDomain;
/** @var \App\Models\Workspace $workspace */
$workspace = $this->workspace;
$customerHealthDecision = $this->customerHealthDecision();
$tenants = $this->workspaceTenants();
$runs = $this->recentRuns();
$commercialLifecycle = $this->workspaceCommercialLifecycleSummary();
$commercialBadge = BadgeCatalog::spec(BadgeDomain::CommercialLifecycleState, $commercialLifecycle['state'] ?? null);
$commercialActionDecisions = is_array($commercialLifecycle['action_decisions'] ?? null) ? $commercialLifecycle['action_decisions'] : [];
$activationLifecycleDecision = $commercialActionDecisions['managed_tenant_activation'] ?? null;
$reviewPackLifecycleDecision = $commercialActionDecisions['review_pack_start'] ?? null;
$readOnlyLifecycleDecision = $commercialActionDecisions['generated_pack_read'] ?? null;
$workspaceEntitlementSummary = $this->workspaceEntitlementSummary();
$planProfile = $workspaceEntitlementSummary['plan_profile'] ?? null;
$entitlementDecisions = $workspaceEntitlementSummary['decisions'] ?? [];
@ -49,63 +40,6 @@
@include('filament.system.pages.directory.partials.customer-health-decision-card', ['decision' => $customerHealthDecision])
@endif
<x-filament::section>
<x-slot name="heading">
Commercial lifecycle
</x-slot>
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2">
<div class="rounded-lg bg-gray-50 px-4 py-3 dark:bg-white/5">
<p class="text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400">Current state</p>
<div class="mt-2 flex items-center gap-2">
<x-filament::badge :color="$commercialBadge->color" :icon="$commercialBadge->icon">
{{ $commercialBadge->label }}
</x-filament::badge>
<span class="text-sm text-gray-500 dark:text-gray-400">{{ $commercialLifecycle['source_label'] ?? 'default active paid' }}</span>
</div>
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">{{ $commercialLifecycle['description'] ?? 'Commercial lifecycle state controls expansion and review-pack starts.' }}</p>
</div>
<div class="rounded-lg bg-gray-50 px-4 py-3 dark:bg-white/5">
<p class="text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400">Lifecycle rationale</p>
<p class="mt-1 text-base font-semibold text-gray-950 dark:text-white">{{ $commercialLifecycle['rationale'] ?? 'No explicit rationale recorded.' }}</p>
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">
{{ $commercialLifecycle['last_changed_by'] ?? 'System default' }}
@if (($commercialLifecycle['last_changed_at'] ?? null) instanceof \Carbon\CarbonInterface)
· {{ $commercialLifecycle['last_changed_at']->diffForHumans() }}
@endif
</p>
</div>
</div>
<div class="mt-4 space-y-3">
@foreach ([
'Managed tenant activation' => $activationLifecycleDecision,
'Review-pack starts' => $reviewPackLifecycleDecision,
'Read-only history and downloads' => $readOnlyLifecycleDecision,
] as $label => $decision)
@if (is_array($decision))
<div class="rounded-lg border border-gray-200 px-4 py-3 dark:border-white/10">
<div class="flex items-start justify-between gap-4">
<div>
<p class="text-sm font-semibold text-gray-950 dark:text-white">{{ $label }}</p>
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">{{ $decision['message'] ?? 'No lifecycle decision message available.' }}</p>
</div>
<x-filament::badge :color="match ($decision['outcome'] ?? null) {
'block' => 'danger',
'warn' => 'warning',
'allow_read_only' => 'info',
default => 'success',
}">
{{ str_replace('_', ' ', (string) ($decision['outcome'] ?? 'allow')) }}
</x-filament::badge>
</div>
</div>
@endif
@endforeach
</div>
</x-filament::section>
@if (is_array($planProfile) && is_array($managedTenantDecision) && is_array($reviewPackDecision))
<x-filament::section>
<x-slot name="heading">

View File

@ -11,8 +11,6 @@
/** @var bool $canManage */
/** @var bool $generationBlocked */
/** @var ?string $generationBlockReason */
/** @var ?string $generationWarningReason */
/** @var ?string $customerWorkspaceUrl */
/** @var ?string $downloadUrl */
/** @var ?string $failedReason */
/** @var ?string $failedReasonDetail */
@ -34,12 +32,6 @@
</div>
@endif
@if ($canManage && ! $generationBlocked && $generationWarningReason)
<div class="mb-3 rounded-lg border border-warning-200 bg-warning-50 px-3 py-2 text-sm text-warning-800 dark:border-warning-500/30 dark:bg-warning-500/10 dark:text-warning-200">
{{ $generationWarningReason }}
</div>
@endif
@if (! $pack)
{{-- State 1: No pack --}}
<div class="flex flex-col items-center gap-3 py-4 text-center">
@ -223,18 +215,5 @@
@endif
</div>
@endif
@if ($canView && $customerWorkspaceUrl)
<div class="mt-3 flex items-center gap-2">
<x-filament::button
size="sm"
color="gray"
tag="a"
:href="$customerWorkspaceUrl"
>
Customer workspace
</x-filament::button>
</div>
@endif
</x-filament::section>
</div>

View File

@ -1,100 +0,0 @@
<?php
declare(strict_types=1);
use App\Filament\Resources\TenantReviewResource;
use App\Models\ReviewPack;
use App\Models\Tenant;
use App\Support\TenantReviewStatus;
use App\Support\Workspaces\WorkspaceContext;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Storage;
uses(RefreshDatabase::class);
pest()->browser()->timeout(20_000);
beforeEach(function (): void {
Storage::fake('exports');
});
it('smokes the customer review workspace handoff from tenant review detail', function (): void {
$tenantPublished = Tenant::factory()->create(['name' => 'Published Tenant']);
[$user, $tenantPublished] = createUserWithTenant(
tenant: $tenantPublished,
role: 'owner',
workspaceRole: 'manager',
);
$tenantWithoutPublished = Tenant::factory()->create([
'workspace_id' => (int) $tenantPublished->workspace_id,
'name' => 'No Published Tenant',
]);
createUserWithTenant(
tenant: $tenantWithoutPublished,
user: $user,
role: 'owner',
workspaceRole: 'manager',
);
$publishedSnapshot = seedTenantReviewEvidence($tenantPublished);
$noPublishedSnapshot = seedTenantReviewEvidence($tenantWithoutPublished);
$publishedReview = composeTenantReviewForTest($tenantPublished, $user, $publishedSnapshot);
$publishedReview->forceFill([
'status' => TenantReviewStatus::Published->value,
'published_at' => now(),
'published_by_user_id' => (int) $user->getKey(),
])->save();
$internalOnlyReview = composeTenantReviewForTest($tenantWithoutPublished, $user, $noPublishedSnapshot);
$internalOnlyReview->forceFill([
'status' => TenantReviewStatus::Ready->value,
'published_at' => null,
'published_by_user_id' => null,
])->save();
Storage::disk('exports')->put('review-packs/customer-review-workspace-smoke.zip', 'PK-test');
ReviewPack::factory()->ready()->create([
'tenant_id' => (int) $tenantPublished->getKey(),
'workspace_id' => (int) $tenantPublished->workspace_id,
'tenant_review_id' => (int) $publishedReview->getKey(),
'evidence_snapshot_id' => (int) $publishedSnapshot->getKey(),
'initiated_by_user_id' => (int) $user->getKey(),
'file_path' => 'review-packs/customer-review-workspace-smoke.zip',
'file_disk' => 'exports',
]);
$this->actingAs($user)->withSession([
WorkspaceContext::SESSION_KEY => (int) $tenantPublished->workspace_id,
WorkspaceContext::LAST_TENANT_IDS_SESSION_KEY => [
(string) $tenantPublished->workspace_id => (int) $tenantPublished->getKey(),
],
]);
visit(TenantReviewResource::tenantScopedUrl('view', ['record' => $publishedReview], $tenantPublished))
->waitForText('Related context')
->assertSee('Open customer workspace')
->assertNoJavaScriptErrors()
->assertNoConsoleLogs()
->click('Open customer workspace')
->waitForText('Customer-safe review workspace')
->assertSee('Clear filters')
->assertSee('Open latest review')
->assertDontSee('Publish review')
->assertDontSee('Refresh review')
->click('Clear filters')
->waitForText('No published review available yet')
->assertSee('No published review available yet')
->click('Open latest review')
->waitForText('Outcome summary')
->assertDontSee('Publish review')
->assertDontSee('Refresh review')
->assertDontSee('Create next review')
->assertDontSee('Export executive pack')
->assertDontSee('Archive review')
->assertNoJavaScriptErrors()
->assertNoConsoleLogs();
});

View File

@ -10,14 +10,10 @@
use App\Models\EvidenceSnapshotItem;
use App\Models\Finding;
use App\Models\OperationRun;
use App\Models\PlatformUser;
use App\Models\ReviewPack;
use App\Models\StoredReport;
use App\Models\Tenant;
use App\Services\Entitlements\WorkspaceCommercialLifecycleResolver;
use App\Services\Settings\SettingsWriter;
use App\Support\Auth\Capabilities;
use App\Support\Auth\PlatformCapabilities;
use App\Support\Evidence\EvidenceCompletenessState;
use App\Support\Evidence\EvidenceSnapshotStatus;
use App\Support\Ui\GovernanceActions\GovernanceActionCatalog;
@ -72,23 +68,6 @@ function evidenceSnapshotHeaderActions(Testable $component): array
return $instance->getCachedHeaderActions();
}
function suspendEvidenceSnapshotWorkspace(Tenant $tenant): void
{
app(SettingsWriter::class)->updateWorkspaceCommercialLifecycle(
actor: PlatformUser::factory()->create([
'capabilities' => [
PlatformCapabilities::ACCESS_SYSTEM_PANEL,
PlatformCapabilities::DIRECTORY_VIEW,
PlatformCapabilities::COMMERCIAL_LIFECYCLE_MANAGE,
],
'is_active' => true,
]),
workspace: $tenant->workspace,
state: WorkspaceCommercialLifecycleResolver::STATE_SUSPENDED_READ_ONLY,
reason: 'Evidence read-only preservation test',
);
}
it('renders the evidence list page for an authorized user', function (): void {
$tenant = Tenant::factory()->create();
[$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'owner');
@ -228,36 +207,6 @@ function suspendEvidenceSnapshotWorkspace(Tenant $tenant): void
->toContain('operation_run', 'review_pack');
});
it('keeps evidence snapshot detail accessible for readonly members while suspended read-only', function (): void {
$tenant = Tenant::factory()->create();
[$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'readonly');
$snapshot = EvidenceSnapshot::query()->create([
'tenant_id' => (int) $tenant->getKey(),
'workspace_id' => (int) $tenant->workspace_id,
'status' => EvidenceSnapshotStatus::Active->value,
'completeness_state' => EvidenceCompletenessState::Complete->value,
'summary' => ['finding_count' => 2],
'generated_at' => now(),
]);
suspendEvidenceSnapshotWorkspace($tenant);
$this->actingAs($user)
->get(EvidenceSnapshotResource::getUrl('view', ['record' => $snapshot], tenant: $tenant, panel: 'tenant'))
->assertOk();
$tenant->makeCurrent();
Filament::setTenant($tenant, true);
Livewire::actingAs($user)
->test(ViewEvidenceSnapshot::class, ['record' => $snapshot->getKey()])
->assertActionVisible('refresh_evidence')
->assertActionDisabled('refresh_evidence')
->assertActionVisible('expire_snapshot')
->assertActionDisabled('expire_snapshot');
});
it('shows artifact truth and next-step guidance for degraded evidence snapshots', function (): void {
$tenant = Tenant::factory()->create();
[$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'owner');

View File

@ -1,99 +0,0 @@
<?php
declare(strict_types=1);
use App\Filament\Pages\Governance\GovernanceInbox;
use App\Models\Tenant;
use App\Models\User;
use App\Models\Workspace;
use App\Models\WorkspaceMembership;
use App\Services\Auth\WorkspaceCapabilityResolver;
use App\Support\Workspaces\WorkspaceContext;
use function Pest\Laravel\mock;
it('redirects governance inbox visits without workspace context into the existing workspace chooser flow', function (): void {
$user = User::factory()->create();
$workspaceA = Workspace::factory()->create();
$workspaceB = Workspace::factory()->create();
WorkspaceMembership::factory()->create([
'workspace_id' => (int) $workspaceA->getKey(),
'user_id' => (int) $user->getKey(),
'role' => 'owner',
]);
WorkspaceMembership::factory()->create([
'workspace_id' => (int) $workspaceB->getKey(),
'user_id' => (int) $user->getKey(),
'role' => 'owner',
]);
$this->actingAs($user)
->get(GovernanceInbox::getUrl(panel: 'admin'))
->assertRedirect('/admin/choose-workspace');
});
it('returns 404 for users outside the active workspace on the governance inbox route', function (): void {
$user = User::factory()->create();
$workspace = Workspace::factory()->create();
WorkspaceMembership::factory()->create([
'workspace_id' => (int) Workspace::factory()->create()->getKey(),
'user_id' => (int) $user->getKey(),
'role' => 'owner',
]);
$this->actingAs($user)
->withSession([WorkspaceContext::SESSION_KEY => (int) $workspace->getKey()])
->get(GovernanceInbox::getUrl(panel: 'admin'))
->assertNotFound();
});
it('returns 403 for workspace members with no qualifying family visibility anywhere', function (): void {
$user = User::factory()->create();
$workspace = Workspace::factory()->create();
WorkspaceMembership::factory()->create([
'workspace_id' => (int) $workspace->getKey(),
'user_id' => (int) $user->getKey(),
'role' => 'owner',
]);
mock(WorkspaceCapabilityResolver::class, function ($mock): void {
$mock->shouldReceive('isMember')->andReturnTrue();
$mock->shouldReceive('can')->andReturnFalse();
});
$this->actingAs($user)
->withSession([WorkspaceContext::SESSION_KEY => (int) $workspace->getKey()])
->get(GovernanceInbox::getUrl(panel: 'admin'))
->assertForbidden();
});
it('allows readonly tenant members to open the governance inbox through operations-family visibility', function (): void {
$tenant = Tenant::factory()->create(['status' => 'active']);
[$user, $tenant] = createUserWithTenant($tenant, role: 'readonly', workspaceRole: 'readonly');
$this->actingAs($user)
->withSession([WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id])
->get(GovernanceInbox::getUrl(panel: 'admin'))
->assertOk()
->assertSee('Governance inbox');
});
it('returns 404 for explicit tenant filters outside the actor scope', function (): void {
$visibleTenant = Tenant::factory()->create(['status' => 'active']);
[$user, $visibleTenant] = createUserWithTenant($visibleTenant, role: 'readonly', workspaceRole: 'readonly');
$hiddenTenant = Tenant::factory()->create([
'status' => 'active',
'workspace_id' => (int) $visibleTenant->workspace_id,
]);
$this->actingAs($user)
->withSession([WorkspaceContext::SESSION_KEY => (int) $visibleTenant->workspace_id])
->get(GovernanceInbox::getUrl(panel: 'admin').'?tenant_id='.(string) $hiddenTenant->getKey())
->assertNotFound();
});

View File

@ -1,64 +0,0 @@
<?php
declare(strict_types=1);
use App\Filament\Pages\Findings\MyFindingsInbox;
use App\Filament\Pages\Governance\GovernanceInbox;
use App\Models\Finding;
use App\Models\OperationRun;
use App\Models\Tenant;
use App\Support\Navigation\CanonicalNavigationContext;
use App\Support\OperationRunLinks;
use App\Support\OperationRunOutcome;
use App\Support\OperationRunStatus;
use App\Support\Workspaces\WorkspaceContext;
use Filament\Facades\Filament;
it('embeds canonical governance inbox navigation context into source links', function (): void {
$tenant = Tenant::factory()->create([
'status' => 'active',
'name' => 'Alpha Tenant',
'external_id' => 'alpha-tenant',
]);
[$user, $tenant] = createUserWithTenant($tenant, role: 'owner', workspaceRole: 'owner');
$finding = Finding::factory()
->for($tenant)
->assignedTo((int) $user->getKey())
->create();
$run = OperationRun::factory()
->forTenant($tenant)
->create([
'status' => OperationRunStatus::Completed->value,
'outcome' => OperationRunOutcome::Failed->value,
'completed_at' => now()->subMinute(),
]);
$context = new CanonicalNavigationContext(
sourceSurface: 'governance.inbox',
canonicalRouteName: GovernanceInbox::getRouteName(Filament::getPanel('admin')),
backLinkLabel: 'Back to governance inbox',
backLinkUrl: GovernanceInbox::getUrl(panel: 'admin'),
);
$response = $this->actingAs($user)
->withSession([WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id])
->get(GovernanceInbox::getUrl(panel: 'admin'));
$response->assertOk();
$expectedMyFindingsUrl = htmlspecialchars(
MyFindingsInbox::getUrl(panel: 'admin').'?'.http_build_query($context->toQuery()),
ENT_QUOTES,
);
$expectedOperationUrl = htmlspecialchars(
OperationRunLinks::tenantlessView($run, $context),
ENT_QUOTES,
);
$response->assertSee($expectedMyFindingsUrl, false)
->assertSee($expectedOperationUrl, false)
->assertSee((string) $finding->getKey())
->assertSee('nav%5Bback_label%5D=Back+to+governance+inbox', false);
});

View File

@ -1,143 +0,0 @@
<?php
declare(strict_types=1);
use App\Filament\Pages\Governance\GovernanceInbox;
use App\Models\AlertDelivery;
use App\Models\Finding;
use App\Models\OperationRun;
use App\Models\Tenant;
use App\Models\TenantTriageReview;
use App\Support\BackupHealth\TenantBackupHealthResolver;
use App\Support\OperationRunOutcome;
use App\Support\OperationRunStatus;
use App\Support\PortfolioTriage\PortfolioArrivalContextToken;
use App\Support\PortfolioTriage\TenantTriageReviewFingerprint;
use App\Support\Workspaces\WorkspaceContext;
it('renders visible governance attention sections on the governance inbox page', function (): void {
$alphaTenant = Tenant::factory()->create([
'status' => 'active',
'name' => 'Alpha Tenant',
'external_id' => 'alpha-tenant',
]);
[$user, $alphaTenant] = createUserWithTenant($alphaTenant, role: 'owner', workspaceRole: 'owner');
$bravoTenant = Tenant::factory()->create([
'status' => 'active',
'workspace_id' => (int) $alphaTenant->workspace_id,
'name' => 'Bravo Tenant',
'external_id' => 'bravo-tenant',
]);
$user->tenants()->syncWithoutDetaching([
(int) $bravoTenant->getKey() => ['role' => 'owner'],
]);
Finding::factory()
->for($alphaTenant)
->assignedTo((int) $user->getKey())
->ownedBy((int) $user->getKey())
->overdueByHours()
->create();
Finding::factory()
->for($bravoTenant)
->reopened()
->create();
OperationRun::factory()
->forTenant($alphaTenant)
->create([
'status' => OperationRunStatus::Completed->value,
'outcome' => OperationRunOutcome::Failed->value,
'completed_at' => now()->subMinute(),
]);
AlertDelivery::factory()->create([
'tenant_id' => null,
'workspace_id' => (int) $alphaTenant->workspace_id,
'status' => AlertDelivery::STATUS_FAILED,
'payload' => [
'title' => 'Delivery failed',
'body' => 'A notification destination failed.',
],
]);
$backupHealthResolver = app(TenantBackupHealthResolver::class);
$fingerprints = app(TenantTriageReviewFingerprint::class);
$alphaBackupFingerprint = $fingerprints->forBackupHealth($backupHealthResolver->assess($alphaTenant));
expect($alphaBackupFingerprint)->not->toBeNull();
TenantTriageReview::factory()
->for($alphaTenant)
->followUpNeeded()
->create([
'workspace_id' => (int) $alphaTenant->workspace_id,
'reviewed_by_user_id' => (int) $user->getKey(),
'concern_family' => PortfolioArrivalContextToken::FAMILY_BACKUP_HEALTH,
'review_fingerprint' => $alphaBackupFingerprint['fingerprint'],
'review_snapshot' => $alphaBackupFingerprint['snapshot'],
]);
$this->actingAs($user)
->withSession([WorkspaceContext::SESSION_KEY => (int) $alphaTenant->workspace_id])
->get(GovernanceInbox::getUrl(panel: 'admin'))
->assertOk()
->assertSee('Assigned findings')
->assertSee('Findings intake')
->assertSee('Operations follow-up')
->assertSee('Alert delivery failures')
->assertSee('Review follow-up')
->assertSee('Open my findings')
->assertSee('Open terminal follow-up')
->assertSee('Open alert deliveries')
->assertSee('Open review follow-up');
});
it('renders honest empty states for tenant and family filtering on the governance inbox page', function (): void {
$alphaTenant = Tenant::factory()->create([
'status' => 'active',
'name' => 'Alpha Tenant',
'external_id' => 'alpha-tenant',
]);
[$user, $alphaTenant] = createUserWithTenant($alphaTenant, role: 'owner', workspaceRole: 'owner');
$bravoTenant = Tenant::factory()->create([
'status' => 'active',
'workspace_id' => (int) $alphaTenant->workspace_id,
'name' => 'Bravo Tenant',
'external_id' => 'bravo-tenant',
]);
$user->tenants()->syncWithoutDetaching([
(int) $bravoTenant->getKey() => ['role' => 'owner'],
]);
Finding::factory()
->for($bravoTenant)
->assignedTo((int) $user->getKey())
->create();
AlertDelivery::factory()->create([
'tenant_id' => null,
'workspace_id' => (int) $alphaTenant->workspace_id,
'status' => AlertDelivery::STATUS_FAILED,
]);
$this->actingAs($user)
->withSession([WorkspaceContext::SESSION_KEY => (int) $alphaTenant->workspace_id])
->get(GovernanceInbox::getUrl(panel: 'admin').'?tenant_id='.(string) $alphaTenant->getKey())
->assertOk()
->assertSee('This tenant filter is hiding other visible attention')
->assertSee('Clear tenant filter');
$this->actingAs($user)
->withSession([WorkspaceContext::SESSION_KEY => (int) $alphaTenant->workspace_id])
->get(GovernanceInbox::getUrl(panel: 'admin').'?tenant_id='.(string) $alphaTenant->getKey().'&family=alert_delivery_failures')
->assertOk()
->assertSee('Alert delivery failures')
->assertSee('No failed alert deliveries match this tenant filter right now.')
->assertDontSee('Open my findings');
});

View File

@ -5,16 +5,13 @@
use App\Filament\Pages\Workspaces\ManagedTenantOnboardingWizard;
use App\Models\AuditLog;
use App\Models\OperationRun;
use App\Models\PlatformUser;
use App\Models\ProviderConnection;
use App\Models\Tenant;
use App\Models\TenantOnboardingSession;
use App\Models\User;
use App\Models\Workspace;
use App\Services\Entitlements\WorkspaceCommercialLifecycleResolver;
use App\Services\Entitlements\WorkspaceEntitlementResolver;
use App\Services\Settings\SettingsWriter;
use App\Support\Auth\PlatformCapabilities;
use App\Support\OperationRunOutcome;
use App\Support\OperationRunStatus;
use App\Support\Workspaces\WorkspaceContext;
@ -24,12 +21,7 @@
/**
* @return array{workspace: Workspace, user: User, tenant: Tenant, draft: TenantOnboardingSession, component: \Livewire\Features\SupportTesting\Testable}
*/
function readyOnboardingEntitlementContext(
int $activeTenantCount = 0,
?int $limitOverride = null,
?string $overrideReason = null,
?string $commercialState = null,
): array
function readyOnboardingEntitlementContext(int $activeTenantCount = 0, ?int $limitOverride = null, ?string $overrideReason = null): array
{
Queue::fake();
@ -118,22 +110,6 @@ function readyOnboardingEntitlementContext(
}
}
if ($commercialState !== null) {
app(SettingsWriter::class)->updateWorkspaceCommercialLifecycle(
actor: PlatformUser::factory()->create([
'capabilities' => [
PlatformCapabilities::ACCESS_SYSTEM_PANEL,
PlatformCapabilities::DIRECTORY_VIEW,
PlatformCapabilities::COMMERCIAL_LIFECYCLE_MANAGE,
],
'is_active' => true,
]),
workspace: $workspace,
state: $commercialState,
reason: 'Onboarding entitlement test commercial state',
);
}
session()->put(WorkspaceContext::SESSION_KEY, (int) $workspace->getKey());
$component = Livewire::actingAs($user)->test(ManagedTenantOnboardingWizard::class, [
@ -211,66 +187,4 @@ function readyOnboardingEntitlementContext(
$context['tenant']->refresh();
expect($context['tenant']->status)->toBe(Tenant::STATUS_ACTIVE);
});
it('allows onboarding activation while a workspace is in trial', function (): void {
$context = readyOnboardingEntitlementContext(
activeTenantCount: 0,
commercialState: WorkspaceCommercialLifecycleResolver::STATE_TRIAL,
);
$context['component']
->assertSee('Activation entitlement')
->assertSee('Trial')
->call('completeOnboarding');
$context['tenant']->refresh();
expect($context['tenant']->status)->toBe(Tenant::STATUS_ACTIVE)
->and(AuditLog::query()
->where('workspace_id', (int) $context['workspace']->getKey())
->where('action', 'managed_tenant_onboarding.activation')
->exists())->toBeTrue();
});
it('blocks onboarding activation with a grace commercial-state reason before tenant mutation', function (): void {
$context = readyOnboardingEntitlementContext(
activeTenantCount: 0,
commercialState: WorkspaceCommercialLifecycleResolver::STATE_GRACE,
);
$context['component']
->assertSee('Activation entitlement')
->assertSee('Grace')
->assertSee('New managed-tenant activation is frozen while this workspace is in grace.')
->call('completeOnboarding');
$context['tenant']->refresh();
expect($context['tenant']->status)->toBe(Tenant::STATUS_ONBOARDING)
->and(AuditLog::query()
->where('workspace_id', (int) $context['workspace']->getKey())
->where('action', 'managed_tenant_onboarding.activation')
->exists())->toBeFalse();
});
it('blocks onboarding activation with a suspended read-only commercial-state reason before tenant mutation', function (): void {
$context = readyOnboardingEntitlementContext(
activeTenantCount: 0,
commercialState: WorkspaceCommercialLifecycleResolver::STATE_SUSPENDED_READ_ONLY,
);
$context['component']
->assertSee('Activation entitlement')
->assertSee('Suspended / read-only')
->assertSee('This workspace is suspended / read-only. New managed-tenant activation is blocked')
->call('completeOnboarding');
$context['tenant']->refresh();
expect($context['tenant']->status)->toBe(Tenant::STATUS_ONBOARDING)
->and(AuditLog::query()
->where('workspace_id', (int) $context['workspace']->getKey())
->where('action', 'managed_tenant_onboarding.activation')
->exists())->toBeFalse();
});
});

View File

@ -27,10 +27,3 @@
->toContain(".:/var/www/repo:ro")
->toContain('TENANTATLAS_REPO_ROOT: /var/www/repo');
});
it('keeps the local queue service in code-reloading listen mode', function (): void {
$compose = file_get_contents(repo_path('docker-compose.yml'));
expect($compose)->toContain('command: php artisan queue:listen --tries=3 --timeout=300 --sleep=3')
->not->toContain('command: php artisan queue:work --tries=3 --timeout=300 --sleep=3 --max-jobs=1000');
});

View File

@ -3,13 +3,7 @@
declare(strict_types=1);
use App\Models\ReviewPack;
use App\Models\AuditLog;
use App\Models\PlatformUser;
use App\Services\Entitlements\WorkspaceCommercialLifecycleResolver;
use App\Services\ReviewPackService;
use App\Services\Settings\SettingsWriter;
use App\Support\Audit\AuditActionId;
use App\Support\Auth\PlatformCapabilities;
use App\Support\ReviewPackStatus;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Storage;
@ -42,56 +36,12 @@ function createReadyPackWithFile(?array $packOverrides = []): array
return [$user, $tenant, $pack];
}
function suspendReadyPackWorkspaceForDownloadTest(ReviewPack $pack): void
{
app(SettingsWriter::class)->updateWorkspaceCommercialLifecycle(
actor: PlatformUser::factory()->create([
'capabilities' => [
PlatformCapabilities::ACCESS_SYSTEM_PANEL,
PlatformCapabilities::DIRECTORY_VIEW,
PlatformCapabilities::COMMERCIAL_LIFECYCLE_MANAGE,
],
'is_active' => true,
]),
workspace: $pack->workspace,
state: WorkspaceCommercialLifecycleResolver::STATE_SUSPENDED_READ_ONLY,
reason: 'Download preservation test',
);
}
// ─── Happy Path: Signed URL → 200 ───────────────────────────
it('downloads a ready pack via signed URL with correct headers', function (): void {
[$user, $tenant, $pack] = createReadyPackWithFile();
$signedUrl = app(ReviewPackService::class)->generateDownloadUrl($pack, [
'source_surface' => 'customer_review_workspace',
]);
$response = $this->actingAs($user)->get($signedUrl);
$response->assertOk();
$response->assertHeader('X-Review-Pack-SHA256', $pack->sha256);
$response->assertDownload();
$audit = AuditLog::query()
->where('action', AuditActionId::ReviewPackDownloaded->value)
->latest('id')
->first();
expect($audit)->not->toBeNull()
->and($audit?->resource_type)->toBe('review_pack')
->and(data_get($audit?->metadata, 'review_pack_id'))->toBe((int) $pack->getKey())
->and(data_get($audit?->metadata, 'source_surface'))->toBe('customer_review_workspace');
});
it('keeps ready pack downloads available while the workspace is suspended read-only', function (): void {
[$user, $tenant, $pack] = createReadyPackWithFile();
suspendReadyPackWorkspaceForDownloadTest($pack);
$signedUrl = app(ReviewPackService::class)->generateDownloadUrl($pack, [
'source_surface' => 'suspended_read_only_check',
]);
$signedUrl = app(ReviewPackService::class)->generateDownloadUrl($pack);
$response = $this->actingAs($user)->get($signedUrl);

View File

@ -8,20 +8,16 @@
use App\Models\EvidenceSnapshot;
use App\Models\Finding;
use App\Models\OperationRun;
use App\Models\PlatformUser;
use App\Models\ReviewPack;
use App\Models\StoredReport;
use App\Models\Tenant;
use App\Models\User;
use App\Services\Entitlements\WorkspaceCommercialLifecycleResolver;
use App\Services\Evidence\EvidenceSnapshotService;
use App\Services\ReviewPackService;
use App\Services\Settings\SettingsWriter;
use App\Support\Auth\PlatformCapabilities;
use App\Support\Evidence\EvidenceSnapshotStatus;
use App\Support\OperationRunType;
use App\Support\Workspaces\WorkspaceContext;
use Illuminate\Support\Facades\Notification;
use Illuminate\Support\Facades\Storage;
use Livewire\Livewire;
@ -112,23 +108,6 @@ function disableReviewPackGenerationForWorkspace(Tenant $tenant, User $user, str
);
}
function setReviewPackCommercialLifecycleState(Tenant $tenant, string $state, string $reason = 'Review pack commercial lifecycle test'): void
{
app(SettingsWriter::class)->updateWorkspaceCommercialLifecycle(
actor: PlatformUser::factory()->create([
'capabilities' => [
PlatformCapabilities::ACCESS_SYSTEM_PANEL,
PlatformCapabilities::DIRECTORY_VIEW,
PlatformCapabilities::COMMERCIAL_LIFECYCLE_MANAGE,
],
'is_active' => true,
]),
workspace: $tenant->workspace,
state: $state,
reason: $reason,
);
}
it('blocks new review pack generation before creating a review pack or operation run when the workspace is not entitled', function (): void {
[$user, $tenant] = createUserWithTenant(role: 'owner');
seedEntitlementReviewPackSnapshot($tenant);
@ -208,87 +187,4 @@ function setReviewPackCommercialLifecycleState(Tenant $tenant, string $state, st
->get(ReviewPackResource::getUrl('view', ['record' => $pack], tenant: $tenant, panel: 'tenant'))
->assertOk()
->assertSee('Download');
});
it('allows review pack generation in trial and active paid states', function (string $state): void {
[$user, $tenant] = createUserWithTenant(role: 'owner');
seedEntitlementReviewPackSnapshot($tenant);
setReviewPackCommercialLifecycleState($tenant, $state);
$pack = app(ReviewPackService::class)->generate($tenant, $user);
expect($pack)->toBeInstanceOf(ReviewPack::class)
->and($pack->operation_run_id)->not->toBeNull()
->and($pack->status)->toBe(\App\Support\ReviewPackStatus::Queued->value);
})->with([
'trial' => [WorkspaceCommercialLifecycleResolver::STATE_TRIAL],
'active paid' => [WorkspaceCommercialLifecycleResolver::STATE_ACTIVE_PAID],
]);
it('warns but allows review pack generation in grace', function (): void {
[$user, $tenant] = createUserWithTenant(role: 'owner');
seedEntitlementReviewPackSnapshot($tenant);
setReviewPackCommercialLifecycleState($tenant, WorkspaceCommercialLifecycleResolver::STATE_GRACE, 'Grace period');
$decision = app(ReviewPackService::class)->reviewPackGenerationDecisionForTenant($tenant);
expect($decision)
->toMatchArray([
'is_blocked' => false,
'is_warning' => true,
'outcome' => WorkspaceCommercialLifecycleResolver::OUTCOME_WARN,
])
->and($decision['warning_reason'])->toContain('grace');
session()->put(WorkspaceContext::SESSION_KEY, (int) $tenant->workspace_id);
setTenantPanelContext($tenant);
Livewire::actingAs($user)
->test(TenantReviewPackCard::class, ['record' => $tenant])
->assertSee('Workspace is in grace. Review-pack starts remain available');
$pack = app(ReviewPackService::class)->generate($tenant, $user);
expect($pack)->toBeInstanceOf(ReviewPack::class)
->and(OperationRun::query()
->where('tenant_id', (int) $tenant->getKey())
->where('type', OperationRunType::ReviewPackGenerate->value)
->exists())->toBeTrue();
});
it('blocks suspended read-only review pack generation before creating a review pack or operation run and sends no run notifications', function (): void {
Notification::fake();
[$user, $tenant] = createUserWithTenant(role: 'owner');
seedEntitlementReviewPackSnapshot($tenant);
setReviewPackCommercialLifecycleState($tenant, WorkspaceCommercialLifecycleResolver::STATE_SUSPENDED_READ_ONLY, 'Suspension');
$initialRunCount = OperationRun::query()
->where('tenant_id', (int) $tenant->getKey())
->where('type', OperationRunType::ReviewPackGenerate->value)
->count();
expect(fn (): ReviewPack => app(ReviewPackService::class)->generate($tenant, $user))
->toThrow(WorkspaceEntitlementBlockedException::class, 'suspended / read-only');
expect(ReviewPack::query()->count())->toBe(0)
->and(OperationRun::query()
->where('tenant_id', (int) $tenant->getKey())
->where('type', OperationRunType::ReviewPackGenerate->value)
->count())->toBe($initialRunCount);
Notification::assertNothingSent();
});
it('does not alter already queued review-pack work when a workspace is suspended later', function (): void {
[$user, $tenant] = createUserWithTenant(role: 'owner');
seedEntitlementReviewPackSnapshot($tenant);
$pack = app(ReviewPackService::class)->generate($tenant, $user);
$initialStatus = (string) $pack->fresh()?->status;
setReviewPackCommercialLifecycleState($tenant, WorkspaceCommercialLifecycleResolver::STATE_SUSPENDED_READ_ONLY, 'Later suspension');
expect($pack->fresh()?->status)->toBe($initialStatus)
->and(OperationRun::query()
->whereKey((int) $pack->operation_run_id)
->exists())->toBeTrue();
});
});

View File

@ -3,23 +3,18 @@
declare(strict_types=1);
use App\Exceptions\ReviewPackEvidenceResolutionException;
use App\Exceptions\Entitlements\WorkspaceEntitlementBlockedException;
use App\Filament\Widgets\Tenant\TenantReviewPackCard;
use App\Jobs\GenerateReviewPackJob;
use App\Models\EvidenceSnapshot;
use App\Models\Finding;
use App\Models\OperationRun;
use App\Models\PlatformUser;
use App\Models\ReviewPack;
use App\Models\StoredReport;
use App\Models\Tenant;
use App\Notifications\OperationRunCompleted;
use App\Notifications\OperationRunQueued;
use App\Services\Entitlements\WorkspaceCommercialLifecycleResolver;
use App\Services\Evidence\EvidenceSnapshotService;
use App\Services\ReviewPackService;
use App\Services\Settings\SettingsWriter;
use App\Support\Auth\PlatformCapabilities;
use App\Support\Evidence\EvidenceSnapshotStatus;
use App\Support\OperationRunOutcome;
use App\Support\OperationRunStatus;
@ -162,23 +157,6 @@ function createEvidenceSnapshotForReviewPack(Tenant $tenant): EvidenceSnapshot
return $snapshot->load('items');
}
function suspendReviewPackGenerationWorkspaceForGenerationTest(Tenant $tenant): void
{
app(SettingsWriter::class)->updateWorkspaceCommercialLifecycle(
actor: PlatformUser::factory()->create([
'capabilities' => [
PlatformCapabilities::ACCESS_SYSTEM_PANEL,
PlatformCapabilities::DIRECTORY_VIEW,
PlatformCapabilities::COMMERCIAL_LIFECYCLE_MANAGE,
],
'is_active' => true,
]),
workspace: $tenant->workspace,
state: WorkspaceCommercialLifecycleResolver::STATE_SUSPENDED_READ_ONLY,
reason: 'Generation notification boundary test',
);
}
// ─── Happy Path ──────────────────────────────────────────────
it('generates a review pack end-to-end (happy path)', function (): void {
@ -232,22 +210,6 @@ function suspendReviewPackGenerationWorkspaceForGenerationTest(Tenant $tenant):
Notification::assertSentTo($user, OperationRunCompleted::class);
});
it('does not send queued or terminal run notifications when suspended read-only blocks generation', function (): void {
[$user, $tenant] = createUserWithTenant();
seedTenantWithData($tenant);
createEvidenceSnapshotForReviewPack($tenant);
suspendReviewPackGenerationWorkspaceForGenerationTest($tenant);
Notification::fake();
expect(fn (): ReviewPack => app(ReviewPackService::class)->generate($tenant, $user))
->toThrow(WorkspaceEntitlementBlockedException::class, 'suspended / read-only');
Notification::assertNotSentTo($user, OperationRunQueued::class);
Notification::assertNotSentTo($user, OperationRunCompleted::class);
});
// ─── Failure Path ──────────────────────────────────────────────
it('marks pack as failed when generation throws an exception', function (): void {

View File

@ -77,9 +77,11 @@ function getReviewPackRbacEmptyStateAction(Testable $component, string $name): ?
'file_disk' => 'exports',
]);
// Note: download route uses signed middleware, not tenant-scoped RBAC.
// Any user with a valid signature can download. This is by design.
$signedUrl = app(ReviewPackService::class)->generateDownloadUrl($pack);
$this->actingAs($user)->get($signedUrl)->assertNotFound();
$this->actingAs($user)->get($signedUrl)->assertOk();
});
// ─── REVIEW_PACK_VIEW Member ────────────────────────────────

View File

@ -1,66 +0,0 @@
<?php
declare(strict_types=1);
use App\Filament\Pages\Reviews\CustomerReviewWorkspace;
use App\Models\Tenant;
use App\Models\User;
use App\Models\Workspace;
use App\Models\WorkspaceMembership;
use App\Support\Workspaces\WorkspaceContext;
use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
it('returns 404 for users outside the active workspace on the customer review workspace', function (): void {
$workspace = Workspace::factory()->create();
$user = User::factory()->create();
$this->actingAs($user)
->withSession([WorkspaceContext::SESSION_KEY => (int) $workspace->getKey()])
->get(CustomerReviewWorkspace::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(CustomerReviewWorkspace::getUrl(panel: 'admin'))
->assertNotFound();
});
it('allows entitled workspace members to access the customer review workspace', function (): void {
$tenant = Tenant::factory()->create();
[$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'readonly');
$this->actingAs($user)
->withSession([WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id])
->get(CustomerReviewWorkspace::getUrl(panel: 'admin'))
->assertOk();
});
it('returns 404 for explicit out-of-scope tenant targeting on the customer review workspace', function (): void {
$tenantAllowed = Tenant::factory()->create(['name' => 'Allowed Tenant']);
[$user, $tenantAllowed] = createUserWithTenant(tenant: $tenantAllowed, role: 'readonly');
$tenantDenied = Tenant::factory()->create([
'workspace_id' => (int) $tenantAllowed->workspace_id,
'name' => 'Denied Tenant',
]);
$otherOwner = User::factory()->create();
createUserWithTenant(tenant: $tenantDenied, user: $otherOwner, role: 'owner');
$this->actingAs($user)
->withSession([WorkspaceContext::SESSION_KEY => (int) $tenantAllowed->workspace_id])
->get(CustomerReviewWorkspace::getUrl(panel: 'admin').'?tenant='.(string) $tenantDenied->getKey())
->assertNotFound();
});

View File

@ -1,160 +0,0 @@
<?php
declare(strict_types=1);
use App\Filament\Pages\Reviews\CustomerReviewWorkspace;
use App\Filament\Resources\EvidenceSnapshotResource;
use App\Filament\Resources\ReviewPackResource;
use App\Filament\Resources\TenantReviewResource\Pages\ViewTenantReview;
use App\Filament\Resources\TenantReviewResource;
use App\Filament\Widgets\Tenant\TenantReviewPackCard;
use App\Models\AuditLog;
use App\Models\EvidenceSnapshot;
use App\Models\ReviewPack;
use App\Models\Tenant;
use App\Models\TenantReview;
use App\Support\Audit\AuditActionId;
use App\Support\Evidence\EvidenceSnapshotStatus;
use App\Support\TenantReviewStatus;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Storage;
use Livewire\Livewire;
uses(RefreshDatabase::class);
beforeEach(function (): void {
Storage::fake('exports');
});
it('renders a customer workspace link from tenant review detail context', function (): void {
$tenant = Tenant::factory()->create();
[$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'owner');
$snapshot = seedTenantReviewEvidence($tenant);
$review = composeTenantReviewForTest($tenant, $user, $snapshot);
$review->forceFill([
'status' => TenantReviewStatus::Published->value,
'published_at' => now(),
'published_by_user_id' => (int) $user->getKey(),
])->save();
$this->actingAs($user)
->get(TenantReviewResource::tenantScopedUrl('view', ['record' => $review], $tenant))
->assertOk()
->assertSee(CustomerReviewWorkspace::tenantPrefilterUrl($tenant), false);
});
it('adds a customer workspace entry to evidence snapshot related context', function (): void {
$tenant = Tenant::factory()->create();
$snapshot = EvidenceSnapshot::query()->create([
'tenant_id' => (int) $tenant->getKey(),
'workspace_id' => (int) $tenant->workspace_id,
'status' => EvidenceSnapshotStatus::Active->value,
'summary' => [],
'generated_at' => now(),
]);
$entry = collect(EvidenceSnapshotResource::relatedContextEntries($snapshot))
->firstWhere('key', 'customer_review_workspace');
expect($entry)->not->toBeNull()
->and($entry['targetUrl'] ?? null)->toBe(CustomerReviewWorkspace::tenantPrefilterUrl($tenant));
});
it('renders a customer workspace link from review pack detail context', function (): void {
$tenant = Tenant::factory()->create();
[$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'owner');
$snapshot = seedTenantReviewEvidence($tenant);
$review = composeTenantReviewForTest($tenant, $user, $snapshot);
$review->forceFill([
'status' => TenantReviewStatus::Published->value,
'published_at' => now(),
'published_by_user_id' => (int) $user->getKey(),
])->save();
Storage::disk('exports')->put('review-packs/customer-workspace-link.zip', 'PK-test');
$pack = ReviewPack::factory()->ready()->create([
'tenant_id' => (int) $tenant->getKey(),
'workspace_id' => (int) $tenant->workspace_id,
'tenant_review_id' => (int) $review->getKey(),
'evidence_snapshot_id' => (int) $snapshot->getKey(),
'initiated_by_user_id' => (int) $user->getKey(),
'file_path' => 'review-packs/customer-workspace-link.zip',
'file_disk' => 'exports',
]);
$this->actingAs($user)
->get(ReviewPackResource::getUrl('view', ['record' => $pack], tenant: $tenant, panel: 'tenant'))
->assertOk()
->assertSee(CustomerReviewWorkspace::tenantPrefilterUrl($tenant), false);
});
it('renders a customer workspace launch button on the tenant review pack widget', function (): void {
$tenant = Tenant::factory()->create();
[$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'owner');
$snapshot = seedTenantReviewEvidence($tenant);
$review = composeTenantReviewForTest($tenant, $user, $snapshot);
$review->forceFill([
'status' => TenantReviewStatus::Published->value,
'published_at' => now(),
'published_by_user_id' => (int) $user->getKey(),
])->save();
Storage::disk('exports')->put('review-packs/widget-customer-workspace.zip', 'PK-test');
ReviewPack::factory()->ready()->create([
'tenant_id' => (int) $tenant->getKey(),
'workspace_id' => (int) $tenant->workspace_id,
'tenant_review_id' => (int) $review->getKey(),
'evidence_snapshot_id' => (int) $snapshot->getKey(),
'initiated_by_user_id' => (int) $user->getKey(),
'file_path' => 'review-packs/widget-customer-workspace.zip',
'file_disk' => 'exports',
]);
setTenantPanelContext($tenant);
Livewire::actingAs($user)
->test(TenantReviewPackCard::class, ['record' => $tenant])
->assertSee('Customer workspace')
->assertSee(CustomerReviewWorkspace::tenantPrefilterUrl($tenant), false);
});
it('keeps the linked tenant review detail read-only for a readonly-capable actor', function (): void {
$tenant = Tenant::factory()->create();
[$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'readonly');
$snapshot = seedTenantReviewEvidence($tenant);
$review = composeTenantReviewForTest($tenant, $user, $snapshot);
$review->forceFill([
'status' => TenantReviewStatus::Published->value,
'published_at' => now(),
'published_by_user_id' => (int) $user->getKey(),
])->save();
setTenantPanelContext($tenant);
Livewire::withQueryParams([CustomerReviewWorkspace::DETAIL_CONTEXT_QUERY_KEY => 1])
->actingAs($user)
->test(ViewTenantReview::class, ['record' => $review->getKey()])
->assertSee('Outcome summary')
->assertActionDoesNotExist('publish_review')
->assertActionDoesNotExist('refresh_review')
->assertActionDoesNotExist('create_next_review')
->assertActionDoesNotExist('export_executive_pack')
->assertActionHidden('archive_review');
$audit = AuditLog::query()
->where('action', AuditActionId::TenantReviewOpened->value)
->latest('id')
->first();
expect($audit)->not->toBeNull()
->and($audit?->resource_type)->toBe('tenant_review')
->and(data_get($audit?->metadata, 'review_id'))->toBe((int) $review->getKey())
->and(data_get($audit?->metadata, 'source_surface'))->toBe('customer_review_workspace');
});

View File

@ -1,156 +0,0 @@
<?php
declare(strict_types=1);
use App\Filament\Pages\Reviews\CustomerReviewWorkspace;
use App\Models\PlatformUser;
use App\Models\ReviewPack;
use App\Models\Tenant;
use App\Services\Entitlements\WorkspaceCommercialLifecycleResolver;
use App\Services\Settings\SettingsWriter;
use App\Support\Auth\PlatformCapabilities;
use App\Support\TenantReviewStatus;
use App\Support\Workspaces\WorkspaceContext;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Livewire\Livewire;
uses(RefreshDatabase::class);
function suspendCustomerReviewWorkspacePackAccessWorkspace(Tenant $tenant): void
{
app(SettingsWriter::class)->updateWorkspaceCommercialLifecycle(
actor: PlatformUser::factory()->create([
'capabilities' => [
PlatformCapabilities::ACCESS_SYSTEM_PANEL,
PlatformCapabilities::DIRECTORY_VIEW,
PlatformCapabilities::COMMERCIAL_LIFECYCLE_MANAGE,
],
'is_active' => true,
]),
workspace: $tenant->workspace,
state: WorkspaceCommercialLifecycleResolver::STATE_SUSPENDED_READ_ONLY,
reason: 'Customer review workspace suspended read-only test',
);
}
it('shows the ready review-pack action for the latest published review', function (): void {
$tenant = Tenant::factory()->create();
[$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'readonly');
$snapshot = seedTenantReviewEvidence($tenant);
$review = composeTenantReviewForTest($tenant, $user, $snapshot);
$review->forceFill([
'status' => TenantReviewStatus::Published->value,
'published_at' => now(),
'published_by_user_id' => (int) $user->getKey(),
])->save();
$pack = ReviewPack::factory()->ready()->create([
'tenant_id' => (int) $tenant->getKey(),
'workspace_id' => (int) $tenant->workspace_id,
'tenant_review_id' => (int) $review->getKey(),
'evidence_snapshot_id' => (int) $snapshot->getKey(),
'initiated_by_user_id' => (int) $user->getKey(),
'expires_at' => now()->addDay(),
]);
$review->forceFill([
'current_export_review_pack_id' => (int) $pack->getKey(),
])->save();
$this->actingAs($user);
setAdminPanelContext();
session()->put(WorkspaceContext::SESSION_KEY, (int) $tenant->workspace_id);
Livewire::actingAs($user)
->test(CustomerReviewWorkspace::class)
->assertTableActionVisible('open_latest_review', $tenant)
->assertTableActionVisible('download_review_pack', $tenant)
->assertSee('Available');
});
it('keeps customer review workspace and pack actions visible while suspended read-only', function (): void {
$tenant = Tenant::factory()->create();
[$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'readonly');
$snapshot = seedTenantReviewEvidence($tenant);
$review = composeTenantReviewForTest($tenant, $user, $snapshot);
$review->forceFill([
'status' => TenantReviewStatus::Published->value,
'published_at' => now(),
'published_by_user_id' => (int) $user->getKey(),
])->save();
$pack = ReviewPack::factory()->ready()->create([
'tenant_id' => (int) $tenant->getKey(),
'workspace_id' => (int) $tenant->workspace_id,
'tenant_review_id' => (int) $review->getKey(),
'evidence_snapshot_id' => (int) $snapshot->getKey(),
'initiated_by_user_id' => (int) $user->getKey(),
'expires_at' => now()->addDay(),
]);
$review->forceFill([
'current_export_review_pack_id' => (int) $pack->getKey(),
])->save();
suspendCustomerReviewWorkspacePackAccessWorkspace($tenant);
$this->actingAs($user);
setAdminPanelContext();
session()->put(WorkspaceContext::SESSION_KEY, (int) $tenant->workspace_id);
Livewire::actingAs($user)
->test(CustomerReviewWorkspace::class)
->assertTableActionVisible('open_latest_review', $tenant)
->assertTableActionVisible('download_review_pack', $tenant)
->assertSee('Available');
});
it('shows an unavailable pack state and hides the download action when no current review pack exists', function (): void {
$tenant = Tenant::factory()->create();
[$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'readonly');
$snapshot = seedTenantReviewEvidence($tenant);
$review = composeTenantReviewForTest($tenant, $user, $snapshot);
$review->forceFill([
'status' => TenantReviewStatus::Published->value,
'published_at' => now(),
'published_by_user_id' => (int) $user->getKey(),
'current_export_review_pack_id' => null,
])->save();
$this->actingAs($user);
setAdminPanelContext();
session()->put(WorkspaceContext::SESSION_KEY, (int) $tenant->workspace_id);
Livewire::actingAs($user)
->test(CustomerReviewWorkspace::class)
->assertTableActionVisible('open_latest_review', $tenant)
->assertTableActionHidden('download_review_pack', $tenant)
->assertSee('Unavailable');
});
it('hides review and pack actions for tenants without a published review', function (): void {
$tenant = Tenant::factory()->create();
[$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'readonly');
$snapshot = seedTenantReviewEvidence($tenant);
$review = composeTenantReviewForTest($tenant, $user, $snapshot);
$review->forceFill([
'status' => TenantReviewStatus::Ready->value,
'published_at' => null,
'published_by_user_id' => null,
'current_export_review_pack_id' => null,
])->save();
$this->actingAs($user);
setAdminPanelContext();
session()->put(WorkspaceContext::SESSION_KEY, (int) $tenant->workspace_id);
Livewire::actingAs($user)
->test(CustomerReviewWorkspace::class)
->assertTableActionHidden('open_latest_review', $tenant)
->assertTableActionHidden('download_review_pack', $tenant)
->assertSee('No published review available yet');
});

View File

@ -1,222 +0,0 @@
<?php
declare(strict_types=1);
use App\Filament\Pages\Reviews\CustomerReviewWorkspace;
use App\Filament\Resources\TenantReviewResource;
use App\Models\Tenant;
use App\Models\User;
use App\Support\TenantReviewStatus;
use App\Support\Workspaces\WorkspaceContext;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Livewire\Livewire;
uses(RefreshDatabase::class);
it('lists only the latest published review per entitled tenant on the customer review workspace', function (): void {
$tenantA = Tenant::factory()->create(['name' => 'Alpha Tenant']);
[$user, $tenantA] = createUserWithTenant(tenant: $tenantA, role: 'readonly');
$tenantB = Tenant::factory()->create([
'workspace_id' => (int) $tenantA->workspace_id,
'name' => 'Beta Tenant',
]);
createUserWithTenant(tenant: $tenantB, user: $user, role: 'readonly');
$tenantDenied = Tenant::factory()->create([
'workspace_id' => (int) $tenantA->workspace_id,
'name' => 'Denied Tenant',
]);
$otherOwner = User::factory()->create();
createUserWithTenant(tenant: $tenantDenied, user: $otherOwner, role: 'owner');
$tenantASnapshot = seedTenantReviewEvidence($tenantA);
$tenantBSnapshot = seedTenantReviewEvidence($tenantB);
$tenantDeniedSnapshot = seedTenantReviewEvidence($tenantDenied);
$olderPublishedReview = composeTenantReviewForTest($tenantA, $user, $tenantASnapshot);
$olderPublishedReview->forceFill([
'status' => TenantReviewStatus::Published->value,
'generated_at' => now()->subDays(3),
'published_at' => now()->subDays(3),
'published_by_user_id' => (int) $user->getKey(),
])->save();
$newerInternalReview = $olderPublishedReview->replicate();
$newerInternalReview->forceFill([
'tenant_id' => (int) $tenantA->getKey(),
'workspace_id' => (int) $tenantA->workspace_id,
'evidence_snapshot_id' => (int) $tenantASnapshot->getKey(),
'status' => TenantReviewStatus::Ready->value,
'generated_at' => now()->subDay(),
'published_at' => null,
'published_by_user_id' => null,
])->save();
$latestPublishedReview = $olderPublishedReview->replicate();
$latestPublishedReview->forceFill([
'tenant_id' => (int) $tenantA->getKey(),
'workspace_id' => (int) $tenantA->workspace_id,
'evidence_snapshot_id' => (int) $tenantASnapshot->getKey(),
'status' => TenantReviewStatus::Published->value,
'generated_at' => now(),
'published_at' => now(),
'published_by_user_id' => (int) $user->getKey(),
])->save();
$betaPublishedReview = composeTenantReviewForTest($tenantB, $user, $tenantBSnapshot);
$betaPublishedReview->forceFill([
'status' => TenantReviewStatus::Published->value,
'generated_at' => now()->subHours(2),
'published_at' => now()->subHours(2),
'published_by_user_id' => (int) $user->getKey(),
])->save();
$deniedPublishedReview = composeTenantReviewForTest($tenantDenied, $otherOwner, $tenantDeniedSnapshot);
$deniedPublishedReview->forceFill([
'status' => TenantReviewStatus::Published->value,
'generated_at' => now()->subHours(3),
'published_at' => now()->subHours(3),
'published_by_user_id' => (int) $otherOwner->getKey(),
])->save();
$this->actingAs($user);
setAdminPanelContext();
session()->put(WorkspaceContext::SESSION_KEY, (int) $tenantA->workspace_id);
Livewire::actingAs($user)
->test(CustomerReviewWorkspace::class)
->assertCanSeeTableRecords([$tenantA->fresh(), $tenantB->fresh()])
->assertCanNotSeeTableRecords([$tenantDenied->fresh()])
->assertSee(TenantReviewResource::tenantScopedUrl('view', ['record' => $latestPublishedReview->fresh()], $tenantA), false)
->assertSee(TenantReviewResource::tenantScopedUrl('view', ['record' => $betaPublishedReview->fresh()], $tenantB), false)
->assertDontSee(TenantReviewResource::tenantScopedUrl('view', ['record' => $olderPublishedReview->fresh()], $tenantA), false)
->assertDontSee(TenantReviewResource::tenantScopedUrl('view', ['record' => $newerInternalReview->fresh()], $tenantA), false)
->assertDontSee('Publish review')
->assertDontSee('Refresh review')
->assertDontSee('Create next review')
->assertDontSee('Regenerate')
->assertDontSee('Expire snapshot')
->assertDontSee(TenantReviewResource::tenantScopedUrl('view', ['record' => $deniedPublishedReview->fresh()], $tenantDenied), false);
});
it('shows entitled tenants without a published review as calm absence rows', function (): void {
$tenantPublished = Tenant::factory()->create(['name' => 'Published Tenant']);
[$user, $tenantPublished] = createUserWithTenant(tenant: $tenantPublished, role: 'readonly');
$tenantWithoutPublished = Tenant::factory()->create([
'workspace_id' => (int) $tenantPublished->workspace_id,
'name' => 'No Published Tenant',
]);
createUserWithTenant(tenant: $tenantWithoutPublished, user: $user, role: 'readonly');
$publishedSnapshot = seedTenantReviewEvidence($tenantPublished);
$noPublishedSnapshot = seedTenantReviewEvidence($tenantWithoutPublished);
$publishedReview = composeTenantReviewForTest($tenantPublished, $user, $publishedSnapshot);
$publishedReview->forceFill([
'status' => TenantReviewStatus::Published->value,
'published_at' => now()->subHour(),
'published_by_user_id' => (int) $user->getKey(),
])->save();
$internalOnlyReview = composeTenantReviewForTest($tenantWithoutPublished, $user, $noPublishedSnapshot);
$internalOnlyReview->forceFill([
'status' => TenantReviewStatus::Ready->value,
'published_at' => null,
'published_by_user_id' => null,
])->save();
$this->actingAs($user);
setAdminPanelContext();
session()->put(WorkspaceContext::SESSION_KEY, (int) $tenantPublished->workspace_id);
Livewire::actingAs($user)
->test(CustomerReviewWorkspace::class)
->assertCanSeeTableRecords([$tenantPublished->fresh(), $tenantWithoutPublished->fresh()])
->assertSee('No published review')
->assertSee('No published review available yet')
->assertDontSee(TenantReviewResource::tenantScopedUrl('view', ['record' => $internalOnlyReview->fresh()], $tenantWithoutPublished), false)
->assertSee(TenantReviewResource::tenantScopedUrl('view', ['record' => $publishedReview->fresh()], $tenantPublished), false);
});
it('defaults the customer review workspace to the remembered tenant when tenant context is available', function (): void {
$tenantA = Tenant::factory()->create(['name' => 'Alpha Tenant']);
[$user, $tenantA] = createUserWithTenant(tenant: $tenantA, role: 'readonly');
$tenantB = Tenant::factory()->create([
'workspace_id' => (int) $tenantA->workspace_id,
'name' => 'Beta Tenant',
]);
createUserWithTenant(tenant: $tenantB, user: $user, role: 'readonly');
$snapshotA = seedTenantReviewEvidence($tenantA);
$snapshotB = seedTenantReviewEvidence($tenantB);
$reviewA = composeTenantReviewForTest($tenantA, $user, $snapshotA);
$reviewA->forceFill([
'status' => TenantReviewStatus::Published->value,
'published_at' => now()->subDay(),
'published_by_user_id' => (int) $user->getKey(),
])->save();
$reviewB = composeTenantReviewForTest($tenantB, $user, $snapshotB);
$reviewB->forceFill([
'status' => TenantReviewStatus::Published->value,
'published_at' => now(),
'published_by_user_id' => (int) $user->getKey(),
])->save();
$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(CustomerReviewWorkspace::class)
->assertSet('tableFilters.tenant_id.value', (string) $tenantB->getKey())
->filterTable('tenant_id', (string) $tenantB->getKey())
->assertCanSeeTableRecords([$tenantB->fresh()])
->assertCanNotSeeTableRecords([$tenantA->fresh()]);
});
it('prefilters the customer review workspace from an explicit tenant query parameter and accepts external tenant identifiers', function (): void {
$tenantA = Tenant::factory()->create(['name' => 'Alpha Tenant']);
[$user, $tenantA] = createUserWithTenant(tenant: $tenantA, role: 'readonly');
$tenantB = Tenant::factory()->create([
'workspace_id' => (int) $tenantA->workspace_id,
'name' => 'Beta Tenant',
]);
createUserWithTenant(tenant: $tenantB, user: $user, role: 'readonly');
$snapshotA = seedTenantReviewEvidence($tenantA);
$snapshotB = seedTenantReviewEvidence($tenantB);
$reviewA = composeTenantReviewForTest($tenantA, $user, $snapshotA);
$reviewA->forceFill([
'status' => TenantReviewStatus::Published->value,
'published_at' => now(),
'published_by_user_id' => (int) $user->getKey(),
])->save();
$reviewB = composeTenantReviewForTest($tenantB, $user, $snapshotB);
$reviewB->forceFill([
'status' => TenantReviewStatus::Published->value,
'published_at' => now()->subDay(),
'published_by_user_id' => (int) $user->getKey(),
])->save();
$this->actingAs($user);
setAdminPanelContext();
session()->put(WorkspaceContext::SESSION_KEY, (int) $tenantA->workspace_id);
Livewire::withQueryParams(['tenant' => (string) $tenantA->external_id])
->test(CustomerReviewWorkspace::class)
->assertSet('tableFilters.tenant_id.value', (string) $tenantA->getKey())
->filterTable('tenant_id', (string) $tenantA->getKey())
->assertCanSeeTableRecords([$tenantA->fresh()])
->assertCanNotSeeTableRecords([$tenantB->fresh()]);
});

View File

@ -5,9 +5,7 @@
use App\Models\OperationRun;
use App\Models\PlatformUser;
use App\Models\User;
use App\Models\Workspace;
use App\Support\Auth\PlatformCapabilities;
use App\Support\System\SystemDirectoryLinks;
use App\Support\System\SystemOperationRunLinks;
use Illuminate\Foundation\Testing\RefreshDatabase;
@ -121,38 +119,3 @@
->get('/system/ops/runbooks')
->assertSuccessful();
});
it('keeps system workspace detail route semantics separate from commercial business-state blocks', function (): void {
$workspace = Workspace::factory()->create();
$this->actingAs(User::factory()->create())
->get(SystemDirectoryLinks::workspaceDetail($workspace))
->assertNotFound();
auth()->guard('web')->logout();
$platformWithoutDirectoryView = PlatformUser::factory()->create([
'capabilities' => [
PlatformCapabilities::ACCESS_SYSTEM_PANEL,
],
'is_active' => true,
]);
$this->actingAs($platformWithoutDirectoryView, 'platform')
->get(SystemDirectoryLinks::workspaceDetail($workspace))
->assertForbidden();
$directoryViewer = PlatformUser::factory()->create([
'capabilities' => [
PlatformCapabilities::ACCESS_SYSTEM_PANEL,
PlatformCapabilities::DIRECTORY_VIEW,
],
'is_active' => true,
]);
$this->actingAs($directoryViewer, 'platform')
->get(SystemDirectoryLinks::workspaceDetail($workspace))
->assertSuccessful()
->assertSee('Commercial lifecycle')
->assertDontSee('Change commercial state');
});

View File

@ -3,25 +3,13 @@
declare(strict_types=1);
use App\Filament\System\Pages\Directory\ViewWorkspace;
use App\Models\AuditLog;
use App\Models\PlatformUser;
use App\Models\Tenant;
use App\Models\User;
use App\Models\Workspace;
use App\Models\WorkspaceMembership;
use App\Models\WorkspaceSetting;
use App\Services\Entitlements\WorkspaceCommercialLifecycleResolver;
use App\Services\Settings\SettingsWriter;
use App\Support\Audit\AuditActionId;
use App\Support\Auth\PlatformCapabilities;
use Filament\Actions\Action;
use Filament\Facades\Filament;
use Livewire\Livewire;
beforeEach(function (): void {
Filament::setCurrentPanel('system');
Filament::bootCurrentPanel();
});
it('renders the read-only workspace entitlement summary on the system workspace detail page', function (): void {
$workspace = Workspace::factory()->create(['name' => 'Acme Workspace']);
@ -91,102 +79,5 @@
->assertSee('Pilot workspace')
->assertSee('Escalation only')
->assertSee('workspace override')
->assertSee('Commercial lifecycle')
->assertSee('Active paid')
->assertSee('default active paid')
->assertDontSee('Save');
});
it('gates the commercial lifecycle mutation action behind a dedicated platform capability', function (): void {
$workspace = Workspace::factory()->create();
$viewer = PlatformUser::factory()->create([
'capabilities' => [
PlatformCapabilities::ACCESS_SYSTEM_PANEL,
PlatformCapabilities::DIRECTORY_VIEW,
],
'is_active' => true,
]);
Livewire::actingAs($viewer, 'platform')
->test(ViewWorkspace::class, ['workspace' => $workspace])
->assertActionHidden('change_commercial_state');
});
it('changes commercial lifecycle state through the confirmed system action and records audit truth', function (): void {
$workspace = Workspace::factory()->create(['name' => 'Lifecycle Workspace']);
$operator = PlatformUser::factory()->create([
'name' => 'Platform Operator',
'capabilities' => [
PlatformCapabilities::ACCESS_SYSTEM_PANEL,
PlatformCapabilities::DIRECTORY_VIEW,
PlatformCapabilities::COMMERCIAL_LIFECYCLE_MANAGE,
],
'is_active' => true,
]);
Livewire::actingAs($operator, 'platform')
->test(ViewWorkspace::class, ['workspace' => $workspace])
->assertActionVisible('change_commercial_state')
->assertActionExists('change_commercial_state', fn (Action $action): bool => $action->getLabel() === 'Change commercial state'
&& $action->isConfirmationRequired())
->callAction('change_commercial_state', data: [
'state' => WorkspaceCommercialLifecycleResolver::STATE_SUSPENDED_READ_ONLY,
'reason' => 'Commercial suspension approved by support',
])
->assertNotified('Commercial state updated');
expect(WorkspaceSetting::query()
->where('workspace_id', (int) $workspace->getKey())
->where('domain', WorkspaceCommercialLifecycleResolver::SETTING_DOMAIN)
->where('key', WorkspaceCommercialLifecycleResolver::SETTING_COMMERCIAL_LIFECYCLE_STATE)
->value('value'))->toBe(WorkspaceCommercialLifecycleResolver::STATE_SUSPENDED_READ_ONLY)
->and(WorkspaceSetting::query()
->where('workspace_id', (int) $workspace->getKey())
->where('domain', WorkspaceCommercialLifecycleResolver::SETTING_DOMAIN)
->where('key', WorkspaceCommercialLifecycleResolver::SETTING_COMMERCIAL_LIFECYCLE_REASON)
->value('value'))->toBe('Commercial suspension approved by support');
$audit = AuditLog::query()
->where('workspace_id', (int) $workspace->getKey())
->where('action', AuditActionId::WorkspaceSettingUpdated->value)
->where('resource_id', WorkspaceCommercialLifecycleResolver::SETTING_DOMAIN.'.'.WorkspaceCommercialLifecycleResolver::SETTING_COMMERCIAL_LIFECYCLE_STATE)
->latest('id')
->first();
expect($audit)->not->toBeNull()
->and($audit?->actor_name)->toBe('Platform Operator')
->and($audit?->metadata['before_state'] ?? null)->toBeNull()
->and($audit?->metadata['after_state'] ?? null)->toBe(WorkspaceCommercialLifecycleResolver::STATE_SUSPENDED_READ_ONLY)
->and($audit?->metadata['after_reason'] ?? null)->toBe('Commercial suspension approved by support');
$summary = app(WorkspaceCommercialLifecycleResolver::class)->summary($workspace);
expect($summary)
->toMatchArray([
'state' => WorkspaceCommercialLifecycleResolver::STATE_SUSPENDED_READ_ONLY,
'source' => WorkspaceCommercialLifecycleResolver::SOURCE_WORKSPACE_SETTING,
'rationale' => 'Commercial suspension approved by support',
'last_changed_by' => 'Platform Operator',
]);
});
it('requires a rationale before changing commercial lifecycle state', function (): void {
$workspace = Workspace::factory()->create();
$operator = PlatformUser::factory()->create([
'capabilities' => [
PlatformCapabilities::ACCESS_SYSTEM_PANEL,
PlatformCapabilities::DIRECTORY_VIEW,
PlatformCapabilities::COMMERCIAL_LIFECYCLE_MANAGE,
],
'is_active' => true,
]);
Livewire::actingAs($operator, 'platform')
->test(ViewWorkspace::class, ['workspace' => $workspace])
->callAction('change_commercial_state', data: [
'state' => WorkspaceCommercialLifecycleResolver::STATE_GRACE,
'reason' => '',
])
->assertHasActionErrors(['reason']);
});
});

View File

@ -1,20 +0,0 @@
<?php
declare(strict_types=1);
use App\Services\Entitlements\WorkspaceCommercialLifecycleResolver;
use App\Support\Badges\BadgeCatalog;
use App\Support\Badges\BadgeDomain;
it('maps commercial lifecycle states through the shared badge catalog', function (string $state, string $label, string $color): void {
$spec = BadgeCatalog::spec(BadgeDomain::CommercialLifecycleState, $state);
expect($spec->label)->toBe($label)
->and($spec->color)->toBe($color)
->and($spec->icon)->not->toBeNull();
})->with([
'trial' => [WorkspaceCommercialLifecycleResolver::STATE_TRIAL, 'Trial', 'info'],
'grace' => [WorkspaceCommercialLifecycleResolver::STATE_GRACE, 'Grace', 'warning'],
'active paid' => [WorkspaceCommercialLifecycleResolver::STATE_ACTIVE_PAID, 'Active paid', 'success'],
'suspended read-only' => [WorkspaceCommercialLifecycleResolver::STATE_SUSPENDED_READ_ONLY, 'Suspended / read-only', 'danger'],
]);

View File

@ -1,199 +0,0 @@
<?php
declare(strict_types=1);
use App\Models\PlatformUser;
use App\Models\Tenant;
use App\Models\User;
use App\Models\Workspace;
use App\Models\WorkspaceMembership;
use App\Services\Entitlements\WorkspaceCommercialLifecycleResolver;
use App\Services\Entitlements\WorkspaceEntitlementResolver;
use App\Services\Settings\SettingsWriter;
use App\Support\Auth\PlatformCapabilities;
use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
/**
* @return array{0: Workspace, 1: User}
*/
function commercialLifecycleWorkspaceManager(): array
{
$workspace = Workspace::factory()->create();
$user = User::factory()->create();
WorkspaceMembership::factory()->create([
'workspace_id' => (int) $workspace->getKey(),
'user_id' => (int) $user->getKey(),
'role' => 'manager',
]);
return [$workspace, $user];
}
function commercialLifecyclePlatformOperator(): PlatformUser
{
return PlatformUser::factory()->create([
'capabilities' => [
PlatformCapabilities::ACCESS_SYSTEM_PANEL,
PlatformCapabilities::DIRECTORY_VIEW,
PlatformCapabilities::COMMERCIAL_LIFECYCLE_MANAGE,
],
'is_active' => true,
]);
}
function setCommercialLifecycleState(Workspace $workspace, string $state, string $reason = 'Unit test commercial state change'): void
{
app(SettingsWriter::class)->updateWorkspaceCommercialLifecycle(
actor: commercialLifecyclePlatformOperator(),
workspace: $workspace,
state: $state,
reason: $reason,
);
}
it('falls back to active paid when no explicit commercial lifecycle setting exists', function (): void {
[$workspace] = commercialLifecycleWorkspaceManager();
$summary = app(WorkspaceCommercialLifecycleResolver::class)->summary($workspace);
expect($summary)
->toMatchArray([
'state' => WorkspaceCommercialLifecycleResolver::STATE_ACTIVE_PAID,
'state_label' => 'Active paid',
'source' => WorkspaceCommercialLifecycleResolver::SOURCE_DEFAULT_ACTIVE_PAID,
'source_label' => 'default active paid',
'rationale' => null,
])
->and($summary['last_changed_at'])->toBeNull()
->and($summary['last_changed_by'])->toBeNull()
->and($summary['action_decisions'][WorkspaceCommercialLifecycleResolver::ACTION_MANAGED_TENANT_ACTIVATION]['outcome'])
->toBe(WorkspaceCommercialLifecycleResolver::OUTCOME_ALLOW)
->and($summary['action_decisions'][WorkspaceCommercialLifecycleResolver::ACTION_REVIEW_PACK_START]['outcome'])
->toBe(WorkspaceCommercialLifecycleResolver::OUTCOME_ALLOW);
});
it('resolves explicit stored commercial lifecycle states with source rationale and platform attribution', function (string $state, string $expectedLabel): void {
[$workspace] = commercialLifecycleWorkspaceManager();
$operator = commercialLifecyclePlatformOperator();
app(SettingsWriter::class)->updateWorkspaceCommercialLifecycle(
actor: $operator,
workspace: $workspace,
state: $state,
reason: 'Support approved commercial lifecycle transition',
);
$summary = app(WorkspaceCommercialLifecycleResolver::class)->summary($workspace);
expect($summary)
->toMatchArray([
'state' => $state,
'state_label' => $expectedLabel,
'source' => WorkspaceCommercialLifecycleResolver::SOURCE_WORKSPACE_SETTING,
'source_label' => 'workspace setting',
'rationale' => 'Support approved commercial lifecycle transition',
'last_changed_by' => $operator->name,
])
->and($summary['last_changed_at'])->not->toBeNull();
})->with([
'trial' => [WorkspaceCommercialLifecycleResolver::STATE_TRIAL, 'Trial'],
'grace' => [WorkspaceCommercialLifecycleResolver::STATE_GRACE, 'Grace'],
'active paid' => [WorkspaceCommercialLifecycleResolver::STATE_ACTIVE_PAID, 'Active paid'],
'suspended read-only' => [WorkspaceCommercialLifecycleResolver::STATE_SUSPENDED_READ_ONLY, 'Suspended / read-only'],
]);
it('blocks activation but warns review pack starts during grace', function (): void {
[$workspace] = commercialLifecycleWorkspaceManager();
setCommercialLifecycleState($workspace, WorkspaceCommercialLifecycleResolver::STATE_GRACE, 'Payment collection pending');
$resolver = app(WorkspaceCommercialLifecycleResolver::class);
$activation = $resolver->actionDecision($workspace, WorkspaceCommercialLifecycleResolver::ACTION_MANAGED_TENANT_ACTIVATION);
$reviewPackStart = $resolver->actionDecision($workspace, WorkspaceCommercialLifecycleResolver::ACTION_REVIEW_PACK_START);
expect($activation)
->toMatchArray([
'outcome' => WorkspaceCommercialLifecycleResolver::OUTCOME_BLOCK,
'is_blocked' => true,
'reason_family' => WorkspaceCommercialLifecycleResolver::REASON_FAMILY_COMMERCIAL_LIFECYCLE,
'state' => WorkspaceCommercialLifecycleResolver::STATE_GRACE,
])
->and($activation['block_reason'])->toContain('grace')
->and($reviewPackStart)
->toMatchArray([
'outcome' => WorkspaceCommercialLifecycleResolver::OUTCOME_WARN,
'is_blocked' => false,
'is_warning' => true,
'reason_family' => WorkspaceCommercialLifecycleResolver::REASON_FAMILY_COMMERCIAL_LIFECYCLE,
'state' => WorkspaceCommercialLifecycleResolver::STATE_GRACE,
])
->and($reviewPackStart['warning_reason'])->toContain('grace');
});
it('blocks new starts but allows read-only history during suspended read-only', function (): void {
[$workspace] = commercialLifecycleWorkspaceManager();
setCommercialLifecycleState($workspace, WorkspaceCommercialLifecycleResolver::STATE_SUSPENDED_READ_ONLY, 'Commercial suspension');
$resolver = app(WorkspaceCommercialLifecycleResolver::class);
expect($resolver->actionDecision($workspace, WorkspaceCommercialLifecycleResolver::ACTION_MANAGED_TENANT_ACTIVATION))
->toMatchArray([
'outcome' => WorkspaceCommercialLifecycleResolver::OUTCOME_BLOCK,
'is_blocked' => true,
'reason_family' => WorkspaceCommercialLifecycleResolver::REASON_FAMILY_COMMERCIAL_LIFECYCLE,
])
->and($resolver->actionDecision($workspace, WorkspaceCommercialLifecycleResolver::ACTION_REVIEW_PACK_START))
->toMatchArray([
'outcome' => WorkspaceCommercialLifecycleResolver::OUTCOME_BLOCK,
'is_blocked' => true,
'reason_family' => WorkspaceCommercialLifecycleResolver::REASON_FAMILY_COMMERCIAL_LIFECYCLE,
])
->and($resolver->actionDecision($workspace, WorkspaceCommercialLifecycleResolver::ACTION_EVIDENCE_READ))
->toMatchArray([
'outcome' => WorkspaceCommercialLifecycleResolver::OUTCOME_ALLOW_READ_ONLY,
'is_blocked' => false,
'reason_family' => WorkspaceCommercialLifecycleResolver::REASON_FAMILY_COMMERCIAL_LIFECYCLE,
]);
});
it('preserves entitlement substrate blocks ahead of lifecycle outcomes', function (): void {
[$workspace, $manager] = commercialLifecycleWorkspaceManager();
setCommercialLifecycleState($workspace, WorkspaceCommercialLifecycleResolver::STATE_GRACE, 'Grace should not bypass substrate');
Tenant::factory()->create([
'workspace_id' => (int) $workspace->getKey(),
'status' => Tenant::STATUS_ACTIVE,
]);
app(SettingsWriter::class)->updateWorkspaceSetting(
actor: $manager,
workspace: $workspace,
domain: WorkspaceEntitlementResolver::SETTING_DOMAIN,
key: WorkspaceEntitlementResolver::SETTING_MANAGED_TENANT_LIMIT_OVERRIDE_VALUE,
value: 1,
);
app(SettingsWriter::class)->updateWorkspaceSetting(
actor: $manager,
workspace: $workspace,
domain: WorkspaceEntitlementResolver::SETTING_DOMAIN,
key: WorkspaceEntitlementResolver::SETTING_REVIEW_PACK_GENERATION_OVERRIDE_VALUE,
value: false,
);
$resolver = app(WorkspaceCommercialLifecycleResolver::class);
expect($resolver->actionDecision($workspace, WorkspaceCommercialLifecycleResolver::ACTION_MANAGED_TENANT_ACTIVATION))
->toMatchArray([
'outcome' => WorkspaceCommercialLifecycleResolver::OUTCOME_BLOCK,
'reason_family' => WorkspaceCommercialLifecycleResolver::REASON_FAMILY_ENTITLEMENT_SUBSTRATE,
])
->and($resolver->actionDecision($workspace, WorkspaceCommercialLifecycleResolver::ACTION_REVIEW_PACK_START))
->toMatchArray([
'outcome' => WorkspaceCommercialLifecycleResolver::OUTCOME_BLOCK,
'reason_family' => WorkspaceCommercialLifecycleResolver::REASON_FAMILY_ENTITLEMENT_SUBSTRATE,
'is_warning' => false,
]);
});

View File

@ -1,197 +0,0 @@
<?php
declare(strict_types=1);
use App\Models\AlertDelivery;
use App\Models\Finding;
use App\Models\OperationRun;
use App\Models\Tenant;
use App\Models\TenantTriageReview;
use App\Models\User;
use App\Models\Workspace;
use App\Support\Auth\Capabilities;
use App\Support\BackupHealth\TenantBackupHealthResolver;
use App\Support\GovernanceInbox\GovernanceInboxSectionBuilder;
use App\Support\Navigation\CanonicalNavigationContext;
use App\Support\OperationRunOutcome;
use App\Support\OperationRunStatus;
use App\Support\PortfolioTriage\PortfolioArrivalContextToken;
use App\Support\PortfolioTriage\TenantTriageReviewFingerprint;
use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
it('builds visible governance inbox sections in canonical order with source links', function (): void {
$workspace = Workspace::factory()->create();
$user = User::factory()->create();
$alphaTenant = Tenant::factory()->create([
'workspace_id' => (int) $workspace->getKey(),
'name' => 'Alpha Tenant',
'external_id' => 'alpha-tenant',
]);
$bravoTenant = Tenant::factory()->create([
'workspace_id' => (int) $workspace->getKey(),
'name' => 'Bravo Tenant',
'external_id' => 'bravo-tenant',
]);
Finding::factory()
->for($alphaTenant)
->assignedTo((int) $user->getKey())
->ownedBy((int) $user->getKey())
->overdueByHours()
->create([
'status' => Finding::STATUS_IN_PROGRESS,
'subject_external_id' => 'assigned-finding',
]);
Finding::factory()
->for($bravoTenant)
->reopened()
->create([
'subject_external_id' => 'intake-finding',
]);
OperationRun::factory()
->forTenant($alphaTenant)
->create([
'status' => OperationRunStatus::Completed->value,
'outcome' => OperationRunOutcome::Failed->value,
'completed_at' => now()->subMinute(),
]);
OperationRun::factory()
->forTenant($bravoTenant)
->create([
'status' => OperationRunStatus::Queued->value,
'outcome' => OperationRunOutcome::Pending->value,
'created_at' => now()->subMinutes(6),
]);
AlertDelivery::factory()->create([
'tenant_id' => null,
'workspace_id' => (int) $workspace->getKey(),
'status' => AlertDelivery::STATUS_FAILED,
'event_type' => 'alerts.failed_delivery',
'payload' => [
'title' => 'Delivery failed',
'body' => 'Alert delivery could not be completed.',
],
]);
$backupHealthResolver = app(TenantBackupHealthResolver::class);
$fingerprints = app(TenantTriageReviewFingerprint::class);
$alphaBackupFingerprint = $fingerprints->forBackupHealth($backupHealthResolver->assess($alphaTenant));
$bravoBackupFingerprint = $fingerprints->forBackupHealth($backupHealthResolver->assess($bravoTenant));
expect($alphaBackupFingerprint)->not->toBeNull()
->and($bravoBackupFingerprint)->not->toBeNull();
TenantTriageReview::factory()
->for($alphaTenant)
->followUpNeeded()
->create([
'workspace_id' => (int) $workspace->getKey(),
'reviewed_by_user_id' => (int) $user->getKey(),
'concern_family' => PortfolioArrivalContextToken::FAMILY_BACKUP_HEALTH,
'review_fingerprint' => $alphaBackupFingerprint['fingerprint'],
'review_snapshot' => $alphaBackupFingerprint['snapshot'],
'reviewed_at' => now()->subDay(),
]);
TenantTriageReview::factory()
->for($bravoTenant)
->reviewed()
->create([
'workspace_id' => (int) $workspace->getKey(),
'reviewed_by_user_id' => (int) $user->getKey(),
'concern_family' => PortfolioArrivalContextToken::FAMILY_BACKUP_HEALTH,
'review_fingerprint' => hash('sha256', 'stale-review-fingerprint'),
'review_snapshot' => $bravoBackupFingerprint['snapshot'],
'reviewed_at' => now()->subDays(2),
]);
$context = new CanonicalNavigationContext(
sourceSurface: 'governance.inbox',
canonicalRouteName: 'filament.admin.pages.governance.inbox',
backLinkLabel: 'Back to governance inbox',
backLinkUrl: '/admin/governance/inbox',
);
$payload = app(GovernanceInboxSectionBuilder::class)->build(
user: $user,
workspace: $workspace,
authorizedTenants: [$alphaTenant, $bravoTenant],
visibleFindingTenants: [$alphaTenant, $bravoTenant],
reviewTenants: [$alphaTenant, $bravoTenant],
canViewAlerts: true,
navigationContext: $context,
);
expect(collect($payload['sections'])->pluck('key')->all())
->toBe([
'assigned_findings',
'intake_findings',
'stale_operations',
'alert_delivery_failures',
'review_follow_up',
])
->and($payload['family_counts'])->toMatchArray([
'assigned_findings' => 1,
'intake_findings' => 1,
'stale_operations' => 2,
'alert_delivery_failures' => 1,
'review_follow_up' => 2,
]);
$sections = collect($payload['sections'])->keyBy('key');
expect($sections['assigned_findings']['dominant_action_url'])
->toContain('/admin/findings/my-work')
->toContain('nav%5Bback_label%5D=Back+to+governance+inbox')
->and($sections['stale_operations']['dominant_action_label'])->toBe('Open terminal follow-up')
->and($sections['stale_operations']['dominant_action_url'])->toContain('problemClass=terminal_follow_up')
->and($sections['alert_delivery_failures']['dominant_action_url'])->toContain('tableFilters%5Bstatus%5D%5Bvalue%5D=failed')
->and(collect($sections['review_follow_up']['entries'])->pluck('status_label')->all())
->toBe(['Follow-up needed', 'Changed since review']);
});
it('keeps an explicitly selected visible family with an honest empty state when tenant filtering removes every row', function (): void {
$workspace = Workspace::factory()->create();
$user = User::factory()->create();
$alphaTenant = Tenant::factory()->create([
'workspace_id' => (int) $workspace->getKey(),
'name' => 'Alpha Tenant',
'external_id' => 'alpha-tenant',
]);
$bravoTenant = Tenant::factory()->create([
'workspace_id' => (int) $workspace->getKey(),
'name' => 'Bravo Tenant',
'external_id' => 'bravo-tenant',
]);
AlertDelivery::factory()->create([
'tenant_id' => null,
'workspace_id' => (int) $workspace->getKey(),
'status' => AlertDelivery::STATUS_FAILED,
]);
$payload = app(GovernanceInboxSectionBuilder::class)->build(
user: $user,
workspace: $workspace,
authorizedTenants: [$alphaTenant, $bravoTenant],
visibleFindingTenants: [],
reviewTenants: [],
canViewAlerts: true,
selectedTenant: $alphaTenant,
selectedFamily: 'alert_delivery_failures',
);
expect($payload['sections'])->toHaveCount(1)
->and($payload['sections'][0]['key'])->toBe('alert_delivery_failures')
->and($payload['sections'][0]['count'])->toBe(0)
->and($payload['sections'][0]['empty_state'])->toContain('tenant filter');
});

View File

@ -1,52 +0,0 @@
<?php
declare(strict_types=1);
use App\Models\Workspace;
use App\Support\Workspaces\WorkspaceResolver;
use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
it('resolves a workspace by slug or id', function (): void {
$workspace = Workspace::factory()->create([
'slug' => 'resolver-smoke-workspace',
]);
$resolver = app(WorkspaceResolver::class);
expect($resolver->resolve('resolver-smoke-workspace')?->is($workspace))->toBeTrue()
->and($resolver->resolve((string) $workspace->getKey())?->is($workspace))->toBeTrue();
});
it('resolves a Livewire serialized workspace route parameter', function (): void {
$workspace = Workspace::factory()->create([
'slug' => 'serialized-route-workspace',
]);
$payload = json_encode([
'id' => $workspace->getKey(),
'name' => $workspace->name,
'slug' => $workspace->slug,
], JSON_THROW_ON_ERROR);
expect(app(WorkspaceResolver::class)->resolve($payload)?->is($workspace))->toBeTrue();
});
it('falls back to serialized id when a Livewire route payload has no slug', function (): void {
$workspace = Workspace::factory()->create();
$payload = json_encode([
'id' => (string) $workspace->getKey(),
], JSON_THROW_ON_ERROR);
expect(app(WorkspaceResolver::class)->resolve($payload)?->is($workspace))->toBeTrue();
});
it('returns null for an unsupported serialized route payload', function (): void {
$payload = json_encode([
'name' => 'Missing key',
], JSON_THROW_ON_ERROR);
expect(app(WorkspaceResolver::class)->resolve($payload))->toBeNull();
});

View File

@ -62,7 +62,7 @@ services:
- laravel.test
- pgsql
- redis
command: php artisan queue:listen --tries=3 --timeout=300 --sleep=3
command: php artisan queue:work --tries=3 --timeout=300 --sleep=3 --max-jobs=1000
pgsql:
image: 'postgres:16'

View File

@ -1,50 +0,0 @@
import json
import subprocess
import sys
import time
def send(proc, payload):
proc.stdin.write((json.dumps(payload) + "\n").encode("utf-8"))
proc.stdin.flush()
def read_line(proc, timeout=10.0):
start = time.time()
while time.time() - start < timeout:
line = proc.stdout.readline()
if line:
return line.decode("utf-8", errors="replace").strip()
time.sleep(0.05)
return ""
def main():
proc = subprocess.Popen(
["python3", "scripts/run-gitea-mcp.py"],
stdin=subprocess.PIPE,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
)
try:
send(proc, {
"jsonrpc": "2.0",
"id": 1,
"method": "initialize",
"params": {
"protocolVersion": "2024-11-05",
"capabilities": {},
"clientInfo": {"name": "test", "version": "1.0.0"},
},
})
init_resp = read_line(proc)
send(proc, {
"jsonrpc": "2.0",
"id": 2,
"method": "tools/list",
"params": {}
})
tools_resp = read_line(proc)
print(tools_resp)
finally:
proc.terminate()
if __name__ == "__main__":
main()

View File

@ -1,72 +0,0 @@
# Preparation Review Checklist: Customer Review Workspace v1
**Purpose**: Validate the customer-safe review-consumption package against the repo's guardrail, disclosure, shared-family, and close-out workflow before implementation
**Created**: 2026-04-27
**Feature**: [spec.md](../spec.md)
## Applicability And Low-Impact Gate
- [x] CHK001 The package explicitly treats this as an operator-facing and read-only/customer-safe surface change, so the low-impact `N/A` path is not used.
- [x] CHK002 The spec, plan, and tasks carry the same native/shared-primitives-first classification, shared-family relevance, state ownership, and close-out targeting without inventing second wording.
## Native, Shared-Family, And State Ownership
- [x] CHK003 The primary surface remains a native Filament page that composes existing review, pack, and evidence viewers instead of introducing a fake-native shell or standalone customer portal.
- [x] CHK004 Shared detail families remain shared: tenant review, review pack, and evidence detail stay on their existing resource routes, while the new page stays a calm entry point rather than a parallel viewer family.
- [x] CHK005 Shell, page, and URL/query state owners are named once, and the package does not collapse them into new persisted customer-review state.
- [x] CHK006 The likely next operator action and primary inspect/open model stay coherent: `Open latest review` is primary, `Download review pack` is the only safe inline shortcut, and deeper proof stays secondary.
## Shared Pattern Reuse
- [x] CHK007 Cross-cutting interaction classes are explicit, and the shared reuse path is named once through `TenantReviewRegisterService`, existing resource URL helpers, `ArtifactTruthPresenter`, `ReviewPackService`, `RedactionIntegrity`, and the audit pipeline.
- [x] CHK008 The package extends existing shared paths where they are sufficient, and any fallback to a bounded page-local read helper or additive audit action is explicitly constrained as a last resort rather than a new default abstraction.
- [x] CHK009 The package does not create a parallel customer-review UX language; it reuses current artifact-truth, publication-readiness, review-pack, and redaction vocabulary.
## OperationRun Start UX Contract
- [x] CHK019 The package explicitly states that the new page does not create, queue, deduplicate, resume, block, complete, or deep-link to an `OperationRun` as a primary workflow.
- [x] CHK020 Any existing `OperationRun` links remain on reused detail surfaces, so queued toast/link/browser-event/dedupe behavior is not reimplemented on the customer workspace page.
- [x] CHK021 No queued DB notification behavior or terminal notification path is added because the slice stays read-only and never starts a run.
- [x] CHK022 No OperationRun exception is required; if implementation later promotes run-oriented behavior onto the page, that deviation must be recorded in the active close-out entry before merge.
## Provider Boundary And Vocabulary
- [x] CHK010 The package keeps provider-specific semantics behind existing normalized review, evidence, and artifact-truth seams and does not spread provider language into a new platform-core contract.
- [x] CHK011 No retained provider-specific shared boundary is introduced; the slice stays within current workspace, tenant, review, evidence, review-pack, accepted-risk, and audit vocabulary.
## Signals, Exceptions, And Test Depth
- [x] CHK012 The triggered repository signal is explicitly handled as `review-mandatory`, with no hidden hard-stop drift accepted into the package.
- [x] CHK013 No bounded exception is required in the preparation package; if implementation discovers a local read helper or additive audit action is unavoidable, that exception must be documented in the active feature close-out entry instead of becoming silent spread.
- [x] CHK014 The required surface test profile is explicit: `standard-native-filament` for the page plus `shared-detail-family` for navigation into existing review, pack, and evidence detail surfaces.
- [x] CHK015 The chosen lane mix is the narrowest honest proof for this disclosure-heavy slice: focused Feature coverage plus one bounded Browser smoke, with optional Unit coverage only if a small read helper is extracted.
## Audience-Aware Disclosure And Decision Hierarchy
- [x] CHK023 Default-visible content stays decision-first and clearly separated from deeper diagnostics and support/raw evidence.
- [x] CHK024 The read-only/customer-safe default path does not expose raw JSON, copied payloads, fingerprints, internal reason ownership, platform-debug semantics, or unrestricted audit detail by default.
- [x] CHK025 Exactly one dominant next action is primary: `Open latest review`; safe artifact download remains secondary and does not compete at equal weight.
- [x] CHK026 Duplicate visible blocker, status, or next-action summaries are avoided by reusing one artifact-truth summary per row and leaving detailed proof to the existing detail surfaces.
- [x] CHK027 Support/raw sections remain hidden or capability-gated through reused detail routes only, and the page keeps Filament visual language, progressive disclosure, and calm read-only presentation.
## Review Outcome
- [x] CHK016 Review outcome class: `acceptable-special-case`
- [x] CHK017 Workflow outcome: `keep`
- [x] CHK018 The final note location is explicit: the active feature PR close-out entry `Guardrail / Exception / Smoke Coverage` records the guardrail result, smoke outcome, and any bounded implementation exception.
## Notes
- This checklist validates the preparation package only: `spec.md`, `plan.md`, `tasks.md`, and the supporting design artifacts. It does not claim application code already exists.
- The slice remains bounded to one read-only customer-safe workspace surface in the current admin plane. No new identity plane, persistence layer, review-generation workflow, remediation path, or raw-diagnostic default path is approved by this package.
- If implementation later proves `TenantReviewRegisterService` reuse insufficient or shows that explicit artifact access requires a new stable `AuditActionId`, that must be recorded as a bounded note in `Guardrail / Exception / Smoke Coverage` rather than silently widening the architecture.
## Implementation Close-out Addendum
- Implemented surface: native `CustomerReviewWorkspace` page and Blade view in the existing admin-plane reviews family, still reusing current tenant review, review-pack, evidence, artifact-truth, RBAC, and audit seams.
- T010 outcome: direct workspace links landed on tenant review detail, review-pack detail, evidence related context, and the tenant review-pack widget. `ReviewRegister` and `EvidenceOverview` remained acceptable reuse via existing row/detail navigation.
- T020 outcome: pack-download plumbing changed, so `ReviewPackDownloadTest.php` and `ReviewPackRbacTest.php` were updated and passed after request-time membership plus `REVIEW_PACK_VIEW` enforcement was added to the signed download route.
- T023 outcome: the current audit infrastructure was reused with additive `tenant_review.opened` and `review_pack.downloaded` action IDs. No new audit store was introduced.
- Smoke evidence outcome: the implementation close-out used the bounded Pest browser smoke plus the focused feature lane as executed smoke proof. No separate manual integrated-browser run was completed.
- Final review outcome class: `acceptable-special-case`.
- Final workflow outcome: `keep`.

View File

@ -1,261 +0,0 @@
openapi: 3.0.3
info:
title: TenantPilot Customer Review Workspace v1 (Conceptual)
version: 0.1.0
description: |
Conceptual contract for the read-oriented customer-safe workspace review
surface planned by Spec 249.
NOTE: The canonical page is planned as a native Filament / Livewire page in
the existing admin plane. The JSON response shapes below describe the
derived workspace view model for planning purposes; they do not require a
new public REST API in v1.
servers:
- url: /
paths:
/admin/reviews/workspace:
get:
summary: View the customer review workspace
description: |
Canonical admin-plane workspace page for customer-safe review
consumption. The page stays read-only and derives its rows from
existing tenant review, review-pack, evidence, and entitlement truth.
parameters:
- in: query
name: tenant_id
required: false
schema:
type: integer
description: |
Optional tenant prefilter carried from an existing tenant-scoped
review, review-pack, evidence, or dashboard entry point.
- in: query
name: source
required: false
schema:
type: string
description: Optional launch-context hint used for page highlighting only.
responses:
'200':
description: Workspace page rendered
content:
text/html:
schema:
type: string
application/json:
schema:
$ref: '#/components/schemas/CustomerReviewWorkspaceCollection'
'403':
description: Forbidden after workspace membership is established but required capability is missing
'404':
description: Not found for non-members or explicit out-of-scope tenant targeting
/admin/t/{tenant}/reviews/{review}:
get:
summary: Open the latest tenant review detail
description: |
Existing tenant-scoped review detail route reused as the primary inspect
action from the workspace page.
parameters:
- in: path
name: tenant
required: true
schema:
type: integer
- in: path
name: review
required: true
schema:
type: integer
responses:
'200':
description: Tenant review detail rendered
content:
text/html:
schema:
type: string
'403':
description: Forbidden for an entitled member missing the review capability
'404':
description: Not found for non-members or tenant / review mismatches
/admin/t/{tenant}/evidence/{evidenceSnapshot}:
get:
summary: Open evidence detail from the customer review flow
description: |
Existing tenant-scoped evidence detail route reused only as optional
proof when the actor explicitly asks for it and has the required
capability.
parameters:
- in: path
name: tenant
required: true
schema:
type: integer
- in: path
name: evidenceSnapshot
required: true
schema:
type: integer
responses:
'200':
description: Evidence detail rendered
content:
text/html:
schema:
type: string
'403':
description: Forbidden for an entitled member missing the evidence capability
'404':
description: Not found for non-members or tenant / evidence mismatches
/admin/review-packs/{reviewPack}/download:
get:
summary: Download the current review pack
description: |
Existing signed download route reused by the workspace page. The pack
must already exist, be ready, and not be expired.
parameters:
- in: path
name: reviewPack
required: true
schema:
type: integer
responses:
'200':
description: Review pack download stream
content:
application/zip:
schema:
type: string
format: binary
'403':
description: Forbidden due to missing signature or invalid signed URL
'404':
description: Review pack not found, not ready, or expired
components:
schemas:
CustomerReviewWorkspaceCollection:
type: object
required:
- workspace_id
- entries
properties:
workspace_id:
type: integer
tenant_prefilter_id:
type: integer
nullable: true
highlighted_tenant_id:
type: integer
nullable: true
launch_source:
type: string
nullable: true
entries:
type: array
items:
$ref: '#/components/schemas/CustomerReviewWorkspaceEntry'
empty_state_message:
type: string
nullable: true
CustomerReviewWorkspaceEntry:
type: object
required:
- tenant_id
- tenant_name
- review_pack_available
properties:
tenant_id:
type: integer
tenant_name:
type: string
latest_published_review_id:
type: integer
nullable: true
latest_review_generated_at:
type: string
format: date-time
nullable: true
latest_review_published_at:
type: string
format: date-time
nullable: true
review_outcome_label:
type: string
nullable: true
review_outcome_explanation:
type: string
nullable: true
key_findings_summary:
$ref: '#/components/schemas/FindingsSummary'
accepted_risk_summary:
$ref: '#/components/schemas/AcceptedRiskSummary'
review_pack:
$ref: '#/components/schemas/ReviewPackAccess'
evidence_proof:
$ref: '#/components/schemas/EvidenceProof'
primary_review_url:
type: string
nullable: true
redaction_note:
type: string
nullable: true
absence_note:
type: string
nullable: true
FindingsSummary:
type: object
nullable: true
properties:
total_visible:
type: integer
attention_required_count:
type: integer
summary_text:
type: string
AcceptedRiskSummary:
type: object
nullable: true
properties:
accepted_count:
type: integer
follow_up_required_count:
type: integer
summary_text:
type: string
ReviewPackAccess:
type: object
required:
- available
properties:
available:
type: boolean
review_pack_id:
type: integer
nullable: true
download_url:
type: string
nullable: true
status_message:
type: string
nullable: true
EvidenceProof:
type: object
nullable: true
properties:
evidence_snapshot_id:
type: integer
nullable: true
detail_url:
type: string
nullable: true
freshness_label:
type: string
nullable: true

View File

@ -1,210 +0,0 @@
# Data Model — Customer Review Workspace v1
**Spec**: [spec.md](spec.md)
No new persisted tables or customer-review entities are required for v1. The feature reuses existing tenant-owned review, review-pack, evidence, findings, and audit truth, then derives one workspace-scoped read model for page presentation.
## Persisted Truth Reused
### Workspace / Tenant Entitlement Context
**Purpose**: Establish the current workspace boundary and the entitled tenant set before any review rows are composed.
**Persisted carriers**:
- existing workspace membership records
- existing tenant membership pivot records and tenant role assignments
- existing capability registry and role-capability map
**Relevant fields / contracts**:
- `workspace_id`
- `tenant_id`
- tenant membership role
- capability grants derived from [../../apps/platform/app/Support/Auth/Capabilities.php](../../apps/platform/app/Support/Auth/Capabilities.php)
**Validation rules**:
- current actor must be a member of the current workspace or the page resolves as not found
- tenant rows may only be composed for tenants in the current workspace where the actor is entitled through the canonical role-capability map
- no cross-workspace or cross-tenant fallback lookups are allowed
### TenantReview
**Purpose**: Canonical source for the latest customer-safe review posture, summary text, findings summary, accepted-risk summary, and primary inspect target.
**Persisted carrier**: existing `tenant_reviews` rows via `TenantReview`
**Relevant fields / relationships**:
- `id`
- `workspace_id`
- `tenant_id`
- `status`
- `generated_at`
- `published_at`
- `summary`
- `evidence_snapshot_id`
- `current_export_review_pack_id`
- `tenant`
- `evidenceSnapshot`
- `currentExportReviewPack`
- `sections`
**Validation / usage rules**:
- the default customer-safe path uses the latest published review per entitled tenant
- draft, ready, failed, archived, and superseded reviews stay off the default-visible page summary unless explicitly reused as internal proof elsewhere
- summary data already shaped into the review artifact remains the preferred source for findings and review-level posture messaging
### TenantReviewSection
**Purpose**: Supporting persisted proof for accepted-risk and section-level disclosure without introducing a new workspace summary store.
**Persisted carrier**: existing `tenant_review_sections` rows
**Relevant fields / relationships**:
- `tenant_review_id`
- `section_key`
- `title`
- `completeness_state`
- `summary_payload`
- `render_payload`
**Validation / usage rules**:
- accepted-risk summaries should come from the existing review section payloads that were already composed for the review artifact
- section payload reuse must remain read-only and redaction-safe
### ReviewPack
**Purpose**: Canonical packaged artifact for customer-safe review consumption and download.
**Persisted carrier**: existing `review_packs` rows via `ReviewPack`
**Relevant fields / relationships**:
- `id`
- `workspace_id`
- `tenant_id`
- `tenant_review_id`
- `status`
- `generated_at`
- `expires_at`
- `summary`
- `file_path`
- `file_disk`
- `sha256`
- `tenantReview`
- `evidenceSnapshot`
**Validation / usage rules**:
- download is available only when the current pack is ready and not expired
- the workspace page may surface availability and a signed download path only when `REVIEW_PACK_VIEW` applies
- the workspace page must not start generation, regeneration, or recovery flows
### EvidenceSnapshot
**Purpose**: Existing proof artifact for freshness and evidence completeness when the actor explicitly asks for supporting detail.
**Persisted carrier**: existing `evidence_snapshots` rows via `EvidenceSnapshot`
**Relevant fields / relationships**:
- `id`
- `workspace_id`
- `tenant_id`
- `status`
- `completeness_state`
- `generated_at`
- `expires_at`
- `summary`
- `items`
**Validation / usage rules**:
- evidence detail is not part of the default-visible customer path
- drilldown remains explicit and capability-gated by `EVIDENCE_VIEW`
- evidence truth remains tenant-owned and derived from the existing snapshot lifecycle
### Audit Log Event Family
**Purpose**: Existing audit truth for explicit review-artifact access and download actions.
**Persisted carrier**: existing `audit_logs` rows via `WorkspaceAuditLogger`
**Relevant fields / contracts**:
- stable `AuditActionId`
- `workspace_id`
- optional `tenant_id`
- actor metadata
- target resource type / id / label
- action context metadata
**Validation / usage rules**:
- no new audit store is introduced
- explicit artifact open/download events should reuse the current audit pipeline
- page render itself should not become a noisy new audit family
## Derived Read Model
### CustomerReviewWorkspaceEntry
**Purpose**: Derived page row or card summarizing the latest customer-safe review state for one entitled tenant.
**Persistence**: none; computed at request time
**Fields**:
- `workspace_id`
- `tenant_id`
- `tenant_name`
- `latest_published_review_id` (nullable)
- `latest_review_generated_at` (nullable)
- `latest_review_published_at` (nullable)
- `review_outcome_label` (nullable, derived from existing artifact truth)
- `review_outcome_explanation` (nullable)
- `key_findings_summary` (nullable, derived from existing review summary)
- `accepted_risk_summary` (nullable, derived from existing review section payloads)
- `review_pack_id` (nullable)
- `review_pack_available` (boolean)
- `review_pack_status_note` (nullable derived string)
- `evidence_snapshot_id` (nullable)
- `primary_review_url` (nullable)
- `review_pack_download_url` (nullable)
- `evidence_detail_url` (nullable)
- `redaction_note` (nullable)
- `absence_note` (nullable derived string)
**Derivation rules**:
- exactly one entry exists per entitled tenant visible in the current workspace scope
- when a published review exists, the entry derives customer-safe posture from that latest published review only
- when no published review exists for an entitled tenant, the entry may carry a derived absence note such as `No published review available yet`; this remains view logic, not domain state
- raw JSON, raw provider payloads, unrestricted audit metadata, fingerprints, and debug-only context are never part of the entry
**Validation rules**:
- entries may only be built for tenants in the current workspace and current entitlement scope
- `review_pack_download_url` is present only when a current pack exists and the actor can view it
- `evidence_detail_url` is present only when the actor can view evidence detail
- absence or unavailable wording must not hint at hidden drafts or hidden operator-only artifacts
## Request-Scoped Page State
### CustomerReviewWorkspaceState
**Purpose**: Livewire-safe page state carrying tenant launch context and remembered filters.
**Persistence**: request, query, and session-backed page state only
**Fields**:
- `tenant_id` (nullable requested prefilter)
- `highlight_tenant_id` (nullable)
- `launch_source` (nullable string such as review, review_pack, evidence, or dashboard)
- `search` (nullable)
- `tableFilters` (session-backed when the implementation uses table filters)
**Validation rules**:
- requested tenant filters must resolve to an entitled tenant or the page should respond as not found for explicit tenant targeting
- state that needs to survive Livewire interactions must remain hydrated public or query/session-backed state
- if implementation adds a secondary status filter, it must operate on customer-safe derived labels only, not raw internal lifecycle states
## State Transition Summary
This slice introduces no new persisted lifecycle or status family. Only derived page-state transitions are expected:
- default workspace view -> tenant-prefiltered view
- tenant-prefiltered view -> cleared workspace view
- published review available -> inspect or download action available subject to capability checks
- no published review available -> truthful absence message only
No queue, publish, generate, regenerate, remediate, or archive transition belongs to this page.

View File

@ -1,310 +0,0 @@
# Implementation Plan: Customer Review Workspace v1
**Branch**: `249-customer-review-workspace` | **Date**: 2026-04-27 | **Spec**: [spec.md](spec.md)
**Input**: Feature specification from [spec.md](spec.md)
**Note**: This template is filled in by the `/speckit.plan` command. See `.specify/scripts/` for helper scripts.
## Summary
Introduce one canonical customer-safe review workspace inside the existing `/admin` plane by adding a native Filament v5 read-only page that derives its content from existing tenant review, review-pack, evidence, findings, redaction, and audit truth. The page should answer the first customer question quickly, then reuse the existing tenant-scoped review, review-pack, and evidence detail routes for proof instead of creating a new truth layer.
This slice is explicitly consumption-only. It does not create or publish reviews, generate or regenerate review packs, remediate findings, widen the identity model, or add persistence. Livewire remains v4 under Filament v5, panel-provider registration stays in [../../apps/platform/bootstrap/providers.php](../../apps/platform/bootstrap/providers.php), no new globally searchable resource is introduced, and no new asset bundle is expected for v1.
## Technical Context
**Language/Version**: PHP 8.4, Laravel 12
**Primary Dependencies**: Filament v5, Livewire v4, Pest v4, existing review/evidence/review-pack/audit/RBAC support services
**Storage**: PostgreSQL via existing `tenant_reviews`, `review_packs`, `evidence_snapshots`, findings / finding-exception truth, workspace memberships, and `audit_logs`; no new persistence planned
**Testing**: Pest v4 feature coverage plus one browser smoke slice, with optional narrow unit coverage only if a row-composition helper emerges during implementation
**Validation Lanes**: confidence, browser
**Target Platform**: Laravel monolith in `apps/platform` running via Sail, with existing `/admin` and tenant-scoped `/admin/t/{tenant}` surfaces
**Project Type**: Web application (Laravel monolith with Filament panels)
**Performance Goals**: page render remains DB-only and workspace-scoped; no Graph calls, no queue starts, and no remote work on render; latest review lookup should stay within one eager-loaded workspace read path
**Constraints**: preserve deny-as-not-found workspace and tenant isolation; keep the first slice in the existing admin plane; keep raw diagnostics and debug semantics out of the default path; avoid new persistence, new customer role families, and new presenter/taxonomy layers
**Scale/Scope**: 1 new admin page, 1 derived workspace summary per entitled tenant, reuse of 5 existing read surfaces and their services, 0 new runtime entities, and 1 explicit browser smoke slice
## Likely Affected Repo Surfaces
- [../../apps/platform/app/Filament/Pages/Reviews/ReviewRegister.php](../../apps/platform/app/Filament/Pages/Reviews/ReviewRegister.php) for the existing workspace review register pattern, filter behavior, and action-surface expectations.
- [../../apps/platform/app/Filament/Pages/Monitoring/EvidenceOverview.php](../../apps/platform/app/Filament/Pages/Monitoring/EvidenceOverview.php) for canonical workspace-page state persistence, tenant-prefilter handling, and clickable-row read-only reporting patterns.
- [../../apps/platform/app/Services/TenantReviews/TenantReviewRegisterService.php](../../apps/platform/app/Services/TenantReviews/TenantReviewRegisterService.php) as the preferred entitlement and workspace query seam to extend or reuse before adding any new helper.
- [../../apps/platform/app/Filament/Resources/TenantReviewResource.php](../../apps/platform/app/Filament/Resources/TenantReviewResource.php), [../../apps/platform/app/Filament/Resources/ReviewPackResource.php](../../apps/platform/app/Filament/Resources/ReviewPackResource.php), and [../../apps/platform/app/Filament/Resources/EvidenceSnapshotResource.php](../../apps/platform/app/Filament/Resources/EvidenceSnapshotResource.php) for existing tenant-scoped proof routes, action-surface rules, and capability gates.
- [../../apps/platform/app/Services/ReviewPackService.php](../../apps/platform/app/Services/ReviewPackService.php), [../../apps/platform/app/Http/Controllers/ReviewPackDownloadController.php](../../apps/platform/app/Http/Controllers/ReviewPackDownloadController.php), and [../../apps/platform/routes/web.php](../../apps/platform/routes/web.php) for signed download generation, current pack availability semantics, and the real download route.
- [../../apps/platform/app/Services/TenantReviews/TenantReviewService.php](../../apps/platform/app/Services/TenantReviews/TenantReviewService.php) plus the existing `TenantReview` summary/section payloads for published review truth, findings summaries, and accepted-risk source data.
- [../../apps/platform/app/Support/Ui/GovernanceArtifactTruth/ArtifactTruthPresenter.php](../../apps/platform/app/Support/Ui/GovernanceArtifactTruth/ArtifactTruthPresenter.php) and [../../apps/platform/app/Support/RedactionIntegrity.php](../../apps/platform/app/Support/RedactionIntegrity.php) for customer-safe outcome and redaction wording.
- [../../apps/platform/app/Services/Auth/RoleCapabilityMap.php](../../apps/platform/app/Services/Auth/RoleCapabilityMap.php), [../../apps/platform/app/Support/Auth/Capabilities.php](../../apps/platform/app/Support/Auth/Capabilities.php), and existing tenant review / evidence policies for capability-first RBAC.
- [../../apps/platform/app/Services/Audit/WorkspaceAuditLogger.php](../../apps/platform/app/Services/Audit/WorkspaceAuditLogger.php) and [../../apps/platform/app/Support/Audit/AuditActionId.php](../../apps/platform/app/Support/Audit/AuditActionId.php) for audit reuse.
- Likely new implementation files if code work later proceeds: `App\Filament\Pages\Reviews\CustomerReviewWorkspace`, a matching Blade view under `resources/views/filament/pages/reviews/`, and focused tests under `tests/Feature/Reviews/` and `tests/Browser/Reviews/`.
## UI / Filament & Livewire Fit
- Implement as a native Filament v5 `Page` using the same `HasTable` / `InteractsWithTable` style already used by the workspace review and evidence overview pages. Do not introduce a new Resource, portal shell, custom SPA, or second panel.
- Keep the page in the existing admin-plane reporting family with one primary inspect affordance and, at most, one inline safe download shortcut. The workspace page itself should not expose bulk actions, More-menu sprawl, or lifecycle controls.
- Livewire v4 hydration must preserve tenant prefilter and launch-context state through public, query-backed, or session-backed state. Do not rely on private page properties for any state that must survive a Livewire interaction.
- Tenant detail links should continue using the existing tenant-scoped route helpers from the resource layer so the workspace page stays a navigation surface, not a duplicate detail renderer.
- The new surface is a Page, not a globally searchable Resource. Existing tenant review, review-pack, and evidence resources already have global search disabled, and that remains unchanged for this slice.
## RBAC / Policy Fit
- Workspace membership remains the first gate. The preferred access check is the existing workspace-entitlement path already used by `TenantReviewRegisterService::canAccessWorkspace(...)` and the current workspace context.
- The safe v1 audience anchor remains the existing readonly-capable tenant role in [../../apps/platform/app/Services/Auth/RoleCapabilityMap.php](../../apps/platform/app/Services/Auth/RoleCapabilityMap.php). No new customer role family or external customer identity plane is planned.
- Page visibility and row composition should derive entitled tenants from the canonical capability registry: `TENANT_VIEW` plus `TENANT_REVIEW_VIEW` for page entry, with `REVIEW_PACK_VIEW`, `EVIDENCE_VIEW`, `TENANT_FINDINGS_VIEW`, `FINDING_EXCEPTION_VIEW`, and `AUDIT_VIEW` gating optional secondary proof.
- Non-members or explicit out-of-scope tenant targets must resolve as not found. Once workspace and tenant membership are established, missing secondary capabilities remain normal authorization failures for execution paths and should not leak hidden content through the UI.
- Policy and gate checks stay capability-first. No role-string checks or customer-only bypasses should appear in the implementation.
## Audit / Logging Fit
- Reuse the existing audit infrastructure through `WorkspaceAuditLogger` and `AuditActionId`. The feature should not create a separate audit store, mirror page-view ledger, or custom analytics table.
- Existing review export generation already logs `tenant_review.exported`, and review-pack download already has a real signed route. The plan assumes explicit customer-facing artifact open/download events can remain on the current audit pipeline.
- If the workspace page needs a distinct access event beyond what current review-pack or review actions already capture, add a stable `AuditActionId` case and log it through the shared audit path rather than page-local ad hoc logging.
- Default page render should not emit noisy audit events. The auditable boundary is explicit artifact access or download, not passive page paint.
## Data & Query Fit
- The preferred row source is a derived workspace read over entitled tenants and their latest published `TenantReview`, with eager-loaded `tenant`, `evidenceSnapshot`, and `currentExportReviewPack` relations.
- Accepted-risk and findings summaries should come from existing review summary / section payloads, not from a new customer-specific aggregation model. `TenantReviewSectionFactory` already shapes accepted-risk and finding outcome data into the review artifact.
- Absence handling must remain derived view logic. The page may surface `No published review available yet` or pack-unavailable messaging, but that language must not become a new persisted lifecycle or publication taxonomy.
- No new table, cache, or materialized view is planned. If the existing register service cannot express the exact latest-published-per-tenant query safely, extend that service or add a bounded page-local read helper rather than introducing a new projector or presenter family.
- Pack availability should remain tied to the current review/export relationship and existing signed download semantics. The page must not trigger generation or regeneration.
## UI / Surface Guardrail Plan
> **Fill for operator-facing or guardrail-relevant workflow changes. Docs-only or template-only work may use concise `N/A`. Copy the spec classification forward; do not rename or expand it here.**
- **Guardrail scope**: changed surfaces
- **Native vs custom classification summary**: native Filament
- **Shared-family relevance**: reporting, evidence/report viewers, navigation entry points, review/download actions, disclosure hierarchy
- **State layers in scope**: page, URL-query
- **Audience modes in scope**: customer/read-only, operator-MSP
- **Decision/diagnostic/raw hierarchy plan**: decision-first, diagnostics-second, support-raw-third
- **Raw/support gating plan**: capability-gated and hidden by default through reused detail routes only
- **One-primary-action / duplicate-truth control**: the dominant next action remains `Open latest review`; download is the only inline safe shortcut, and deeper proof stays on existing detail surfaces instead of being repeated on the workspace page
- **Handling modes by drift class or surface**: review-mandatory
- **Repository-signal treatment**: review-mandatory now, future hard-stop candidate if implementation introduces a second customer-review truth path
- **Special surface test profiles**: standard-native-filament, shared-detail-family
- **Required tests or manual smoke**: functional-core, bounded-browser-smoke
- **Exception path and spread control**: none expected; any need for a local presenter, custom disclosure taxonomy, or new detail shell should be treated as exception-required drift
- **Active feature PR close-out entry**: Guardrail / Exception / Smoke Coverage
## Shared Pattern & System Fit
> **Fill when the feature touches notifications, status messaging, action links, header actions, dashboard signals/cards, navigation entry points, alerts, evidence/report viewers, or any other shared interaction family. Docs-only or template-only work may use concise `N/A`. Carry the same decision forward from the spec instead of renaming it here.**
- **Cross-cutting feature marker**: yes
- **Systems touched**: `ReviewRegister`, `EvidenceOverview`, `TenantReviewRegisterService`, `TenantReviewResource`, `ReviewPackResource`, `EvidenceSnapshotResource`, `ReviewPackService`, `TenantReviewService`, `ArtifactTruthPresenter`, `SurfaceCompressionContext`, `ActionSurfaceDeclaration`, `RedactionIntegrity`, `WorkspaceAuditLogger`, `AuditActionId`, and the existing capability / policy seams
- **Shared abstractions reused**: `TenantReviewRegisterService`, `ArtifactTruthPresenter`, `SurfaceCompressionContext`, Filament action-surface declarations, existing tenant-scoped resource URL helpers, `ReviewPackService`, `RedactionIntegrity`, and the existing audit pipeline
- **New abstraction introduced? why?**: none by default. If implementation discovers that the current register service cannot safely express latest-published-per-tenant rows, the smallest acceptable addition is a bounded read helper or service extension for this page only
- **Why the existing abstraction was sufficient or insufficient**: current review, evidence, and pack abstractions already hold the truth and disclosure language; they are insufficient only because there is no calm customer-safe workspace entry point today
- **Bounded deviation / spread control**: none planned. The new page must compose existing truth, not rename it or mirror it into a customer-specific presenter framework
## OperationRun UX Impact
> **Fill when the feature creates, queues, deduplicates, resumes, blocks, completes, or deep-links to an `OperationRun`. Docs-only or template-only work may use concise `N/A`.**
- **Touches OperationRun start/completion/link UX?**: no
- **Central contract reused**: `N/A`
- **Delegated UX behaviors**: `N/A`
- **Surface-owned behavior kept local**: read-only inspection and signed artifact download only; any existing `OperationRun` links stay on reused detail surfaces and are not promoted into the default customer path
- **Queued DB-notification policy**: `N/A`
- **Terminal notification path**: `N/A`
- **Exception path**: none
## Provider Boundary & Portability Fit
> **Fill when the feature touches shared provider/platform seams, identity scope, governed-subject taxonomy, compare strategy selection, provider connection descriptors, or operator vocabulary that may leak provider-specific semantics into platform-core truth. Docs-only or template-only work may use concise `N/A`.**
- **Shared provider/platform boundary touched?**: no
- **Provider-owned seams**: `N/A`
- **Platform-core seams**: existing workspace, tenant, review, evidence, findings, and audit vocabulary only
- **Neutral platform terms / contracts preserved**: `workspace`, `tenant`, `review`, `evidence`, `review pack`, `accepted risk`, and existing artifact-truth wording
- **Retained provider-specific semantics and why**: none; the feature consumes provider-shaped artifacts only through already-normalized platform surfaces
- **Bounded extraction or follow-up path**: `N/A`
## Constitution Check
*GATE: Must pass before Phase 0 research. Re-check after Phase 1 design.*
- Inventory-first / snapshot truth: PASS. The slice consumes existing `TenantReview`, `ReviewPack`, and `EvidenceSnapshot` artifacts as read-only truth and does not redefine source-of-truth boundaries.
- Read/write separation: PASS. The workspace page is read-only and adds no create, publish, regenerate, expire, triage, or remediation action. Any destructive-like action that already exists on reused detail pages remains outside the default path and must continue using confirmation.
- Graph contract path: PASS. No new Graph calls or provider contract work are part of this slice.
- Deterministic capabilities: PASS. The plan reuses the canonical capability registry in [../../apps/platform/app/Support/Auth/Capabilities.php](../../apps/platform/app/Support/Auth/Capabilities.php) and the existing role map.
- Workspace isolation + tenant isolation: PASS. Workspace membership remains a 404 boundary, tenant entitlement remains a 404 boundary, and explicit out-of-scope tenant filters must not leak existence.
- RBAC-UX plane separation: PASS. Everything stays in the existing `/admin` plane and tenant-scoped detail routes; no `/system` surface or cross-plane flow is added.
- Destructive confirmation standard: PASS. No destructive action is planned on the workspace page. If implementation later discovers any destructive affordance on a reused surface must be exposed, it must remain confirmation-protected and out of the default customer-safe path.
- Global search safety: PASS. The new slice is a Page, not a Resource. Existing tenant review, review-pack, and evidence resources are already not globally searchable, and no new searchable resource is introduced.
- OperationRun and Ops-UX: PASS by non-use. The workspace page starts no run, emits no run UX, and performs no queue orchestration.
- Data minimization: PASS. Default-visible content stays decision-first; raw JSON, unrestricted audit metadata, fingerprints, debug semantics, and raw provider payloads stay hidden.
- Test governance (TEST-GOV-001): PASS. Planned proof stays in focused feature tests plus one bounded browser smoke slice, with optional unit coverage only if a local read helper appears.
- Proportionality / no premature abstraction: PASS. No new persistence, presenter family, enum family, or identity plane is planned; the narrowest shape is one page over existing truth seams.
- Persisted truth (PERSIST-001): PASS. No new table, cache, or stored customer-review projection is planned.
- Behavioral state (STATE-001): PASS. Any unavailable or no-published-review wording remains derived UI state, not a new persisted lifecycle.
- UI semantics / shared pattern first / Filament-native UI: PASS. The plan reuses Filament pages/resources, existing badge and artifact-truth presenters, existing download service, and the current disclosure language rather than inventing a new UI framework.
- Provider boundary (PROV-001): PASS. The slice stays within already-normalized review and evidence artifacts and does not deepen provider coupling.
- Filament / Laravel planning contract: PASS. Filament v5 remains on Livewire v4, provider registration stays in [../../apps/platform/bootstrap/providers.php](../../apps/platform/bootstrap/providers.php), no new panel registration is needed, and no new panel-only or shared asset registration is expected.
- Asset strategy: PASS. Default assumption is no new assets. If implementation later registers any Filament asset anyway, deployment continues to require `cd apps/platform && php artisan filament:assets`.
**Gate evaluation**: PASS.
- The slice stays inside the existing admin plane and current workspace/tenant membership model.
- The page remains a customer-safe consumption surface, not a new review-generation or remediation workflow.
- Existing review, evidence, pack, redaction, and audit seams are sufficient for v1 if the implementation resists adding a second presenter or persistence layer.
**Post-design re-check**: PASS (design artifacts: [research.md](research.md), [data-model.md](data-model.md), [quickstart.md](quickstart.md), [contracts/customer-review-workspace.openapi.yaml](contracts/customer-review-workspace.openapi.yaml)).
## Test Governance Check
> **Fill for any runtime-changing or test-affecting feature. Docs-only or template-only work may state concise `N/A` or `none`.**
- **Test purpose / classification by changed surface**: Feature for workspace-page behavior, authorization, and pack-access semantics; Browser for the calm customer-safe disclosure smoke path; optional Unit only if a bounded row-composition helper is extracted
- **Affected validation lanes**: confidence, browser
- **Why this lane mix is the narrowest sufficient proof**: feature coverage is the cheapest way to prove deny-as-not-found behavior, capability-gated secondary actions, empty states, and signed download wiring on a native Filament page; one browser smoke is justified because the product value of this slice is the disclosure experience itself
- **Narrowest proving command(s)**:
- `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Reviews/CustomerReviewWorkspacePageTest.php`
- `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Reviews/CustomerReviewWorkspaceAuthorizationTest.php`
- `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Reviews/CustomerReviewWorkspacePackAccessTest.php`
- `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Browser/Reviews/CustomerReviewWorkspaceSmokeTest.php`
- **Fixture / helper / factory / seed / context cost risks**: moderate but contained; reuse existing workspace membership, tenant membership, published review, ready pack, evidence snapshot, findings, and finding-exception fixtures rather than introducing heavy new helpers
- **Expensive defaults or shared helper growth introduced?**: no; any helper added for row composition or launch-context state should stay explicit and page-local
- **Heavy-family additions, promotions, or visibility changes**: exactly one browser smoke slice only; no broader browser family or heavy-governance lane expansion should be needed
- **Surface-class relief / special coverage rule**: standard-native-filament relief for route, auth, filters, and empty states; shared-detail-family checks only where navigation into existing review/pack/evidence surfaces needs proof
- **Closing validation and reviewer handoff**: rerun the four focused commands above, verify the page never shows admin or remediation controls by default, verify out-of-scope tenant targeting stays 404-safe, and verify download remains signed and capability-bound through the existing pack path
- **Budget / baseline / trend follow-up**: none expected beyond a small feature-local browser cost
- **Review-stop questions**: lane fit, hidden fixture cost, accidental browser family growth, duplicated detail rendering, audit proof adequacy
- **Escalation path**: `document-in-feature` for contained audit-test placement drift; `reject-or-split` if implementation grows into write workflows, new persistence, or a larger browser suite
- **Active feature PR close-out entry**: Guardrail / Exception / Smoke Coverage
- **Why no dedicated follow-up spec is needed**: routine test and disclosure upkeep stays inside this feature unless implementation proves a structural need for a broader customer-access program
## Rollout & Risk Controls
- Keep the v1 audience anchored to the current readonly-capable tenant role plus existing review/evidence capabilities. No navigation or deep link should become visible without those gates.
- Treat the page as a new read-only entry point only. Do not move generation, publish, regenerate, refresh, expire, triage, or remediation controls onto it during implementation.
- Prefer extending the existing register and detail seams over introducing any persisted projection, local presenter family, or customer-only vocabulary.
- Keep signed pack download on the existing route and controller path. Do not replace it with a new download endpoint just for the workspace page.
- Validate the page with one browser smoke before considering any broader navigation prominence changes. The rollout risk is disclosure drift, not infrastructure change.
- No migration, queue worker change, or asset build sequence change is expected for this slice.
## Project Structure
### Documentation (this feature)
```text
specs/249-customer-review-workspace/
├── plan.md
├── research.md
├── data-model.md
├── quickstart.md
├── contracts/
│ └── customer-review-workspace.openapi.yaml
└── tasks.md # Created later by /speckit.tasks, not by this plan step
```
### Source Code (repository root)
```text
apps/platform/
├── app/
│ ├── Filament/
│ │ ├── Pages/Reviews/
│ │ │ ├── ReviewRegister.php
│ │ │ └── CustomerReviewWorkspace.php # likely new page if implementation proceeds
│ │ ├── Pages/Monitoring/EvidenceOverview.php
│ │ └── Resources/
│ │ ├── TenantReviewResource.php
│ │ ├── ReviewPackResource.php
│ │ └── EvidenceSnapshotResource.php
│ ├── Http/Controllers/ReviewPackDownloadController.php
│ ├── Models/TenantReview.php
│ ├── Services/
│ │ ├── Audit/WorkspaceAuditLogger.php
│ │ ├── ReviewPackService.php
│ │ └── TenantReviews/
│ │ ├── TenantReviewRegisterService.php
│ │ └── TenantReviewService.php
│ ├── Support/
│ │ ├── Audit/AuditActionId.php
│ │ ├── Auth/Capabilities.php
│ │ ├── RedactionIntegrity.php
│ │ └── Ui/GovernanceArtifactTruth/ArtifactTruthPresenter.php
│ └── Policies/
│ ├── TenantReviewPolicy.php
│ └── EvidenceSnapshotPolicy.php
├── bootstrap/providers.php
├── resources/views/filament/pages/reviews/ # likely new page view if implementation proceeds
├── routes/web.php
└── tests/
├── Browser/Reviews/
├── Feature/Reviews/
└── Feature/ReviewPack/
```
**Structure Decision**: Laravel monolith. Implementation should stay entirely inside `apps/platform`, add at most one new read-only page and matching Blade view, and reuse the existing review, pack, evidence, RBAC, and audit seams rather than creating a separate customer-facing subsystem.
## Complexity Tracking
| Violation | Why Needed | Simpler Alternative Rejected Because |
|-----------|------------|-------------------------------------|
| None expected | The intended implementation is one native page plus derived queries over existing truth | A separate portal, persistence layer, or customer presenter framework would import unnecessary scope and ownership cost |
## Proportionality Review
- **Current operator problem**: review artifacts already exist, but readonly-capable tenant actors still lack one coherent customer-safe workspace surface to consume them.
- **Existing structure is insufficient because**: current review, pack, and evidence surfaces are truthful but fragmented and more operator-oriented than a calm customer-first entry point.
- **Narrowest correct implementation**: add one native Filament page over existing tenant review, review-pack, evidence, findings, redaction, and audit seams, with only derived row composition and no new persistence.
- **Ownership cost created**: one page, one view, one bounded query/composition seam if required, and focused feature/browser tests.
- **Alternative intentionally rejected**: a new customer portal, a new identity plane, and a new persisted customer-review projection were all rejected because existing admin-plane RBAC and review artifacts are sufficient for the first slice.
- **Release truth**: current-release customer-safe consumption slice, not future-release portal preparation.
## Phase 0 — Research (output: research.md)
Research resolves the remaining implementation-shaping decisions:
- place the new page in the existing admin-plane reviews family rather than extending `ReviewRegister` into a dual-persona page or creating a portal shell
- reuse `TenantReviewRegisterService` as the entitlement/query seam before adding any new helper
- keep tenant prefilter and launch context in Livewire-safe public/query/session-backed state
- reuse `ArtifactTruthPresenter`, existing review summary payloads, and `RedactionIntegrity` for disclosure instead of adding a customer presenter layer
- reuse the existing signed review-pack download route and audit pipeline rather than inventing a new consumption endpoint
**Output**: [research.md](research.md)
## Phase 1 — Design (outputs: data-model.md, contracts/, quickstart.md)
Design artifacts capture the narrow implementation shape:
- no new persistence; reused truth stays in tenant reviews, review packs, evidence snapshots, findings/exceptions, memberships, and audit logs
- one derived workspace entry model documents what the new page must compose without becoming a stored entity
- the conceptual contract documents the page route, tenant-detail handoff, and signed pack-download semantics
- quickstart records the intended implementation order, validation commands, Filament/Livewire assumptions, provider-registration location, and no-new-assets posture
**Artifacts**:
- [data-model.md](data-model.md)
- [contracts/customer-review-workspace.openapi.yaml](contracts/customer-review-workspace.openapi.yaml)
- [quickstart.md](quickstart.md)
## Phase 2 — Planning (for tasks.md)
Dependency-ordered implementation outline for the later `tasks.md` step:
1. Add a native `CustomerReviewWorkspace` admin page and Blade view in the reviews family, keeping the action surface read-only and customer-safe.
2. Reuse or minimally extend `TenantReviewRegisterService` to resolve workspace access, entitled tenants, and the latest published review per entitled tenant with the required eager loads.
3. Compose row content from existing review summary / section payloads, `ArtifactTruthPresenter`, current export review-pack relationships, and `RedactionIntegrity` notes, without creating a new presenter or persistence layer.
4. Preserve launch-context tenant prefiltering and Livewire-safe filter state using the same workspace-page state patterns already proven in `ReviewRegister` and `EvidenceOverview`.
5. Wire the dominant inspect action to the existing tenant-scoped review detail route and keep review-pack download on the current signed route; evidence drilldown remains explicit and capability-gated.
6. Reuse the audit pipeline for explicit artifact access or download events only if the current path does not already emit a truthful stable event; do not add a new audit store.
7. Add focused feature coverage for page behavior, authorization, and pack access, then one browser smoke test for calm disclosure and absence of admin controls. Run Pint after implementation.
## Guardrail / Exception / Smoke Coverage
- Guardrail result: PASS. Filament remains v5 on Livewire v4, panel provider registration stays unchanged in `apps/platform/bootstrap/providers.php`, the feature adds no new globally searchable Resource, no destructive action on the workspace page, and no new asset bundle. Deployment asset handling stays unchanged: `cd apps/platform && php artisan filament:assets` only matters if future registered assets are added.
- Shared seam outcome: `TenantReviewRegisterService` remained the entitlement and latest-published query seam. No local helper or second customer-review truth layer was introduced.
- Launch-path outcome: direct customer-workspace links landed on tenant review detail, review-pack detail, evidence related context, and the tenant review-pack widget. `ReviewRegister` and `EvidenceOverview` were satisfied through existing row/detail navigation reuse instead of duplicate workspace buttons.
- Read-only detail outcome: the workspace handoff now appends a dedicated customer-workspace context query flag, and `ViewTenantReview` suppresses management actions in that customer-safe flow while preserving the established operator detail route behavior outside that flow.
- Pack-download outcome: the existing signed route was retained, but it now also enforces tenant membership plus `REVIEW_PACK_VIEW` at request time. That touched download plumbing and required `ReviewPackDownloadTest.php` plus `ReviewPackRbacTest.php` updates.
- Audit outcome: the existing audit infrastructure was reused with additive `tenant_review.opened` and `review_pack.downloaded` action IDs logged through `WorkspaceAuditLogger`. No new audit store or parallel logging path was introduced.
- Validation lane result: `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Reviews/CustomerReviewWorkspacePageTest.php tests/Feature/Reviews/CustomerReviewWorkspaceAuthorizationTest.php tests/Feature/Reviews/CustomerReviewWorkspacePackAccessTest.php tests/Feature/Reviews/CustomerReviewWorkspaceLaunchLinksTest.php tests/Feature/Evidence/EvidenceSnapshotResourceTest.php tests/Feature/ReviewPack/ReviewPackWidgetTest.php tests/Feature/ReviewPack/ReviewPackResourceTest.php tests/Feature/ReviewPack/ReviewPackDownloadTest.php tests/Feature/ReviewPack/ReviewPackRbacTest.php tests/Feature/TenantReview/TenantReviewUiContractTest.php tests/Browser/Reviews/CustomerReviewWorkspaceSmokeTest.php` passed with `83 passed (372 assertions)`.
- Smoke evidence: the executed smoke proof was the bounded Pest browser harness in `tests/Browser/Reviews/CustomerReviewWorkspaceSmokeTest.php`, which passed with `1 passed (19 assertions)`. No separate manual integrated-browser run was performed.
- Formatting result: `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail bin pint --dirty --format agent` passed.
- Review outcome class: `acceptable-special-case`.
- Workflow outcome: `keep`.
- Exception note: none. The implementation stayed within the planned admin-plane, read-only, shared-primitives-first shape.

View File

@ -1,59 +0,0 @@
# Quickstart — Customer Review Workspace v1
## Preconditions
- Docker is running and the Sail stack for `apps/platform` is available.
- The feature remains inside the existing Laravel monolith and admin plane.
- The first slice stays read-oriented: no new customer portal, no new identity plane, no new persistence, and no remediation or generation workflow.
## Intended Implementation Order
1. Add the native admin `CustomerReviewWorkspace` page and its Blade view under the existing reviews family.
2. Reuse or minimally extend `TenantReviewRegisterService` to resolve workspace membership, entitled tenants, and latest published reviews per entitled tenant.
3. Compose customer-safe row content from existing `TenantReview` summary / section payloads, `ArtifactTruthPresenter`, `currentExportReviewPack`, and `RedactionIntegrity`.
4. Preserve tenant launch context and remembered filters through Livewire-safe public/query/session-backed state.
5. Wire `Open latest review` to the existing tenant-scoped review detail route and keep review-pack consumption on the existing signed download path.
6. Reuse the existing audit pipeline for any explicit artifact access event that is not already covered by the current review / export flow.
7. Add focused feature coverage and one browser smoke test, then run Pint.
## Targeted Validation Commands (after implementation)
- `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Reviews/CustomerReviewWorkspacePageTest.php`
- `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Reviews/CustomerReviewWorkspaceAuthorizationTest.php`
- `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Reviews/CustomerReviewWorkspacePackAccessTest.php`
- `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Browser/Reviews/CustomerReviewWorkspaceSmokeTest.php`
- If implementation changes pack-download plumbing directly: `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/ReviewPack/ReviewPackDownloadTest.php`
- `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail bin pint --dirty --format agent`
## Smoke Checklist Reference (after implementation)
Implementation close-out used the bounded browser smoke in `tests/Browser/Reviews/CustomerReviewWorkspaceSmokeTest.php` plus the focused feature lane as the executed smoke evidence. The checklist below remains the human reference checklist, but no separate manual integrated-browser run was executed for this implementation close-out.
1. Sign in to `/admin` as a readonly-capable tenant actor, select a workspace, and open `/admin/reviews/workspace`.
2. Confirm that the page shows only entitled tenants, the latest customer-safe review posture, and no create, publish, regenerate, refresh, expire, triage, or remediation controls.
3. Launch the page from an existing tenant-scoped review or evidence route and confirm the tenant prefilter survives the first page load.
4. Open the latest review for a tenant with a published review and confirm the detail remains read-oriented for the readonly actor.
5. Use the pack action for a tenant with a current pack and confirm the download path stays signed and customer-safe; for a tenant without a current pack, confirm the page shows a calm unavailable state instead of a generation action.
6. Attempt an explicit out-of-scope tenant filter or deep link and confirm the result stays not found without leaking tenant existence.
## Executed Validation Evidence
- `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Reviews/CustomerReviewWorkspacePageTest.php tests/Feature/Reviews/CustomerReviewWorkspaceAuthorizationTest.php tests/Feature/Reviews/CustomerReviewWorkspacePackAccessTest.php tests/Feature/Reviews/CustomerReviewWorkspaceLaunchLinksTest.php tests/Feature/Evidence/EvidenceSnapshotResourceTest.php tests/Feature/ReviewPack/ReviewPackWidgetTest.php tests/Feature/ReviewPack/ReviewPackResourceTest.php tests/Feature/ReviewPack/ReviewPackDownloadTest.php tests/Feature/ReviewPack/ReviewPackRbacTest.php tests/Feature/TenantReview/TenantReviewUiContractTest.php tests/Browser/Reviews/CustomerReviewWorkspaceSmokeTest.php` -> `83 passed (372 assertions)`
- `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Browser/Reviews/CustomerReviewWorkspaceSmokeTest.php` -> `1 passed (19 assertions)`
- `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail bin pint --dirty --format agent` -> `pass`
## Close-out Notes
- `TenantReviewRegisterService` reuse held; no page-local helper was needed.
- The review-pack download route remained signed, but now also enforces tenant membership and `REVIEW_PACK_VIEW` at request time.
- Explicit artifact access is now audited through additive `tenant_review.opened` and `review_pack.downloaded` action IDs on the existing audit pipeline.
- `ReviewRegister` and `EvidenceOverview` satisfied the launch-path requirement through existing row/detail navigation reuse rather than new duplicate workspace buttons.
## Notes
- Filament v5 already runs on Livewire v4 in this repo.
- Panel providers remain registered through [../../apps/platform/bootstrap/providers.php](../../apps/platform/bootstrap/providers.php); this slice does not add or move providers.
- No new globally searchable Resource is part of v1. Existing review, review-pack, and evidence Resources already keep global search disabled.
- No destructive action belongs on the new workspace page. If implementation accidentally introduces one, it must use `->requiresConfirmation()` and stay outside the customer-safe default path.
- No new registered asset bundle is expected. If implementation later registers a Filament asset anyway, deployment still requires `cd apps/platform && php artisan filament:assets`.
- This remains a customer-safe consumption slice only. Review creation, publication, regeneration, remediation, and operator/debug workflows remain on existing internal surfaces or future specs.

View File

@ -1,166 +0,0 @@
# Research — Customer Review Workspace v1
**Date**: 2026-04-27
**Spec**: [spec.md](spec.md)
This document resolves the planning decisions that shape the smallest safe implementation slice for Spec 249.
## Decision 1 — Place the new surface as a native admin reviews page
**Decision**: Implement the customer-safe workspace as a new native Filament page under the existing admin reviews family, with the planned route shape `/admin/reviews/workspace`. Do not create a new panel, a public/customer portal shell, or a new Resource just to host the view.
**Rationale**:
- The repo already has native workspace-level read-only pages for reporting and monitoring.
- The existing review, review-pack, and evidence Resources already own tenant-scoped detail and proof routes.
- A dedicated page keeps the first slice calm and customer-safe without overloading an operator-oriented registry.
**Evidence**:
- [../../apps/platform/app/Filament/Pages/Reviews/ReviewRegister.php](../../apps/platform/app/Filament/Pages/Reviews/ReviewRegister.php) already provides the workspace review register pattern.
- [../../apps/platform/app/Filament/Pages/Monitoring/EvidenceOverview.php](../../apps/platform/app/Filament/Pages/Monitoring/EvidenceOverview.php) already provides a workspace-scoped read-only page pattern with tenant prefilters.
- [../../apps/platform/bootstrap/providers.php](../../apps/platform/bootstrap/providers.php) already registers the existing panel providers. No new provider registration is needed.
**Alternatives considered**:
- Extend `ReviewRegister` into a dual-persona page.
- Rejected: it already carries operator-oriented filters and export semantics, which would blur the customer-safe default path.
- Create a new customer portal or new identity plane.
- Rejected: outside the bounded v1 scope and unnecessary because the current admin plane plus readonly-capable roles already exists.
## Decision 2 — Reuse `TenantReviewRegisterService` as the entitlement and query seam
**Decision**: Prefer extending or reusing [../../apps/platform/app/Services/TenantReviews/TenantReviewRegisterService.php](../../apps/platform/app/Services/TenantReviews/TenantReviewRegisterService.php) for workspace access checks, entitled-tenant discovery, and the base review query before adding any new helper.
**Rationale**:
- The service already centralizes workspace membership and entitled tenant selection for the current review register.
- Reusing it keeps entitlement logic in one place and avoids new raw tenant-role queries inside the page.
- It is the narrowest existing seam that can be extended toward latest-published-per-tenant behavior.
**Evidence**:
- `authorizedTenants(...)` already derives tenant scope from the canonical role/capability map.
- `query(...)` already scopes tenant reviews to the current workspace and eager-loads `tenant`, `evidenceSnapshot`, and `currentExportReviewPack`.
- `canAccessWorkspace(...)` already exposes the workspace-membership check needed for deny-as-not-found page gating.
**Alternatives considered**:
- Build a new persisted workspace summary table.
- Rejected: violates the no-new-persistence rule for a derived read surface.
- Recreate entitlement logic directly inside the page class.
- Rejected: duplicates existing membership and capability behavior.
## Decision 3 — Derive the default path from the latest published tenant review per entitled tenant
**Decision**: The default workspace page should derive each tenant summary from the latest published `TenantReview` for that entitled tenant, with eager-loaded `currentExportReviewPack` and `evidenceSnapshot` relationships.
**Rationale**:
- The spec requires the default path to stay customer-safe and exclude draft, failed, and other internal-only states.
- The current `TenantReview` model already distinguishes published reviews and holds the summary relationships the new page needs.
- This keeps the page read-oriented and avoids a separate customer-review lifecycle.
**Evidence**:
- [../../apps/platform/app/Models/TenantReview.php](../../apps/platform/app/Models/TenantReview.php) already exposes `published()` and `currentExportReviewPack()`.
- [../../apps/platform/app/Services/TenantReviews/TenantReviewService.php](../../apps/platform/app/Services/TenantReviews/TenantReviewService.php) already stores review summary payloads, evidence basis, and export readiness.
- Existing review composition already emits `finding_outcomes` and accepted-risk related payloads through the review artifact family.
**Alternatives considered**:
- Surface draft or ready reviews when no published review exists.
- Rejected: leaks internal lifecycle meaning into the customer-safe path.
- Create a second customer-review publication model.
- Rejected: duplicates review truth and imports unnecessary workflow complexity.
## Decision 4 — Keep page state Livewire-safe and tenant-prefilter aware
**Decision**: Tenant launch context, requested tenant filters, and any remembered page state must live in public, query-backed, or session-backed state, following the existing workspace-page patterns. Do not keep required filter state in private properties.
**Rationale**:
- Existing workspace pages already show how canonical admin pages preserve tenant prefilters and survive Livewire follow-up requests.
- This repo has already hit Livewire state-reset issues when tenant context lived in non-hydrated private properties.
- The workspace page needs tenant prefiltering from review, evidence, and related entry points.
**Evidence**:
- [../../apps/platform/app/Filament/Pages/Reviews/ReviewRegister.php](../../apps/platform/app/Filament/Pages/Reviews/ReviewRegister.php) uses canonical admin filter-state sync on mount.
- [../../apps/platform/app/Filament/Pages/Monitoring/EvidenceOverview.php](../../apps/platform/app/Filament/Pages/Monitoring/EvidenceOverview.php) documents its page-state contract for tenant prefilters and remembered search/filter state.
- Repo memory already records that private page state can reset during Livewire actions on admin canonical pages.
**Alternatives considered**:
- Keep launch context in a private property only.
- Rejected: too brittle across Livewire requests.
- Use only client-side state.
- Rejected: breaks server-side truth and shareable canonical page behavior.
## Decision 5 — Reuse artifact-truth and redaction seams for customer-safe disclosure
**Decision**: Reuse [../../apps/platform/app/Support/Ui/GovernanceArtifactTruth/ArtifactTruthPresenter.php](../../apps/platform/app/Support/Ui/GovernanceArtifactTruth/ArtifactTruthPresenter.php), `SurfaceCompressionContext`, and [../../apps/platform/app/Support/RedactionIntegrity.php](../../apps/platform/app/Support/RedactionIntegrity.php) for outcome, freshness, and redaction-safe wording.
**Rationale**:
- These seams already normalize review, review-pack, and evidence truth into operator-safe summaries.
- Reusing them preserves vocabulary and prevents a second customer-review explanation system.
- `RedactionIntegrity` already owns the repos protected-value and support-diagnostics notes.
**Evidence**:
- `ArtifactTruthPresenter::for(...)` already supports `TenantReview`, `ReviewPack`, and `EvidenceSnapshot`.
- `ReviewRegister`, `EvidenceOverview`, `TenantReviewResource`, and `ReviewPackResource` already depend on these truth envelopes.
- `RedactionIntegrity` already defines reusable disclosure notes for protected values and support diagnostics.
**Alternatives considered**:
- Introduce a customer-only presenter or status taxonomy.
- Rejected: duplicates shared artifact truth and increases review drift risk.
- Inline page-local disclosure strings only.
- Rejected: likely to diverge from existing review and pack semantics.
## Decision 6 — Keep review-pack consumption on the existing signed download path
**Decision**: Pack consumption should stay on the existing signed route and download controller, with the workspace page only generating or surfacing the already-authorized download path through [../../apps/platform/app/Services/ReviewPackService.php](../../apps/platform/app/Services/ReviewPackService.php).
**Rationale**:
- The repo already has a real signed download route and a dedicated download controller.
- Reusing that path keeps the new page consumption-only and avoids inventing a customer-specific download endpoint.
- The page must not trigger pack generation or regeneration.
**Evidence**:
- [../../apps/platform/app/Services/ReviewPackService.php](../../apps/platform/app/Services/ReviewPackService.php) already generates signed download URLs.
- [../../apps/platform/routes/web.php](../../apps/platform/routes/web.php) already exposes `/admin/review-packs/{reviewPack}/download` as the signed download route.
- [../../apps/platform/app/Http/Controllers/ReviewPackDownloadController.php](../../apps/platform/app/Http/Controllers/ReviewPackDownloadController.php) already enforces pack readiness and expiry constraints.
**Alternatives considered**:
- Add a new workspace-page-specific download endpoint.
- Rejected: duplicates current signed download behavior.
- Offer generate/regenerate from the workspace page.
- Rejected: out of scope and not customer-safe for v1.
## Decision 7 — Reuse the current audit pipeline and add new action IDs only if needed
**Decision**: Reuse `WorkspaceAuditLogger` and `AuditActionId` for any explicit artifact access or download events surfaced by the new page, and only add new stable action IDs if the existing review/export path does not already provide a truthful event.
**Rationale**:
- The repo already has a canonical workspace-scoped audit path.
- This slice needs auditability for explicit artifact consumption, not a new access-analytics subsystem.
- Stable action IDs are preferable to page-local logging if an additional event is truly required.
**Evidence**:
- [../../apps/platform/app/Services/TenantReviews/TenantReviewService.php](../../apps/platform/app/Services/TenantReviews/TenantReviewService.php) already logs review creation/refresh through `WorkspaceAuditLogger`.
- [../../apps/platform/app/Services/ReviewPackService.php](../../apps/platform/app/Services/ReviewPackService.php) already logs review-pack export activity.
- [../../apps/platform/app/Support/Audit/AuditActionId.php](../../apps/platform/app/Support/Audit/AuditActionId.php) is the stable audit action registry.
**Alternatives considered**:
- Add a new customer-review audit table.
- Rejected: violates the no-new-persistence rule.
- Emit page-render audits for every visit.
- Rejected: too noisy and not aligned with the explicit-artifact-access requirement.
## Decision 8 — Keep the slice Filament-native, asset-light, and non-searchable
**Decision**: Keep the slice on the existing Filament v5 / Livewire v4 stack, do not add a new Resource or global-search entry, and plan for no new asset bundle unless implementation proves otherwise.
**Rationale**:
- The feature is a new page over existing truth, not a new object family.
- Existing review, pack, and evidence Resources already disable global search because they are tenant-scoped.
- A native page avoids a second shell and keeps the deploy story unchanged.
**Evidence**:
- `TenantReviewResource`, `ReviewPackResource`, and `EvidenceSnapshotResource` already set global search off.
- Panel providers are already registered in [../../apps/platform/bootstrap/providers.php](../../apps/platform/bootstrap/providers.php).
- The repos Filament guidance already expects provider registration to remain in `bootstrap/providers.php` and assets to stay minimal unless explicitly registered.
**Alternatives considered**:
- Add a new searchable Resource just for the workspace page.
- Rejected: the surface is a page-level dashboard, not a new record type.
- Add a custom asset bundle or custom portal shell up front.
- Rejected: unnecessary for the first read-only slice.

View File

@ -1,299 +0,0 @@
# Feature Specification: Customer Review Workspace v1
**Feature Branch**: `249-customer-review-workspace`
**Created**: 2026-04-27
**Status**: Draft
**Input**: User description: "Prepare the Spec Kit feature for Customer Review Workspace v1 as the smallest customer-safe read-only review consumption slice in the existing admin plane, reusing current review, evidence, review-pack, RBAC, redaction, and audit truth without inventing a new customer portal or remediation flow."
## Spec Candidate Check *(mandatory - SPEC-GATE-001)*
- **Problem**: TenantPilot already has strong tenant review, evidence snapshot, and review-pack foundations, but customers and readonly-capable tenant actors still lack one calm, trustworthy workspace surface to consume the latest review state without being dropped into operator-heavy reporting detail.
- **Today's failure**: The product can generate review artifacts, but it cannot yet present them as a clearly customer-safe, read-only review experience. That leaves a sellable release gap and risks pushing readonly actors toward internal surfaces with too much operator context or unclear next steps.
- **User-visible improvement**: An authorized readonly-capable actor can open one workspace review surface, see the latest customer-safe review state per entitled tenant, understand key findings and accepted risks, and open or download existing review artifacts without seeing admin or remediation controls.
- **Smallest enterprise-capable version**: One canonical read-only workspace review page in the current `/admin` plane, defaulting to the latest published customer-safe review per entitled tenant, with calm outcome summaries, accepted-risk visibility, existing review-pack consumption, redaction-safe disclosure, and explicit absence of admin/remediation actions.
- **Explicit non-goals**: No new customer portal, no new identity plane, no new persistence model, no review authoring or publishing workflow, no remediation or exception editing, no review-pack generation/regeneration flow, no support desk workflow, no broad cross-tenant decision inbox, and no raw JSON or platform-debug surface.
- **Permanent complexity imported**: One new canonical read-only page, one bounded derived workspace projection over existing review/evidence/review-pack truth, focused authorization and audit coverage, and one explicit browser smoke slice for customer-safe disclosure.
- **Why now**: The implementation ledger marks this as a P0 release blocker. Existing review strength is real, but customer-safe review consumption is still the clearest missing sellable surface in the current queue.
- **Why not local**: Reusing isolated links into `TenantReviewResource`, `ReviewPackResource`, and `EvidenceSnapshotResource` without a canonical workspace entry point would preserve the current fragmentation and would not create a truthful customer-safe default path.
- **Approval class**: Core Enterprise
- **Red flags triggered**: Multi-surface reuse and customer-facing wording. Defense: the slice stays inside the existing admin plane, imports no new persistence or identity system, reuses current artifact truth and RBAC seams, and explicitly forbids write paths.
- **Score**: Nutzen: 2 | Dringlichkeit: 2 | Scope: 2 | Komplexitaet: 1 | Produktnaehe: 2 | Wiederverwendung: 2 | **Gesamt: 11/12**
- **Decision**: approve
## Spec Scope Fields *(mandatory)*
- **Scope**: canonical-view
- **Primary Routes**:
- new canonical admin route for a read-only customer review workspace under `/admin/reviews/workspace`
- existing `/admin/reviews` workspace review register on `App\Filament\Pages\Reviews\ReviewRegister` as supporting context, not the primary customer-safe path
- existing tenant-scoped review detail on `App\Filament\Resources\TenantReviewResource`
- existing tenant-scoped review-pack detail/download on `App\Filament\Resources\ReviewPackResource`
- existing tenant-scoped evidence detail on `App\Filament\Resources\EvidenceSnapshotResource`
- **Data Ownership**: All consumed truth remains tenant-owned and derived from existing `TenantReview`, `ReviewPack`, `EvidenceSnapshot`, finding/exception, and audit records bound to the current workspace and tenant. No new workspace-owned customer-review table, cache, mirror entity, or publication store is introduced.
- **RBAC**:
- workspace membership remains the first isolation boundary
- page entry requires established workspace scope plus at least one entitled tenant where the actor has `Capabilities::TENANT_VIEW` and `Capabilities::TENANT_REVIEW_VIEW`
- tenant rows and deep links only render for tenants the actor can access in the current workspace
- review-pack download remains gated by `Capabilities::REVIEW_PACK_VIEW`
- evidence drilldown remains gated by `Capabilities::EVIDENCE_VIEW`
- findings and accepted-risk sections reuse `Capabilities::TENANT_FINDINGS_VIEW` and `Capabilities::FINDING_EXCEPTION_VIEW`
- audit-related secondary disclosure, if present, remains gated by `Capabilities::AUDIT_VIEW`
- no new role family or customer identity plane is introduced; existing readonly-capable roles in `App\Services\Auth\RoleCapabilityMap` remain authoritative for v1
For canonical-view specs, the spec MUST define:
- **Default filter behavior when tenant-context is active**: When launched from a tenant-scoped review, review-pack, evidence, or tenant dashboard surface, the workspace page prefilters to that tenant and highlights its latest customer-safe review first. Without a launch context, it shows all entitled tenants in the current workspace.
- **Explicit entitlement checks preventing cross-tenant leakage**: Workspace membership is checked before page render. Tenant-scoped rows, summaries, and deep links are resolved only for tenants where the actor is both a workspace member and tenant-entitled. Explicit tenant filters or record opens that reference an inaccessible tenant resolve as not found rather than showing an empty hint.
## Cross-Cutting / Shared Pattern Reuse *(mandatory when the feature touches notifications, status messaging, action links, header actions, dashboard signals/cards, alerts, navigation entry points, evidence/report viewers, or any other existing shared operator interaction family; otherwise write `N/A - no shared interaction family touched`)*
- **Cross-cutting feature?**: yes
- **Interaction class(es)**: evidence/report viewers, status messaging, navigation entry points, review/download actions, and artifact-truth presentation
- **Systems touched**: `App\Filament\Pages\Reviews\ReviewRegister`, `App\Filament\Pages\Monitoring\EvidenceOverview`, `App\Filament\Resources\TenantReviewResource`, `App\Filament\Resources\ReviewPackResource`, `App\Filament\Resources\EvidenceSnapshotResource`, `App\Services\ReviewPackService`, `App\Services\TenantReviews\TenantReviewService`, `App\Support\Ui\GovernanceArtifactTruth\ArtifactTruthPresenter`, `App\Support\RedactionIntegrity`, `App\Support\OperationRunLinks`, existing audit infrastructure, and tenant/workspace authorization seams
- **Existing pattern(s) to extend**: current read-only registry/detail reporting surfaces, existing governance artifact truth envelopes, existing review-pack download semantics, existing redaction notes, and existing workspace/tenant-scoped navigation patterns
- **Shared contract / presenter / builder / renderer to reuse**: `ArtifactTruthPresenter`, `SurfaceCompressionContext`, `ActionSurfaceDeclaration`, `ReviewPackService`, `RedactionIntegrity`, and existing tenant-scoped resource view surfaces
- **Why the existing shared path is sufficient or insufficient**: Existing review/evidence/review-pack surfaces already provide the underlying truth, disclosure semantics, and safe detail rendering. They are insufficient only because they do not offer one calm workspace entry point oriented around customer-safe consumption. The feature should add that entry point, not a parallel truth layer.
- **Allowed deviation and why**: none. The new page must reuse current truth, badge, redaction, and download language instead of inventing a second customer-review vocabulary.
- **Consistency impact**: Outcome, freshness, accepted-risk, pack-availability, and redaction notes must keep the same meaning across the new workspace page and the reused review, evidence, and review-pack detail surfaces.
- **Review focus**: Reviewers must block any new page-local status taxonomy, raw-payload viewer, or customer-specific mirror presenter that duplicates the existing review and artifact truth contracts.
## OperationRun UX Impact *(mandatory when the feature creates, queues, deduplicates, resumes, blocks, completes, or deep-links to an `OperationRun`; otherwise write `N/A - no OperationRun start or link semantics touched`)*
- **Touches OperationRun start/completion/link UX?**: no
- **Shared OperationRun UX contract/layer reused**: `N/A`
- **Delegated start/completion UX behaviors**: `N/A`
- **Local surface-owned behavior that remains**: The workspace page is read-only. Existing `OperationRun` links stay on reused detail surfaces and are not promoted into the default-visible customer path.
- **Queued DB-notification policy**: `N/A`
- **Terminal notification path**: `N/A`
- **Exception required?**: none
## Provider Boundary / Platform Core Check *(mandatory when the feature changes shared provider/platform seams, identity scope, governed-subject taxonomy, compare strategy selection, provider connection descriptors, or operator vocabulary that may leak provider-specific semantics into platform-core truth; otherwise write `N/A - no shared provider/platform boundary touched`)*
N/A - no shared provider/platform boundary is widened. The feature consumes existing review and evidence artifacts without introducing new provider-shaped contracts or customer-identity semantics.
## UI / Surface Guardrail Impact *(mandatory when operator-facing surfaces are changed; otherwise write `N/A`)*
| Surface / Change | Operator-facing surface change? | Native vs Custom | Shared-Family Relevance | State Layers Touched | Exception Needed? | Low-Impact / `N/A` Note |
|---|---|---|---|---|---|---|
| Customer review workspace page | yes | Native Filament page reusing existing review/detail resources | reporting, evidence viewers, download actions, disclosure hierarchy | page state, tenant prefilter state, disclosure state | no | Adds one canonical customer-safe workspace path without creating a separate portal shell |
## Decision-First Surface Role *(mandatory when operator-facing surfaces are changed)*
| Surface | Decision Role | Human-in-the-loop Moment | Immediately Visible for First Decision | On-Demand Detail / Evidence | Why This Is Primary or Why Not | Workflow Alignment | Attention-load Reduction |
|---|---|---|---|---|---|---|---|
| Customer review workspace page | Primary Decision Surface | A readonly-capable tenant actor decides whether the latest review is consumable as-is or needs a follow-up conversation with the workspace operator team | latest customer-safe review outcome, key finding counts, accepted-risk summary, published date, and pack availability | latest review detail, review-pack detail/download, and evidence detail only when explicitly opened and capability-allowed | Primary because it becomes the first truthful customer-safe entry point instead of forcing users to reconstruct the answer from internal reporting resources | Keeps review consumption inside one calm workspace path and uses existing detail routes only when the user asks for proof | Replaces cross-surface searching with one page that summarizes what matters first and delays diagnostics until requested |
## Audience-Aware Disclosure *(mandatory when operator-facing surfaces are changed)*
| Surface | Audience Modes In Scope | Decision-First Default-Visible Content | Operator Diagnostics | Support / Raw Evidence | One Dominant Next Action | Hidden / Gated By Default | Duplicate-Truth Prevention |
|---|---|---|---|---|---|---|---|
| Customer review workspace page | customer-read-only, operator-MSP | latest published review state, executive outcome, key findings, accepted risks, published or generated time, and review-pack availability | deeper evidence freshness, full section detail, and secondary related links only after explicit open | raw JSON, unrestricted audit metadata, provider payloads, and platform-only debug semantics remain hidden and are never part of the default page | `Open latest review` | raw/support detail is excluded from the page; evidence and audit drilldown remain capability-gated on reused detail routes | the workspace page states one summary truth per tenant and relies on existing review/pack/evidence detail pages for proof instead of repeating the same explanation in parallel blocks |
## UI/UX Surface Classification *(mandatory when operator-facing surfaces are changed)*
| Surface | Action Surface Class | Surface Type | Likely Next Operator Action | Primary Inspect/Open Model | Row Click | Secondary Actions Placement | Destructive Actions Placement | Canonical Collection Route | Canonical Detail Route | Scope Signals | Canonical Noun | Critical Truth Visible by Default | Exception Type / Justification |
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| Customer review workspace page | List / Table / Read-only workspace report | Read-only registry report | Open the latest review for one tenant or download the latest available review pack | full-row navigation to the latest customer-safe tenant review | required | one safe inline download shortcut when a pack is already available; any deeper proof remains inside the opened detail view | none | `/admin/reviews/workspace` | `/admin/t/{tenant}/reviews/{record}` with secondary reuse of tenant-scoped review-pack and evidence detail routes | workspace context, tenant filter, and latest published-review status | Customer review | whether a tenant has a current customer-safe review, what it says at a high level, and whether a pack is available | none |
## Operator Surface Contract *(mandatory when operator-facing surfaces are changed)*
| Surface | Primary Persona | Decision / Operator Action Supported | Surface Type | Primary Operator Question | Default-visible Information | Diagnostics-only Information | Status Dimensions Used | Mutation Scope | Primary Actions | Dangerous Actions |
|---|---|---|---|---|---|---|---|---|---|---|
| Customer review workspace page | Readonly tenant actor inside the existing admin plane | Consume the latest customer-safe review and decide whether a follow-up conversation is needed | Workspace read-only review overview | What is the latest reviewed state for my entitled tenant, what risks are already accepted, and what can I safely open or download? | tenant identity, latest published review state, outcome summary, key findings summary, accepted-risk summary, latest review time, and review-pack availability | secondary proof routes, evidence freshness detail, and audit-aware artifact provenance only after explicit drilldown | review lifecycle, governance outcome, evidence freshness, pack availability | none | Open latest review, Download review pack | none |
## Proportionality Review *(mandatory when structural complexity is introduced)*
- **New source of truth?**: no
- **New persisted entity/table/artifact?**: no
- **New abstraction?**: no. V1 should reuse existing review, evidence, redaction, and artifact-truth seams directly.
- **New enum/state/reason family?**: no
- **New cross-domain UI framework/taxonomy?**: no
- **Current operator problem**: Review artifacts already exist, but there is still no product-honest customer-safe way to consume them as a coherent workspace review experience.
- **Existing structure is insufficient because**: Existing review register and tenant-scoped resource views are good proof surfaces, but they are not a calm customer-default path and they spread the answer across several internal pages.
- **Narrowest correct implementation**: Add one read-only workspace page over existing tenant review, review-pack, evidence, redaction, and RBAC truth, and defer any customer-specific identity, publishing workflow, or portal shell.
- **Ownership cost**: One page, one bounded workspace query/projection, focused authorization tests, and a small browser smoke slice.
- **Alternative intentionally rejected**: A separate customer portal or customer-specific persistence model was rejected because the repo already has the required review artifacts and readonly-capable roles in the current admin plane.
- **Release truth**: current-release blocker, not future-release preparation
### Compatibility posture
This feature assumes a pre-production environment.
Backward compatibility, legacy aliases, migration shims, historical fixtures, and compatibility-specific tests are out of scope unless explicitly required by this spec.
Canonical replacement is preferred over preservation.
## Testing / Lane / Runtime Impact *(mandatory for runtime behavior changes)*
- **Test purpose / classification**: Feature, Browser
- **Validation lane(s)**: confidence, browser
- **Why this classification and these lanes are sufficient**: Focused feature tests prove workspace and tenant isolation, capability gating, default-visible disclosure, deep-link rules, and no-write behavior. One explicit browser smoke test proves the calm read-only surface, the absence of admin actions, and the expected open/download flow under realistic UI conditions.
- **New or expanded test families**: one bounded `Reviews/CustomerReviewWorkspace` feature family and one explicit browser smoke test for the same surface
- **Fixture / helper cost impact**: moderate but contained; reuse existing workspace membership, tenant membership, tenant review, review pack, evidence snapshot, finding, finding exception, and audit fixtures instead of adding new heavy provider or queue defaults
- **Heavy-family visibility / justification**: exactly one browser smoke is justified because the core value of this slice is a customer-safe disclosure experience; no broader browser or heavy-governance family is introduced
- **Special surface test profile**: standard-native-filament, shared-detail-family
- **Standard-native relief or required special coverage**: standard Filament feature coverage is sufficient for routing, authorization, empty states, and deep-link rules; a single browser smoke should verify that the default-visible page stays calm and read-only
- **Reviewer handoff**: Reviewers must confirm that readonly actors can use the surface, unauthorized tenant filters or deep links do not leak tenant presence, raw diagnostics never appear by default, and no create, publish, regenerate, refresh, expire, triage, or remediation action becomes visible on the customer workspace page.
- **Budget / baseline / trend impact**: low feature-local increase only
- **Escalation needed**: none
- **Active feature PR close-out entry**: Guardrail / Exception / Smoke Coverage
- **Planned validation commands**:
- `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Reviews/CustomerReviewWorkspacePageTest.php`
- `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Reviews/CustomerReviewWorkspaceAuthorizationTest.php`
- `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Reviews/CustomerReviewWorkspacePackAccessTest.php`
- `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Browser/Reviews/CustomerReviewWorkspaceSmokeTest.php`
## Scope Boundaries
### In Scope
- one canonical workspace-level read-only customer review surface in the existing admin plane
- latest published customer-safe review state per entitled tenant
- key findings and accepted-risk summaries derived from existing review and finding-exception truth
- opening existing tenant review detail pages from the workspace surface
- opening or downloading existing review-pack artifacts when already available and permitted
- optional drilldown into existing evidence detail only through explicit, capability-gated navigation
- redaction-safe disclosure using existing redaction semantics and notes
- auditability for explicit artifact access and download actions using the current audit infrastructure
### Non-Goals
- any new customer portal shell, customer account model, or external identity plane
- authoring, publishing, archiving, regenerating, refreshing, expiring, or deleting review artifacts
- exception editing, risk acceptance changes, or findings remediation flows
- raw JSON, provider payloads, unrestricted audit metadata, support diagnostics, or platform-debug semantics in the default path
- new review persistence, new publication state families, or new workspace-owned review entities
- support desk flow, billing, contracts, or broader customer lifecycle workflows
- cross-tenant decision inboxes, promotion workflows, or broad MSP workboards
## Assumptions
- The customer-safe default path should use the latest published review for each entitled tenant. Draft, failed, or otherwise internal-only review states stay off the default workspace page.
- Existing readonly-capable tenant roles are sufficient for v1 and do not require a new customer-only role family.
- Accepted-risk disclosure can be derived from existing finding and finding-exception truth without creating a parallel customer-review reason model.
- Existing redaction notes and review-pack download controls are sufficient for v1 customer-safe disclosure.
## Risks
- Some tenants may have strong internal review artifacts but no published customer-safe review yet, which can make the new surface appear empty unless absence states are explained clearly.
- Existing review detail pages may still contain operator-oriented sections that need tighter entry rules or more careful disclosure when reached from the new workspace path.
- Partial capability combinations could produce uneven disclosure if the implementation does not clearly separate page-level access from optional deep-link sections.
- A later implementation could try to fold review-pack generation or broader customer portal scope into this slice; that must be rejected as out-of-scope growth.
## Follow-up Candidates
- customer-facing portal or external identity work only if the current admin-plane read-only model becomes insufficient
- support diagnostic pack linkage from customer review artifacts once the support packaging flow needs direct customer-facing entry
- explicit review publication workflow maturity if published versus ready review semantics need a broader operator workflow
- broader customer lifecycle and commercial packaging once review consumption no longer fits inside the existing admin plane
## User Scenarios & Testing *(mandatory)*
### User Story 1 - Open the latest customer-safe review (Priority: P1)
As a readonly-capable tenant actor, I want one workspace page that shows the latest customer-safe review state for my entitled tenant so I can understand the current posture without navigating several internal reporting screens.
**Why this priority**: This is the core product gap. If the user still needs to reconstruct the latest review state manually, the slice fails its purpose.
**Independent Test**: Sign in as a readonly-capable tenant actor with one or more entitled tenants, open the customer review workspace, and verify that each visible tenant row shows only the latest published customer-safe review summary.
**Acceptance Scenarios**:
1. **Given** the actor is entitled to one or more tenants with published reviews, **When** they open the workspace review page, **Then** they see one latest customer-safe review entry per entitled tenant and no draft-only review rows.
2. **Given** the actor launches the page from a tenant-scoped review or evidence route, **When** the workspace page opens, **Then** that tenant is prefiltered and its latest published review is highlighted first.
3. **Given** the actor has no entitled tenants with published reviews, **When** they open the page, **Then** they see a truthful absence state that does not reveal hidden drafts or inaccessible tenants.
---
### User Story 2 - Understand findings and accepted risks without admin controls (Priority: P1)
As a readonly-capable tenant actor, I want the latest review summary to explain key findings and accepted risks in calm language so I can understand what matters without seeing remediation or operator-only actions.
**Why this priority**: Customer-safe review consumption is not useful if the page still looks like an operator console or hides the meaning behind the review outcome.
**Independent Test**: Open the workspace page and the latest review detail for a tenant that has findings and accepted risks, then verify that the user can understand the current outcome without seeing create, publish, regenerate, expire, triage, or remediation controls.
**Acceptance Scenarios**:
1. **Given** a tenant has a published review with findings and accepted risks, **When** the actor opens the workspace page, **Then** the row or summary exposes the high-level counts and meaning of those items without requiring a drilldown first.
2. **Given** the actor opens the latest review detail from the workspace page, **When** the detail loads, **Then** the review remains read-only and does not expose admin or remediation actions the actor cannot use.
3. **Given** raw diagnostics or unrestricted audit metadata exist behind the review, **When** the actor uses the customer workspace flow, **Then** those details remain hidden from the default-visible path.
---
### User Story 3 - Consume the current review pack safely (Priority: P2)
As a readonly-capable tenant actor, I want to open or download the current review pack when it already exists so I can consume the packaged review output without triggering generation or seeing unsafe disclosure.
**Why this priority**: Review consumption is incomplete if the user can read the summary but cannot reach the packaged artifact that already represents the customer-safe deliverable.
**Independent Test**: From the workspace page, open a tenant that has a current review pack and verify that download works through existing access and redaction rules, while tenants without an available pack show a calm unavailable state.
**Acceptance Scenarios**:
1. **Given** a tenant has a current review pack and the actor has `REVIEW_PACK_VIEW`, **When** they choose the pack action, **Then** they can open or download the existing artifact without any generate or regenerate prompt.
2. **Given** a tenant has no current downloadable review pack, **When** the actor views the workspace page, **Then** the page shows that the pack is unavailable and does not offer a generation action.
3. **Given** a review pack includes redaction-safe content only, **When** the actor downloads it, **Then** the artifact and surrounding disclosure continue to honor existing redaction semantics.
### Edge Cases
- What happens when a tenant has a ready review but nothing published yet? The workspace page shows `No published review available yet` rather than exposing internal-only lifecycle states.
- What happens when a query parameter or remembered filter points at a tenant outside the actor's scope? The page resolves as not found for explicit tenant targeting and silently omits inaccessible tenants from broad workspace listings.
- What happens when the actor can view reviews but not review packs or evidence? The page remains usable, but pack and evidence actions are absent rather than replaced with leaking hints.
- What happens when a review pack exists but is expired or otherwise unavailable for consumption? The page shows an unavailable state and does not offer regeneration or admin recovery actions.
## Requirements *(mandatory)*
**Constitution alignment (required):** This feature does not introduce Graph calls, write/change behavior, or long-running work. It does change runtime behavior, authorization posture, disclosure rules, and audit expectations for a new read-only customer-facing surface in the existing admin plane.
**Constitution alignment (PROP-001 / ABSTR-001 / PERSIST-001 / STATE-001 / BLOAT-001):** This feature must stay derived. It must not add new persistence, new customer-state families, new publication semantics, or a parallel presenter framework.
**Constitution alignment (XCUT-001):** The feature must extend existing review, evidence, review-pack, and artifact-truth paths rather than creating a local customer-review semantic layer.
**Constitution alignment (DECIDE-AUD-001 / OPSURF-001):** The default path must remain customer-readable, decision-first, and free from raw diagnostics, with deeper proof only on demand.
**Constitution alignment (TEST-GOV-001):** The implementation must add focused feature tests plus one explicit browser smoke test; no hidden heavy family may spread from this slice.
**Constitution alignment (RBAC-UX):** Workspace and tenant membership remain deny-as-not-found boundaries; page and deep-link authorization must use canonical capability checks rather than raw role checks.
**Constitution alignment (UI-FIL-001 / UI-NAMING-001 / DECIDE-001 / ACTSURF-001):** The new surface must remain a native Filament read-only reporting page with one dominant inspect action, one optional safe download shortcut, and no destructive or remediation controls.
### Functional Requirements
- **FR-001**: The system MUST provide one canonical read-only customer review workspace in the existing `/admin` plane for the current workspace.
- **FR-002**: The system MUST list only entitled tenants and MUST derive each visible row or card from existing tenant-owned review, evidence, review-pack, and findings truth.
- **FR-003**: The default-visible page MUST show the latest published customer-safe review state per entitled tenant and MUST NOT expose draft, failed, or other internal-only review states as the primary customer path.
- **FR-004**: The page MUST show, for each visible tenant, the current review outcome, latest review time, key findings summary, accepted-risk summary, and review-pack availability in calm, read-only language.
- **FR-005**: The page MUST offer a primary inspect action that opens the existing tenant-scoped review detail for the latest customer-safe review.
- **FR-006**: The page MUST allow entitled actors to open or download an existing review pack only through current `REVIEW_PACK_VIEW` access and existing redaction-safe artifact rules.
- **FR-007**: The page MUST NOT expose review generation, publication, regeneration, refresh, expire, triage, risk acceptance, remediation, or admin-setting actions.
- **FR-008**: The page and its deep links MUST enforce workspace and tenant isolation such that non-members or out-of-scope tenant targets resolve as not found.
- **FR-009**: Within an established workspace and tenant scope, optional sections and actions MUST be gated through the canonical capability registry, including `TENANT_VIEW`, `TENANT_REVIEW_VIEW`, `REVIEW_PACK_VIEW`, `EVIDENCE_VIEW`, `TENANT_FINDINGS_VIEW`, `FINDING_EXCEPTION_VIEW`, and `AUDIT_VIEW` where relevant.
- **FR-010**: The feature MUST reuse existing artifact truth and publication-readiness semantics from current review, review-pack, and evidence surfaces and MUST NOT create a separate customer-review truth model.
- **FR-011**: Raw operator diagnostics, raw JSON or provider payloads, unrestricted audit metadata, and platform-only debug semantics MUST remain out of the default-visible customer workspace path.
- **FR-012**: Explicit artifact opens or downloads exposed through this surface MUST remain auditable using the current audit infrastructure without introducing a new audit store.
- **FR-013**: When entered from a tenant-scoped review, review-pack, evidence, or related tenant context, the workspace page MUST preserve that tenant context as a safe prefilter.
- **FR-014**: When no published customer-safe review or downloadable review pack exists, the page MUST show a truthful unavailable state instead of hinting at hidden drafts, operator-only artifacts, or unavailable generation paths.
## 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 |
|---|---|---|---|---|---|---|---|---|---|---|
| Customer Review Workspace | new `App\Filament\Pages\Reviews\CustomerReviewWorkspace` | `Clear filters` only when a tenant or status prefilter is active | clickable row or card opening the latest tenant review | `Open latest review`, `Download review pack` when already available and permitted | none | `Clear filters` when filtered; otherwise an explanatory no-data state is allowed because the page is strictly read-only and intentionally has no create CTA | `N/A` - detail actions remain on reused tenant-scoped review and review-pack resources | `N/A` | yes - explicit artifact access and download events only | No destructive actions. No More menu required unless the implementation cannot keep open/download as the only visible actions. |
### Key Entities *(include if feature involves data)*
- **Customer Review Workspace Entry**: A derived workspace-scoped summary for one entitled tenant that combines the latest published tenant review, high-level findings and accepted-risk summaries, and current review-pack availability without becoming a persisted entity.
- **TenantReview**: The existing tenant-owned review artifact that anchors the latest customer-safe review state, lifecycle, executive summary, and deep-link target.
- **ReviewPack**: The existing tenant-owned downloadable artifact that packages review consumption and already carries redaction-aware access rules.
- **EvidenceSnapshot**: The existing tenant-owned supporting artifact that proves freshness and completeness when the actor explicitly drills deeper than the customer-safe default path.
## Success Criteria *(mandatory)*
### Measurable Outcomes
- **SC-001**: An entitled readonly-capable actor can reach the latest customer-safe review state for an entitled tenant in two steps or fewer from workspace context.
- **SC-002**: In 100% of validated readonly scenarios, the default-visible customer workspace path shows no admin, remediation, regeneration, or raw-diagnostics actions.
- **SC-003**: In 100% of validated unauthorized workspace or tenant access scenarios, the feature does not reveal another tenant's presence, review existence, or artifact availability.
- **SC-004**: For tenants with a published review and an available review pack, entitled users can open the latest review or download the pack on their first attempt without operator assistance.
- **SC-005**: For tenants without a published customer-safe review or current pack, the surface explains the absence truthfully without exposing draft-only or operator-only state.

View File

@ -1,205 +0,0 @@
---
description: "Task list for feature implementation"
---
# Tasks: Customer Review Workspace v1
**Input**: Design documents from `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/249-customer-review-workspace/`
**Prerequisites**: `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/249-customer-review-workspace/plan.md` (required), `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/249-customer-review-workspace/spec.md` (required), `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/249-customer-review-workspace/checklists/requirements.md` (required), `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/249-customer-review-workspace/research.md`, `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/249-customer-review-workspace/data-model.md`, `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/249-customer-review-workspace/contracts/customer-review-workspace.openapi.yaml`, `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/249-customer-review-workspace/quickstart.md`
**Tests**: Required (Pest) for all runtime behavior changes. Keep proof in the narrow `confidence` lane plus one explicit `browser` smoke slice, using the targeted Sail commands already captured in the feature spec, plan, and quickstart artifacts.
## Test Governance Notes
- Lane assignment: `confidence` plus one explicit `browser` smoke slice are the narrowest sufficient proof for latest-published selection, deny-as-not-found boundaries, capability-gated pack access, and calm customer-safe disclosure.
- Keep new coverage inside `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/tests/Feature/Reviews/CustomerReviewWorkspace*.php` plus `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/tests/Browser/Reviews/CustomerReviewWorkspaceSmokeTest.php`; do not widen this slice into a new portal or customer-journey test family.
- Reuse existing workspace membership, tenant membership, published review, review-pack, evidence snapshot, finding, and finding-exception fixtures; any helper introduced for row composition or launch-context state must stay explicit and cheap by default.
- If implementation needs a bounded local read helper or a new audit action ID, record the outcome as `document-in-feature` or escalate to `follow-up-spec` in the final close-out task.
---
## Phase 1: Setup (Shared Infrastructure)
**Purpose**: Lock the bounded slice, proof commands, and guardrail expectations before runtime edits begin.
- [x] T001 Review the bounded slice, explicit non-goals, open planning choices, and guardrail outcomes in `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/249-customer-review-workspace/spec.md`, `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/249-customer-review-workspace/plan.md`, and `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/249-customer-review-workspace/checklists/requirements.md`
- [x] T002 [P] Review the latest-published selection contract, absence-state rules, signed pack-download boundary, and audit expectations in `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/249-customer-review-workspace/research.md`, `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/249-customer-review-workspace/data-model.md`, and `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/249-customer-review-workspace/contracts/customer-review-workspace.openapi.yaml`
- [x] T003 [P] Confirm the focused Sail/Pest commands, browser smoke command, and smoke-checklist/substitution note in `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/249-customer-review-workspace/quickstart.md` and keep the validation plan unchanged unless touched runtime truth requires an adjacent proof file
---
## Phase 2: Foundational (Blocking Prerequisites)
**Purpose**: Establish the shared page shell, isolation enforcement, and query seam that every user story depends on.
**⚠️ CRITICAL**: No user story work should begin until this phase is complete.
- [x] T004 [P] Add shared authorization coverage for workspace membership, explicit tenant-prefilter targeting, deny-as-not-found 404 boundaries, and capability-first 403 semantics in `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/tests/Feature/Reviews/CustomerReviewWorkspaceAuthorizationTest.php`
- [x] T005 Create the native read-only workspace page shell and Blade view in `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/app/Filament/Pages/Reviews/CustomerReviewWorkspace.php` and `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/resources/views/filament/pages/reviews/customer-review-workspace.blade.php`, keeping it in the same reviews family as `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/app/Filament/Pages/Reviews/ReviewRegister.php` and touching explicit panel discovery only if repo verification proves the page is not auto-discovered
- [x] T006 Resolve the row-query seam by reusing or minimally extending `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/app/Services/TenantReviews/TenantReviewRegisterService.php` for workspace access and latest-published-per-entitled-tenant reads; only if that seam cannot safely express the query add a bounded helper beside `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/app/Filament/Pages/Reviews/CustomerReviewWorkspace.php` and record the choice in `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/249-customer-review-workspace/plan.md`
- [x] T007 [P] Thread Livewire-safe tenant prefilter, highlight, and clear-filter state through `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/app/Filament/Pages/Reviews/CustomerReviewWorkspace.php`, reusing the current workspace-page state patterns from `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/app/Filament/Pages/Reviews/ReviewRegister.php` and `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/app/Filament/Pages/Monitoring/EvidenceOverview.php`
**Checkpoint**: Foundation ready. The customer-safe page shell, 404/403 boundaries, and query-seam decision are in place.
---
## Phase 3: User Story 1 - Open The Latest Customer-Safe Review (Priority: P1) 🎯 MVP
**Goal**: Let a readonly-capable tenant actor open one workspace page that shows the latest published customer-safe review for each entitled tenant without surfacing internal-only review states.
**Independent Test**: Sign in as a readonly-capable tenant actor, open `/admin/reviews/workspace`, and confirm each visible tenant shows only its latest published review summary while tenants without a published review show a truthful absence state.
### Tests for User Story 1
- [x] T008 [P] [US1] Add workspace page feature coverage for latest published review selection, tenant launch-context highlighting, and truthful no-published-review absence handling in `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/tests/Feature/Reviews/CustomerReviewWorkspacePageTest.php`
### Implementation for User Story 1
- [x] T009 [US1] Compose one derived workspace entry per entitled tenant from existing `TenantReview`, `currentExportReviewPack`, and `evidenceSnapshot` truth in `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/app/Filament/Pages/Reviews/CustomerReviewWorkspace.php` and `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/app/Services/TenantReviews/TenantReviewRegisterService.php` or the bounded helper chosen in T006
- [x] T010 [US1] Add or reuse safe customer-workspace launch links from `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/app/Filament/Pages/Reviews/ReviewRegister.php`, `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/app/Filament/Pages/Monitoring/EvidenceOverview.php`, `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/app/Filament/Resources/TenantReviewResource.php`, `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/app/Filament/Resources/ReviewPackResource.php`, `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/app/Filament/Resources/EvidenceSnapshotResource.php`, and the nearest tenant dashboard review entry surface under `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/app/Filament/Widgets/Tenant/` so tenant context arrives as a safe prefilter without creating a second summary shell
- [x] T011 [US1] Render the calm row summary and route the dominant `Open latest review` affordance through the existing tenant review detail path in `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/app/Filament/Pages/Reviews/CustomerReviewWorkspace.php`, `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/resources/views/filament/pages/reviews/customer-review-workspace.blade.php`, and `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/app/Filament/Resources/TenantReviewResource.php`
- [x] T012 [US1] Keep tenants without a published review visible only as truthful absence states and never as draft, ready, failed, or internal-only fallbacks in `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/app/Filament/Pages/Reviews/CustomerReviewWorkspace.php` and `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/resources/views/filament/pages/reviews/customer-review-workspace.blade.php`
**Checkpoint**: User Story 1 is independently functional when the workspace page truthfully selects the latest published review and handles no-published-review tenants safely.
---
## Phase 4: User Story 2 - Understand Findings And Accepted Risks Without Admin Controls (Priority: P1)
**Goal**: Let a readonly-capable tenant actor understand key findings and accepted risks from the latest review in calm language without seeing remediation, publishing, or debug controls.
**Independent Test**: Open the workspace page and the linked latest review detail for a tenant with findings and accepted risks, then confirm the actor can understand the review outcome without seeing admin or remediation actions.
### Tests for User Story 2
- [x] T013 [P] [US2] Extend workspace page feature coverage for key-finding and accepted-risk summaries, hidden raw/support detail by default, and absent admin or remediation controls on the workspace page in `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/tests/Feature/Reviews/CustomerReviewWorkspacePageTest.php`
- [x] T014 [P] [US2] Add browser smoke coverage for calm default-visible content, one dominant `Open latest review` action, safe secondary actions, and absent admin or remediation controls in `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/tests/Browser/Reviews/CustomerReviewWorkspaceSmokeTest.php`
- [x] T015 [P] [US2] Extend the smallest existing tenant-review detail readonly or action-surface test under `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/tests/Feature/Reviews/` after repo verification so the workspace launch path proves detail inspection stays read-only for readonly-capable actors
### Implementation for User Story 2
- [x] T016 [US2] Render key-finding and accepted-risk summaries by reusing review summary and section payloads from `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/app/Services/TenantReviews/TenantReviewService.php` together with `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/app/Support/Ui/GovernanceArtifactTruth/ArtifactTruthPresenter.php`, `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/app/Support/RedactionIntegrity.php`, `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/app/Filament/Pages/Reviews/CustomerReviewWorkspace.php`, and `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/resources/views/filament/pages/reviews/customer-review-workspace.blade.php`, extending shared helpers only if repo verification shows a missing customer-safe summary field
- [x] T017 [US2] Keep default-visible content limited to customer-safe outcome, findings, accepted risks, freshness context, and explicit secondary proof links while excluding raw JSON, unrestricted audit metadata, and diagnostics from `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/resources/views/filament/pages/reviews/customer-review-workspace.blade.php`
- [x] T018 [US2] If the workspace-to-detail handoff exposes any admin, remediation, publish, regenerate, expire, triage, or exception-edit controls to readonly-capable actors, tighten the smallest existing tenant-review detail surface in `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/app/Filament/Resources/TenantReviewResource.php` or its matching page class after repo verification instead of adding a second customer-detail shell
**Checkpoint**: User Story 2 is independently functional when summaries stay calm, raw detail stays secondary, and readonly actors never see admin or remediation controls in the customer-safe flow.
---
## Phase 5: User Story 3 - Consume The Current Review Pack Safely (Priority: P2)
**Goal**: Let a readonly-capable tenant actor open or download the current review pack when it already exists, while keeping unavailable states calm and preserving signed-download safety.
**Independent Test**: From the workspace page, use the pack action for a tenant with a current pack and for one without a current pack, then confirm only the existing safe download path is exposed and no generation or regeneration flow appears.
### Tests for User Story 3
- [x] T019 [P] [US3] Add review-pack access feature coverage for visible download action only with `REVIEW_PACK_VIEW`, calm unavailable state when no current pack exists, preserved signed download behavior, and truthful audit reuse or additive action-ID wiring in `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/tests/Feature/Reviews/CustomerReviewWorkspacePackAccessTest.php`
- [x] T020 [P] [US3] If workspace implementation touches pack-download plumbing, extend `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/tests/Feature/ReviewPack/ReviewPackDownloadTest.php` to prove no generate or regenerate path was introduced; otherwise leave pack-download regression coverage unchanged and record that outcome in the final close-out task
### Implementation for User Story 3
- [x] T021 [US3] Surface current review-pack availability and the one safe inline `Download review pack` shortcut from the existing current-export relation and signed route semantics in `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/app/Filament/Pages/Reviews/CustomerReviewWorkspace.php`, `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/resources/views/filament/pages/reviews/customer-review-workspace.blade.php`, and `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/app/Services/ReviewPackService.php`
- [x] T022 [US3] Keep review-pack and evidence secondary actions capability-gated through existing `REVIEW_PACK_VIEW` and `EVIDENCE_VIEW` checks plus the current resource route helpers in `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/app/Filament/Pages/Reviews/CustomerReviewWorkspace.php`, `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/resources/views/filament/pages/reviews/customer-review-workspace.blade.php`, `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/app/Filament/Resources/ReviewPackResource.php`, and `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/app/Filament/Resources/EvidenceSnapshotResource.php`
- [x] T023 [US3] Reuse the existing audit pipeline for explicit artifact open or download events surfaced by the workspace page, adding a stable `AuditActionId` and `WorkspaceAuditLogger` wiring only if repo verification shows the current review or pack path does not already emit a truthful event in `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/app/Support/Audit/AuditActionId.php`, `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/app/Services/Audit/WorkspaceAuditLogger.php`, and the smallest calling surface selected during implementation
**Checkpoint**: User Story 3 is independently functional when pack visibility and download stay capability-gated, unavailable states stay calm, and audit reuse remains bounded.
---
## Phase 6: Polish & Cross-Cutting Concerns
**Purpose**: Run the focused validation suite, capture executed smoke evidence, format touched files, and record the feature-local close-out without widening scope.
- [x] T024 Run the targeted workspace-page Sail/Pest command from `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/249-customer-review-workspace/plan.md` and `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/249-customer-review-workspace/quickstart.md` against `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/tests/Feature/Reviews/CustomerReviewWorkspacePageTest.php`
- [x] T025 Run the targeted authorization Sail/Pest command from `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/249-customer-review-workspace/plan.md` and `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/249-customer-review-workspace/quickstart.md` against `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/tests/Feature/Reviews/CustomerReviewWorkspaceAuthorizationTest.php`
- [x] T026 Run the targeted pack-access Sail/Pest command from `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/249-customer-review-workspace/plan.md` and `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/249-customer-review-workspace/quickstart.md` against `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/tests/Feature/Reviews/CustomerReviewWorkspacePackAccessTest.php` and `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/tests/Feature/ReviewPack/ReviewPackDownloadTest.php` if T020 touched that file
- [x] T027 Run the explicit browser smoke command from `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/249-customer-review-workspace/plan.md` and `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/249-customer-review-workspace/quickstart.md` against `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/tests/Browser/Reviews/CustomerReviewWorkspaceSmokeTest.php`
- [x] T028 Satisfy the smoke-evidence checklist in `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/249-customer-review-workspace/quickstart.md` through either a human manual run or an explicitly documented bounded browser-smoke substitution for readonly workspace entry, tenant-prefilter launch, read-only review detail open, pack available or unavailable behavior, and out-of-scope tenant targeting
- [x] T029 Run dirty-only Pint through Sail for touched platform files using the command recorded in `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/249-customer-review-workspace/quickstart.md`
- [x] T030 Record the final `Guardrail / Exception / Smoke Coverage` close-out, lane results, executed smoke-evidence outcome, review outcome class (`acceptable-special-case` unless implementation proves otherwise), workflow outcome (`keep` unless implementation proves otherwise), and any bounded `document-in-feature` note for the `TenantReviewRegisterService` versus local-helper choice or audit-action wiring in `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/249-customer-review-workspace/plan.md`, `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/249-customer-review-workspace/quickstart.md`, and `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/249-customer-review-workspace/checklists/requirements.md`
## Close-out Notes
- T006 reused `TenantReviewRegisterService` for workspace entitlement and latest-published-per-tenant reads; no page-local helper was introduced.
- T010 landed direct customer-workspace launch links on tenant review detail, review-pack detail, evidence related context, and the tenant review-pack widget. `ReviewRegister` and `EvidenceOverview` satisfied the task through existing row/detail navigation reuse rather than new duplicate launch buttons.
- T018 was closed by making the tenant-review detail route enter a customer-safe read-only mode when launched from the workspace path, leaving the normal operator detail route behavior unchanged.
- T020 touched pack-download plumbing. `ReviewPackDownloadTest.php` and `ReviewPackRbacTest.php` were updated and passed after capability enforcement and audit logging were added to the signed download route.
- T023 reused the existing audit store and `WorkspaceAuditLogger` with additive `tenant_review.opened` and `review_pack.downloaded` action IDs; no new audit store or parallel audit pipeline was introduced.
- T028 used the bounded Pest browser smoke plus the focused feature lane as the executed smoke evidence. No separate human integrated-browser manual smoke run was performed.
---
## Dependencies & Execution Order
### Phase Dependencies
- **Phase 1 (Setup)**: starts immediately.
- **Phase 2 (Foundational)**: depends on Phase 1 and blocks all user stories until the page shell, auth boundaries, and query-seam choice are in place.
- **Phase 3 (US1)**: depends on Phase 2 and establishes the MVP customer-safe workspace path.
- **Phase 4 (US2)**: depends on Phase 2 and is safest after US1 because both stories extend the same page and view surfaces.
- **Phase 5 (US3)**: depends on Phase 2 and is safest after US1 because pack actions and audit reuse build on the same workspace rows.
- **Phase 6 (Polish)**: depends on every implemented story.
### User Story Dependencies
- **US1 (P1)**: first independently shippable increment once Phase 2 is complete.
- **US2 (P1)**: independently testable after Phase 2, but should merge after US1 because the same page and view files are shared hotspots.
- **US3 (P2)**: independently testable after Phase 2, but should merge after US1 because pack actions depend on the same workspace row composition.
### Within Each User Story
- Write the listed feature and browser coverage first and make it fail for the intended gap before implementation.
- Resolve shared service or route-helper decisions before widening the page view for that story.
- Re-run the narrowest relevant proof command after each story checkpoint before moving to the next story.
---
## Parallel Opportunities
### Phase 1
- T002 and T003 can run in parallel after T001 confirms the bounded slice.
### Phase 2
- T004 and T005 can run in parallel.
- After T005 establishes the page shell, T006 and T007 can proceed in parallel because the query seam and page-state plumbing touch different primary files.
### User Story 1
- T008 can run before implementation while T009 and T010 are split across service and entry-link work.
- T011 should follow T009 and T010 because the absence state depends on the final row composition.
### User Story 2
- T013, T014, and T015 can run in parallel.
- After the tests exist, T016 and T017 can overlap before T018 checks whether the reused detail surface needs a bounded hardening pass.
### User Story 3
- T019 and T020 can run in parallel.
- After pack-access tests are in place, T021 and T022 can overlap before T023 finalizes audit reuse or additive wiring.
---
## Implementation Strategy
### Suggested MVP Scope
- MVP = **Phase 2 + User Story 1** only. That delivers the canonical read-only workspace page, the latest-published selection rule, tenant-prefilter entry, and truthful no-published-review handling without widening into summary hardening or pack-specific follow-up.
### Incremental Delivery
1. Complete Phase 1 and Phase 2.
2. Deliver US1 and validate the customer-safe workspace path.
3. Deliver US2 and validate findings, accepted-risk summaries, and absence of admin controls.
4. Deliver US3 and validate pack visibility, download safety, and audit reuse.
5. Finish with Phase 6 validation, executed smoke evidence, formatting, and close-out recording.
### Team Strategy
1. Finish Phase 2 together before splitting story work.
2. Parallelize test authoring inside each story before converging on the shared page and view files.
3. Sequence merges touching `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/app/Filament/Pages/Reviews/CustomerReviewWorkspace.php` and `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/resources/views/filament/pages/reviews/customer-review-workspace.blade.php` story-by-story because they are the main conflict hotspots for this slice.

View File

@ -1,70 +0,0 @@
# Preparation Review Checklist: Decision-Based Governance Inbox v1
**Purpose**: Validate the governance inbox preparation package against the repo's guardrail, disclosure, shared-family, and close-out workflow before implementation
**Created**: 2026-04-28
**Feature**: [spec.md](../spec.md)
## Applicability And Low-Impact Gate
- [x] CHK001 The package explicitly treats this as an operator-facing workspace decision surface, so the low-impact `N/A` path is not used.
- [x] CHK002 The spec, plan, and tasks carry the same native/shared-primitives-first classification, shared-family relevance, state ownership, and close-out targeting without inventing second wording.
## Native, Shared-Family, And State Ownership
- [x] CHK003 The inbox remains a native Filament page that reuses existing source surfaces instead of introducing a fake-native task console or separate monitoring shell.
- [x] CHK004 Shared families remain shared: findings, operations, alerts, and review follow-up stay on their existing source pages, while the new page stays a routing and decision layer.
- [x] CHK005 Page and URL-query state owners are named once, and the package does not collapse them into new persisted workflow state.
- [x] CHK006 The likely next operator action and primary inspect/open model stay coherent: each section has one dominant source CTA and the page owns no mutation lane.
## Shared Pattern Reuse
- [x] CHK007 Cross-cutting interaction classes are explicit, and the shared reuse path is named once through `CanonicalNavigationContext`, `RelatedNavigationResolver`, `OperateHubShell`, `OperationRunLinks`, `BadgeRenderer`, `UiEnforcement`, and the existing source pages.
- [x] CHK008 The package extends existing shared paths where they are sufficient, and any fallback to a bounded `Support/GovernanceInbox/` seam is explicitly constrained as a last resort rather than a new default abstraction.
- [x] CHK009 The package does not create a parallel operator UX language for claim, acknowledge, stale-run handling, or review follow-up; it routes into the current source-family vocabulary.
## OperationRun Start UX Contract
- [x] CHK019 The package explicitly states that the inbox only deep-links into existing `OperationRun` detail and does not start, queue, or complete runs.
- [x] CHK020 Canonical operation URLs are delegated to the shared `OperationRunLinks` path rather than recomposed locally on the inbox page.
- [x] CHK021 No queued DB-notification or terminal-notification behavior is added because the slice is read-only.
- [x] CHK022 No OperationRun exception is required; if implementation later adds local run-start or blocked-run messaging, that would be out-of-scope drift.
## Provider Boundary And Vocabulary
- [x] CHK010 The package keeps provider-specific semantics behind existing normalized governance, alerting, and review seams and does not spread provider language into a new platform-core contract.
- [x] CHK011 No retained provider-specific shared boundary is introduced; the slice stays inside existing workspace, tenant, operations, findings, alerts, and review vocabulary.
## Signals, Exceptions, And Test Depth
- [x] CHK012 The triggered repository signal is explicitly handled as `review-mandatory`, with no hidden hard-stop drift accepted into the package.
- [x] CHK013 No bounded exception is required in the preparation package; if implementation proves a bounded assembly helper is necessary, it must be recorded in the active feature close-out entry.
- [x] CHK014 The required surface test profile is explicit: `global-context-shell`.
- [x] CHK015 The chosen lane mix is the narrowest honest proof for this slice: focused `Unit` plus `Feature` coverage only.
## Audience-Aware Disclosure And Decision Hierarchy
- [x] CHK023 Default-visible content stays decision-first and clearly separated from deeper diagnostics and support or raw evidence.
- [x] CHK024 The inbox default path does not expose raw JSON, copied payloads, provider diagnostics, or other debug semantics by default.
- [x] CHK025 Exactly one dominant next action remains primary per section or entry: open the relevant existing source surface.
- [x] CHK026 Duplicate visible blocker, status, or next-action summaries are avoided by keeping proof and detailed reasoning on the source pages.
- [x] CHK027 Support/raw sections remain off the inbox page entirely, and the page stays within Filament visual language, progressive disclosure, and calm read-only presentation.
## Review Outcome
- [x] CHK016 Review outcome class: `acceptable-special-case`
- [x] CHK017 Workflow outcome: `keep`
- [x] CHK018 The final note location is explicit: the active feature PR close-out entry `Guardrail / Exception / Smoke Coverage` records any bounded assembly-seam exception and the final proof outcome.
## Notes
- This checklist validates the preparation package only: `spec.md`, `plan.md`, `tasks.md`, and supporting design artifacts. It does not claim application code exists.
- The slice remains bounded to one read-only workspace decision surface in the current admin plane. No new task engine, no new attention state, and no local mutation lane are approved by this package.
- If implementation later proves that a bounded `Support/GovernanceInbox/` seam is necessary, that must stay derived and page-scoped rather than becoming a generalized workflow framework.
## Guardrail / Exception / Smoke Coverage
- Implementation status: complete for the bounded v1 slice.
- Guardrail result: PASS. The implemented page stayed native, read-only, shared-primitives-first, and inside the existing admin plane without adding a new task engine, persisted inbox truth, or local mutation lane.
- Bounded exception result: `document-in-feature`. `apps/platform/app/Support/GovernanceInbox/GovernanceInboxSectionBuilder.php` was added as the smallest readable cross-family assembly seam.
- Validation result: the focused unit and feature proof command passed with `10 passed (53 assertions)`, and dirty-only Pint passed.
- Smoke result: PASS. A manual integrated-browser run on `/admin/governance/inbox` verified route load, canonical operations drill-through with `nav` context, and successful return to the inbox.

View File

@ -1,159 +0,0 @@
openapi: 3.1.0
info:
title: Decision-Based Governance Inbox v1
version: 0.1.0
summary: Conceptual contract for the canonical governance inbox page.
paths:
/admin/governance/inbox:
get:
summary: Render the governance inbox page
description: >-
Returns the derived governance inbox composition for the current workspace actor.
This is a conceptual page contract used for planning, not a public API commitment.
parameters:
- in: query
name: tenant_id
schema:
type: integer
nullable: true
description: Optional tenant prefilter. Out-of-scope values resolve as not found.
- in: query
name: family
schema:
type: string
enum:
- assigned_findings
- intake_findings
- stale_operations
- alert_delivery_failures
- review_follow_up
description: Optional source-family filter. `stale_operations` is the canonical key for both stale and terminal-follow-up operations attention.
- in: query
name: nav[source_surface]
schema:
type: string
description: Optional shared navigation context source.
responses:
'200':
description: Derived governance inbox payload for page rendering.
content:
application/json:
schema:
type: object
required:
- title
- applied_scope
- sections
properties:
title:
type: string
example: Governance inbox
applied_scope:
type: object
properties:
tenant_id:
type: integer
nullable: true
family:
type: string
nullable: true
workspace_scoped:
type: boolean
sections:
type: array
items:
type: object
required:
- key
- label
- count
- summary
- dominant_action
- entries
properties:
key:
type: string
description: Family key; `stale_operations` covers stale and terminal-follow-up operations attention.
label:
type: string
count:
type: integer
summary:
type: string
empty_state:
type: string
nullable: true
description: Family-specific empty-state copy used when the family is explicitly selected but has no visible entries.
dominant_action:
type: object
required:
- label
- url
properties:
label:
type: string
url:
type: string
entries:
type: array
items:
type: object
required:
- family_key
- source_model
- source_key
- headline
- status_label
- destination_url
properties:
family_key:
type: string
description: Matches the owning section key; `stale_operations` covers stale and terminal-follow-up operations attention.
source_model:
type: string
source_key:
type: string
tenant_id:
type: integer
nullable: true
tenant_label:
type: string
nullable: true
headline:
type: string
subline:
type: string
nullable: true
urgency_rank:
type: integer
status_label:
type: string
destination_url:
type: string
back_label:
type: string
nullable: true
'404':
description: Workspace membership missing or explicit tenant prefilter is outside scope.
content:
application/json:
schema:
type: object
required:
- message
properties:
message:
type: string
example: Not Found
'403':
description: Workspace member is in scope but lacks every qualifying visible-family capability for the inbox.
content:
application/json:
schema:
type: object
required:
- message
properties:
message:
type: string
example: Forbidden

View File

@ -1,103 +0,0 @@
# Data Model: Decision-Based Governance Inbox v1
**Date**: 2026-04-28
**Feature**: [spec.md](spec.md)
## Model Posture
This slice introduces no new persisted entity. Every object below is a derived read model used to compose one decision-first page over existing repo truth.
## Existing Source Truth
| Source Model | Ownership | Relevant Truth Reused |
|---|---|---|
| `Finding` | tenant-owned | assigned work, intake work, severity, due or overdue state, reopened state, tenant entitlement |
| `OperationRun` | tenant-owned with workspace monitoring access | stale or terminal-follow-up attention, canonical run destination |
| `AlertDelivery` | workspace-scoped | failed or otherwise operator-relevant alert delivery outcomes |
| `TenantReview` | tenant-owned | latest review drill-through destination |
| `TenantTriageReview` | tenant-owned | follow-up-needed and changed-since-review attention |
## Derived Read Models
### GovernanceInboxSection
Represents one visible source family on the inbox page.
| Field | Type | Notes |
|---|---|---|
| `key` | string | bounded page-local family key such as `assigned_findings`, `intake_findings`, `stale_operations`, `alert_delivery_failures`, `review_follow_up`; `stale_operations` is the canonical key for both stale and terminal-follow-up operations attention |
| `label` | string | operator-facing section title aligned to the source family |
| `count` | int | visible item count for the current actor and active filters |
| `summary` | string | calm one-line summary of why the family matters |
| `dominant_action_label` | string | primary CTA label, routed to the existing source surface |
| `dominant_action_url` | string | canonical source destination |
| `entries` | list<GovernanceAttentionEntry> | bounded preview list, not a second queue truth |
| `empty_state` | string | optional local empty explanation when the family is selected explicitly |
### GovernanceAttentionEntry
Represents one preview item inside a visible section.
| Field | Type | Notes |
|---|---|---|
| `family_key` | string | matches the owning `GovernanceInboxSection.key` |
| `source_model` | string | `Finding`, `OperationRun`, `AlertDelivery`, `TenantReview`, or `TenantTriageReview` |
| `source_key` | string | stable source identifier for routing only |
| `tenant_id` | int or null | nullable for workspace-scoped alert or run cases |
| `tenant_label` | string or null | only shown when truthful |
| `headline` | string | concise operator-facing summary |
| `subline` | string or null | bounded reason, owner, or due-state context |
| `urgency_rank` | int | derived sort priority within the family |
| `status_label` | string | reused source-family wording |
| `destination_url` | string | existing canonical route |
| `back_label` | string | return label back to the inbox |
## Filter State
### GovernanceInboxFilterState
| Field | Type | Notes |
|---|---|---|
| `tenant_id` | int or null | optional tenant prefilter; explicit out-of-scope values return `404` |
| `family` | string or null | optional family filter for one visible source family; `stale_operations` remains the canonical filter key for stale or terminal-follow-up operations attention |
| `nav` | array or null | optional shared navigation payload used for return continuity |
## Ordering Rules
### Section Order
1. Assigned findings
2. Findings intake
3. Stale or terminal-follow-up operations
4. Alert-delivery failures
5. Review follow-up
This order is deliberately explicit and page-local. It is not a new persisted workflow taxonomy.
### Entry Order
- Findings-based sections reuse their existing queue ordering.
- Operations reuse the current monitoring-attention ordering exposed by the canonical operations surface.
- Alert-delivery failures order newest unresolved operator-relevant failures first.
- Review follow-up orders explicit follow-up-needed states before changed-since-review states.
## Relationships
- One `GovernanceInboxSection` maps to one existing source family.
- One `GovernanceInboxSection` has many derived `GovernanceAttentionEntry` values.
- Each `GovernanceAttentionEntry` points to exactly one existing source record and one existing source destination.
- No derived object owns or mutates source truth.
## Persistence Rules
- No new table.
- No new cache.
- No new inbox-specific audit stream.
- No new acknowledged, snoozed, or assigned state.
## Data Integrity Rules
- Hidden tenants never contribute to derived section counts or entry previews.
- Family visibility is capability-driven; invisible families do not render empty placeholders.
- Tenantless alert or operation entries must not invent tenant labels.
- Source destinations must stay canonical and existing; the inbox must not invent a parallel detail shell.

View File

@ -1,305 +0,0 @@
# Implementation Plan: Decision-Based Governance Inbox v1
**Branch**: `250-decision-governance-inbox` | **Date**: 2026-04-28 | **Spec**: [spec.md](spec.md)
**Input**: Feature specification from [spec.md](spec.md)
**Note**: This template is filled in by the `/speckit.plan` command. See `.specify/scripts/` for helper scripts.
## Summary
Introduce one canonical workspace governance inbox inside the existing `/admin` plane by adding a native Filament v5 read-only page that composes existing findings, alerts, stale-operations, and portfolio-triage signals into one decision-first work surface. The page should answer the first operator question quickly, then route into the existing source pages for execution and proof instead of creating a new cross-domain task engine.
This slice is explicitly composition-only. It does not replace `My Findings`, `Findings intake`, `Operations`, `Alerts`, or review-triage detail surfaces; it does not add acknowledge, snooze, claim, or assignment mutations; and it does not create persistence. Livewire remains v4 under Filament v5, panel-provider registration stays in `apps/platform/bootstrap/providers.php`, no new globally searchable resource is introduced, and no new asset bundle is expected for v1.
## Technical Context
**Language/Version**: PHP 8.4, Laravel 12
**Primary Dependencies**: Filament v5, Livewire v4, Pest v4, existing findings, alerts, operations, and review-triage services
**Storage**: PostgreSQL via existing `findings`, `operation_runs`, `alert_deliveries`, `tenant_reviews`, and `tenant_triage_reviews`; no new persistence planned
**Testing**: Pest v4 unit plus feature coverage
**Validation Lanes**: fast-feedback, confidence
**Target Platform**: Laravel monolith in `apps/platform` running via Sail, with existing `/admin` and tenant-scoped `/admin/t/{tenant}` surfaces
**Project Type**: Web application (Laravel monolith with Filament panels)
**Performance Goals**: page render remains DB-only and workspace-scoped; no Graph calls, no queue starts, and no remote work on render; family previews should be fetched through bounded derived queries rather than one polymorphic persistence layer
**Constraints**: preserve deny-as-not-found workspace and tenant isolation; keep the first slice in the existing admin plane; avoid new persistence, new workflow states, new task engines, and page-local mutation semantics; reuse source-page routing and action hierarchies
**Scale/Scope**: 1 new admin page, 5 derived source families, 0 new runtime entities, and 1 bounded derived section assembly seam
## Likely Affected Repo Surfaces
- `apps/platform/app/Filament/Pages/Findings/MyFindingsInbox.php` for assigned-findings truth, urgency ordering, and workspace-shell tenant-prefilter behavior.
- `apps/platform/app/Filament/Pages/Findings/FindingsIntakeQueue.php` for intake truth, `Needs triage` semantics, and read-first queue behavior.
- `apps/platform/app/Filament/Pages/Monitoring/Operations.php` plus `apps/platform/app/Filament/Pages/Operations/TenantlessOperationRunViewer.php` for stale or terminal-follow-up operation attention and canonical run drill-through.
- `apps/platform/app/Filament/Pages/Monitoring/Alerts.php`, `apps/platform/app/Filament/Resources/AlertDeliveryResource.php`, and the existing alerts cluster for alert-family entry points and delivery-failure truth.
- `apps/platform/app/Filament/Resources/TenantReviewResource.php`, `apps/platform/app/Services/TenantReviews/TenantReviewRegisterService.php`, and `apps/platform/app/Services/PortfolioTriage/TenantTriageReviewService.php` for review follow-up and triage-state truth.
- `apps/platform/app/Models/Finding.php`, `apps/platform/app/Models/OperationRun.php`, `apps/platform/app/Models/AlertDelivery.php`, `apps/platform/app/Models/TenantReview.php`, and `apps/platform/app/Models/TenantTriageReview.php` for the source data contracts.
- `apps/platform/app/Support/Navigation/CanonicalNavigationContext.php`, `apps/platform/app/Support/Navigation/RelatedNavigationResolver.php`, and `apps/platform/app/Support/OperationRunLinks.php` for source-page routing and return-link continuity.
- `apps/platform/app/Support/OperateHub/OperateHubShell.php`, `apps/platform/app/Support/Filament/CanonicalAdminTenantFilterState.php`, and `apps/platform/app/Support/Filament/TablePaginationProfiles.php` for workspace scope and durable filter state.
- `apps/platform/app/Support/Badges/BadgeRenderer.php`, `apps/platform/app/Support/Ui/ActionSurface/ActionSurfaceDeclaration.php`, `apps/platform/app/Support/Rbac/UiEnforcement.php`, and `apps/platform/app/Support/Rbac/UiTooltips.php` for existing status, action, and capability affordance patterns.
- Likely new implementation files if code work later proceeds: `apps/platform/app/Filament/Pages/Governance/GovernanceInbox.php`, `apps/platform/resources/views/filament/pages/governance/governance-inbox.blade.php`, and a bounded support namespace under `apps/platform/app/Support/GovernanceInbox/` only if the page cannot stay readable with page-local composition.
## UI / Filament & Livewire Fit
- Implement as a native Filament v5 `Page` in the existing admin plane, not as a new Resource, custom SPA shell, or second monitoring console.
- Keep the inbox read-first and section-based. Each visible family should render one calm summary block plus bounded preview entries and one dominant CTA into the existing source surface.
- Do not model the inbox as a polymorphic table over mixed Eloquent records if that forces a new persisted or generic task abstraction. Section composition over existing family queries is the preferred v1 shape.
- Livewire v4 hydration must preserve tenant and family filter state through public, query-backed, or session-backed state. Do not rely on private properties for any state that must survive a Livewire interaction.
- The new surface is a `Page`, not a globally searchable `Resource`. Existing source resources retain their current search posture.
## RBAC / Policy Fit
- Workspace membership remains the first gate. The inbox should not render at all for non-members, and explicit out-of-scope tenant targeting must stay `404`.
- Page access stays capability-derived: the actor must be a workspace member and have visibility to at least one family through the same capability contract the source page already uses. In-scope workspace members who lack every qualifying family capability should receive `403`, not a silent empty shell.
- Findings families reuse tenant capability checks such as `Capabilities::TENANT_FINDINGS_VIEW`, while source mutations like claim or triage continue to enforce `Capabilities::TENANT_FINDINGS_ASSIGN` or `Capabilities::TENANT_FINDINGS_TRIAGE` on their existing surfaces.
- Review follow-up entries reuse `Capabilities::TENANT_REVIEW_VIEW`; any manual follow-up mutation remains on the existing review/triage seam and continues to require `Capabilities::TENANT_TRIAGE_REVIEW_MANAGE`.
- Alert-family visibility remains workspace-scoped through `Capabilities::ALERTS_VIEW`.
- Operations entries must only appear when the underlying run destination would already be visible through the existing operation-viewer and tenant-entitlement rules. The inbox must not invent a weaker path.
## Audit / Logging Fit
- The inbox is read-only and should not create a new page-view audit stream.
- Existing mutation or download actions continue to log on their existing source surfaces.
- The only acceptable additional audit work in v1 would be reuse of existing action IDs on underlying source pages if implementation discovers a missing drill-through event, but the inbox itself should not become a new audit-heavy surface.
## Data & Query Fit
- Prefer derived section queries over a generic inbox-item projector or persisted cache.
- The findings sections should reuse the same inclusion and urgency rules already owned by `MyFindingsInbox` and `FindingsIntakeQueue` rather than duplicating lifecycle logic with new constants.
- The operations section should reuse the same stale or terminal-follow-up classification that already drives the canonical Operations page. Section-level operations CTAs may land on `/admin/operations`, but entry-level operation drill-through should land on the canonical run detail route `/admin/operations/{run}`.
- The alert section should derive from alert-delivery failure truth and the alerts overview, not from alert-rule configuration state.
- The review-follow-up section should derive from `TenantTriageReview` state and existing review register truth, not from a new parallel follow-up model.
- If implementation needs one bounded derived assembly seam, it should remain a page-scoped support helper that normalizes sections and preview entries only.
## UI / Surface Guardrail Plan
- **Guardrail scope**: changed surfaces
- **Native vs custom classification summary**: native Filament
- **Shared-family relevance**: governance queues, monitoring drill-through, navigation continuity, badge/status reuse
- **State layers in scope**: page, URL-query
- **Audience modes in scope**: operator-MSP
- **Decision/diagnostic/raw hierarchy plan**: decision-first, diagnostics-second, support-raw-third on source pages only
- **Raw/support gating plan**: hidden by default on the inbox page; source pages keep their existing capability-gated disclosure
- **One-primary-action / duplicate-truth control**: each section gets one dominant CTA into an existing source surface; later detail stays off the inbox page
- **Handling modes by drift class or surface**: review-mandatory
- **Repository-signal treatment**: review-mandatory now; future hard-stop candidate if implementation introduces a generic task model or local mutations
- **Special surface test profiles**: global-context-shell
- **Required tests or manual smoke**: functional-core, state-contract
- **Exception path and spread control**: none planned; any new cross-domain workflow state or local mutation must be treated as exception-required drift
- **Active feature PR close-out entry**: Guardrail / Exception / Smoke Coverage
## Shared Pattern & System Fit
- **Cross-cutting feature marker**: yes
- **Systems touched**: `MyFindingsInbox`, `FindingsIntakeQueue`, `Operations`, `Alerts`, `AlertDeliveryResource`, `TenantReviewResource`, `TenantTriageReviewService`, `CanonicalNavigationContext`, `RelatedNavigationResolver`, `OperateHubShell`, `OperationRunLinks`, `BadgeRenderer`, `UiEnforcement`, and existing source-page action-surface declarations
- **Shared abstractions reused**: `CanonicalNavigationContext`, `RelatedNavigationResolver`, `OperateHubShell`, `OperationRunLinks`, `BadgeRenderer`, `UiEnforcement`, `ActionSurfaceDeclaration`, and current source-page query rules
- **New abstraction introduced? why?**: one bounded section or entry assembler may be needed to keep the page readable and deterministic across families, but it must remain derived and page-scoped
- **Why the existing abstraction was sufficient or insufficient**: existing source pages are sufficient for truth and mutation, but insufficient as the first workspace attention surface because they only answer one family each
- **Bounded deviation / spread control**: none planned. If a support namespace is added, it must stay under `Support/GovernanceInbox/`, remain read-only, and not become a cross-product task engine
## OperationRun UX Impact
- **Touches OperationRun start/completion/link UX?**: yes, deep-link only
- **Central contract reused**: `OperationRunLinks` and the existing tenantless operation viewer
- **Delegated UX behaviors**: existing canonical run URL resolution and navigation context only
- **Surface-owned behavior kept local**: deciding whether an operation attention entry appears and which existing run destination is primary
- **Queued DB-notification policy**: `N/A`
- **Terminal notification path**: `N/A`
- **Exception path**: none
## Provider Boundary & Portability Fit
- **Shared provider/platform boundary touched?**: no
- **Provider-owned seams**: `N/A`
- **Platform-core seams**: existing governance, alerts, operations, and review vocabulary only
- **Neutral platform terms / contracts preserved**: `governance inbox`, `attention`, `operation`, `review follow-up`, `alert delivery failure`, and existing source nouns
- **Retained provider-specific semantics and why**: none
- **Bounded extraction or follow-up path**: `N/A`
## Constitution Check
*GATE: Must pass before Phase 0 research. Re-check after Phase 1 design.*
- Inventory-first / snapshot truth: PASS. The inbox consumes existing findings, operations, alerts, and review state only.
- Read/write separation: PASS. The page stays read-only and pushes execution back to source surfaces.
- Graph contract path: PASS. No new Graph calls or provider contract work is part of this slice.
- Deterministic capabilities: PASS. The plan reuses existing capability registries and source-page rules.
- Workspace isolation + tenant isolation: PASS. Workspace membership remains a `404` boundary; explicit out-of-scope tenant filters remain `404`; broad listings omit hidden rows.
- RBAC-UX plane separation: PASS. Everything stays inside the admin `/admin` plane.
- Destructive confirmation standard: PASS by non-use. The inbox introduces no destructive or risky action.
- Global search safety: PASS. The new slice is a Page, not a searchable Resource.
- OperationRun and Ops-UX: PASS by deep-link-only reuse. The page starts no run and adds no new run UX state.
- Data minimization: PASS. Default-visible content stays limited to family, urgency, scope, and next action.
- Test governance (TEST-GOV-001): PASS. Planned proof stays in focused `Unit` and `Feature` lanes only.
- Proportionality / no premature abstraction: PASS with one bounded exception. If a section assembler is needed, it remains page-scoped and derived.
- Persisted truth (PERSIST-001): PASS. No new table, cache, or stored attention entity is planned.
- Behavioral state (STATE-001): PASS. The inbox reuses existing source states and does not add a second workflow state family.
- Shared pattern first / UI semantics / Filament native UI: PASS. Existing navigation, badge, and queue semantics are reused.
- Provider boundary (PROV-001): PASS. The slice stays on already-normalized platform seams.
- Filament / Laravel planning contract: PASS. Filament v5 remains on Livewire v4, provider registration remains in `apps/platform/bootstrap/providers.php`, and no new panel is required.
- Asset strategy: PASS. No new asset registration is planned; if implementation later registers an asset anyway, deployment keeps the normal `cd apps/platform && php artisan filament:assets` step.
**Gate evaluation**: PASS.
- The inbox stays inside the existing admin plane and current workspace or tenant membership model.
- The page remains a read-only decision hub, not a new execution workflow.
- Existing source pages and services are sufficient for v1 if implementation resists introducing a generic inbox state model.
**Post-design re-check**: PASS (design artifacts: [research.md](research.md), [data-model.md](data-model.md), [quickstart.md](quickstart.md), [contracts/governance-inbox.openapi.yaml](contracts/governance-inbox.openapi.yaml)).
## Test Governance Check
- **Test purpose / classification by changed surface**: Unit for section and preview assembly plus source-link decisions; Feature for page rendering, authorization, filter behavior, and navigation continuity
- **Affected validation lanes**: fast-feedback, confidence
- **Why this lane mix is the narrowest sufficient proof**: unit coverage proves family assembly without Filament boot cost; feature coverage proves page access, family visibility, tenant-prefilter behavior, and source-page routing on a native page
- **Narrowest proving command(s)**:
- `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Unit/Support/GovernanceInbox/GovernanceInboxSectionBuilderTest.php`
- `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Governance/GovernanceInboxPageTest.php`
- `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Governance/GovernanceInboxAuthorizationTest.php`
- `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Governance/GovernanceInboxNavigationContextTest.php`
- **Fixture / helper / factory / seed / context cost risks**: moderate; reuse findings, operation runs, alert deliveries, reviews, and triage-review fixtures rather than adding browser setup or generic workflow helpers
- **Expensive defaults or shared helper growth introduced?**: no; any section assembler must stay cheap by default and avoid eager-loading broad unrelated state
- **Heavy-family additions, promotions, or visibility changes**: none
- **Surface-class relief / special coverage rule**: `global-context-shell` coverage is required because tenant-prefilter and navigation continuity are part of the page contract
- **Closing validation and reviewer handoff**: rerun the four focused commands above, verify the page stays read-only, and verify every CTA lands on an existing source surface with hidden tenants omitted from counts and labels
- **Budget / baseline / trend follow-up**: none expected beyond a small feature-local unit plus feature increase
- **Review-stop questions**: lane fit, hidden fixture cost, accidental generic workflow helpers, source-page duplication risk
- **Escalation path**: `document-in-feature` for contained assembly-seam notes; `reject-or-split` if implementation introduces a generic task model or local mutation lane
- **Active feature PR close-out entry**: Guardrail / Exception / Smoke Coverage
- **Why no dedicated follow-up spec is needed**: routine read-surface and navigation upkeep stays inside this feature unless implementation proves a structural need for a broader workflow engine
## Rollout & Risk Controls
- Keep the v1 audience anchored to existing workspace operators and tenant-entitled actors only.
- Treat the page as a routing surface. Do not add local claim, acknowledge, snooze, or follow-up mutation actions during implementation.
- Prefer extending existing source query seams over introducing new persisted or cross-domain workflow state.
- Keep navigation labels aligned with the source pages so the inbox reads as an entry surface, not a replacement shell.
- Validate the page with focused unit and feature coverage before considering any broader dashboard-entry or widget work.
## Project Structure
### Documentation (this feature)
```text
specs/250-decision-governance-inbox/
├── plan.md
├── research.md
├── data-model.md
├── quickstart.md
├── contracts/
│ └── governance-inbox.openapi.yaml
└── tasks.md # Created later by /speckit.tasks, not by this plan step
```
### Source Code (repository root)
```text
apps/platform/
├── app/
│ ├── Filament/
│ │ ├── Pages/
│ │ │ ├── Findings/
│ │ │ │ ├── MyFindingsInbox.php
│ │ │ │ └── FindingsIntakeQueue.php
│ │ │ ├── Governance/
│ │ │ │ └── GovernanceInbox.php # likely new page if implementation proceeds
│ │ │ ├── Monitoring/
│ │ │ │ ├── Operations.php
│ │ │ │ └── Alerts.php
│ │ │ └── Operations/
│ │ │ └── TenantlessOperationRunViewer.php
│ │ └── Resources/
│ │ ├── AlertDeliveryResource.php
│ │ └── TenantReviewResource.php
│ ├── Models/
│ │ ├── Finding.php
│ │ ├── OperationRun.php
│ │ ├── AlertDelivery.php
│ │ ├── TenantReview.php
│ │ └── TenantTriageReview.php
│ ├── Services/
│ │ ├── PortfolioTriage/TenantTriageReviewService.php
│ │ └── TenantReviews/TenantReviewRegisterService.php
│ ├── Support/
│ │ ├── Badges/
│ │ ├── Filament/
│ │ ├── GovernanceInbox/ # only if a bounded support seam is required
│ │ ├── Navigation/
│ │ ├── OperateHub/
│ │ ├── OperationRunLinks.php
│ │ ├── Rbac/
│ │ └── Ui/ActionSurface/
│ └── Policies/
├── bootstrap/providers.php
├── resources/views/filament/pages/governance/ # likely new page view if implementation proceeds
└── tests/
├── Feature/Governance/
└── Unit/Support/GovernanceInbox/
```
**Structure Decision**: Laravel monolith. Implementation should stay entirely inside `apps/platform`, add at most one new read-only page and matching view, and reuse existing source-page routing, RBAC, and status semantics rather than creating a separate workflow subsystem.
## Complexity Tracking
| Violation | Why Needed | Simpler Alternative Rejected Because |
|-----------|------------|-------------------------------------|
| BLOAT-001 - bounded section or entry assembler | one page still needs deterministic cross-family section ordering and source-surface links | inline page composition alone risks duplicated ordering rules and unreadable page code once five families are involved |
## Proportionality Review
- **Current operator problem**: operators cannot decide what needs attention first from one workspace surface despite the repo already having real findings, alerts, operations, and review-follow-up truth.
- **Existing structure is insufficient because**: current pages answer only one family each and force entity-first reconstruction before the operator can act.
- **Narrowest correct implementation**: add one read-only workspace inbox page over existing source-page queries and routing seams, with at most one bounded derived section or entry assembly helper.
- **Ownership cost created**: one page, one view, one bounded derived assembly seam, and focused unit plus feature coverage.
- **Alternative intentionally rejected**: a persisted inbox-item table or generic task engine was rejected because it adds durable workflow truth before the read-only decision surface is proven.
- **Release truth**: current-release workflow compression, not future workboard preparation.
## Phase 0 — Research (output: research.md)
Research resolved the remaining implementation-shaping decisions:
- choose a section-based composition page over a polymorphic task table or persisted queue
- reuse findings queue semantics from `MyFindingsInbox` and `FindingsIntakeQueue`
- reuse stale or terminal-follow-up operation semantics from `Operations`
- treat alert-delivery failures as the narrow alert-family slice for v1 instead of alert-rule configuration
- reuse `TenantTriageReview` follow-up truth for review-family attention
- rely on `CanonicalNavigationContext` and `OperationRunLinks` for drill-through continuity
**Output**: [research.md](research.md)
## Phase 1 — Design (outputs: data-model.md, contracts/, quickstart.md)
Design artifacts capture the narrow implementation shape:
- existing persisted truth reused: findings, operation runs, alert deliveries, tenant reviews, and triage reviews
- new code-owned truth limited to derived inbox sections and preview entries only
- conceptual contract covers one workspace page with optional tenant and family filters plus source-surface links
- quickstart documents the intended slice order, validation commands, and read-only posture
**Artifacts**:
- [data-model.md](data-model.md)
- [contracts/governance-inbox.openapi.yaml](contracts/governance-inbox.openapi.yaml)
- [quickstart.md](quickstart.md)
## Phase 2 — Planning (for tasks.md)
Dependency-ordered implementation outline for the later `tasks.md` step:
1. Add the native governance inbox page shell and read-only view in the admin plane.
2. Resolve the bounded section assembly seam, preferring reuse of source-page query rules over a new workflow subsystem.
3. Add family sections for assigned findings, intake, stale operations, alert-delivery failures, and triage follow-up.
4. Reuse `CanonicalNavigationContext`, `RelatedNavigationResolver`, and `OperationRunLinks` for every drill-through path.
5. Add tenant and family filter state with honest empty-state behavior and `404` handling for explicit out-of-scope tenant targeting.
6. Add focused unit and feature tests only; no browser, queue, or heavy-governance family is expected.
## Guardrail / Exception / Smoke Coverage
- Guardrail result: PASS. Filament remains v5 on Livewire v4, panel provider registration stays unchanged in `apps/platform/bootstrap/providers.php`, the slice adds no new globally searchable Resource, no destructive inbox action, and no new registered asset bundle. Deployment asset handling stays unchanged: `cd apps/platform && php artisan filament:assets` only matters if future registered assets are introduced.
- Shared seam outcome: `document-in-feature`. A bounded derived helper was required as `apps/platform/app/Support/GovernanceInbox/GovernanceInboxSectionBuilder.php` because the existing source pages did not expose a reusable cross-family inbox API. The seam stayed page-scoped and read-only; no persisted inbox state or generic workflow engine was introduced.
- Source CTA outcome: PASS. Assigned findings route to `MyFindingsInbox` and tenant finding detail, intake routes to `FindingsIntakeQueue` and tenant finding detail, operations route through `OperationRunLinks` into the canonical tenantless monitoring detail, alerts route to `AlertDeliveryResource` index or view, and review follow-up routes into the existing tenant review or customer review surfaces. The inbox page itself remains mutation-free.
- Filter and authorization outcome: PASS. Workspace membership remains the first gate, explicit out-of-scope tenant filters still resolve as `404`, in-scope members with no visible families still receive `403`, and tenant or family filters stay query-only and capability-safe.
- Validation lane result: `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Unit/Support/GovernanceInbox/GovernanceInboxSectionBuilderTest.php tests/Feature/Governance/GovernanceInboxAuthorizationTest.php tests/Feature/Governance/GovernanceInboxPageTest.php tests/Feature/Governance/GovernanceInboxNavigationContextTest.php` passed with `10 passed (53 assertions)`.
- Smoke evidence: integrated-browser smoke on `http://localhost/admin/governance/inbox` passed in an authenticated workspace session. The inbox loaded successfully, the operations-family CTA opened the canonical `/admin/operations` route with `problemClass=terminal_follow_up` plus the shared `nav` payload, the monitoring page rendered a visible `Back to governance inbox` control, and that return link brought the session back to `/admin/governance/inbox`.
- Formatting result: `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail bin pint --dirty --format agent` passed.
- Review outcome class: `acceptable-special-case`.
- Workflow outcome: `keep`.
- Exception note: none beyond the bounded section-builder seam already recorded above.

View File

@ -1,65 +0,0 @@
# Quickstart: Decision-Based Governance Inbox v1
**Date**: 2026-04-28
**Feature**: [spec.md](spec.md)
## Purpose
This quickstart captures the smallest intended implementation and validation path for the governance inbox slice. It is preparation-only guidance for later implementation work.
## Planned Implementation Shape
1. Add one native Filament page at `/admin/governance/inbox`.
2. Compose five bounded source families from existing repo truth:
- assigned findings
- findings intake
- stale or terminal-follow-up operations
- alert-delivery failures
- review follow-up
3. Keep the page read-only and route every action into an existing source surface.
4. Keep tenant and family filters query-safe and workspace-safe.
## Planned Validation Commands
Run the minimum proving commands once implementation exists:
```bash
export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Unit/Support/GovernanceInbox/GovernanceInboxSectionBuilderTest.php
export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Governance/GovernanceInboxPageTest.php
export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Governance/GovernanceInboxAuthorizationTest.php
export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Governance/GovernanceInboxNavigationContextTest.php
export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail bin pint --dirty --format agent
```
## Manual Review Checklist For Later Implementation
- Open `/admin/governance/inbox` as a workspace operator with at least two visible signal families.
- Verify the page stays read-only and does not offer claim, snooze, acknowledge, assign, or triage mutation controls.
- Verify a tenant-scoped launch prefilters the page to the current tenant.
- Verify explicit out-of-scope `tenant_id` query input returns `404`.
- Verify each visible section opens an existing source surface and preserves a back-link or source context.
## Guardrails To Preserve
- No new persisted inbox-item table.
- No generic cross-domain task engine.
- No browser-only validation requirement by default.
- No raw-support or debug detail rendered on the inbox page.
## Close-Out Target For Later Implementation
Record the final outcome in `Guardrail / Exception / Smoke Coverage` once implementation happens, including:
- whether a bounded `Support/GovernanceInbox/` seam was actually needed
- whether all source CTAs stayed on existing canonical surfaces
- whether any contained drift resolved as `document-in-feature`
- the final proof outcome from the focused unit and feature validation commands
## Guardrail / Exception / Smoke Coverage
- Guardrail result: PASS. The implemented slice stayed on the existing Filament v5 / Livewire v4 admin plane, kept provider registration untouched in `apps/platform/bootstrap/providers.php`, introduced no destructive inbox action, and added no new registered asset bundle.
- Bounded seam result: `document-in-feature`. The final implementation required `apps/platform/app/Support/GovernanceInbox/GovernanceInboxSectionBuilder.php` as a derived page-scoped assembler because the current source pages did not expose a reusable cross-family API.
- Source-surface result: PASS. All dominant section CTAs and preview-entry links stayed on existing findings, operations, alerts, and review surfaces; no inbox-local mutation lane or detail shell was added.
- Focused proof result: `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Unit/Support/GovernanceInbox/GovernanceInboxSectionBuilderTest.php tests/Feature/Governance/GovernanceInboxAuthorizationTest.php tests/Feature/Governance/GovernanceInboxPageTest.php tests/Feature/Governance/GovernanceInboxNavigationContextTest.php` passed with `10 passed (53 assertions)`.
- Formatting result: `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail bin pint --dirty --format agent` passed.
- Smoke result: PASS. Manual integrated-browser smoke confirmed `/admin/governance/inbox` loads in workspace context, the operations CTA navigates to the canonical monitoring route with return context, and the explicit back link returns to the inbox.

View File

@ -1,104 +0,0 @@
# Research: Decision-Based Governance Inbox v1
**Date**: 2026-04-28
**Feature**: [spec.md](spec.md)
## Decision Summary
The repo already contains the underlying governance attention signals. The missing product slice is not another source page or another workflow state, but one bounded decision-first page that composes the existing source seams into a calm workspace starting point.
## Key Decisions
### 1. Use section-based composition, not a generic task engine
- **Decision**: Build the inbox as one read-only Filament page with bounded family sections and preview entries.
- **Why**: A polymorphic table or persisted inbox-item model would import a second workflow truth before the first read-only operator surface is proven.
- **Repo truth**: Findings, operations, alerts, and review follow-up already have their own truthful pages and models.
### 2. Reuse findings queue semantics directly
- **Decision**: The assigned-findings and intake sections should reuse the inclusion and urgency rules already owned by `MyFindingsInbox` and `FindingsIntakeQueue`.
- **Why**: Those pages already codify open-status filtering, tenant entitlement, urgency ordering, and calm empty states.
- **Source seams**:
- `apps/platform/app/Filament/Pages/Findings/MyFindingsInbox.php`
- `apps/platform/app/Filament/Pages/Findings/FindingsIntakeQueue.php`
### 3. Use stale or terminal-follow-up operations as the operations-family signal
- **Decision**: The operations section should derive from the same stale or follow-up attention rules already exposed on the canonical `Operations` page.
- **Why**: The repo already has a canonical operations monitoring surface and run-detail route; the inbox should route into it instead of inventing a second operations diagnostic layer.
- **Source seams**:
- `apps/platform/app/Filament/Pages/Monitoring/Operations.php`
- `apps/platform/app/Filament/Pages/Operations/TenantlessOperationRunViewer.php`
- `apps/platform/app/Support/OperationRunLinks.php`
### 4. Keep the alert-family slice narrow: failed alert deliveries, not alert-rule config
- **Decision**: The alerts section should surface delivery failures or similar operator-attention alert outcomes, not alert-rule configuration.
- **Why**: Delivery failure is the actionable alerting gap that belongs in an attention inbox. Alert-rule editing stays a configuration workflow on its existing surfaces.
- **Source seams**:
- `apps/platform/app/Filament/Pages/Monitoring/Alerts.php`
- `apps/platform/app/Filament/Resources/AlertDeliveryResource.php`
- `apps/platform/app/Models/AlertDelivery.php`
### 5. Use triage-review follow-up as the review-family signal
- **Decision**: The review section should derive from `TenantTriageReview` states such as `follow_up_needed` and changed-since-review semantics.
- **Why**: The repo already distinguishes review follow-up from the underlying review artifact; the inbox should reuse that distinction rather than invent a second attention reason model.
- **Source seams**:
- `apps/platform/app/Services/PortfolioTriage/TenantTriageReviewService.php`
- `apps/platform/app/Models/TenantTriageReview.php`
- `apps/platform/app/Filament/Resources/TenantReviewResource.php`
### 6. Preserve navigation continuity through shared context helpers
- **Decision**: Every section and preview entry should use existing navigation helpers for back links and canonical destinations.
- **Why**: The inbox only reduces attention load if it preserves return context instead of opening detached utility flows.
- **Source seams**:
- `apps/platform/app/Support/Navigation/CanonicalNavigationContext.php`
- `apps/platform/app/Support/Navigation/RelatedNavigationResolver.php`
- `apps/platform/app/Support/OperateHub/OperateHubShell.php`
### 7. Keep the inbox read-only in v1
- **Decision**: No claim, snooze, acknowledge, assign, or triage mutations are introduced on the inbox page.
- **Why**: Those mutations already belong to source surfaces and would force the inbox to become a second workflow owner.
- **Result**: The inbox remains a decision hub, not an execution surface.
## Access Model Decision
- Workspace membership remains the first gate.
- The page only needs to exist for actors who can already see at least one family.
- Rows and counts must stay family-specific:
- findings sections require `Capabilities::TENANT_FINDINGS_VIEW`
- review follow-up requires `Capabilities::TENANT_REVIEW_VIEW`
- alert-family sections require `Capabilities::ALERTS_VIEW`
- source mutations remain on source pages with their existing capabilities
- Explicit out-of-scope `tenant_id` inputs return `404`.
## Rejected Alternatives
### Rejected: persisted inbox-item table
- **Reason**: adds durable workflow truth, migration cost, audit burden, and new lifecycle semantics before the read-only composition page is proven.
### Rejected: generic cross-domain work-item abstraction
- **Reason**: over-generalizes five concrete families into a second vocabulary and invites a platform-level task framework that current-release truth does not require.
### Rejected: extend one existing page instead of adding a canonical inbox
- **Reason**: no single existing page can truthfully host all five families without becoming the wrong domain owner.
## Implications For Implementation
- Prefer one bounded `Support/GovernanceInbox/` seam only if page-local composition becomes unreadable.
- Keep source-family labels close to existing UI copy to avoid a second UX language.
- Keep empty states honest:
- tenant-prefilter-hidden attention -> `Clear tenant filter`
- globally calm -> one neutral workspace return CTA
- Do not add page-level audit noise for mere page views.
## Planning Outcome
The smallest viable implementation slice is one new read-only workspace page that reuses existing source-page queries, existing navigation helpers, and existing capability semantics. No new persistence or mutation lane is justified.

View File

@ -1,294 +0,0 @@
# Feature Specification: Decision-Based Governance Inbox v1
**Feature Branch**: `250-decision-governance-inbox`
**Created**: 2026-04-28
**Status**: Draft
**Input**: User description: "Select the next best open spec candidate from roadmap and spec-candidates, then prepare a narrow repo-grounded Spec Kit package for a decision-oriented governance inbox that consolidates existing findings, alerts, stale operations, and portfolio triage signals without implementing application code."
## Spec Candidate Check *(mandatory - SPEC-GATE-001)*
- **Problem**: TenantPilot already has real findings queues, alerting, operations monitoring, and portfolio triage state, but operators still have to reconstruct what needs attention by moving across several surfaces before they can decide what to open next.
- **Today's failure**: The product still behaves like an entity-first console instead of a decision-first work surface. Operators can miss stale operations, alert-delivery failures, review follow-up, or unassigned findings because each signal family lives on its own page.
- **User-visible improvement**: One canonical workspace inbox shows the most important governance attention from more than one existing signal family and routes the operator straight into the right existing execution or evidence surface.
- **Smallest enterprise-capable version**: One new read-first workspace page under `/admin` that aggregates existing assigned-findings, findings-intake, stale-operations, alert-delivery-failure, and review-follow-up signals into bounded sections with calm summaries and direct links into existing source pages. No new mutation lane ships on the inbox itself.
- **Explicit non-goals**: No replacement of `My Findings`, `Findings intake`, `Operations`, `Alerts`, or review-triage detail pages; no new persisted inbox-item table; no generic cross-domain task engine; no new acknowledge, snooze, or assignment state; no customer-facing inbox; no AI recommendations; no cross-workspace workboard.
- **Permanent complexity imported**: One canonical inbox page, one bounded derived section or entry assembly seam, one cross-family priority order, query-state handling for tenant and family filters, and focused unit plus feature coverage.
- **Why now**: The implementation ledger marks the missing decision inbox as a P0 workflow blocker immediately after Customer Review Workspace, while `spec-candidates.md` still lists it as P1. This package follows the stronger ledger urgency because the repo already has the underlying signal families, so the next product value is compression of operator attention, not another isolated source page.
- **Why not local**: Extending only `My Findings`, only `Operations`, or only `Alerts` would keep the current multi-page reconstruction problem intact and would not provide one truthful starting point for workspace attention.
- **Approval class**: Workflow Compression
- **Red flags triggered**: One mild `many surfaces` flag because the page composes several existing signal families. Defense: the slice stays read-only, introduces no new persistence, and explicitly reuses underlying source pages instead of replacing them.
- **Score**: Nutzen: 2 | Dringlichkeit: 2 | Scope: 1 | Komplexitaet: 1 | Produktnaehe: 2 | Wiederverwendung: 2 | **Gesamt: 10/12**
- **Decision**: approve
## Spec Scope Fields *(mandatory)*
- **Scope**: canonical-view
- **Primary Routes**:
- new canonical workspace route `/admin/governance/inbox`
- existing `/admin/findings/my-work`
- existing `/admin/findings/intake`
- existing `/admin/operations`
- existing alerts cluster routes under `/admin/alerts/*`
- existing `/admin/reviews` and tenant-scoped review detail routes used for triage follow-up drill-through
- **Data Ownership**:
- tenant-owned `Finding`, `OperationRun`, `TenantReview`, and `TenantTriageReview` remain the only source of truth for their respective sections
- workspace-scoped `AlertDelivery`, `AlertRule`, and `AlertDestination` remain the alerting source of truth
- the governance inbox is a derived read surface only; it introduces no new table, cache, mirror entity, or workflow state
- **RBAC**:
- workspace membership remains the first access boundary for the inbox page
- page entry is allowed only when the actor is a workspace member and at least one source family is visible through existing capabilities
- non-members and explicit out-of-scope tenant targeting remain `404` deny-as-not-found boundaries
- in-scope workspace members who lack every qualifying source-family capability receive `403` instead of a silent empty shell
- assigned-findings and intake sections only include tenants where the actor has `Capabilities::TENANT_FINDINGS_VIEW`
- triage follow-up rows only include tenants where the actor has `Capabilities::TENANT_REVIEW_VIEW`; any follow-up mutation remains on the existing review surface and continues to require `Capabilities::TENANT_TRIAGE_REVIEW_MANAGE`
- alert-delivery failure sections only appear for actors who can access workspace alerts through `Capabilities::ALERTS_VIEW`
- operation-attention rows only appear when the actor could already open the underlying operation destination through the existing run and tenant entitlement rules
- the inbox itself is read-first; source-surface mutations such as claim, triage, acknowledge, or follow-up continue to enforce their existing server-side Gates or Policies
For canonical-view specs, the spec MUST define:
- **Default filter behavior when tenant-context is active**: When launched from a tenant-scoped findings, review, or tenant dashboard surface, the inbox prefilters to that tenant while keeping the family filter on `All attention`. Operators may clear only the tenant prefilter to return to all visible attention across the workspace.
- **Explicit entitlement checks preventing cross-tenant leakage**: Explicit `tenant_id` inputs outside the actor's visible scope resolve as not found. Broad workspace listings silently omit inaccessible tenants, hidden signal families, and blocked drill-through targets from counts, labels, and empty-state hints.
## Cross-Cutting / Shared Pattern Reuse *(mandatory when the feature touches notifications, status messaging, action links, header actions, dashboard signals/cards, alerts, navigation entry points, evidence/report viewers, or any other existing shared operator interaction family; otherwise write `N/A - no shared interaction family touched`)*
- **Cross-cutting feature?**: yes
- **Interaction class(es)**: navigation entry points, dashboard signals/cards, status messaging, action links, monitoring and governance drill-through, and badge semantics
- **Systems touched**: `MyFindingsInbox`, `FindingsIntakeQueue`, `Operations`, `Alerts`, `TenantReviewResource`, `TenantTriageReviewService`, `CanonicalNavigationContext`, `RelatedNavigationResolver`, `OperateHubShell`, `OperationRunLinks`, `BadgeRenderer`, `UiEnforcement`, and existing alert, findings, and review source pages
- **Existing pattern(s) to extend**: native Filament workspace pages with tenant-prefilter state, existing queue summaries, `OperateHubShell` scope handling, `CanonicalNavigationContext` back-link continuity, and `ActionSurfaceDeclaration` documentation
- **Shared contract / presenter / builder / renderer to reuse**: `CanonicalNavigationContext`, `RelatedNavigationResolver`, `OperateHubShell`, `OperationRunLinks`, `BadgeRenderer`, `UiEnforcement`, `CanonicalAdminTenantFilterState`, and the existing source-page query rules from `MyFindingsInbox`, `FindingsIntakeQueue`, `Operations`, `Alerts`, and review/triage services
- **Why the existing shared path is sufficient or insufficient**: Existing source pages already own the underlying truth and mutation semantics, but they are insufficient as a first decision surface because they only answer one family at a time. The inbox should compose those seams, not replace them.
- **Allowed deviation and why**: none planned. If implementation needs a bounded local section assembler, it must remain derived, page-scoped, and must not become a cross-product task framework.
- **Consistency impact**: Priority language, empty-state language, badge semantics, and drill-through labels must stay aligned with the existing source surfaces so the inbox feels like a routing layer over product truth rather than a parallel UX language.
- **Review focus**: Reviewers must block any implementation that duplicates local claim, acknowledge, triage, or stale-run mutation semantics on the inbox page or invents a second cross-domain workflow state.
## OperationRun UX Impact *(mandatory when the feature creates, queues, deduplicates, resumes, blocks, completes, or deep-links to an `OperationRun`; otherwise write `N/A - no OperationRun start or link semantics touched`)*
- **Touches OperationRun start/completion/link UX?**: yes, deep-link only
- **Shared OperationRun UX contract/layer reused**: `OperationRunLinks` plus the existing tenantless operation viewer path
- **Delegated start/completion UX behaviors**: canonical `Open operation` / run-detail URL resolution and existing operation-context navigation only; no new queued toast, run-enqueued event, or terminal-notification behavior is introduced
- **Local surface-owned behavior that remains**: the inbox only decides whether an operations attention section is shown and which existing run link is primary for that entry
- **Queued DB-notification policy**: `N/A`
- **Terminal notification path**: `N/A`
- **Exception required?**: none
## Provider Boundary / Platform Core Check *(mandatory when the feature changes shared provider/platform seams, identity scope, governed-subject taxonomy, compare strategy selection, provider connection descriptors, or operator vocabulary that may leak provider-specific semantics into platform-core truth; otherwise write `N/A - no shared provider/platform boundary touched`)*
N/A - no shared provider/platform boundary is widened. The inbox consumes already-normalized governance, alerts, operations, and review seams without introducing new provider-specific contracts.
## UI / Surface Guardrail Impact *(mandatory when operator-facing surfaces are changed; otherwise write `N/A`)*
| Surface / Change | Operator-facing surface change? | Native vs Custom | Shared-Family Relevance | State Layers Touched | Exception Needed? | Low-Impact / `N/A` Note |
|---|---|---|---|---|---|---|
| Governance inbox page | yes | Native Filament page plus shared primitives | governance queues, monitoring drill-through, navigation continuity, badge/status reuse | page, URL-query | no | One new canonical read-only decision surface; source pages remain authoritative |
## Decision-First Surface Role *(mandatory when operator-facing surfaces are changed)*
| Surface | Decision Role | Human-in-the-loop Moment | Immediately Visible for First Decision | On-Demand Detail / Evidence | Why This Is Primary or Why Not | Workflow Alignment | Attention-load Reduction |
|---|---|---|---|---|---|---|---|
| Governance inbox page | Primary Decision Surface | Operator opens the workspace and decides which existing governance surface needs attention first | visible attention by family, tenant scope, urgency, count, and dominant next action | full source detail, operation detail, alerts context, and review/finding evidence only after opening the source surface | Primary because it becomes the first workspace attention surface across more than one signal family | Follows the operator question `what needs attention now?` before the entity-specific question `what does this record contain?` | Replaces multi-page search across findings, alerts, operations, and review follow-up with one calm starting point |
## Audience-Aware Disclosure *(mandatory when operator-facing surfaces are changed)*
| Surface | Audience Modes In Scope | Decision-First Default-Visible Content | Operator Diagnostics | Support / Raw Evidence | One Dominant Next Action | Hidden / Gated By Default | Duplicate-Truth Prevention |
|---|---|---|---|---|---|---|---|
| Governance inbox page | operator-MSP | family summary, top attention entries, urgency cues, tenant scope, and direct next action into the existing source surface | source-specific detail remains on `My Findings`, `Findings intake`, `Operations`, `Alerts`, and review surfaces | raw payloads, alert body details, operation diagnostics, and evidence payloads stay on existing source pages and remain capability-gated there | `Open attention source` per section or entry | raw/support detail is not rendered on the inbox page | the inbox states the decision truth once, then relies on source pages for proof rather than re-explaining the same blocker in parallel blocks |
## UI/UX Surface Classification *(mandatory when operator-facing surfaces are changed)*
| Surface | Action Surface Class | Surface Type | Likely Next Operator Action | Primary Inspect/Open Model | Row Click | Secondary Actions Placement | Destructive Actions Placement | Canonical Collection Route | Canonical Detail Route | Scope Signals | Canonical Noun | Critical Truth Visible by Default | Exception Type / Justification |
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| Governance inbox page | Utility / Workspace Decision | Read-only workflow hub | Open the existing source surface for the highest-priority attention family or entry | explicit section or preview-entry CTA into the underlying source surface | forbidden | section footers or preview-entry links only | none | `/admin/governance/inbox` | existing source-specific routes, including `/admin/findings/my-work`, `/admin/findings/intake`, `/admin/operations/{run}` for entry-level operations drill-through, alerts cluster routes, and review routes | active workspace, optional tenant prefilter, family filter | Governance inbox | which attention family needs action now and where the operator should go next | none |
## Operator Surface Contract *(mandatory when operator-facing surfaces are changed)*
| Surface | Primary Persona | Decision / Operator Action Supported | Surface Type | Primary Operator Question | Default-visible Information | Diagnostics-only Information | Status Dimensions Used | Mutation Scope | Primary Actions | Dangerous Actions |
|---|---|---|---|---|---|---|---|---|---|---|
| Governance inbox page | Workspace operator / MSP operator | Decide which existing governance surface should be opened next | Workspace decision hub | What needs attention right now across my visible governance surfaces, and where should I go to act? | section counts, top items, tenant label when applicable, urgency cues, family label, and source CTA | source-specific reason detail, evidence, alert metadata, and full operation diagnostics remain on source surfaces | urgency, source family, tenant scope, follow-up state, delivery failure state, stale/terminal attention state | none on the inbox page itself | Open my findings, Open intake, Open operation, Open alerts, Open review follow-up | none |
## Proportionality Review *(mandatory when structural complexity is introduced)*
- **New source of truth?**: no
- **New persisted entity/table/artifact?**: no
- **New abstraction?**: yes - one bounded derived section or entry assembly seam may be needed to compose multi-family attention into one page
- **New enum/state/reason family?**: no persisted family; any family keys remain local derived page constants only
- **New cross-domain UI framework/taxonomy?**: no
- **Current operator problem**: operators cannot answer `what needs attention now?` from one workspace surface, even though the repo already has real findings, alerts, operations, and review-follow-up truth
- **Existing structure is insufficient because**: current pages answer only one family each and force entity-first reconstruction before the operator can act
- **Narrowest correct implementation**: one read-only workspace page that derives its sections from existing source-page query semantics and routes operators into the existing source surfaces
- **Ownership cost**: one new page, one bounded derived assembly seam, tenant and family query-state handling, and focused unit plus feature coverage
- **Alternative intentionally rejected**: a generic cross-product task engine or persisted inbox-item table was rejected because it would import new workflow truth before the read-only decision surface is proven
- **Release truth**: current-release workflow compression, not future-release workboard infrastructure
### Compatibility posture
This feature assumes a pre-production environment.
Backward compatibility, legacy aliases, migration shims, historical fixtures, and compatibility-specific tests are out of scope unless explicitly required by this spec.
Canonical replacement is preferred over preservation.
## Testing / Lane / Runtime Impact *(mandatory for runtime behavior changes)*
- **Test purpose / classification**: Unit, Feature
- **Validation lane(s)**: fast-feedback, confidence
- **Why this classification and these lanes are sufficient**: unit coverage proves attention-family assembly, ordering, and source-link decisions cheaply; focused feature coverage proves workspace membership, per-family visibility, tenant-prefilter behavior, and navigation continuity on a native Filament page. Browser coverage is not the narrowest honest proof for this slice.
- **New or expanded test families**: one focused `GovernanceInbox` feature family and one focused `Unit/Support/GovernanceInbox` family
- **Fixture / helper cost impact**: moderate; tests need visible and hidden tenants, findings in assigned and intake states, stale or terminal-follow-up runs, failed alert deliveries, and triage review states, but should reuse existing factories and avoid browser or heavy-governance setup
- **Heavy-family visibility / justification**: none
- **Special surface test profile**: global-context-shell
- **Standard-native relief or required special coverage**: ordinary feature coverage is sufficient once explicit tests prove tenant-prefilter state, family omission, and source-surface navigation context
- **Reviewer handoff**: reviewers must confirm that hidden tenant signals never leak into counts or labels, the page stays read-only, and every CTA lands on an existing source surface rather than a new local mutation lane
- **Budget / baseline / trend impact**: low feature-local increase only
- **Escalation needed**: none
- **Active feature PR close-out entry**: Guardrail / Exception / Smoke Coverage
- **Planned validation commands**:
- `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Unit/Support/GovernanceInbox/GovernanceInboxSectionBuilderTest.php`
- `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Governance/GovernanceInboxPageTest.php`
- `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Governance/GovernanceInboxAuthorizationTest.php`
- `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Governance/GovernanceInboxNavigationContextTest.php`
## Scope Boundaries
### In Scope
- one canonical workspace-level governance inbox page in the existing admin plane
- bounded attention sections for assigned findings, findings intake, stale or terminal-follow-up operations, alert-delivery failures, and review follow-up signals
- calm counts and top-entry previews per visible family
- existing source-surface links with preserved navigation context
- tenant and family filters with honest empty-state behavior
### Non-Goals
- replacing or hiding the existing source pages that already own findings, operations, alerts, or review state
- new acknowledge, snooze, claim, triage, or assign actions on the inbox page
- a new persisted inbox-item or work-state table
- cross-workspace or customer-facing inboxes
- AI prioritization, autonomous routing, or recommendation logic
- raw-support or debug detail on the inbox page itself
## Assumptions
- existing source pages already expose enough truth to derive section counts and top previews without introducing a second workflow state
- alert-delivery failures are the narrowest alert-family attention slice for v1; alert-rule configuration remains secondary
- existing `CanonicalNavigationContext` and `OperationRunLinks` seams are sufficient for return-link continuity
- the page can stay useful even when only a subset of families is visible for the current actor
## Risks
- a single mixed attention list could tempt implementation toward a new generic task model; this must be resisted in favor of bounded section composition
- some operations or alert items may be workspace-scoped while other families are tenant-scoped, which increases the chance of misleading empty states if filter logic is not explicit
- if the page tries to surface too much source detail, it can become a duplicate of the underlying pages instead of a decision hub
## Follow-up Candidates
- bounded acknowledge or snooze semantics once a real cross-family attention state exists in the product
- dashboard or workspace-overview entry signals into the governance inbox after the canonical page is proven
- a broader decision-based operating system slice only after the first read-only inbox is adopted successfully
## User Scenarios & Testing *(mandatory)*
### User Story 1 - See Multi-Family Attention In One Place (Priority: P1)
As a workspace operator, I want one inbox that shows the most important governance attention across more than one signal family so I can decide where to work next without scanning multiple pages first.
**Why this priority**: This is the core missing value. Without a multi-family attention surface, the product still forces page-hopping before any decision can be made.
**Independent Test**: Seed visible assigned findings, intake findings, stale operations, alert-delivery failures, and triage follow-up. Open the inbox and verify that the page shows more than one visible family with calm counts and top entries.
**Acceptance Scenarios**:
1. **Given** the actor has visible assigned findings and stale operations, **When** they open the governance inbox, **Then** both families appear with separate counts, urgency cues, and one dominant source CTA each.
2. **Given** the actor can access only findings and not alerts, **When** they open the governance inbox, **Then** alert sections, labels, and counts do not appear at all.
3. **Given** no visible attention exists in any accessible family, **When** they open the governance inbox, **Then** the page shows one calm empty state and does not imply hidden work exists elsewhere.
---
### User Story 2 - Open The Right Existing Source Surface With Context (Priority: P1)
As a workspace operator, I want the inbox to route me into the correct existing page with preserved context so the inbox stays a decision hub and not a duplicate execution surface.
**Why this priority**: The page only reduces attention load if the next click is obvious and lands in the existing product truth.
**Independent Test**: Open the governance inbox, choose one attention entry from findings, operations, and review follow-up, and verify that each CTA lands on the correct existing destination with back-link or context continuity preserved.
**Acceptance Scenarios**:
1. **Given** an assigned-findings section is visible, **When** the actor chooses its dominant action, **Then** the destination opens the existing `My Findings` or tenant finding detail surface instead of a new local inbox detail shell.
2. **Given** an operations attention entry is visible, **When** the actor opens it, **Then** the destination uses the canonical operation URL path and preserves a return path back to the inbox.
3. **Given** a review follow-up section is visible, **When** the actor opens it, **Then** the destination lands on the existing review or triage surface rather than a duplicate summary on the inbox page.
---
### User Story 3 - Filter The Inbox Honestly Without Leakage (Priority: P2)
As a workspace operator, I want the governance inbox to respect tenant context and family filters without leaking hidden tenants, hidden families, or inaccessible records.
**Why this priority**: A decision hub is dangerous if it implies missing or hidden work incorrectly or if it leaks cross-tenant state through filter labels or empty-state hints.
**Independent Test**: Open the inbox with an active tenant context, with an explicit family filter, and with an inaccessible tenant query parameter. Verify the resulting rows, counts, and empty states are truthful and capability-safe.
**Acceptance Scenarios**:
1. **Given** an active tenant context exists, **When** the actor opens the governance inbox, **Then** the page prefilters to that tenant and allows the actor to clear only the tenant prefilter back to all visible attention.
2. **Given** a `tenant_id` query parameter references a tenant outside the actor's scope, **When** the governance inbox loads, **Then** the request resolves as not found instead of rendering an empty or hinting state.
3. **Given** the actor applies a family filter for one accessible family, **When** the page renders, **Then** counts, previews, and empty-state copy describe only that visible family and do not mention hidden families.
### Edge Cases
- a single tenant may contribute more than one visible family at once; the inbox must keep those families separate instead of inventing a merged workflow state
- alert-delivery failure rows may be workspace-scoped and tenantless; the page must not fabricate tenant labels or tenant-only actions for them
- an operation run may remain in the workspace database after the actor loses tenant entitlement; the inbox must omit it rather than leak stale references
- a tenant prefilter can hide otherwise visible attention in other tenants; the empty state must explain the tenant boundary honestly before claiming the workspace is calm
## Requirements *(mandatory)*
**Constitution alignment (required):** This feature adds no Microsoft Graph call, no new queue start, no new `OperationRun`, and no new persisted truth. It adds one derived read-only decision surface over existing findings, alerts, operations, and review-triage truth.
**Constitution alignment (PROP-001 / ABSTR-001 / PERSIST-001 / STATE-001 / BLOAT-001):** The inbox must stay derived. It must not create a new task engine, persisted attention table, or cross-domain workflow state. Any new assembly seam must remain bounded to page composition and reuse existing source-state semantics.
**Constitution alignment (XCUT-001):** The inbox must extend existing shared navigation, badge, and source-surface patterns rather than inventing a parallel interaction family for claim, acknowledge, stale-run handling, or review follow-up.
**Constitution alignment (DECIDE-AUD-001 / OPSURF-001):** The inbox must remain decision-first. Default-visible content is family, urgency, scope, and next action only. Diagnostics, evidence, and raw-support details stay on the source pages.
**Constitution alignment (TEST-GOV-001):** The implementation must stay in focused `Unit` and `Feature` lanes. No browser or heavy-governance family is justified by default for this slice.
**Constitution alignment (RBAC-UX):** Workspace membership remains the first boundary. Explicit out-of-scope tenant filters return `404`. Once workspace membership is established, missing per-family capabilities continue to suppress rows or source actions instead of leaking inaccessible truth.
**Constitution alignment (RBAC-UX - page access):** Non-members and out-of-scope tenant targeting return `404`, while in-scope workspace members who lack every qualifying family capability receive `403` on page access.
### Functional Requirements
- **FR-001**: The system MUST provide a canonical governance inbox at `/admin/governance/inbox` inside the existing admin plane.
- **FR-002**: The inbox MUST aggregate visible attention from more than one underlying signal family using existing product truth rather than a new persisted workflow state.
- **FR-003**: The first supported attention families in v1 MUST be assigned findings, findings intake, stale or terminal-follow-up operations, alert-delivery failures, and review follow-up.
- **FR-004**: The inbox MUST remain read-first. It MUST route to existing source surfaces for claim, triage, operation review, alert drill-through, or review follow-up instead of re-implementing those mutations locally.
- **FR-005**: The inbox MUST expose family counts, top attention previews, tenant scope when applicable, and one dominant source CTA per visible section.
- **FR-006**: The inbox MUST support an optional tenant prefilter and optional family filter. When tenant context is active, the tenant prefilter is applied by default.
- **FR-007**: The inbox MUST omit inaccessible tenants, inaccessible families, and inaccessible source actions from counts, labels, empty-state hints, and preview content.
- **FR-008**: If the actor explicitly targets an out-of-scope tenant through query state, the inbox MUST return `404` deny-as-not-found semantics.
- **FR-009**: Operation-related entries MUST reuse canonical run URLs and existing operation lifecycle semantics instead of inventing local stale-run logic.
- **FR-010**: Alert-related entries MUST derive from existing alert delivery or alert overview truth and MUST NOT duplicate alert-rule configuration state as work items.
- **FR-011**: Review-follow-up entries MUST derive from existing tenant review and triage-review truth and MUST NOT create a second follow-up state family.
- **FR-012**: The inbox MUST NOT introduce a new globally searchable resource, a new panel, or a new asset bundle for v1.
- **FR-013**: The inbox MUST enforce `404` for non-members and explicit out-of-scope tenant targeting, and `403` for in-scope workspace members who lack any qualifying visible-family capability.
## 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 |
|---|---|---|---|---|---|---|---|---|---|---|
| Governance inbox page | `apps/platform/app/Filament/Pages/Governance/GovernanceInbox.php` | `Clear tenant filter` only when a tenant prefilter is active | explicit section and preview-entry CTA into existing source surfaces; no local detail shell | none | none | `Clear tenant filter` when the tenant filter alone hides attention; otherwise `Open workspace dashboard` | n/a | n/a | no direct audit; page stays read-only | Action Surface Contract stays satisfied because the page has one dominant navigation goal and no local mutation lane |
### Key Entities *(include if feature involves data)*
- **Governance inbox section**: A derived grouping for one source family that carries a title, visible count, dominant next action, and top previews.
- **Governance attention entry**: A derived preview item that points to one existing source surface and carries only the minimal status, scope, and urgency information needed for the next click.
## Success Criteria *(mandatory)*
### Measurable Outcomes
- **SC-001**: In acceptance review, an operator can determine within 15 seconds whether assigned findings, intake findings, stale operations, alert-delivery failures, or review follow-up require attention from one page.
- **SC-002**: 100% of covered automated tests show that hidden tenants and hidden families do not leak into counts, labels, or empty-state hints.
- **SC-003**: 100% of covered automated tests show that each visible family routes to an existing canonical source surface rather than a new local mutation or detail shell.
- **SC-004**: With seeded workspace data from at least two signal families, the inbox can show both on one page without introducing a new persisted workflow state.

View File

@ -1,173 +0,0 @@
---
description: "Task list for Decision-Based Governance Inbox v1"
---
# Tasks: Decision-Based Governance Inbox v1
**Input**: Design documents from `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/250-decision-governance-inbox/`
**Prerequisites**: `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/250-decision-governance-inbox/plan.md` (required), `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/250-decision-governance-inbox/spec.md` (required), `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/250-decision-governance-inbox/checklists/requirements.md` (required), `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/250-decision-governance-inbox/research.md`, `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/250-decision-governance-inbox/data-model.md`, `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/250-decision-governance-inbox/contracts/governance-inbox.openapi.yaml`, `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/250-decision-governance-inbox/quickstart.md`
**Tests**: Required (Pest). Keep proof in focused `Unit` and `Feature` lanes only, using the targeted Sail commands already captured in the feature artifacts.
**Operations**: The inbox introduces no new `OperationRun`, queue, or result ledger. It only deep-links into existing run detail surfaces through the shared operation-link contract.
**RBAC**: Workspace membership remains the first gate. Explicit out-of-scope tenant filters remain `404`. Source-family rows and source-family destinations stay capability-gated through existing registries and policies.
**Organization**: Tasks are grouped by user story so the multi-family read surface, source-surface routing, and filter-safety behavior remain independently testable after the shared foundation is in place.
## Test Governance Checklist
- [x] Lane assignment stays `Unit` plus `Feature` and remains the narrowest sufficient proof for the changed behavior.
- [x] New or changed tests stay under `apps/platform/tests/Unit/Support/GovernanceInbox/` and `apps/platform/tests/Feature/Governance/` only; no browser or heavy-governance lane is added.
- [x] Shared helpers, factories, fixtures, and context defaults stay cheap by default; do not add a generic workflow fixture or seeded inbox-item state.
- [x] Planned validation commands cover section assembly, page access, and navigation continuity without pulling in unrelated lane cost.
- [x] The declared surface test profile remains `global-context-shell` because tenant-prefilter and navigation continuity are part of the page contract.
- [x] Any bounded assembly-seam drift resolves as `document-in-feature` unless implementation proves a structural workflow-engine need.
## Phase 1: Setup (Shared Context)
**Purpose**: Confirm the bounded slice, source seams, and reviewer stop conditions before runtime implementation begins.
- [x] T001 Review the bounded slice, explicit non-goals, and guardrail expectations in `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/250-decision-governance-inbox/spec.md`, `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/250-decision-governance-inbox/plan.md`, and `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/250-decision-governance-inbox/checklists/requirements.md`
- [x] T002 [P] Review the implementation-shaping decisions in `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/250-decision-governance-inbox/research.md`, `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/250-decision-governance-inbox/data-model.md`, `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/250-decision-governance-inbox/contracts/governance-inbox.openapi.yaml`, and `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/250-decision-governance-inbox/quickstart.md`
- [x] T003 [P] Confirm the source-page seams that must remain authoritative: `apps/platform/app/Filament/Pages/Findings/MyFindingsInbox.php`, `apps/platform/app/Filament/Pages/Findings/FindingsIntakeQueue.php`, `apps/platform/app/Filament/Pages/Monitoring/Operations.php`, `apps/platform/app/Filament/Pages/Monitoring/Alerts.php`, `apps/platform/app/Filament/Resources/AlertDeliveryResource.php`, `apps/platform/app/Filament/Resources/TenantReviewResource.php`, and `apps/platform/app/Services/PortfolioTriage/TenantTriageReviewService.php`
---
## Phase 2: Foundational (Blocking Prerequisites)
**Purpose**: Establish the read-only page shell, authorization boundaries, and bounded assembly seam that every user story depends on.
**Critical**: No user story work should begin until this phase is complete.
- [x] T004 [P] Add focused authorization coverage for workspace membership, explicit out-of-scope tenant-prefilter `404` behavior, in-scope member `403` behavior when no qualifying family capability exists, and family-level omission rules in `apps/platform/tests/Feature/Governance/GovernanceInboxAuthorizationTest.php`
- [x] T005 Create the native governance inbox page shell and Blade view in `apps/platform/app/Filament/Pages/Governance/GovernanceInbox.php` and `apps/platform/resources/views/filament/pages/governance/governance-inbox.blade.php`, keeping the surface read-only and inside the admin plane
- [x] T006 Resolve the section-assembly seam by reusing existing source-page query rules first; only if the page becomes unreadable, add a bounded helper under `apps/platform/app/Support/GovernanceInbox/` and record the choice in `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/250-decision-governance-inbox/plan.md`
- [x] T007 [P] Thread tenant and family filter state plus navigation context through `apps/platform/app/Filament/Pages/Governance/GovernanceInbox.php`, reusing `CanonicalNavigationContext` and `CanonicalAdminTenantFilterState` rather than introducing a page-local state system
**Checkpoint**: The inbox page shell, page access rules, and bounded assembly decision exist. User-story work can now proceed independently.
---
## Phase 3: User Story 1 - See Multi-Family Attention In One Place (Priority: P1) MVP
**Goal**: Let a workspace operator see more than one visible signal family from one decision-first page without introducing a second workflow state.
**Independent Test**: Seed visible assigned findings, intake findings, stale operations, alert-delivery failures, and review follow-up, then verify the inbox shows calm section summaries and top previews from more than one family.
### Tests for User Story 1
- [x] T008 [P] [US1] Add unit coverage for derived section and preview-entry assembly in `apps/platform/tests/Unit/Support/GovernanceInbox/GovernanceInboxSectionBuilderTest.php`
- [x] T009 [P] [US1] Add feature coverage for multi-family page rendering, calm counts, and honest global empty-state behavior in `apps/platform/tests/Feature/Governance/GovernanceInboxPageTest.php`
### Implementation for User Story 1
- [x] T010 [US1] Derive the assigned-findings and intake sections from the existing query semantics in `apps/platform/app/Filament/Pages/Findings/MyFindingsInbox.php` and `apps/platform/app/Filament/Pages/Findings/FindingsIntakeQueue.php` without introducing new workflow-state constants
- [x] T011 [US1] Derive the operations and alerts sections from `apps/platform/app/Filament/Pages/Monitoring/Operations.php`, `apps/platform/app/Filament/Pages/Monitoring/Alerts.php`, and `apps/platform/app/Filament/Resources/AlertDeliveryResource.php`, keeping the alert-family slice focused on delivery-failure attention rather than alert-rule configuration
- [x] T012 [US1] Derive the review follow-up section from `apps/platform/app/Services/PortfolioTriage/TenantTriageReviewService.php`, `apps/platform/app/Models/TenantTriageReview.php`, and the existing review register truth, then render all visible sections on `apps/platform/resources/views/filament/pages/governance/governance-inbox.blade.php`
**Checkpoint**: User Story 1 is independently functional when more than one visible family can appear on the inbox page without new persisted workflow state.
---
## Phase 4: User Story 2 - Open The Right Existing Source Surface With Context (Priority: P1)
**Goal**: Route every visible section and preview entry into the correct existing source surface so the inbox stays a decision hub rather than becoming a duplicate execution shell.
**Independent Test**: Open the inbox, use findings, operations, alerts, and review-follow-up CTAs, and verify each destination is an existing canonical source route with preserved return or source context.
### Tests for User Story 2
- [x] T013 [P] [US2] Add focused navigation-context coverage for source-surface CTAs and back-link continuity in `apps/platform/tests/Feature/Governance/GovernanceInboxNavigationContextTest.php`
### Implementation for User Story 2
- [x] T014 [US2] Route findings and review-follow-up sections through existing source pages using `apps/platform/app/Support/Navigation/CanonicalNavigationContext.php`, `apps/platform/app/Support/Navigation/RelatedNavigationResolver.php`, and the existing resource URL helpers on `apps/platform/app/Filament/Resources/TenantReviewResource.php`
- [x] T015 [US2] Route operation attention entries through `apps/platform/app/Support/OperationRunLinks.php` and the canonical tenantless operation detail route `/admin/operations/{run}` instead of inventing a new inbox-local detail shell
- [x] T016 [US2] Keep the inbox read-only by ensuring claim, triage, acknowledge, snooze, and follow-up mutations remain on their source surfaces; if any source surface needs small back-link hardening, change the smallest source page rather than adding local mutations on `apps/platform/app/Filament/Pages/Governance/GovernanceInbox.php`
**Checkpoint**: User Story 2 is independently functional when every visible CTA lands on an existing source surface with preserved context and the inbox still owns no mutations.
---
## Phase 5: User Story 3 - Filter The Inbox Honestly Without Leakage (Priority: P2)
**Goal**: Keep tenant and family filtering honest so the inbox never leaks hidden tenants, hidden families, or inaccessible source destinations.
**Independent Test**: Load the inbox with an active tenant context, a family filter, and an explicit hidden tenant query parameter, then verify the resulting counts, labels, and empty states are truthful.
### Tests for User Story 3
- [x] T017 [P] [US3] Extend feature coverage for tenant-prefilter state, family filters, hidden-family omission, and tenant-specific empty-state branches in `apps/platform/tests/Feature/Governance/GovernanceInboxPageTest.php`
### Implementation for User Story 3
- [x] T018 [US3] Add family and tenant filter handling to `apps/platform/app/Filament/Pages/Governance/GovernanceInbox.php`, keeping active tenant context durable and clearable without inventing a second filter persistence system
- [x] T019 [US3] Ensure hidden tenants and hidden families never contribute to section counts, preview labels, or empty-state hints, and keep tenantless alert or operations entries truthful when rendered on `apps/platform/resources/views/filament/pages/governance/governance-inbox.blade.php`
**Checkpoint**: User Story 3 is independently functional when tenant and family filters remain capability-safe and globally honest.
---
## Phase 6: Polish & Cross-Cutting Concerns
**Purpose**: Finish narrow validation, formatting, and reviewer close-out without widening scope.
- [x] T020 [P] Run the focused unit validation command from `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/250-decision-governance-inbox/plan.md` and `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/250-decision-governance-inbox/quickstart.md` against `apps/platform/tests/Unit/Support/GovernanceInbox/GovernanceInboxSectionBuilderTest.php`
- [x] T021 [P] Run the focused page and authorization validation commands from `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/250-decision-governance-inbox/plan.md` and `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/250-decision-governance-inbox/quickstart.md` against `apps/platform/tests/Feature/Governance/GovernanceInboxPageTest.php` and `apps/platform/tests/Feature/Governance/GovernanceInboxAuthorizationTest.php`
- [x] T022 [P] Run the focused navigation-context validation command from `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/250-decision-governance-inbox/plan.md` and `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/250-decision-governance-inbox/quickstart.md` against `apps/platform/tests/Feature/Governance/GovernanceInboxNavigationContextTest.php`
- [x] T023 Run dirty-only Pint for touched platform files using the command recorded in `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/250-decision-governance-inbox/quickstart.md`
- [x] T024 Record the final `Guardrail / Exception / Smoke Coverage` close-out, including whether a bounded `Support/GovernanceInbox/` seam was needed and whether any contained drift resolved as `document-in-feature`, in `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/250-decision-governance-inbox/plan.md`, `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/250-decision-governance-inbox/quickstart.md`, and `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/250-decision-governance-inbox/checklists/requirements.md`
---
## Dependencies & Execution Order
### Phase Dependencies
- **Phase 1 (Setup)**: no dependencies; start immediately.
- **Phase 2 (Foundational)**: depends on Phase 1 and blocks all user-story work.
- **Phase 3 (US1)**: depends on Phase 2 and establishes the first independently valuable slice.
- **Phase 4 (US2)**: depends on Phase 2 and is safest after US1 because it reuses the same page and view files.
- **Phase 5 (US3)**: depends on Phase 2 and is safest after US1 because filter and empty-state behavior depend on the final visible sections.
- **Phase 6 (Polish)**: depends on the stories being complete.
### User Story Dependencies
- **US1 (P1)**: independently shippable as the minimal read-only decision surface once Phase 2 is complete.
- **US2 (P1)**: independently testable after Phase 2, but should merge after US1 because the same page composition and routing files are shared hotspots.
- **US3 (P2)**: independently testable after Phase 2, but should merge after US1 because family and tenant filters depend on the visible section set.
### Within Each User Story
- Write the listed Pest coverage first and make it fail for the intended gap.
- Finish shared query or routing reuse before widening the page view.
- Re-run the narrowest relevant proof command after each story checkpoint before moving to the next story.
---
## Implementation Strategy
### Suggested MVP Scope
- First shippable slice = **Phase 2 + User Story 1 + User Story 2**. That delivers the canonical decision-first inbox page with the required multi-family attention surface and the required routing into existing source surfaces.
### Incremental Delivery
1. Complete Phase 1 and Phase 2.
2. Deliver US1 and validate the multi-family read surface.
3. Deliver US2 and validate that all CTAs land on existing source surfaces.
4. Deliver US3 and validate filter honesty plus `404` handling.
5. Finish with Phase 6 validation, formatting, and close-out recording.
### Team Strategy
1. Finish Phase 2 together before splitting story work.
2. Parallelize unit and feature test authoring inside each story first.
3. Serialize merges touching `apps/platform/app/Filament/Pages/Governance/GovernanceInbox.php` and `apps/platform/resources/views/filament/pages/governance/governance-inbox.blade.php`, because they are the main conflict hotspots for this slice.
## Notes
- [P] tasks should stay on different files or clearly isolated seams.
- Each story remains independently testable, but the first shippable slice includes both US1 and US2 because routing into existing source surfaces is part of the required product contract.
- Re-run the narrowest relevant Pest command after each story checkpoint before moving forward.
- Stop at each checkpoint if the page starts drifting toward a generic workflow engine or local mutation lane.

View File

@ -1,42 +0,0 @@
# Specification Quality Checklist: Commercial Entitlements and Billing-State Maturity
**Purpose**: Validate specification completeness and readiness before planning or implementation.
**Created**: 2026-04-28
**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
## Review Outcome
- [x] Review outcome class: acceptable-special-case
- [x] Workflow outcome: keep
- [x] Test-governance impact is explicitly recorded in the spec
## Notes
- Repo-specific surface names and existing product terms are used to anchor the spec to current truth, but the spec does not prescribe languages, frameworks, APIs, or low-level implementation design.
- No open clarification markers remain. The bounded assumptions are the default `active_paid` resolution for unset workspaces and the distinct `grace` behavior that freezes onboarding expansion without blocking in-scope review-pack starts.
- Implementation close-out keeps the workflow outcome as `keep`. The Livewire browser-smoke finding was fixed inside scope by making workspace route resolution accept Livewire serialized workspace parameters; no follow-up spec is required.

View File

@ -1,465 +0,0 @@
openapi: 3.0.3
info:
title: TenantPilot Admin/System - Workspace Commercial Lifecycle Overlay (Conceptual)
version: 0.1.0
description: |
Conceptual contract for the commercial lifecycle overlay that follows the
existing workspace entitlement substrate from Spec 247.
NOTE: These routes are implemented as existing Filament pages, resources,
widgets, and Livewire-backed actions. Exact Livewire payload shapes are not
part of this contract. This file captures logical route boundaries, the
system/admin split, and the required 404 / 403 / business-state semantics.
servers:
- url: /admin
- url: /system
paths:
/directory/workspaces/{workspace}:
get:
summary: View read-only workspace commercial lifecycle summary in the system plane
description: |
Renders the existing system directory workspace detail page with the
effective lifecycle state, rationale, affected behavior summary, and the
reused entitlement substrate summary.
parameters:
- $ref: '#/components/parameters/WorkspaceId'
responses:
'200':
description: System workspace detail rendered
content:
text/html:
schema:
type: string
x-logical-view-model:
$ref: '#/components/schemas/SystemWorkspaceCommercialLifecycleView'
'403':
$ref: '#/components/responses/Forbidden'
'404':
$ref: '#/components/responses/NotFound'
/directory/workspaces/{workspace}/actions/change-commercial-state:
post:
summary: Change the workspace commercial lifecycle state from the system plane
description: |
Conceptual contract for the confirmation-protected state-change action
on the existing system workspace detail page.
Behavior:
- Platform user with directory visibility but without the dedicated
lifecycle-manage capability: 403
- Wrong plane or non-platform actor: 404 semantics at the panel boundary
- Authorized platform user: state and rationale are written through the
existing workspace settings audit path
parameters:
- $ref: '#/components/parameters/WorkspaceId'
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/ChangeCommercialLifecycleCommand'
responses:
'204':
description: Commercial lifecycle state changed successfully
'403':
$ref: '#/components/responses/Forbidden'
'404':
$ref: '#/components/responses/NotFound'
'422':
$ref: '#/components/responses/ValidationError'
/onboarding/{onboardingDraft}:
get:
summary: View onboarding workflow with lifecycle-aware completion state
description: |
Renders the existing managed-tenant onboarding wizard. The completion
step must include the commercial lifecycle outcome after the underlying
entitlement substrate has been evaluated.
parameters:
- $ref: '#/components/parameters/OnboardingDraftId'
responses:
'200':
description: Onboarding wizard rendered
content:
text/html:
schema:
type: string
x-logical-view-model:
$ref: '#/components/schemas/OnboardingCommercialLifecycleView'
'403':
$ref: '#/components/responses/Forbidden'
'404':
$ref: '#/components/responses/NotFound'
/onboarding/{onboardingDraft}/actions/complete:
post:
summary: Complete onboarding when entitlement, lifecycle state, and existing readiness all allow
parameters:
- $ref: '#/components/parameters/OnboardingDraftId'
responses:
'204':
description: Onboarding completed
'403':
$ref: '#/components/responses/Forbidden'
'404':
$ref: '#/components/responses/NotFound'
'409':
$ref: '#/components/responses/BusinessStateBlocked'
/review-packs/actions/generate:
post:
summary: Generate a review pack from the current tenant context
description: |
Conceptual contract for the tenant dashboard and review-pack list start
action family.
Behavior ordering:
1. authorization
2. underlying entitlement substrate decision
3. lifecycle overlay decision
4. existing dedupe / queued-start flow when allowed
A lifecycle-blocked attempt is future-start-only in this slice: it
creates no new `ReviewPack`, creates no new `OperationRun`, emits no
queued or terminal review-pack notification, and does not affect any
review-pack work that was already queued or running.
requestBody:
required: false
content:
application/json:
schema:
$ref: '#/components/schemas/ReviewPackGenerationCommand'
responses:
'202':
description: Generation accepted or deduped through the existing flow
'403':
$ref: '#/components/responses/Forbidden'
'404':
$ref: '#/components/responses/NotFound'
'409':
$ref: '#/components/responses/BusinessStateBlocked'
/tenant-reviews/{tenantReview}/actions/export-executive-pack:
post:
summary: Export an executive pack from an existing tenant review
description: |
Conceptual contract for the review register and tenant review detail
export action family. The lifecycle overlay must block before any new
`ReviewPack` or `OperationRun` is created, emit no queued or terminal
review-pack notification for the blocked attempt, and leave any
already-created queued or running review-pack work unchanged.
parameters:
- $ref: '#/components/parameters/TenantReviewId'
responses:
'202':
description: Export accepted or deduped through the existing flow
'403':
$ref: '#/components/responses/Forbidden'
'404':
$ref: '#/components/responses/NotFound'
'409':
$ref: '#/components/responses/BusinessStateBlocked'
/review-packs/{reviewPack}/actions/regenerate:
post:
summary: Regenerate an existing review pack
description: |
Conceptual contract for the existing review-pack detail regenerate
action. Existing confirmation and dedupe behavior remain in place when
the lifecycle overlay allows the start. A lifecycle-blocked attempt is
future-start-only: it creates no new `ReviewPack`, creates no new
`OperationRun`, emits no queued or terminal review-pack notification,
and leaves any already-created queued or running review-pack work
unchanged.
parameters:
- $ref: '#/components/parameters/ReviewPackId'
requestBody:
required: false
content:
application/json:
schema:
$ref: '#/components/schemas/ReviewPackGenerationCommand'
responses:
'202':
description: Regeneration accepted or deduped through the existing flow
'403':
$ref: '#/components/responses/Forbidden'
'404':
$ref: '#/components/responses/NotFound'
'409':
$ref: '#/components/responses/BusinessStateBlocked'
/tenant-reviews/{tenantReview}:
get:
summary: View existing tenant review while the workspace may be suspended read-only
parameters:
- $ref: '#/components/parameters/TenantReviewId'
responses:
'200':
description: Existing tenant review rendered when current RBAC allows it
content:
text/html:
schema:
type: string
x-logical-view-model:
$ref: '#/components/schemas/PreservedReadOnlyView'
'403':
$ref: '#/components/responses/Forbidden'
'404':
$ref: '#/components/responses/NotFound'
/review-packs/{reviewPack}:
get:
summary: View existing review pack while the workspace may be suspended read-only
parameters:
- $ref: '#/components/parameters/ReviewPackId'
responses:
'200':
description: Existing review pack rendered when current RBAC allows it
content:
text/html:
schema:
type: string
x-logical-view-model:
$ref: '#/components/schemas/PreservedReadOnlyView'
'403':
$ref: '#/components/responses/Forbidden'
'404':
$ref: '#/components/responses/NotFound'
/review-packs/{reviewPack}/download:
get:
summary: Download an already-generated review pack while the workspace may be suspended read-only
parameters:
- $ref: '#/components/parameters/ReviewPackId'
responses:
'200':
description: Existing generated pack download is still available when current RBAC allows it
'403':
$ref: '#/components/responses/Forbidden'
'404':
$ref: '#/components/responses/NotFound'
/evidence-snapshots/{evidenceSnapshot}:
get:
summary: View existing evidence snapshot while the workspace may be suspended read-only
parameters:
- $ref: '#/components/parameters/EvidenceSnapshotId'
responses:
'200':
description: Existing evidence snapshot rendered when current RBAC allows it
content:
text/html:
schema:
type: string
x-logical-view-model:
$ref: '#/components/schemas/PreservedReadOnlyView'
'403':
$ref: '#/components/responses/Forbidden'
'404':
$ref: '#/components/responses/NotFound'
components:
parameters:
WorkspaceId:
name: workspace
in: path
required: true
schema:
type: integer
OnboardingDraftId:
name: onboardingDraft
in: path
required: true
schema:
type: integer
TenantReviewId:
name: tenantReview
in: path
required: true
schema:
type: integer
ReviewPackId:
name: reviewPack
in: path
required: true
schema:
type: integer
EvidenceSnapshotId:
name: evidenceSnapshot
in: path
required: true
schema:
type: integer
responses:
Forbidden:
description: Established-scope actor lacks the required capability
NotFound:
description: Wrong plane, non-member scope, or inaccessible record
BusinessStateBlocked:
description: Actor is otherwise authorized, but the workspace commercial state or underlying entitlement substrate blocks the requested action
content:
application/json:
schema:
$ref: '#/components/schemas/CommercialLifecycleBlockResponse'
ValidationError:
description: Submitted commercial lifecycle state change failed validation
schemas:
ChangeCommercialLifecycleCommand:
type: object
required:
- state
- reason
properties:
state:
$ref: '#/components/schemas/CommercialLifecycleState'
reason:
type: string
description: Required for every explicit lifecycle state change, including an explicit return to active_paid.
minLength: 1
maxLength: 500
CommercialLifecycleState:
type: string
enum:
- trial
- grace
- active_paid
- suspended_read_only
ReviewPackGenerationCommand:
type: object
properties:
include_pii:
type: boolean
include_operations:
type: boolean
SystemWorkspaceCommercialLifecycleView:
type: object
required:
- workspace_id
- lifecycle
- affected_behaviors
properties:
workspace_id:
type: integer
lifecycle:
$ref: '#/components/schemas/CommercialLifecycleDecision'
affected_behaviors:
type: array
items:
$ref: '#/components/schemas/CommercialLifecycleActionDecision'
entitlement_substrate:
type: object
description: Existing Spec 247 workspace entitlement summary reused for context
primary_action:
$ref: '#/components/schemas/NextAction'
nullable: true
OnboardingCommercialLifecycleView:
type: object
required:
- onboarding_draft_id
- action_decision
properties:
onboarding_draft_id:
type: integer
action_decision:
$ref: '#/components/schemas/CommercialLifecycleActionDecision'
entitlement_substrate:
type: object
nullable: true
CommercialLifecycleDecision:
type: object
required:
- state
- label
- source
- source_label
properties:
state:
$ref: '#/components/schemas/CommercialLifecycleState'
label:
type: string
source:
type: string
enum:
- default_active_paid
- workspace_setting
source_label:
type: string
description: Rendered source label from the shared lifecycle source mapping used by system detail surfaces.
rationale:
type: string
nullable: true
last_changed_at:
type: string
format: date-time
nullable: true
last_changed_by:
type: string
nullable: true
CommercialLifecycleActionDecision:
type: object
required:
- action_key
- outcome
- lifecycle_state
properties:
action_key:
type: string
enum:
- managed_tenant_activation
- review_pack_start
- review_history_read
- evidence_read
- generated_pack_read
outcome:
type: string
enum:
- allow
- warn
- block
- allow_read_only
reason_family:
type: string
nullable: true
enum:
- commercial_lifecycle
- entitlement_substrate
message:
type: string
nullable: true
lifecycle_state:
$ref: '#/components/schemas/CommercialLifecycleState'
underlying_entitlement_key:
type: string
nullable: true
CommercialLifecycleBlockResponse:
type: object
required:
- reason_family
- message
properties:
reason_family:
type: string
enum:
- commercial_lifecycle
- entitlement_substrate
lifecycle_state:
$ref: '#/components/schemas/CommercialLifecycleState'
nullable: true
message:
type: string
PreservedReadOnlyView:
type: object
required:
- read_only_access_preserved
properties:
read_only_access_preserved:
type: boolean
enum: [true]
lifecycle_state:
$ref: '#/components/schemas/CommercialLifecycleState'
message:
type: string
nullable: true
description: Optional calm explanation that the workspace is suspended read-only while current history access remains available
NextAction:
type: object
required:
- label
properties:
label:
type: string
enabled:
type: boolean
reason:
type: string
nullable: true

View File

@ -1,170 +0,0 @@
# Data Model: Commercial Entitlements and Billing-State Maturity
**Date**: 2026-04-28
**Branch**: `251-commercial-entitlements-billing-state`
## Overview
This slice adds no new table. Persisted truth stays in existing `workspace_settings` rows, while the commercial lifecycle overlay and action-family outcomes remain derived.
## Persisted Truth
### 1. Workspace Commercial Lifecycle Setting Aggregate
**Persistence**: Existing `App\Models\WorkspaceSetting` rows
**Ownership**: Workspace-owned
**Scope**: One workspace, no new tenant-owned or platform-owned persistence
The slice reuses explicit settings keys under the existing `entitlements` domain.
| Setting key | Type | Nullable | Validation | Notes |
|-------------|------|----------|------------|-------|
| `entitlements.commercial_lifecycle_state` | string | yes | when present, must be one of `trial`, `grace`, `active_paid`, `suspended_read_only` | `null` means the workspace has never been explicitly set and resolves to the implicit default `active_paid` |
| `entitlements.commercial_lifecycle_reason` | string | yes | required on every explicit lifecycle state change; trimmed; max 500 chars | Operator-entered rationale shown on system and contextual admin surfaces |
**Write rules**:
- Lifecycle mutation happens from the system plane only and updates state plus rationale together through the existing workspace settings write/audit path.
- The future `Change commercial state` action is confirmation-protected and requires explicit rationale for every explicit lifecycle transition, including an explicit return to `active_paid`.
- Once a platform operator explicitly sets `active_paid`, that remains a stored state like the other three values. `null` is reserved for untouched workspaces only.
**Relationships**:
- `workspace_settings.workspace_id` anchors lifecycle truth to the workspace.
- `workspace_settings.updated_by_user_id` remains the attribution source for state change metadata.
## Existing Substrate Truth Reused
### 2. Workspace Entitlement Substrate Summary
**Persistence**: Existing Spec 247 workspace entitlement settings + code-owned plan-profile catalog
**Owner**: `WorkspaceEntitlementResolver`
This slice does not remodel the substrate. It reuses:
- `plan_profile`
- `managed_tenant_activation_limit`
- `review_pack_generation_enabled`
- substrate rationale/source/current-usage metadata
The lifecycle overlay may warn or restrict after substrate resolution, but it must never expand access beyond what the substrate already allows.
## Code-Owned Truth
### 3. Commercial Lifecycle State Catalog Entry
**Persistence**: none, code-owned
**Ownership**: Product/runtime configuration
**Scope**: current release only
| Field | Type | Required | Notes |
|-------|------|----------|-------|
| `id` | string | yes | Stable internal identifier stored in `entitlements.commercial_lifecycle_state` |
| `label` | string | yes | Operator-facing state label |
| `description` | string | yes | Short explanation for system detail and contextual messaging |
| `onboarding_outcome` | string | yes | `allow` or `block` |
| `review_pack_start_outcome` | string | yes | `allow`, `warn`, or `block` |
| `preserves_read_only_history` | bool | yes | Whether existing review/evidence/generated-pack consumption remains explicitly preserved |
| `is_default` | bool | yes | Exactly one default entry: `active_paid` |
**Behavior matrix**:
| State | Onboarding activation | Review-pack starts | Existing review/evidence/download access |
|-------|-----------------------|--------------------|------------------------------------------|
| `trial` | allow | allow | allow |
| `active_paid` | allow | allow | allow |
| `grace` | block | warn (start still allowed) | allow |
| `suspended_read_only` | block | block | allow |
## Derived Truth
### 4. Effective Commercial Lifecycle Decision
**Persistence**: none, derived at runtime
**Owner**: bounded `WorkspaceCommercialLifecycleResolver`
| Field | Type | Required | Notes |
|-------|------|----------|-------|
| `workspace_id` | int | yes | Workspace being evaluated |
| `state` | string | yes | Effective lifecycle state |
| `label` | string | yes | Operator-facing label |
| `source` | string | yes | `default_active_paid` or `workspace_setting`; any rendered source label must come from one shared mapping |
| `rationale` | string | no | Explicit operator rationale when source is `workspace_setting` |
| `last_changed_at` | datetime | no | Derived from the most recent lifecycle-related `WorkspaceSetting` row |
| `last_changed_by` | string | no | Derived actor attribution |
| `entitlement_summary` | object | yes | Existing Spec 247 substrate summary reused for support/context |
| `action_decisions` | object | yes | Per-action-family outcomes described below |
### 5. Commercial Lifecycle Action Decision
**Persistence**: none, derived at runtime
| Field | Type | Required | Notes |
|-------|------|----------|-------|
| `action_key` | string | yes | One of `managed_tenant_activation`, `review_pack_start`, `review_history_read`, `evidence_read`, `generated_pack_read` |
| `outcome` | string | yes | `allow`, `warn`, `block`, or `allow_read_only` |
| `reason_family` | string | no | `commercial_lifecycle`, `entitlement_substrate`, or `null` when fully allowed |
| `message` | string | no | Operator-safe explanation or warning |
| `lifecycle_state` | string | yes | Effective state that produced the action decision |
| `underlying_entitlement_key` | string | no | Present for onboarding/review-pack start decisions to preserve substrate traceability |
**Decision ordering rules**:
- The substrate entitlement decision runs first.
- If the substrate already blocks the action, the lifecycle overlay must not replace that reason.
- If the substrate allows the action, the lifecycle overlay may warn or block according to the state matrix.
- Authorization is not part of this derived decision; 404 and 403 semantics remain outside and happen earlier.
## Supporting Derived View Models
### 6. System Workspace Commercial Lifecycle View Model
**Persistence**: none
**Consumer**: `App\Filament\System\Pages\Directory\ViewWorkspace`
Contains:
- effective lifecycle state, label, rationale, and last-change attribution
- the two in-scope action-family outcomes
- the reused entitlement substrate summary for support context
- the one dominant mutation affordance metadata for `Change commercial state`
### 7. Contextual Admin Lifecycle Gate View Models
**Persistence**: none
**Consumers**: `ManagedTenantOnboardingWizard`, review-pack entry surfaces, and suspended read-only history surfaces
Contains:
- the immediate action-family outcome (`allow`, `warn`, `block`, or `allow_read_only`)
- one operator-safe explanation
- enough substrate context to keep lifecycle blocks distinct from underlying entitlement blocks
## Derived Query Dependencies
| Need | Source | Notes |
|------|--------|-------|
| Underlying plan-profile and entitlement truth | `WorkspaceEntitlementResolver` | Remains the canonical substrate |
| Lifecycle last-change attribution | existing `workspace_settings.updated_by_user_id` and timestamps | Derived from lifecycle-related rows only |
| Active managed-tenant usage | existing tenant/workspace runtime truth | Reused from the substrate summary |
| Existing review/history/evidence/download availability | existing review pack, review, evidence snapshot, and RBAC truth | No new persistence needed |
| Review-pack no-run proof | existing `review_packs` and `operation_runs` tables | Used only in tests to prove blocked starts do not write new run state |
## State Transitions
There is no new table-backed lifecycle entity. State changes are explicit workspace-setting transitions plus audit entries.
| From | To | Trigger | Consequence |
|------|----|---------|-------------|
| `null` (implicit default) | any explicit state | platform operator saves lifecycle state on the system detail page | workspace now has explicit commercial posture, rationale, and attribution |
| `trial` | `grace` | platform operator state change | new managed-tenant activation blocks; review-pack starts remain allowed with warning |
| `grace` | `suspended_read_only` | platform operator state change | onboarding and new review-pack starts block; history/evidence/download remain available |
| `suspended_read_only` | `active_paid` | platform operator state change | future starts again defer to underlying entitlement truth |
| any explicit state | another explicit state | platform operator state change | previous state is replaced; audit history preserves the transition trail |
## Boundaries Explicitly Preserved
- No new billing/customer/subscription entity exists.
- No new automated timers, expiry jobs, renewal reminders, or scheduled transitions are introduced.
- No new broad suspension contract is added for unrelated mutable surfaces.
- Existing read-only review/evidence/generated-pack access remains governed by current RBAC and redaction rules.

View File

@ -1,297 +0,0 @@
# Implementation Plan: Commercial Entitlements and Billing-State Maturity
**Branch**: `251-commercial-entitlements-billing-state` | **Date**: 2026-04-28 | **Spec**: `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/251-commercial-entitlements-billing-state/spec.md`
**Input**: Feature specification from `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/251-commercial-entitlements-billing-state/spec.md`
**Note**: This template is filled in by the `/speckit.plan` command. See `.specify/scripts/` for helper scripts.
## Summary
- Layer one bounded workspace commercial lifecycle overlay on top of the already-real Spec 247 entitlement substrate, not beside it. The existing `WorkspaceEntitlementResolver` remains canonical for plan/default/override truth, and the new slice adds one explicit lifecycle state plus action-family outcomes for onboarding activation, review-pack start, and preserved read-only history access.
- Keep mutation narrow and platform-owned: persist lifecycle state through the existing workspace settings infrastructure, expose inspection plus state change from the existing system workspace detail surface, and keep `/admin` limited to contextual allow, warn, or block messaging on onboarding and review-pack surfaces.
- Preserve current review/evidence/download truth while suspended. New lifecycle blocking must stop future onboarding activation and future review-pack starts before any tenant mutation, `ReviewPack`, or `OperationRun` creation, while leaving already-generated history and evidence consumption under current RBAC intact.
## Technical Context
**Language/Version**: PHP 8.4 (Laravel 12)
**Primary Dependencies**: Filament v5 + Livewire v4, existing workspace settings stack (`SettingsRegistry`, `SettingsResolver`, `SettingsWriter`), `WorkspaceEntitlementResolver`, `ReviewPackService`, system directory detail page
**Storage**: PostgreSQL via existing `workspace_settings` rows plus existing audit log records; no new table or billing/account model
**Testing**: Pest unit and feature tests via Laravel Sail
**Validation Lanes**: fast-feedback, confidence
**Target Platform**: Monorepo Laravel web application in `apps/platform`, using existing Filament admin and system panels
**Project Type**: web
**Performance Goals**: Reuse existing settings reads and current workspace aggregates only, add no new external calls during render, keep review-pack dedupe and shared run UX unchanged when allowed, and short-circuit blocked review-pack starts before any `ReviewPack` or `OperationRun` write
**Constraints**: One commercial lifecycle overlay only, four bounded states, two real gated behavior families, preserved authorized read-only history/evidence/download access while suspended, explicit `/admin` vs `/system` separation, no payment provider/invoice/checkout/website/broad billing-engine scope
**Scale/Scope**: One bounded lifecycle resolver, one system-plane mutation surface, one platform capability addition, one onboarding gate, one review-pack action-family gate, and focused lifecycle/read-only test coverage
## Filament v5 / Panel Notes
- **Livewire v4.0+ compliance**: The slice stays inside existing Filament v5 pages, widgets, resources, and Livewire-backed actions. No Livewire v3 assumptions or compatibility work are introduced.
- **Provider registration location**: No panel/provider registration changes are planned. Existing Laravel 12 + Filament provider registration remains in `bootstrap/providers.php`.
- **Global search**: No new globally searchable resource is introduced. Current global-search behavior remains unchanged.
- **Destructive and high-impact actions**: The future `Change commercial state` action on the system workspace detail page must use `->requiresConfirmation()`, require platform authorization, and write audit history. The `Suspended / read-only` transition is the only high-risk path in scope. Review-pack and onboarding blocks remain non-destructive business-state responses, not hidden authorization failures.
- **Asset strategy**: No new panel or shared assets are planned. Deployment remains unchanged, including `cd apps/platform && php artisan filament:assets` when registered Filament assets are deployed elsewhere in the product.
## UI / Surface Guardrail Plan
- **Guardrail scope**: changed surfaces
- **Native vs custom classification summary**: mixed
- **Shared-family relevance**: system detail controls, status messaging, onboarding helper text, review-pack action gating, review/evidence viewer messaging
- **State layers in scope**: page, detail
- **Audience modes in scope**: operator-MSP, support-platform, customer/read-only
- **Decision/diagnostic/raw hierarchy plan**: decision-first on the system workspace detail surface and on the immediate onboarding/review-pack action context; diagnostics-second via the existing entitlement substrate and review/run/history context; no new raw/support payload surface is planned
- **Raw/support gating plan**: capability-gated system-plane inspection only; customer/read-only surfaces remain calm and evidence-first
- **One-primary-action / duplicate-truth control**: `/system/directory/workspaces/{workspace}` remains the only mutation surface; onboarding and review-pack surfaces show only the local lifecycle consequence required for the immediate action; suspended read-only history pages do not restate the whole commercial profile
- **Handling modes by drift class or surface**: review-mandatory because one lifecycle vocabulary must stay consistent across system, onboarding, review-pack, and read-only history surfaces
- **Repository-signal treatment**: review-mandatory
- **Special surface test profiles**: standard-native-filament, shared-detail-family, monitoring-state-page
- **Required tests or manual smoke**: functional-core, state-contract
- **Exception path and spread control**: no second admin-plane commercial mutation surface, no page-local lifecycle labels, and no broad suspension sweep across unrelated mutable surfaces
- **Active feature PR close-out entry**: Guardrail
## Shared Pattern & System Fit
- **Cross-cutting feature marker**: yes
- **Systems touched**: `SettingsRegistry`, `SettingsResolver`, `SettingsWriter`, existing workspace-setting audit path, `WorkspaceEntitlementResolver`, `WorkspaceSettings` as the current entitlement substrate reference, `App\Filament\System\Pages\Directory\ViewWorkspace`, `ManagedTenantOnboardingWizard`, `ReviewPackService`, `TenantReviewPackCard`, `ReviewRegister`, `TenantReviewResource`, `ReviewPackResource`, `CustomerReviewWorkspace`, `EvidenceSnapshotResource`, and `WorkspaceEntitlementBlockedException`
- **Shared abstractions reused**: `WorkspaceEntitlementResolver`, `WorkspacePlanProfileCatalog`, `SettingsResolver`, `SettingsWriter`, current workspace audit logging, `ReviewPackService`, `OperationUxPresenter`, `OperationRunLinks`, `Capabilities`, `PlatformCapabilities`, and existing Filament action/resource surfaces
- **New abstraction introduced? why?**: one bounded `WorkspaceCommercialLifecycleResolver` is justified because the existing entitlement resolver answers per-key entitlement truth but does not express one workspace-wide lifecycle posture with action-family outcomes, preserved read-only semantics, or system/admin messaging
- **Why the existing abstraction was sufficient or insufficient**: Spec 247 already provides canonical entitlement substrate truth and must remain the foundation. It is insufficient for `trial`, `grace`, `active_paid`, and `suspended_read_only` because those states cut across more than one entitlement key and need one central business-state explanation
- **Bounded deviation / spread control**: no page-local lifecycle conditionals and no second exception taxonomy by default; prefer reusing the existing blocked decision payload/catch path for review-pack actions unless implementation proves that the current class name or payload cannot carry lifecycle metadata cleanly
## OperationRun UX Impact
- **Touches OperationRun start/completion/link UX?**: yes
- **Central contract reused**: existing shared review-pack OperationRun start UX through `ReviewPackService`, `OperationUxPresenter`, and `OperationRunLinks`
- **Delegated UX behaviors**: queued toast, run link, run-enqueued browser event, dedupe messaging, and existing terminal notifications remain unchanged when review-pack generation is allowed; lifecycle-blocked starts create no `OperationRun`, no queued DB notification, and no terminal notification
- **Surface-owned behavior kept local**: onboarding completion helper text, review-pack tooltips/disabled state, and suspended read-only explanation on history surfaces remain local projections of the central lifecycle decision
- **Queued DB-notification policy**: unchanged explicit opt-in only
- **Terminal notification path**: unchanged central lifecycle mechanism for existing review-pack runs only
- **Exception path**: none planned; lifecycle blocking must happen before `ReviewPackService` creates or reuses a `ReviewPack` or `OperationRun`, and the preferred later implementation is to extend the current blocked-decision payload rather than invent a second parallel business-state exception family
## Provider Boundary & Portability Fit
- **Shared provider/platform boundary touched?**: no
- **Provider-owned seams**: N/A
- **Platform-core seams**: workspace commercial lifecycle vocabulary, lifecycle rationale, action-family outcomes, system/admin messaging, and audit semantics
- **Neutral platform terms / contracts preserved**: `workspace`, `trial`, `grace`, `active paid`, `suspended / read-only`, `commercial state`, `review pack`, `managed tenant activation`
- **Retained provider-specific semantics and why**: none; review-pack generation stays provider-backed operationally, but the new lifecycle vocabulary remains platform-core and provider-neutral
- **Bounded extraction or follow-up path**: none
## Constitution Check
*GATE: Must pass before Phase 0 research. Re-check after Phase 1 design.*
- Inventory-first: PASS - the slice adds workspace-owned business state, not new inventory or backup truth.
- Read/write separation: PASS - the only new write is a confirmation-protected, audited system-plane lifecycle mutation using existing workspace settings persistence.
- Graph contract path: PASS - no new Microsoft Graph path is introduced.
- Deterministic capabilities: PASS - admin-plane capabilities remain unchanged, and any new platform capability stays registry-backed.
- RBAC-UX: PASS - `/admin` and `/system` remain separated; wrong-plane and non-member access stay 404; member-without-capability stays 403; otherwise-authorized actors get a business-state block or warning instead of authorization failure.
- Workspace isolation: PASS - admin-plane contextual behavior still requires established workspace context.
- RBAC-UX destructive confirmation: PASS - the future system-plane state-change action must require confirmation and rationale.
- RBAC-UX global search: PASS - no new searchable resource or search scope is introduced.
- Tenant isolation: PASS - onboarding, review-pack, review history, evidence, and download surfaces remain tenant-safe.
- Run observability: PASS - review-pack generation keeps the existing `OperationRun` path when allowed, and blocked starts stop before run creation.
- OperationRun start UX: PASS - the plan preserves shared review-pack start UX and inserts lifecycle blocking before run creation.
- Ops-UX 3-surface feedback: PASS - existing feedback stays toast + progress surfaces + terminal notification only when a run exists.
- Ops-UX lifecycle: PASS - no new `OperationRun` lifecycle contract is introduced.
- Ops-UX summary counts: N/A - no `summary_counts` shape change is planned.
- Ops-UX guards: N/A - no new run guard family is planned in the planning slice.
- Ops-UX system runs: N/A - initiator-null behavior is unchanged.
- Automation: N/A - no new queued or scheduled workflow family is introduced.
- Data minimization: PASS - no payment payloads, account records, or provider secrets are introduced.
- Test governance (TEST-GOV-001): PASS - the plan stays in focused unit + feature lanes with explicit proof commands and limited fixture growth.
- Proportionality (PROP-001): PASS - persistence stays in existing settings rows, and the only new structural element is one bounded lifecycle overlay.
- No premature abstraction (ABSTR-001): PASS - no interface, registry, strategy system, or framework is planned; only one local resolver is added because multiple real surfaces already need the same lifecycle decision.
- Persisted truth (PERSIST-001): PASS - no new table or durable artifact is introduced.
- Behavioral state (STATE-001): PASS - `grace` and `suspended_read_only` create distinct action-family consequences immediately, and `trial` remains justified because it is part of the explicit platform-managed commercial posture and audit workflow even though its two in-scope gated families currently match `active_paid`.
- UI semantics (UI-SEM-001): PASS - the plan prefers direct mapping from lifecycle truth to helper text and badges instead of a new presentation framework.
- Shared pattern first (XCUT-001): PASS - system detail, onboarding, review-pack, and read-only history surfaces all reuse the existing substrate and shared run path first.
- Provider boundary (PROV-001): PASS - the new vocabulary is platform-core, not Microsoft-shaped.
- V1 explicitness / few layers (V1-EXP-001, LAYER-001): PASS - one explicit state family plus one thin overlay resolver is the narrowest viable shape.
- Spec discipline / bloat check (SPEC-DISC-001, BLOAT-001): PASS - the plan keeps the whole lifecycle overlay in one coherent spec and includes proportionality review below.
- Badge semantics (BADGE-001): PASS - any future lifecycle badge must reuse shared badge semantics or stay plain text; no page-local color taxonomy is planned.
- Filament-native UI (UI-FIL-001): PASS - the slice extends existing Filament pages, resources, widgets, and the current system Blade page.
- Filament-native UI local Blade/Tailwind: PASS - the existing system Blade view remains the only custom-rendered surface in scope and must preserve current Filament visual language.
- UI/UX surface taxonomy (UI-CONST-001 / UI-SURF-001): PASS - existing system detail, guided onboarding, action family, and read-only viewer surface types remain intact.
- Decision-first operating model (DECIDE-001): PASS - system workspace detail is primary, onboarding/review-pack surfaces stay contextual, and read-only history/evidence pages remain tertiary evidence/diagnostics.
- Audience-aware disclosure (DECIDE-AUD-001 / OPSURF-001): PASS - system detail stays platform/support-facing, admin action gates stay operator-first, and suspended read-only pages keep customer-safe history access without raw platform diagnostics.
- UI/UX inspect model (UI-HARD-001): PASS - no duplicate inspect affordances are added.
- UI/UX action hierarchy (UI-HARD-001 / UI-EX-001): PASS - the plan keeps one system mutation action and existing onboarding/review-pack primary actions in place.
- UI/UX scope, truth, and naming (UI-HARD-001 / UI-NAMING-001 / OPSURF-001): PASS - labels remain narrow and billing-provider-free.
- UI/UX placeholder ban (UI-HARD-001): PASS - no empty action groups are planned.
- UI naming (UI-NAMING-001): PASS - primary labels stay `Change commercial state`, `Complete onboarding`, `Generate pack`, `Regenerate`, and `Export executive pack`.
- Operator surfaces (OPSURF-001): PASS - mutation scope remains explicit, and `/admin` surfaces only show contextual lifecycle truth.
- Operator surface page contract: PASS - the spec already defines the required surface contracts.
- Filament UI Action Surface Contract: PASS - touched surfaces already have contracts or exemptions; the plan preserves them while adding lifecycle truth.
- Filament UI UX-001 (Layout & IA): PASS - no new page shell or panel is introduced.
- Action-surface discipline (ACTSURF-001 / HDR-001): PASS - one system primary action and existing onboarding/review-pack action families remain the only primary mutations in scope.
- UI review workflow: PASS - guardrail, shared-family, and exception posture remain explicit in this plan.
## Test Governance Check
- **Test purpose / classification by changed surface**: `Unit` for the bounded lifecycle overlay and behavior matrix; `Feature` for system-plane mutation, onboarding activation gating, review-pack start blocking, and preserved suspended read-only consumption
- **Affected validation lanes**: `fast-feedback`, `confidence`
- **Why this lane mix is the narrowest sufficient proof**: the business risk is deterministic decision ordering plus existing Filament/Livewire and service entry points. Browser or heavy-governance coverage would add cost without proving additional current-release risk for this bounded overlay.
- **Narrowest proving command(s)**:
- `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Unit/Entitlements/WorkspaceCommercialLifecycleResolverTest.php`
- `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/System/ViewWorkspaceEntitlementsTest.php tests/Feature/System/Spec113/AuthorizationSemanticsTest.php tests/Feature/Onboarding/ManagedTenantOnboardingEntitlementTest.php tests/Feature/Onboarding/OnboardingRbacSemanticsTest.php`
- `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/ReviewPack/ReviewPackEntitlementEnforcementTest.php tests/Feature/ReviewPack/ReviewPackGenerationTest.php tests/Feature/ReviewPack/ReviewPackDownloadTest.php tests/Feature/Reviews/CustomerReviewWorkspacePageTest.php tests/Feature/Reviews/CustomerReviewWorkspacePackAccessTest.php tests/Feature/Evidence/EvidenceSnapshotResourceTest.php`
- **Fixture / helper / factory / seed / context cost risks**: limited to workspace, platform user, workspace member, onboarding draft, tenant, existing review pack, tenant review, and evidence snapshot fixtures
- **Expensive defaults or shared helper growth introduced?**: no - implementation should reuse existing factories and opt-in tenant/review/evidence helpers only
- **Heavy-family additions, promotions, or visibility changes**: none
- **Surface-class relief / special coverage rule**: standard-native relief for system detail and onboarding; monitoring-state-page proof for no new run creation; shared-detail-family proof for preserved view/download access while suspended
- **Closing validation and reviewer handoff**: rerun the exact targeted commands above, verify 404 vs 403 vs business-state outcomes separately, verify system detail source labels remain consistent, verify blocked review-pack starts create no new `ReviewPack` or `OperationRun` and emit no queued or terminal notification, verify already queued or running review-pack runs continue unaffected after later suspension, and verify suspended workspaces still allow authorized review/evidence/download access
- **Budget / baseline / trend follow-up**: none expected beyond ordinary feature-local growth
- **Review-stop questions**: does the state vocabulary stay narrow enough, does the system/admin split remain intact, does suspended read-only coverage avoid broad mutation-sweep scope, and does the blocked-decision transport avoid a second exception framework
- **Escalation path**: document-in-feature if only payload wording or helper reuse needs adjustment; follow-up-spec only if the overlay starts pulling unrelated mutable surfaces into suspension logic
- **Active feature PR close-out entry**: Guardrail
- **Why no dedicated follow-up spec is needed**: the testing cost stays local to one overlay resolver and a small set of existing pages/services/resources; no new browser or heavy-governance harness is justified
## Project Structure
### Documentation (this feature)
```text
specs/251-commercial-entitlements-billing-state/
├── plan.md
├── research.md
├── data-model.md
├── quickstart.md
├── contracts/
│ └── workspace-commercial-lifecycle-overlay.logical.openapi.yaml
├── checklists/
│ └── requirements.md
└── tasks.md # Created later by /speckit.tasks, not by this plan step
```
### Source Code (repository root)
```text
apps/platform/
├── app/
│ ├── Exceptions/Entitlements/WorkspaceEntitlementBlockedException.php
│ ├── Filament/
│ │ ├── Pages/
│ │ │ ├── Reviews/CustomerReviewWorkspace.php
│ │ │ ├── Reviews/ReviewRegister.php
│ │ │ ├── Settings/WorkspaceSettings.php
│ │ │ └── Workspaces/ManagedTenantOnboardingWizard.php
│ │ ├── Resources/
│ │ │ ├── EvidenceSnapshotResource.php
│ │ │ ├── EvidenceSnapshotResource/Pages/ViewEvidenceSnapshot.php
│ │ │ ├── ReviewPackResource.php
│ │ │ ├── ReviewPackResource/Pages/ViewReviewPack.php
│ │ │ ├── TenantReviewResource.php
│ │ │ └── TenantReviewResource/Pages/ViewTenantReview.php
│ │ ├── System/Pages/Directory/ViewWorkspace.php
│ │ └── Widgets/Tenant/TenantReviewPackCard.php
│ ├── Models/WorkspaceSetting.php
│ ├── Services/
│ │ ├── Entitlements/WorkspaceCommercialLifecycleResolver.php # likely new bounded overlay service
│ │ ├── Entitlements/WorkspaceEntitlementResolver.php
│ │ ├── ReviewPackService.php
│ │ └── Settings/
│ │ ├── SettingsResolver.php
│ │ └── SettingsWriter.php
│ ├── Support/
│ │ ├── Auth/Capabilities.php
│ │ ├── Auth/PlatformCapabilities.php
│ │ └── Settings/SettingsRegistry.php
├── resources/views/filament/system/pages/directory/view-workspace.blade.php
└── tests/
├── Feature/
└── Unit/
```
**Structure Decision**: Single Laravel/Filament application inside `apps/platform`, with one new bounded lifecycle overlay service and changes limited to existing settings persistence, system detail, onboarding, review-pack, and read-only review/evidence/download surfaces plus focused Pest coverage.
## Likely Implementation Surfaces
- `app/Support/Settings/SettingsRegistry.php` to register lifecycle-setting definitions and validation using the existing workspace settings infrastructure
- `app/Services/Entitlements/WorkspaceCommercialLifecycleResolver.php` as the new bounded overlay, with `WorkspaceEntitlementResolver.php` remaining the canonical substrate provider
- `app/Support/Auth/PlatformCapabilities.php` and related platform authorization helpers for one dedicated commercial-lifecycle management capability
- `app/Filament/System/Pages/Directory/ViewWorkspace.php` and `resources/views/filament/system/pages/directory/view-workspace.blade.php` for read-only summary plus the confirmation-protected state-change action
- `app/Filament/Pages/Workspaces/ManagedTenantOnboardingWizard.php` for contextual lifecycle messaging and activation blocking before tenant mutation
- `app/Services/ReviewPackService.php`, `app/Filament/Widgets/Tenant/TenantReviewPackCard.php`, `app/Filament/Pages/Reviews/ReviewRegister.php`, `app/Filament/Resources/TenantReviewResource.php`, `app/Filament/Resources/ReviewPackResource.php`, `app/Filament/Resources/ReviewPackResource/Pages/ViewReviewPack.php`, and `app/Filament/Resources/TenantReviewResource/Pages/ViewTenantReview.php` for shared start gating and tooltip/disabled-state reuse
- Existing read-only consumption surfaces `CustomerReviewWorkspace.php`, `ViewTenantReview.php`, `ViewReviewPack.php`, `ViewEvidenceSnapshot.php`, and the current review-pack download path to prove suspended history/evidence access remains available under existing RBAC
- Focused unit and feature tests under `tests/Unit/Entitlements`, `tests/Feature/System/Directory`, `tests/Feature/Onboarding`, `tests/Feature/ReviewPack`, `tests/Feature/Reviews`, and `tests/Feature/Evidence`
## Complexity Tracking
| Violation | Why Needed | Simpler Alternative Rejected Because |
|-----------|------------|-------------------------------------|
| One bounded `WorkspaceCommercialLifecycleResolver` | Two real gated behavior families plus preserved read-only consumption need one shared workspace-wide decision layered above existing entitlements | Page-local conditionals in onboarding, review-pack resources/widgets, and system detail would drift immediately and undermine business-state consistency |
| Four-state commercial lifecycle vocabulary | Platform operators need one auditable commercial posture that distinguishes trial, grace, active paid, and suspended/read-only on the single system decision surface | Three unlabeled booleans or ad hoc flags would either collapse grace into suspension or lose the explicit platform-side lifecycle state needed for support and audit |
## Proportionality Review
- **Current operator problem**: The repo can already answer per-key entitlement questions, but it cannot say in one place whether a workspace is currently trialing, in grace, fully active paid, or suspended/read-only, nor can it explain why onboarding and review-pack starts are blocked while history remains readable.
- **Existing structure is insufficient because**: `WorkspaceEntitlementResolver` and current workspace settings expose substrate truth only. They do not provide one workspace-wide lifecycle posture, one system-owned mutation path, or one action-family outcome that distinguishes lifecycle blocks from entitlement blocks and authorization failures.
- **Narrowest correct implementation**: Keep persistence inside existing `workspace_settings`, add only one four-state lifecycle family plus rationale, derive action-family outcomes in one bounded overlay service, mutate it from one system detail page, and apply it only to onboarding activation, review-pack starts, and preserved read-only history/evidence/download semantics.
- **Ownership cost created**: One new state vocabulary, one overlay service, one platform capability, cross-surface copy discipline, and focused lifecycle/read-only tests.
- **Alternative intentionally rejected**: A billing/subscription engine, customer-account model, payment-provider seam, or many local page booleans was rejected because the current release only needs a single workspace commercial overlay on top of the already-real entitlement substrate.
- **Release truth**: current-release truth. The four-state vocabulary is justified now because platform operators already need to set and audit those named postures, even though only `grace` and `suspended_read_only` introduce new blocked outcomes for the two in-scope action families in this slice.
## Phase 0 — Research (output: `research.md`)
See: `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/251-commercial-entitlements-billing-state/research.md`
Goals:
- Confirm the narrowest reuse of the existing `entitlements` settings domain and audit path for lifecycle state and rationale.
- Confirm that one bounded overlay service can compose `WorkspaceEntitlementResolver` without creating a second commercial framework.
- Confirm that lifecycle mutation remains platform-only on the existing system workspace detail page and does not leak into `/admin` self-service.
- Confirm that review-pack start blocking happens before `ReviewPack` or `OperationRun` creation and can reuse the current blocked-decision transport.
- Confirm that suspended read-only preservation remains bounded to existing review, evidence, and generated-pack consumption surfaces instead of becoming a broad product-wide suspension sweep.
## Phase 1 — Design & Contracts (outputs: `data-model.md`, `contracts/`, `quickstart.md`)
See:
- `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/251-commercial-entitlements-billing-state/data-model.md`
- `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/251-commercial-entitlements-billing-state/contracts/workspace-commercial-lifecycle-overlay.logical.openapi.yaml`
- `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/251-commercial-entitlements-billing-state/quickstart.md`
Design focus:
- Persist `commercial_lifecycle_state` plus rationale through the existing `entitlements` settings domain instead of adding a new table or billing domain.
- Keep the overlay inside `App\Services\Entitlements` and let it compose `WorkspaceEntitlementResolver` rather than replacing it.
- Extend the existing system workspace detail page with a read-only lifecycle summary and one confirmation-protected `Change commercial state` action, while leaving `WorkspaceSettings` as substrate truth rather than a second mutation plane.
- Gate `ManagedTenantOnboardingWizard` completion from the central lifecycle decision after underlying entitlement truth is known.
- Gate review-pack `Generate pack`, `Regenerate`, and `Export executive pack` starts through `ReviewPackService` and current action surfaces, stopping before any `ReviewPack` or `OperationRun` write when the lifecycle blocks the action.
- Preserve `CustomerReviewWorkspace`, review detail, evidence detail, review-pack detail, and pack download access under current RBAC while suspended, and keep any broader mutable-surface suspension work explicitly out of scope.
## Phase 1 — Agent Context Update
After Phase 1 artifacts are generated, update Copilot context from the completed plan:
- `/Users/ahmeddarrazi/Documents/projects/wt-plattform/.specify/scripts/bash/update-agent-context.sh copilot`
## Phase 2 — Implementation Outline (tasks created later by `/speckit.tasks`)
- Register lifecycle state and rationale setting definitions under the existing `entitlements` settings domain and wire them into the current workspace-setting audit path.
- Add one bounded `WorkspaceCommercialLifecycleResolver` that composes underlying entitlement decisions and yields action-family outcomes plus suspended read-only allowances.
- Add one dedicated platform capability for commercial lifecycle management and enforce it on the system detail mutation action only.
- Extend `ViewWorkspace` plus its Blade view with current lifecycle state, affected behavior summary, and a confirmation-protected `Change commercial state` action.
- Gate onboarding completion in `ManagedTenantOnboardingWizard` using the shared lifecycle decision while preserving existing tenant operability checks and 404/403 semantics.
- Gate review-pack start surfaces and `ReviewPackService` using the shared lifecycle decision, preserving current queued-start UX when allowed and reusing the existing blocked-decision transport when blocked.
- Prove suspended read-only continuation by asserting existing review/evidence/download surfaces remain available under current RBAC while no new onboarding activation or review-pack run can start.
- Add focused Sail/Pest unit and feature coverage only.
## Constitution Check (Post-Design)
Re-check result: PASS. The design keeps Filament v5 + Livewire v4 compliance intact, leaves provider registration unchanged in `bootstrap/providers.php`, introduces no new globally searchable resource, keeps asset strategy unchanged, preserves strict `/admin` vs `/system` separation, layers one bounded lifecycle resolver above the existing entitlement substrate, and blocks review-pack starts before `OperationRun` creation rather than forking shared run UX.
## Planning Readiness
- Outcome: keep
- No unresolved clarification markers remain in the plan-phase artifacts.
- No application implementation is included in this planning step.
- The next repo-native step is `/speckit.tasks` for an implementation task breakdown, not code changes.
## Implementation Close-Out
- **Workflow outcome**: keep.
- **Implementation result**: one bounded commercial lifecycle overlay was implemented through existing workspace settings, one system-plane `Change commercial state` action, onboarding activation gating, review-pack start allow/warn/block semantics, and preserved suspended read-only review/evidence/download access.
- **Blocked-decision transport**: document-in-feature. The existing `WorkspaceEntitlementBlockedException` transport remains sufficient for review-pack blocked starts; no second business-state exception family was introduced.
- **Preserved read-only scope**: document-in-feature. Suspension stays bounded to onboarding activation and new review-pack starts in this spec; broader mutable-surface suspension remains out of scope.
- **Browser smoke path**: `/system/login` as `operator@tenantpilot.io`, `/system/directory/workspaces/1`, open `Change commercial state`, set `Trial` with rationale, confirm, observe updated lifecycle summary and notification follow-up, then restore `Active paid`.
- **Browser smoke result**: pass after fixing `WorkspaceResolver` to accept Livewire serialized workspace route parameters; the follow-up notification update no longer emits console errors or 404/419 markers.
- **Lane results**: targeted unit/support/system/onboarding/review-pack/read-only Pest lanes passed; dirty-only Pint passed; `git diff --check` passed.

View File

@ -1,109 +0,0 @@
# Quickstart: Commercial Entitlements and Billing-State Maturity
**Date**: 2026-04-28
**Branch**: `251-commercial-entitlements-billing-state`
This quickstart is the intended reviewer flow after implementation. It stays bounded to the commercial lifecycle overlay described in the spec.
## Prerequisites
1. Start the local platform stack.
- `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail up -d`
2. Ensure one platform user has directory visibility plus the dedicated commercial lifecycle management capability.
3. Ensure one workspace member can complete onboarding, one reporting operator can manage review packs, and one customer-safe or operator read-only actor can open review/evidence/download surfaces under current RBAC.
4. Seed or factory-create:
- one workspace with untouched lifecycle state
- one onboarding draft in that workspace
- one tenant with an existing review, evidence snapshot, and generated review pack
- one workspace already at or above the managed-tenant activation limit for substrate-block verification
## Scenario 1: Change workspace commercial state from the system plane
1. Open `/system/directory/workspaces/{workspace}` as the authorized platform user.
2. Confirm the page shows:
- current lifecycle state
- source label
- rationale and last-changed attribution
- affected behavior summary for onboarding and review-pack starts
- the underlying entitlement substrate summary for context
3. Use `Change commercial state` to move the workspace to `trial` with rationale.
4. Confirm the page updates immediately and the change is attributable.
5. Repeat with `grace`, `suspended_read_only`, and `active_paid`.
6. Confirm every explicit state change requires rationale, including a return to `active_paid`, and that the `Suspended / read-only` path also requires explicit confirmation.
## Scenario 2: Gate onboarding activation with business-state truth
1. Open `/admin/onboarding/{onboardingDraft}` for a workspace in `trial` or `active_paid`.
2. Confirm the completion step allows `Complete onboarding` when the underlying entitlement substrate also allows it.
3. Switch the same workspace to `grace` from the system plane.
4. Refresh the onboarding draft and confirm:
- the action remains visible for an otherwise authorized actor
- the step explains that expansion is frozen during grace
- no tenant activation occurs
5. Repeat with `suspended_read_only` and confirm the block message changes to read-only suspension semantics instead of a permission failure.
## Scenario 3: Gate review-pack starts before any run is created
1. Use a workspace in `trial` or `active_paid` where the underlying review-pack entitlement allows generation.
2. Trigger the current start family from:
- tenant dashboard review-pack card
- review register export action
- tenant review detail export action
- review-pack detail regenerate action
3. Confirm the existing queued-start UX remains unchanged when allowed.
4. Move the workspace to `grace`.
5. Confirm review-pack starts remain allowed with a grace warning.
6. Start one allowed review-pack action and leave the resulting work queued or running.
7. Move the workspace to `suspended_read_only`.
8. Confirm the already-created run remains visible and continues with the existing run UX.
9. Repeat the same start actions and confirm:
- each surface shows the same lifecycle-based reason
- no new `ReviewPack` row is created
- no new `OperationRun` row is created
- no queued or terminal review-pack notification is emitted for the blocked attempt
## Scenario 4: Preserve read-only review, evidence, and generated-pack access while suspended
1. Keep the workspace in `suspended_read_only`.
2. Open the current read-only consumption surfaces as an already-authorized actor:
- `CustomerReviewWorkspace`
- tenant review detail
- review-pack detail
- evidence snapshot detail
- current review-pack download link
3. Confirm:
- the pages still render
- already-generated review packs remain downloadable
- existing review/evidence history remains visible
- any read-only explanation stays calm and does not masquerade as 403 or 404
4. Confirm the slice does not add broad new suspension behavior to unrelated mutable controls outside the spec boundary.
## RBAC and Plane Semantics Checks
1. Access lifecycle mutation from `/admin` and confirm there is no self-service control surface.
2. Access `/system/directory/workspaces/{workspace}` as a platform user lacking the dedicated lifecycle capability and confirm authorization is enforced without leaking admin-plane truth.
3. Access onboarding or review-pack surfaces as a non-member or wrong-plane actor and confirm 404.
4. Access the same surfaces as an established-scope actor lacking the relevant capability and confirm 403.
5. Access the action as an otherwise authorized actor whose workspace lifecycle blocks the action and confirm a truthful business-state block instead of 403 or 404.
## Targeted Validation Commands
```bash
export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Unit/Entitlements/WorkspaceCommercialLifecycleResolverTest.php
export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/System/ViewWorkspaceEntitlementsTest.php tests/Feature/System/Spec113/AuthorizationSemanticsTest.php tests/Feature/Onboarding/ManagedTenantOnboardingEntitlementTest.php tests/Feature/Onboarding/OnboardingRbacSemanticsTest.php
export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/ReviewPack/ReviewPackEntitlementEnforcementTest.php tests/Feature/ReviewPack/ReviewPackGenerationTest.php tests/Feature/ReviewPack/ReviewPackDownloadTest.php tests/Feature/Reviews/CustomerReviewWorkspacePageTest.php tests/Feature/Reviews/CustomerReviewWorkspacePackAccessTest.php tests/Feature/Evidence/EvidenceSnapshotResourceTest.php
export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail bin pint --dirty --format agent
```
## Out of Scope Confirmations
While validating this slice, confirm that the implementation does not add or imply:
- payment-provider credentials, invoices, checkout, taxes, or public pricing UI
- customer-account, subscription, or contract models
- automated expiry/reminder/renewal logic
- a second admin-plane commercial settings surface
- a broad suspension engine across unrelated mutable product surfaces

View File

@ -1,84 +0,0 @@
# Research: Commercial Entitlements and Billing-State Maturity
**Date**: 2026-04-28
**Branch**: `251-commercial-entitlements-billing-state`
## Decision 1: Persist lifecycle truth inside the existing `entitlements` settings domain
- **Decision**: Store the workspace commercial lifecycle overlay through explicit `WorkspaceSetting` keys in the existing `entitlements` domain, conceptually `commercial_lifecycle_state` plus `commercial_lifecycle_reason`.
- **Rationale**: Spec 247 already proved that workspace-owned commercial truth belongs in the existing workspace settings infrastructure. Reusing that path keeps audit behavior, validation, and source-of-truth ownership consistent without inventing a billing/account model or a second persistence family.
- **Alternatives considered**:
- New `subscriptions`, `billing_states`, or `customer_accounts` tables: rejected because the spec explicitly forbids broad billing/account scope.
- A separate `commercial` settings domain: rejected because the new state is an overlay on the already-real entitlement substrate, not a second independent settings family.
## Decision 2: Add one bounded lifecycle overlay service above `WorkspaceEntitlementResolver`
- **Decision**: Introduce one bounded `WorkspaceCommercialLifecycleResolver` in `App\Services\Entitlements` that composes `WorkspaceEntitlementResolver` instead of replacing it.
- **Rationale**: The underlying entitlement resolver remains canonical for plan-profile defaults, override values, and per-key allow/block truth. The new feature needs one additional workspace-wide layer that can answer lifecycle state, lifecycle rationale, and action-family outcomes across onboarding, review-pack starts, and preserved read-only history access.
- **Alternatives considered**:
- Extend `WorkspaceEntitlementResolver` until it also owns lifecycle posture: rejected because that would blur substrate truth with the new overlay and make future review of state ordering harder.
- Local page/service conditionals in onboarding, review-pack resources, and system detail: rejected because they would drift immediately.
## Decision 3: Keep system-plane mutation on the existing workspace detail page only
- **Decision**: Make `/system/directory/workspaces/{workspace}` the only mutation surface for lifecycle state changes, with inspection plus a confirmation-protected `Change commercial state` action.
- **Rationale**: The spec requires platform-managed lifecycle mutation. The existing system workspace detail page already exposes commercial truth read-only and is the narrowest platform context that can show state, rationale, and audit attribution without creating a second control plane.
- **Alternatives considered**:
- Add lifecycle mutation to `/admin/settings/workspace`: rejected because the slice must not become a self-service workspace-admin commercial control surface.
- Create a dedicated system commercial page/resource: rejected because the existing workspace detail page already anchors the platform/support workflow.
## Decision 4: Preserve explicit business-state versus authorization semantics
- **Decision**: Keep non-member and wrong-plane access as 404, keep established-scope capability denial as 403, and treat lifecycle blocking or warnings as business-state results for otherwise authorized actors.
- **Rationale**: This is the main operator value of the slice. The commercial lifecycle overlay must explain why an action is blocked without pretending the actor lacks scope or permission.
- **Alternatives considered**:
- Hide blocked actions entirely: rejected because it would erase the commercial explanation the feature exists to provide.
- Return 403 for lifecycle blocks: rejected because it would conflate business state with authorization.
## Decision 5: Review-pack lifecycle blocking must happen before `ReviewPack` or `OperationRun` creation
- **Decision**: Reuse `ReviewPackService` as the hard enforcement boundary and block lifecycle-restricted starts before any `ReviewPack` or `OperationRun` write occurs.
- **Rationale**: Current review-pack start surfaces already converge on `ReviewPackService`. Blocking at the service boundary prevents UI-surface bypass and preserves the shared OperationRun start UX for allowed actions.
- **Alternatives considered**:
- UI-only disabling on each widget/resource/page action: rejected because it would not protect direct action execution.
- A new review-pack lifecycle queue/framework: rejected because the slice changes eligibility only, not run orchestration.
## Decision 6: Reuse the existing blocked-decision transport if it can carry lifecycle metadata cleanly
- **Decision**: Prefer reusing `WorkspaceEntitlementBlockedException` and extending its decision payload for lifecycle blocks, rather than introducing a second parallel business-state exception family.
- **Rationale**: Review-pack widgets/resources already catch `WorkspaceEntitlementBlockedException` and project its `block_reason` into user-visible feedback. Reusing that transport keeps the change narrow unless implementation proves the class name or payload shape is too substrate-specific.
- **Alternatives considered**:
- New `WorkspaceCommercialLifecycleBlockedException`: rejected for now because it would widen changes across all review-pack action surfaces without proving extra value.
- Plain string returns without a shared decision payload: rejected because the UI surfaces already consume structured block context.
## Decision 7: Preserve suspended read-only access by leaving existing history/evidence/download routes outside the new gate
- **Decision**: Keep `CustomerReviewWorkspace`, `ViewTenantReview`, `ViewReviewPack`, `ViewEvidenceSnapshot`, and current review-pack download access outside the new lifecycle start gate, while allowing them to show a calm read-only explanation when helpful.
- **Rationale**: The feature promise is not "suspend everything." It is "block future starts while preserving safe existing history." Existing view/download routes already encode current RBAC and redaction semantics and are the narrowest place to preserve that truth.
- **Alternatives considered**:
- Broad product-wide suspension of all mutable controls: rejected because the spec explicitly forbids a broad suspension engine.
- No plan for preserved read access: rejected because suspension would otherwise appear as total lockout and break the evidence/history requirement.
## Decision 8: Keep the four-state vocabulary, but justify it narrowly
- **Decision**: Keep exactly four lifecycle states: `trial`, `grace`, `active_paid`, and `suspended_read_only`.
- **Rationale**: The spec requires these named postures, and platform operators need to set and audit them explicitly from one system surface. `grace` and `suspended_read_only` have immediate distinct action-family consequences. `trial` remains in scope because the platform/support workflow and audit trail need to distinguish temporary non-paid posture from steady active paid posture now, even though both allow the two in-scope gated behavior families.
- **Alternatives considered**:
- Collapse to three states by removing `trial`: rejected because it would erase a required current-release commercial posture and force later renaming/migration when trial lifecycle work grows.
- Persist only booleans like `is_suspended` and `is_in_grace`: rejected because that would not yield one clear operator-facing commercial state.
## Decision 9: Prove the slice with focused unit and feature lanes only
- **Decision**: Use one unit family for lifecycle resolution and focused feature tests for system mutation, onboarding gating, review-pack no-run blocking, and suspended read-only consumption.
- **Rationale**: The primary risk is correctness of decision ordering and bounded surface behavior, not browser layout or heavy orchestration.
- **Alternatives considered**:
- Browser tests: rejected because no browser-only interaction risk is introduced in the planning slice.
- Heavy-governance suite expansion: rejected because the scope is feature-local and uses existing surfaces.
## Decision 10: Leave panels, assets, and global search unchanged
- **Decision**: Do not add new panels, provider registration changes, global-search resources, or Filament assets as part of this slice.
- **Rationale**: The feature is a business-state overlay inside existing admin and system surfaces. Infrastructure changes would widen scope without helping the current release.
- **Alternatives considered**:
- New commercial panel: rejected because `/system` detail already anchors the platform workflow.
- Asset-backed custom commercial UI: rejected because current Filament components and the existing Blade detail view are sufficient.

View File

@ -1,332 +0,0 @@
# Feature Specification: Commercial Entitlements and Billing-State Maturity
**Feature Branch**: `251-commercial-entitlements-billing-state`
**Created**: 2026-04-28
**Status**: Draft
**Input**: User description: "Commercial lifecycle follow-up on top of the already-real Spec 247 entitlement substrate, with one central workspace lifecycle resolution, bounded lifecycle states, two real gated behaviors, explicit read-only suspension semantics, and audited state changes without expanding into a billing engine."
## Spec Candidate Check *(mandatory — SPEC-GATE-001)*
- **Problem**: TenantPilot already resolves plan-profile entitlements for a workspace, but it still lacks one central commercial lifecycle state that explains whether the workspace is in trial, grace, normal paid use, or suspended/read-only posture.
- **Today's failure**: Operators can hit blocked onboarding or reporting actions without one consistent business-state explanation, and a future suspension or grace posture would otherwise be implemented as scattered local conditionals or mistaken as RBAC denial.
- **User-visible improvement**: Platform operators can set one auditable workspace commercial lifecycle state, and tenant/workspace operators then see a truthful allow, warn, or read-only message directly at onboarding and review-pack action surfaces without losing safe access to existing history and evidence.
- **Smallest enterprise-capable version**: Add one platform-managed workspace commercial lifecycle overlay on top of the existing entitlement substrate, resolve four bounded lifecycle states, gate managed-tenant onboarding activation plus review-pack start actions from that central decision, and preserve safe read-only access to existing review/evidence history while suspended.
- **Explicit non-goals**: No payment providers, invoicing, taxes, accounting, checkout, public pricing, website work, customer-account modeling, subscription engine, automated renewal reminders, broad entitlement spread, or customer self-service lifecycle management.
- **Permanent complexity imported**: One bounded lifecycle state family, one small central lifecycle resolution layer on top of the existing entitlement substrate, one platform-side state change surface with audit, and focused unit plus feature coverage.
- **Why now**: This directly extends real repo truth from Spec 247 and `WorkspaceEntitlementResolver`, so it is implementation-ready as a narrow follow-up. Localization remains a broader missing foundation, and external support-desk handoff still lacks a concrete external target.
- **Why not local**: The same commercial posture must drive system support visibility, onboarding activation, review-pack generation, and suspended read-only access rules. Local page checks would drift immediately and recreate the current manual explanation problem.
- **Approval class**: Core Enterprise
- **Red flags triggered**: New state axis, foundation-sounding commercial theme, and multi-surface touchpoint. Defense: this slice is limited to one overlay on top of an existing resolver, one platform mutation surface, two already-real gated behaviors, and explicit read-only preservation instead of a broader billing platform.
- **Score**: Nutzen: 2 | Dringlichkeit: 2 | Scope: 2 | Komplexität: 1 | Produktnähe: 2 | Wiederverwendung: 2 | **Gesamt: 11/12**
- **Decision**: approve
## Spec Scope Fields *(mandatory)*
- **Scope**: workspace
- **Primary Routes**:
- `/system/directory/workspaces/{workspace}` for platform-side inspection and lifecycle state change
- `/admin/onboarding/{onboardingDraft}` for managed-tenant onboarding activation
- `/admin/reviews` plus existing tenant review detail, tenant dashboard, and review-pack registry/detail surfaces for `Generate pack`, `Regenerate`, and `Export executive pack`
- existing read-only review, evidence, and generated-pack consumption surfaces that must remain available while suspended/read-only
- **Data Ownership**: Commercial lifecycle state remains workspace-owned truth and is stored through the existing workspace settings infrastructure. Existing plan profiles and entitlement decisions from Spec 247 remain the underlying workspace-owned substrate. Tenant-owned review packs, evidence snapshots, review history, and onboarding records stay tenant-owned and are not remodeled by this slice.
- **RBAC**: Platform users with directory visibility plus a dedicated commercial lifecycle management capability may inspect and change state on `/system`. Workspace or tenant members keep their existing onboarding and review-pack capabilities on `/admin`, but lifecycle state is a business-state overlay rather than a self-service setting. Non-members and wrong-plane actors continue to receive 404. Members missing capability continue to receive 403. Members with the required capability but blocked by lifecycle state receive a truthful business-state block instead of an authorization failure.
For canonical-view specs, the spec MUST define:
- **Default filter behavior when tenant-context is active**: N/A - this slice does not introduce a new tenantless collection or cross-tenant list.
- **Explicit entitlement checks preventing cross-tenant leakage**: Existing workspace and tenant isolation remain authoritative. The lifecycle overlay never reveals tenant-owned history or artifacts outside the already-authorized workspace and tenant scope.
## Cross-Cutting / Shared Pattern Reuse *(mandatory when the feature touches notifications, status messaging, action links, header actions, dashboard signals/cards, alerts, navigation entry points, evidence/report viewers, or any other existing shared operator interaction family; otherwise write `N/A - no shared interaction family touched`)*
- **Cross-cutting feature?**: yes
- **Interaction class(es)**: status messaging, action gating, system detail controls, operation-start blocking, evidence/report viewers
- **Systems touched**: existing workspace settings persistence, existing workspace entitlement resolution, system workspace detail view, onboarding activation gate, review-pack generation entry family, audit logging, and existing read-only review/evidence/download surfaces
- **Existing pattern(s) to extend**: existing workspace entitlement resolver and summary pattern, existing workspace-setting audit path, existing review-pack start UX, existing onboarding activation gate, and existing system detail summary surfaces
- **Shared contract / presenter / builder / renderer to reuse**: the current workspace entitlement resolution path and its audit-backed settings persistence remain the canonical substrate; this slice adds one bounded commercial lifecycle decision layer on top rather than a second parallel commercial framework
- **Why the existing shared path is sufficient or insufficient**: The current entitlement substrate is already sufficient for plan defaults, overrides, and per-key allow/block decisions. It is insufficient for one workspace-wide lifecycle posture that can say "expansion frozen" or "read-only suspended" consistently across multiple surfaces.
- **Allowed deviation and why**: none. No surface may invent local lifecycle labels, local business-state copy, or page-specific suspension rules.
- **Consistency impact**: State labels, source labels, block reasons, and read-only explanations must mean the same thing on the system workspace page, onboarding completion step, review-pack start actions, and preserved read-only review/evidence surfaces.
- **Review focus**: Reviewers must verify that all in-scope surfaces consume one shared lifecycle decision, that lifecycle overlay semantics do not expand access beyond current entitlements, and that suspended read-only messaging does not drift across surfaces.
## OperationRun UX Impact *(mandatory when the feature creates, queues, deduplicates, resumes, blocks, completes, or deep-links to an `OperationRun`; otherwise write `N/A - no OperationRun start or link semantics touched`)*
- **Touches OperationRun start/completion/link UX?**: yes
- **Shared OperationRun UX contract/layer reused**: existing review-pack queued-start, `Open operation`, and canonical run-link behavior remain unchanged when lifecycle state allows the start action
- **Delegated start/completion UX behaviors**: queued toast, `Open operation` link, dedupe behavior, and terminal lifecycle feedback stay on the existing review-pack path when allowed. A lifecycle block stops earlier and produces no queued-start feedback because no run is created.
- **Local surface-owned behavior that remains**: local surfaces only render lifecycle state, blocked reason, and the safe next step. They do not replace the existing review-pack run UX.
- **Queued DB-notification policy**: unchanged
- **Terminal notification path**: central lifecycle mechanism for existing review-pack runs only
- **Exception required?**: none
## Provider Boundary / Platform Core Check *(mandatory when the feature changes shared provider/platform seams, identity scope, governed-subject taxonomy, compare strategy selection, provider connection descriptors, or operator vocabulary that may leak provider-specific semantics into platform-core truth; otherwise write `N/A - no shared provider/platform boundary touched`)*
N/A - no shared provider/platform boundary is changed. Commercial lifecycle state is platform-core workspace truth and must remain provider-neutral even when it gates provider-backed review-pack workflows.
## UI / Surface Guardrail Impact *(mandatory when operator-facing surfaces are changed; otherwise write `N/A`)*
| Surface / Change | Operator-facing surface change? | Native vs Custom | Shared-Family Relevance | State Layers Touched | Exception Needed? | Low-Impact / `N/A` Note |
|---|---|---|---|---|---|---|
| Platform workspace commercial-state controls | yes | Native Filament system detail page | detail summary, header actions, status messaging | detail page, header action, summary card | no | Single platform mutation surface only |
| Managed tenant onboarding activation gate | yes | Native Filament wizard | action gating, helper text, business-state callout | completion step, confirmation action | no | Reuses the existing activation step |
| Review-pack generation entry family | yes | Native Filament widget/resource/page actions | operation-start gating, helper text, state badges | widget action, detail action, list/header action | no | Only `Generate pack`, `Regenerate`, and `Export executive pack` are in scope |
| Existing read-only review, evidence, and generated-pack consumption surfaces | yes | Native Filament detail and download surfaces | evidence/report viewers, detail messaging | detail page, download action, read-only summary | no | No new routes; the slice only preserves safe read-only availability during suspension |
## Decision-First Surface Role *(mandatory when operator-facing surfaces are changed)*
| Surface | Decision Role | Human-in-the-loop Moment | Immediately Visible for First Decision | On-Demand Detail / Evidence | Why This Is Primary or Why Not | Workflow Alignment | Attention-load Reduction |
|---|---|---|---|---|---|---|---|
| Platform workspace commercial-state controls | Primary Decision Surface | Platform operator decides whether a workspace should remain trial, move into grace, return to active paid, or become suspended/read-only | Current state, rationale, affected action families, and last changed attribution | Existing entitlement summary and related workspace diagnostics | Primary because this is the one place where commercial posture is intentionally changed | Follows platform support/commercial workflow rather than customer admin navigation | Prevents founders or support staff from reconstructing state from ad hoc notes and blocked actions |
| Managed tenant onboarding activation gate | Primary Decision Surface | Workspace operator decides whether the current tenant may be activated now | Lifecycle state, whether activation is allowed, and the business-state reason when blocked | Existing onboarding verification and readiness diagnostics remain secondary | Primary because onboarding completion is the actual high-impact mutation point | Keeps the commercial decision inside the activation workflow | Removes the need to ask support whether a block is about permissions or billing state |
| Review-pack generation entry family | Secondary Context Surface | Reporting operator decides whether to start, retry, or export a review pack from the current tenant or review context | Lifecycle state, whether the start action is blocked, and the safe fallback when suspended/read-only | Existing run state, artifact truth, and review history remain secondary | Not primary because the family exists to continue reporting/review workflows, not to manage commercial posture itself | Stays inside existing report-generation workflows | Avoids a second support lookup just to understand why generation is blocked |
| Existing read-only review, evidence, and generated-pack consumption surfaces | Tertiary Evidence / Diagnostics Surface | Customer-safe or operator read-only consumer verifies existing history while the workspace is suspended/read-only | Existing history, evidence, and generated pack availability plus a calm read-only explanation | Raw provider or support diagnostics remain secondary and capability-gated | Not primary because these surfaces answer "what history is still safe to read" rather than "what state should change" | Preserves evidence-first review consumption instead of forcing new export workarounds | Prevents suspended workspaces from looking completely unavailable when history should still be readable |
## Audience-Aware Disclosure *(mandatory when operator-facing surfaces are changed)*
| Surface | Audience Modes In Scope | Decision-First Default-Visible Content | Operator Diagnostics | Support / Raw Evidence | One Dominant Next Action | Hidden / Gated By Default | Duplicate-Truth Prevention |
|---|---|---|---|---|---|---|---|
| Platform workspace commercial-state controls | support-platform, operator-platform | Current lifecycle state, rationale, last changed attribution, and affected behavior summary | Existing workspace entitlement summary and tenant counts | No raw settings payload or internal debug data by default | `Change commercial state` | Raw settings rows and internal debugging remain hidden | The page states the lifecycle blocker once and reuses the same labels later rather than restating them differently |
| Managed tenant onboarding activation gate | operator-MSP | Activation allowed/blocked, current lifecycle state, and why the block is business-state rather than permission-state | Existing readiness and verification diagnostics already on the wizard | No support/raw payloads on the default path | `Complete onboarding` when allowed, otherwise `Request commercial review` | Deeper commercial diagnostics stay off the onboarding surface | The step shows one lifecycle explanation and does not restate the whole workspace commercial profile |
| Review-pack generation entry family | operator-MSP | Start action availability, current lifecycle state, and the safe fallback when generation is blocked | Existing run state and artifact status | No raw support diagnostics on start surfaces | `Generate pack`, `Regenerate`, or `Export executive pack` when allowed; otherwise `View current pack` | System-only lifecycle controls stay off these surfaces | One shared lifecycle reason is reused across all in-scope start actions |
| Existing read-only review, evidence, and generated-pack consumption surfaces | customer-read-only, operator-MSP | What history remains available and why the workspace is read-only rather than fully inaccessible | Existing review history and artifact provenance | Support/raw details remain collapsed or gated | `View current review` or `Download current pack` | Any mutation affordance stays blocked in suspended/read-only posture | The read-only explanation appears once and later sections add evidence rather than repeating the same blocker |
## UI/UX Surface Classification *(mandatory when operator-facing surfaces are changed)*
| Surface | Action Surface Class | Surface Type | Likely Next Operator Action | Primary Inspect/Open Model | Row Click | Secondary Actions Placement | Destructive Actions Placement | Canonical Collection Route | Canonical Detail Route | Scope Signals | Canonical Noun | Critical Truth Visible by Default | Exception Type / Justification |
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| Platform workspace commercial-state controls | System / Detail / Diagnostics | Read-only detail with bounded mutation action | Change the workspace lifecycle state | Dedicated workspace detail page | forbidden | Existing admin-workspace and related navigation stay secondary | `Change commercial state` contains the high-risk `Suspended / read-only` path with explicit confirmation | `/system/directory/workspaces` | `/system/directory/workspaces/{workspace}` | Platform workspace identity plus current lifecycle state | Commercial lifecycle | Current state, rationale, and affected behaviors | Acceptable detail-surface exception because mutation stays bounded to one header action on the detail page |
| Managed tenant onboarding activation gate | Workflow / Guided action entry | Onboarding completion step | Complete onboarding or stop because commercial state blocks expansion | In-page completion step | forbidden | Existing back-navigation and tenant links stay secondary | Existing `Cancel draft` and `Delete draft` remain the only destructive actions | `/admin/onboarding` | `/admin/onboarding/{onboardingDraft}` | Workspace context plus current tenant | Onboarding commercial state | Activation allowed or blocked and why | Existing wizard exception remains valid |
| Review-pack generation entry family | Contextual action family | Widget/resource/page start actions | Start, retry, or export a review pack when allowed | Explicit action on the current tenant or review context | mixed - existing registry rows may still open detail, but start actions remain explicit | Existing `View` and `Download` stay secondary and outside the blocked start gate | Existing destructive actions remain out of scope and keep current placement | `/admin/reviews` plus existing tenant review-pack collection surfaces | Existing tenant review detail and review-pack detail surfaces | Active workspace, active tenant, review or pack context | Review-pack generation | Start allowed or blocked, and the safe read-only fallback | Grouped-action family exception is documented here so all start actions share one gate |
| Existing read-only review, evidence, and generated-pack consumption surfaces | Detail / Report viewer / Download | Read-only detail and artifact consumption | View history or download an already-generated pack | Existing review or pack detail page | allowed where the current collection already opens detail | Supporting navigation remains secondary | none | Existing review and review-pack collections | Existing review, evidence, and review-pack detail routes | Active workspace, active tenant, current artifact or review | Review history / Generated pack | Safe read-only availability during suspension | No new surface type introduced |
## Operator Surface Contract *(mandatory when operator-facing surfaces are changed)*
| Surface | Primary Persona | Decision / Operator Action Supported | Surface Type | Primary Operator Question | Default-visible Information | Diagnostics-only Information | Status Dimensions Used | Mutation Scope | Primary Actions | Dangerous Actions |
|---|---|---|---|---|---|---|---|---|---|---|
| Platform workspace commercial-state controls | Platform commercial or support operator | Decide the current commercial posture of a workspace | System detail page | What lifecycle state should this workspace be in now? | State, rationale, affected behaviors, and last changed attribution | Existing entitlement summary and workspace diagnostics | commercial lifecycle, entitlement substrate | TenantPilot only | Change commercial state | Set suspended/read-only |
| Managed tenant onboarding activation gate | Workspace owner or manager completing onboarding | Decide whether the current tenant can be activated now | Guided workflow step | Can I activate this tenant under the current commercial posture? | Current lifecycle state, whether activation is allowed, and the block reason when not | Existing verification and bootstrap detail | onboarding readiness, commercial lifecycle, entitlement availability | TenantPilot only for activation state | Complete onboarding | Cancel draft, Delete draft |
| Review-pack generation entry family | Workspace manager or reporting operator | Decide whether a new pack run may start now | Contextual start-action family | Can I start, retry, or export a pack from this context? | Current lifecycle state, whether the start action is blocked, and the safe fallback | Existing run state, review status, and artifact truth | commercial lifecycle, entitlement availability, run state, artifact status | TenantPilot only until the existing run starts | Generate pack, Regenerate, Export executive pack, View current pack | Existing destructive actions remain unchanged and out of scope |
| Existing read-only review, evidence, and generated-pack consumption surfaces | Customer-safe reader or workspace operator | Consume already-generated history safely while the workspace is read-only | Read-only detail and download surfaces | What history can I still read or download safely? | Existing review/evidence/download truth plus a calm read-only explanation | Raw provider diagnostics and support-only detail | commercial lifecycle, artifact availability | none | View current review, Download current pack | none |
## Proportionality Review *(mandatory when structural complexity is introduced)*
- **New source of truth?**: yes - one workspace-owned commercial lifecycle state becomes current-release business truth, but it is stored through existing workspace settings rather than a new table
- **New persisted entity/table/artifact?**: no
- **New abstraction?**: yes - one bounded lifecycle resolution layer on top of the existing entitlement substrate
- **New enum/state/reason family?**: yes - the four-state lifecycle family (`trial`, `grace`, `active_paid`, `suspended_read_only`)
- **New cross-domain UI framework/taxonomy?**: no
- **Current operator problem**: Support and operators cannot truthfully explain whether a workspace is in a normal commercial state, an expansion freeze, or a read-only suspension without reconstructing the answer from scattered surface behavior.
- **Existing structure is insufficient because**: Spec 247 gives per-key entitlement truth, but it does not provide one workspace-wide lifecycle posture that can say "activation blocked but reading is still safe" or "new runs blocked while history remains available."
- **Narrowest correct implementation**: Keep persistence inside the existing workspace settings infrastructure, add one small state family and one shared resolution layer, mutate it from one system detail page, and apply it only to two already-real start behaviors plus suspended read-only preservation.
- **Ownership cost**: One state vocabulary, one additional decision layer, cross-surface copy discipline, and focused tests for state transitions plus allowed/blocked behavior.
- **Alternative intentionally rejected**: A new subscription/customer-account model or many per-surface lifecycle flags was rejected because the repo has no current billing domain and the smallest safe slice only needs one central commercial posture.
- **Release truth**: current-release truth with later follow-up candidates for automation, billing integration, and broader lifecycle-aware entitlement spread
### Compatibility posture
This feature assumes a pre-production environment.
Backward compatibility, legacy aliases, migration shims, historical fixtures, and compatibility-specific tests are out of scope unless explicitly required by this spec.
Canonical replacement is preferred over preservation.
## Testing / Lane / Runtime Impact *(mandatory for runtime behavior changes)*
- **Test purpose / classification**: Unit, Feature
- **Validation lane(s)**: fast-feedback, confidence
- **Why this classification and these lanes are sufficient**: Unit coverage proves default state resolution, state precedence over existing entitlements, and state-to-behavior mapping. Focused feature coverage proves platform mutation, audit logging, onboarding blocks, review-pack start blocks, and preserved read-only access without expanding into browser or heavy-governance lanes.
- **New or expanded test families**: one bounded lifecycle resolver unit family plus focused extensions to the existing system detail, onboarding, review-pack, and preserved read-only feature families
- **Fixture / helper cost impact**: Add only workspace, platform user, workspace member, onboarding draft, active tenant count, existing review pack, and existing evidence/history fixtures required to prove the state consequences. Avoid payment-provider mocks, browser harnesses, or new heavy support fixtures.
- **Heavy-family visibility / justification**: none
- **Special surface test profile**: standard-native-filament, shared-detail-family, monitoring-state-page
- **Standard-native relief or required special coverage**: Standard Filament feature coverage is sufficient for the system detail mutation surface and onboarding gate. Review-pack gating still needs monitoring-state assertions to prove blocked starts create no run, while suspended read-only preservation needs one detail/download assertion on existing artifact surfaces.
- **Reviewer handoff**: Reviewers must confirm that lifecycle blocks remain distinct from 404 and 403 outcomes, that source labels stay consistent on the system detail surface, that `grace` and `suspended_read_only` do not collapse into one behavior, that blocked review-pack starts create no queued or terminal notification, that already queued or running review-pack runs remain unaffected by later suspension, and that existing read-only history/download access remains available under current RBAC.
- **Budget / baseline / trend impact**: low feature-local increase only
- **Escalation needed**: none
- **Active feature PR close-out entry**: Guardrail
- **Planned validation commands**:
- `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Unit/Entitlements/WorkspaceCommercialLifecycleResolverTest.php`
- `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/System/ViewWorkspaceEntitlementsTest.php tests/Feature/System/Spec113/AuthorizationSemanticsTest.php tests/Feature/Onboarding/ManagedTenantOnboardingEntitlementTest.php tests/Feature/Onboarding/OnboardingRbacSemanticsTest.php`
- `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/ReviewPack/ReviewPackEntitlementEnforcementTest.php tests/Feature/ReviewPack/ReviewPackGenerationTest.php tests/Feature/ReviewPack/ReviewPackDownloadTest.php tests/Feature/Reviews/CustomerReviewWorkspacePageTest.php tests/Feature/Reviews/CustomerReviewWorkspacePackAccessTest.php tests/Feature/Evidence/EvidenceSnapshotResourceTest.php`
## Scope Boundaries *(required for this slice)*
### In Scope
- One central workspace commercial lifecycle overlay with exactly four states: `trial`, `grace`, `active_paid`, and `suspended_read_only`
- One platform-managed lifecycle change path with rationale and audit, persisted through the existing workspace settings infrastructure
- One shared lifecycle resolution path layered on top of the existing Spec 247 entitlement substrate
- Lifecycle gating of managed-tenant onboarding activation
- Lifecycle gating of review-pack `Generate pack`, `Regenerate`, and `Export executive pack` entry points
- Suspended/read-only preservation of authorized review history, evidence, and already-generated review-pack consumption
- Explicit business-state messaging that distinguishes lifecycle blocks from RBAC failures
### Non-Goals
- Payment providers, invoices, taxes, accounting, checkout, public pricing, website work, and payment failure workflows
- New customer-account, subscription, contract, or offer models
- Automated timers, expiries, reminders, or scheduled state transitions
- Customer self-service state changes from the workspace admin plane
- Broad entitlement expansion across seats, exports, retention, support SLAs, or unrelated feature flags
- Broad suspension logic across every mutable surface in the product
- A second commercial control plane outside the existing system workspace detail flow
## Assumptions
- Spec 247 remains the canonical entitlement substrate. Commercial lifecycle state is an overlay that can warn or restrict, not a replacement for plan-profile and per-key entitlement truth.
- Commercial lifecycle mutation is platform-managed in this slice. Workspace and tenant operators may observe the resulting state where it matters, but they do not change it themselves.
- If no explicit lifecycle state has been set for a workspace, the system resolves to `active_paid` so that current repo behavior stays unchanged until a platform operator intentionally selects a different state.
- `grace` is intentionally narrower than `suspended_read_only`: it freezes new managed-tenant activation but continues to allow existing review-pack start behavior when the underlying entitlement substrate still allows it.
- `suspended_read_only` preserves existing review/evidence/download access under current RBAC and redaction rules, but blocks new onboarding activation and new review-pack start actions.
## Risks
- `grace` and `suspended_read_only` can drift into near-duplicates if blocked-action copy and tests do not keep their consequences distinct.
- A later customer-account or billing source could require revisiting how manual lifecycle transitions are sourced, even though that broader domain is intentionally out of scope here.
- A future admin-plane commercial settings surface could confuse ownership if it appears without preserving platform-only mutation authority.
- Mid-flight review-pack runs created before a workspace becomes suspended could create confusion if the product does not clearly state that this slice only blocks future starts.
## Deferred Adjacent Candidates
- **Localization v1** remains a separate, broader foundation candidate because it requires cross-product locale resolution and copy governance beyond this bounded commercial lifecycle slice.
- **External Support Desk / PSA Handoff** remains a separate candidate because repo docs still do not define one concrete external desk target to hand off into.
- Broader billing lifecycle automation, reminders, and external billing-source integration stay deferred until a real account and payment domain exists in repo truth.
## User Scenarios & Testing *(mandatory)*
### User Story 1 - Set one workspace commercial lifecycle state centrally (Priority: P1)
As a platform commercial or support operator, I want to set a workspace's current commercial lifecycle state once so downstream product behavior follows one audited source of truth instead of local exceptions.
**Why this priority**: Without one central lifecycle state, every later gate or support explanation would duplicate commercial truth and drift away from the already-real entitlement substrate.
**Independent Test**: Open the existing system workspace detail surface, change the lifecycle state with rationale, and verify that the new state is visible there and auditable without touching onboarding or reporting flows.
**Acceptance Scenarios**:
1. **Given** a workspace has no explicit commercial lifecycle state, **When** an authorized platform operator sets it to `trial` with rationale, **Then** the workspace resolves to `trial`, the change is auditable, and the system detail surface shows the new state and rationale.
2. **Given** a workspace is currently in `grace`, **When** an authorized platform operator changes it to `suspended_read_only`, **Then** the previous state is replaced, the new state is auditable, and later gated surfaces consume the new state.
3. **Given** a workspace is in `suspended_read_only`, **When** an authorized platform operator returns it to `active_paid`, **Then** future gated actions again use the normal underlying entitlement substrate instead of the suspended overlay.
---
### User Story 2 - Truthfully block tenant activation when lifecycle state freezes expansion (Priority: P1)
As an authorized workspace operator, I want the onboarding completion step to tell me whether the tenant may be activated under the current commercial lifecycle state so I can distinguish business-state blocking from permissions or onboarding readiness problems.
**Why this priority**: Managed-tenant activation is the highest-risk first-slice mutation and the clearest place where a grace or suspended posture must stop expansion without ambiguity.
**Independent Test**: Seed workspaces in `trial`, `active_paid`, `grace`, and `suspended_read_only`, open the existing onboarding completion step, and verify that the same action becomes allowed or blocked with the correct business-state explanation before any activation mutation happens.
**Acceptance Scenarios**:
1. **Given** a workspace is `trial` or `active_paid` and the existing entitlement substrate allows activation, **When** an authorized operator reaches the onboarding completion step, **Then** the step allows completion and no lifecycle block is shown.
2. **Given** a workspace is in `grace`, **When** the same operator reaches the completion step, **Then** the action remains visible but blocked with a business-state explanation that new managed-tenant activation is frozen during grace.
3. **Given** a workspace is in `suspended_read_only`, **When** the operator reaches the same step, **Then** activation is blocked before any tenant mutation occurs and the step explains that the workspace is read-only rather than lacking permission.
---
### User Story 3 - Block new review-pack starts while preserving safe historical access (Priority: P2)
As a reporting operator or customer-safe reader, I want new review-pack start actions to obey the current commercial lifecycle state while already-generated history remains safely readable so suspension does not erase needed evidence.
**Why this priority**: Review-pack generation already exists on multiple real surfaces, and suspension is only trustworthy if it blocks new starts consistently while preserving safe access to history and already-generated evidence.
**Independent Test**: Seed a workspace with an existing generated pack and history, switch it to `suspended_read_only`, verify that `Generate pack`, `Regenerate`, and `Export executive pack` stop before any new run or artifact is created, that blocked starts emit no queued or terminal review-pack notification, that already queued or running review-pack work continues unchanged, and then confirm that authorized readers can still view or download the already-generated artifacts.
**Acceptance Scenarios**:
1. **Given** a workspace is `active_paid` or `trial` and the existing review-pack entitlement allows generation, **When** an authorized operator starts `Generate pack`, `Regenerate`, or `Export executive pack`, **Then** the current review-pack flow continues unchanged.
2. **Given** a workspace is in `grace` and the underlying review-pack entitlement allows generation, **When** an authorized operator starts the same action, **Then** the action remains allowed with a grace warning and without blocking the run.
3. **Given** a workspace is in `suspended_read_only`, **When** an authorized operator attempts `Generate pack`, `Regenerate`, or `Export executive pack`, **Then** the action is blocked before any new `ReviewPack` or `OperationRun` is created and no queued or terminal review-pack notification is emitted for the blocked attempt.
4. **Given** a review-pack run was already created while the workspace lifecycle state still allowed it, **When** the workspace later moves to `suspended_read_only`, **Then** the existing queued or running review-pack work may complete unchanged because this slice only blocks future start attempts.
5. **Given** a workspace is in `suspended_read_only` and already has generated review packs, evidence, or review history, **When** an authorized reader opens or downloads those existing artifacts, **Then** the existing read-only access continues under current RBAC and redaction rules.
### Edge Cases
- A workspace with no explicit lifecycle state must still resolve deterministically to `active_paid` so current Spec 247 behavior does not change accidentally.
- If the lifecycle state allows a behavior but the underlying entitlement substrate blocks it, the underlying entitlement block still applies and must remain distinguishable from lifecycle blocking.
- If the lifecycle state becomes `suspended_read_only` while a review-pack run is already queued or running, the existing run may complete; the new state only blocks future start attempts in this slice.
- A workspace member who lacks the relevant onboarding or review-pack capability must still receive 403 even when the workspace lifecycle state is otherwise permissive.
- A non-member or wrong-plane actor must not learn whether a workspace is in `grace` or `suspended_read_only`; those requests continue to resolve as 404.
- Suspended/read-only behavior must never revoke access to already-generated artifacts or review/evidence history that the actor is otherwise allowed to read.
## Requirements *(mandatory)*
**Constitution alignment (required):** This feature changes runtime behavior and writes workspace-owned commercial state, but it adds no Microsoft Graph calls, no new provider dispatch path, and no new queued workflow family. Lifecycle state changes use the existing workspace settings infrastructure and audit foundation. Existing review-pack `OperationRun` behavior is reused only when lifecycle state allows a start action.
**Constitution alignment (PROP-001 / ABSTR-001 / PERSIST-001 / STATE-001 / BLOAT-001):** The feature introduces one new business-state family because current-release operator workflows now need a workspace-wide commercial posture that per-key entitlement decisions cannot express alone. A narrower local-only approach would still scatter lifecycle semantics across onboarding and review-pack surfaces.
**Constitution alignment (XCUT-001):** All in-scope gated behaviors and preserved read-only surfaces must consume the same lifecycle decision. No local page is allowed to invent its own trial, grace, or suspension semantics.
**Constitution alignment (DECIDE-AUD-001 / OPSURF-001):** Blocked onboarding and blocked review-pack starts must show customer-safe or operator-safe default content first, with diagnostics and support-only detail remaining secondary. Suspended read-only surfaces must preserve one calm next step instead of turning history surfaces into error pages.
**Constitution alignment (PROV-001):** Commercial lifecycle state is platform-core workspace truth and must not import provider-specific vocabulary or billing-provider semantics.
**Constitution alignment (TEST-GOV-001):** Proof remains in focused unit plus feature lanes. New fixtures stay limited to workspace, platform operator, workspace member, onboarding draft, tenant count, and existing review-pack/evidence artifacts.
**Constitution alignment (OPS-UX):** This feature does not create a new run family. Existing review-pack generation keeps the current queued toast, operation link, and terminal notification path when lifecycle state allows it. Blocked lifecycle starts create no run and no run lifecycle feedback.
**Constitution alignment (OPS-UX-START-001):** Lifecycle gating sits before review-pack run creation and delegates all allowed queued-start UX to the existing shared review-pack path.
**Constitution alignment (RBAC-UX):** Two authorization planes are involved: platform `/system` for lifecycle mutation and tenant/admin `/admin` for contextual blocked-or-allowed behavior. Wrong-plane or non-member requests remain 404. Members missing capability remain 403. Lifecycle blocking is a product-state response for otherwise-authorized actors and must not masquerade as authorization failure.
**Constitution alignment (BADGE-001):** If lifecycle badges or state chips are rendered, their labels and visual semantics must come from one shared lifecycle vocabulary rather than page-local color mapping.
**Constitution alignment (UI-FIL-001):** The slice must extend existing native Filament detail, wizard, widget, resource, and download surfaces. No custom commercial panel or page-local status language is allowed.
**Constitution alignment (UI-NAMING-001):** Primary labels remain product-facing and specific: `Trial`, `Grace`, `Active paid`, `Suspended / read-only`, `Change commercial state`, `Complete onboarding`, `Generate pack`, `Regenerate`, and `Export executive pack`. Billing-provider or checkout terminology remains out of scope.
**Constitution alignment (DECIDE-001):** The system workspace detail page is the one primary commercial decision surface. Onboarding and review-pack surfaces remain contextual decision points that only show the commercial truth required for the immediate action. Existing history/evidence surfaces remain tertiary read-only contexts.
**Constitution alignment (UI-CONST-001 / UI-SURF-001 / ACTSURF-001 / UI-HARD-001 / UI-EX-001 / UI-REVIEW-001 / HDR-001):** The feature must preserve the existing system detail, guided onboarding, grouped review-pack actions, and read-only artifact consumption patterns. It may not create a second admin-plane commercial management surface or redundant inspect actions.
**Constitution alignment (ACTSURF-001 - action hierarchy):** Lifecycle mutation stays on the system workspace detail page. Onboarding completion remains the primary activation action. Review-pack start actions remain the primary reporting mutations where they already exist. View/download history remains secondary but available during suspension.
**Constitution alignment (UI-SEM-001 / LAYER-001 / TEST-TRUTH-001):** One thin lifecycle overlay is justified because direct reads from the existing entitlement substrate cannot express one workspace-wide read-only posture. Tests must prove business outcomes such as allowed, warned, blocked, and preserved-read behavior rather than badge rendering alone.
**Constitution alignment (Filament Action Surfaces):** The action-surface contract remains satisfied with the documented system detail exception for one bounded mutation action, the existing onboarding wizard exception, and the existing review-pack action family. No empty action groups or redundant view actions are introduced by this slice.
**Constitution alignment (UX-001 — Layout & Information Architecture):** The feature extends the existing system detail, onboarding, and review-pack surfaces with bounded state information only. It does not create a new commercial page shell or duplicate summary screen.
### Functional Requirements
- **FR-251-001 Central lifecycle state**: The system MUST resolve one commercial lifecycle state per workspace with exactly four values: `trial`, `grace`, `active_paid`, and `suspended_read_only`.
- **FR-251-002 Existing entitlement substrate remains canonical**: The system MUST layer lifecycle state on top of the existing Spec 247 entitlement substrate rather than replacing plan profiles, entitlement keys, or override logic.
- **FR-251-003 Deterministic default**: If no explicit lifecycle state has been stored for a workspace, the system MUST resolve to `active_paid` so existing behavior remains unchanged until an operator intentionally changes state.
- **FR-251-004 Workspace-owned persistence**: The system MUST store lifecycle state and rationale through the existing workspace settings infrastructure instead of introducing a new customer-account, subscription, or billing table.
- **FR-251-005 Platform-managed mutation**: Only authorized platform users MAY change or override lifecycle state in this slice, and the workspace or tenant admin plane MUST NOT become a self-service lifecycle control surface.
- **FR-251-006 Decision shape**: The effective lifecycle decision MUST include the state, source, operator-visible rationale, last changed attribution, and a summary of which in-scope behaviors are currently warned, allowed, or blocked.
- **FR-251-007 State precedence**: Lifecycle state MUST apply after the existing entitlement substrate and MAY only warn or restrict. It MUST NOT expand access beyond what the underlying entitlement decision already allows.
- **FR-251-008 Onboarding activation gate**: Managed-tenant onboarding activation MUST consult the shared lifecycle decision before mutation. `grace` and `suspended_read_only` MUST block activation before any tenant activation state changes occur.
- **FR-251-009 Review-pack start gate**: `Generate pack`, `Regenerate`, and `Export executive pack` MUST consult the shared lifecycle decision before creating or reusing a `ReviewPack` or `OperationRun`. `suspended_read_only` MUST block those actions before any new run or artifact start occurs.
- **FR-251-010 Grace semantics**: `grace` MUST have a distinct behavioral consequence from `active_paid` by freezing new managed-tenant onboarding activation while leaving in-scope review-pack start behavior under the existing entitlement substrate.
- **FR-251-011 Suspended read-only semantics**: `suspended_read_only` MUST block onboarding activation and review-pack start actions while preserving authorized read-only access to existing review history, evidence, and already-generated review-pack consumption.
- **FR-251-012 In-flight behavior boundary**: A lifecycle state change to `suspended_read_only` MUST affect future start attempts only in this slice and MUST NOT retroactively cancel already-created review-pack runs.
- **FR-251-013 Message semantics**: Gated surfaces MUST clearly distinguish lifecycle business-state blocking from entitlement-limit blocking and from authorization failure.
- **FR-251-014 System visibility**: The system workspace detail surface MUST show the current lifecycle state, rationale, affected behavior summary, and last changed attribution to authorized platform users.
- **FR-251-015 Auditability**: Every lifecycle state change and manual override MUST create an auditable record containing old state, new state, actor, and rationale.
- **FR-251-016 No scattered lifecycle conditionals**: Onboarding, review-pack generation, and preserved read-only surfaces MUST use the shared lifecycle decision rather than local page-specific commercial booleans.
- **FR-251-017 Bounded non-goals**: This slice MUST NOT introduce payment providers, invoices, taxes, accounting, checkout, public pricing, website work, customer-account modeling, broad billing automation, or broad entitlement spread beyond the in-scope behaviors above.
## 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 |
|---|---|---|---|---|---|---|---|---|---|---|
| Platform workspace commercial-state controls | existing system workspace detail surface | none on collection | dedicated detail route | none | none | N/A | `Change commercial state` with bounded state selection and rationale; `Suspended / read-only` path requires explicit confirmation | N/A | yes | Existing system-detail exception remains bounded to one platform mutation surface |
| Managed tenant onboarding activation gate | existing onboarding wizard completion step | existing back-navigation and tenant links | N/A - guided workflow | none | none | existing onboarding start state unchanged | `Complete onboarding` remains the primary action and becomes lifecycle-gated | N/A | yes - existing onboarding activation audit path | Existing wizard exception remains valid |
| Review-pack generation entry family | existing tenant dashboard, review register, tenant review detail, and review-pack detail/registry surfaces | current `Generate pack`, `Regenerate`, and `Export executive pack` actions stay primary where already present | existing registry/detail affordances remain unchanged | existing `View` or `Download` shortcuts remain secondary where already present | none | existing `Generate` CTA remains where already present | existing start actions are lifecycle-gated; `View` and `Download` remain outside the blocked-start gate | N/A | no new audit requirement for blocked attempts | Grouped action family stays consistent and does not invent new local start actions |
| Existing read-only review, evidence, and generated-pack consumption surfaces | existing review/evidence/detail/download surfaces | none | existing detail routes | existing `View` or `Download` actions remain available under current RBAC | none | N/A | existing read-only view/download actions remain available during suspension | N/A | no new audit action; read-only continuation only | No new surface is created; the slice only preserves availability semantics |
### Key Entities *(include if feature involves data)*
- **Workspace Commercial Lifecycle Setting**: Workspace-owned commercial posture consisting of lifecycle state, rationale, and last change attribution, persisted through the existing workspace settings infrastructure.
- **Effective Commercial Lifecycle Decision**: Derived decision that overlays the existing entitlement substrate and answers whether in-scope behaviors are allowed, warned, or blocked, plus why.
## Success Criteria *(mandatory)*
### Measurable Outcomes
- **SC-001**: Authorized platform operators can inspect and change a workspace commercial lifecycle state from one system workspace detail surface and see the updated state plus rationale immediately afterward.
- **SC-002**: Authorized workspace operators can determine in under 30 seconds whether onboarding activation or review-pack start is blocked by commercial state rather than by missing permission or underlying entitlement limits.
- **SC-003**: 100% of `suspended_read_only` blocked onboarding or review-pack start attempts stop before activation mutation or new run/artifact creation, while authorized readers still retain access to already-generated history and evidence.
- **SC-004**: Every commercial lifecycle state change produces one auditable old-state to new-state record with actor and rationale, and platform support can inspect that state from one canonical system surface.

View File

@ -1,190 +0,0 @@
---
description: "Task list for feature implementation"
---
# Tasks: Commercial Entitlements and Billing-State Maturity
**Input**: Design documents from `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/251-commercial-entitlements-billing-state/`
**Prerequisites**: `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/251-commercial-entitlements-billing-state/plan.md` (required), `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/251-commercial-entitlements-billing-state/spec.md` (required), `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/251-commercial-entitlements-billing-state/checklists/requirements.md` (required), `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/251-commercial-entitlements-billing-state/research.md`, `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/251-commercial-entitlements-billing-state/data-model.md`, `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/251-commercial-entitlements-billing-state/contracts/workspace-commercial-lifecycle-overlay.logical.openapi.yaml`, `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/251-commercial-entitlements-billing-state/quickstart.md`
**Tests**: Required (Pest) for all runtime behavior changes. Keep proof in focused `Unit` plus `Feature` lanes only, using the targeted Sail commands already captured in the feature spec, plan, and quickstart artifacts.
## Test Governance Notes
- Lane assignment: `fast-feedback` and `confidence` are the narrowest sufficient proof for resolver precedence, system-plane mutation, onboarding gating, review-pack start blocking, and preserved suspended read-only continuation.
- Keep new coverage inside `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/tests/Unit/Entitlements/` plus focused `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/tests/Feature/System/`, `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/tests/Feature/Onboarding/`, `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/tests/Feature/ReviewPack/`, `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/tests/Feature/Reviews/`, and `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/tests/Feature/Evidence/`; do not widen this slice into browser or heavy-governance families.
- Reuse existing workspace, platform-user, workspace-member, onboarding-draft, tenant, review-pack, and evidence fixtures; any new helper or factory state must stay opt-in and cheap by default.
- If implementation needs a bounded exception for blocked-decision transport or preserved read-only scope, record `document-in-feature` or `follow-up-spec` in the final close-out task instead of widening feature scope.
## Scope Control Notes
- Keep implementation inside one commercial lifecycle overlay, one system-plane lifecycle mutation surface, managed-tenant onboarding activation gating, review-pack generation/regeneration/export gating, and preserved read-only review/evidence/download semantics while suspended.
- Do not add payment provider, invoicing, checkout, website, customer-account, localization, external support-desk handoff, or broad billing-platform work.
---
## Phase 1: Setup (Shared Infrastructure)
**Purpose**: Lock the bounded slice, contract semantics, and validation plan before runtime edits begin.
- [x] T001 Review the bounded slice, explicit non-goals, scope-control decisions, and review outcomes in `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/251-commercial-entitlements-billing-state/spec.md`, `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/251-commercial-entitlements-billing-state/plan.md`, and `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/251-commercial-entitlements-billing-state/checklists/requirements.md`
- [x] T002 [P] Review the lifecycle-state model, system/admin split, preserved read-only contract, and 404 versus 403 versus business-state semantics in `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/251-commercial-entitlements-billing-state/research.md`, `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/251-commercial-entitlements-billing-state/data-model.md`, and `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/251-commercial-entitlements-billing-state/contracts/workspace-commercial-lifecycle-overlay.logical.openapi.yaml`
- [x] T003 [P] Confirm the focused Sail/Pest proof commands and reviewer scenarios in `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/251-commercial-entitlements-billing-state/quickstart.md`
---
## Phase 2: Foundational (Blocking Prerequisites)
**Purpose**: Add the shared lifecycle primitives that every user story depends on.
**⚠️ CRITICAL**: No user story work should begin until this phase is complete.
- [x] T004 [P] Register the commercial lifecycle state and rationale setting definitions, validation metadata, and operator-facing labels in `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/app/Support/Settings/SettingsRegistry.php`
- [x] T005 [P] Add the bounded four-state catalog, action-decision matrix, and shared overlay resolution logic in `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/app/Services/Entitlements/WorkspaceCommercialLifecycleResolver.php`
- [x] T006 Thread lifecycle setting resolution, default `active_paid` fallback, and lifecycle change attribution through `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/app/Services/Settings/SettingsResolver.php`, `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/app/Services/Settings/SettingsWriter.php`, and `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/app/Services/Entitlements/WorkspaceCommercialLifecycleResolver.php`
**Checkpoint**: Foundation ready. User story work can now proceed independently without inventing local lifecycle state.
---
## Phase 3: User Story 1 - Set Workspace Commercial State Centrally (Priority: P1) 🎯 MVP
**Goal**: Let an authorized platform operator inspect and change one workspace commercial lifecycle state from the existing system workspace detail surface.
**Independent Test**: Open `/system/directory/workspaces/{workspace}` as an authorized and unauthorized platform actor, change the lifecycle state with rationale, and verify the page shows current state, affected behavior summary, last-changed attribution, and audit-backed mutation semantics without creating a second control plane.
### Tests for User Story 1
- [x] T007 [P] [US1] Add unit coverage for default `active_paid` fallback, explicit stored states, `default_active_paid` versus `workspace_setting` source resolution, grace versus suspended action outcomes, and last-change attribution in `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/tests/Unit/Entitlements/WorkspaceCommercialLifecycleResolverTest.php`
- [x] T008 [P] [US1] Extend system-plane feature coverage for lifecycle summary and source-label rendering, capability-gated mutation, confirmation plus rationale validation for every explicit transition, and 404 versus 403 semantics in `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/tests/Feature/System/ViewWorkspaceEntitlementsTest.php` and `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/tests/Feature/System/Spec113/AuthorizationSemanticsTest.php`
### Implementation for User Story 1
- [x] T009 [US1] Add the dedicated commercial-lifecycle management capability and apply it to the system workspace detail action surface in `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/app/Support/Auth/PlatformCapabilities.php` and `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/app/Filament/System/Pages/Directory/ViewWorkspace.php`
- [x] T010 [US1] Project the shared lifecycle state, source label, rationale, affected-behavior summary, and last-changed attribution onto `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/app/Filament/System/Pages/Directory/ViewWorkspace.php` and `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/resources/views/filament/system/pages/directory/view-workspace.blade.php`
- [x] T011 [US1] Add the confirmation-protected `Change commercial state` action with audited old/new state writes and rationale validation for every explicit lifecycle transition in `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/app/Filament/System/Pages/Directory/ViewWorkspace.php` and `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/app/Services/Settings/SettingsWriter.php`
**Checkpoint**: User Story 1 is independently functional when the system plane exposes one canonical lifecycle decision and one audited mutation path.
---
## Phase 4: User Story 2 - Truthfully Gate Managed-Tenant Activation (Priority: P1)
**Goal**: Keep onboarding completion visible to otherwise authorized workspace actors while blocking activation with business-state truth when `grace` or `suspended_read_only` freezes expansion.
**Independent Test**: Seed workspaces in `trial`, `active_paid`, `grace`, and `suspended_read_only`, open the existing onboarding completion step, and verify that activation is either allowed or blocked with the correct lifecycle explanation before any tenant activation mutation occurs.
### Tests for User Story 2
- [x] T012 [P] [US2] Extend onboarding feature coverage for trial/active allow, grace block, suspended block, and 404 versus 403 versus business-state outcomes in `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/tests/Feature/Onboarding/ManagedTenantOnboardingEntitlementTest.php` and `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/tests/Feature/Onboarding/OnboardingRbacSemanticsTest.php`
### Implementation for User Story 2
- [x] T013 [US2] Project the shared lifecycle decision onto the onboarding completion step and helper text in `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/app/Filament/Pages/Workspaces/ManagedTenantOnboardingWizard.php`
- [x] T014 [US2] Enforce lifecycle blocking before any tenant activation mutation or onboarding completion audit path in `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/app/Filament/Pages/Workspaces/ManagedTenantOnboardingWizard.php`
- [x] T015 [US2] Keep grace and suspended explanations distinct from entitlement-limit and authorization failures by sourcing block messaging from `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/app/Services/Entitlements/WorkspaceCommercialLifecycleResolver.php` inside `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/app/Filament/Pages/Workspaces/ManagedTenantOnboardingWizard.php`
**Checkpoint**: User Story 2 is independently functional when onboarding activation exposes one truthful lifecycle decision and never mutates tenant state after a commercial-state block.
---
## Phase 5: User Story 3 - Block New Review-Pack Starts While Preserving Read-Only History (Priority: P2)
**Goal**: Reuse one lifecycle decision for `Generate pack`, `Regenerate`, and `Export executive pack` while keeping current review, evidence, and already-generated pack consumption available under existing RBAC during suspension.
**Independent Test**: Switch a workspace with existing review history, evidence, and generated packs to `suspended_read_only`, verify that all in-scope start actions block before any new `ReviewPack` or `OperationRun` write occurs, and confirm that authorized actors can still view or download existing artifacts.
### Tests for User Story 3
- [x] T016 [P] [US3] Extend review-pack feature coverage for allowed `trial`/`active_paid`, warned-but-allowed `grace` starts, blocked `suspended_read_only` starts, no new `ReviewPack` or `OperationRun` writes, no queued or terminal notification on blocked starts, and already queued or running review-pack work remaining unaffected by later suspension in `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/tests/Feature/ReviewPack/ReviewPackEntitlementEnforcementTest.php` and `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/tests/Feature/ReviewPack/ReviewPackGenerationTest.php`
- [x] T017 [P] [US3] Extend suspended read-only consumption coverage for customer review workspace access, current pack download, and evidence snapshot detail access in `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/tests/Feature/Reviews/CustomerReviewWorkspacePageTest.php`, `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/tests/Feature/Reviews/CustomerReviewWorkspacePackAccessTest.php`, `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/tests/Feature/ReviewPack/ReviewPackDownloadTest.php`, and `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/tests/Feature/Evidence/EvidenceSnapshotResourceTest.php`
### Implementation for User Story 3
- [x] T018 [US3] Enforce lifecycle gating before any new `ReviewPack`, `OperationRun`, or blocked-start notification path and reuse the existing blocked-decision transport instead of adding a second exception path while leaving already-created runs unaffected in `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/app/Services/ReviewPackService.php` and `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/app/Exceptions/Entitlements/WorkspaceEntitlementBlockedException.php`
- [x] T019 [P] [US3] Project lifecycle allow/warn/block messaging onto the tenant dashboard and review register start surfaces in `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/app/Filament/Widgets/Tenant/TenantReviewPackCard.php` and `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/app/Filament/Pages/Reviews/ReviewRegister.php`
- [x] T020 [P] [US3] Gate `Generate pack`, `Regenerate`, and `Export executive pack` actions while keeping `View` and `Download` affordances unchanged in `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/app/Filament/Resources/TenantReviewResource.php`, `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/app/Filament/Resources/TenantReviewResource/Pages/ViewTenantReview.php`, `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/app/Filament/Resources/ReviewPackResource.php`, `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/app/Filament/Resources/ReviewPackResource/Pages/ListReviewPacks.php`, and `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/app/Filament/Resources/ReviewPackResource/Pages/ViewReviewPack.php`
- [x] T021 [US3] Preserve suspended read-only review history, evidence, and generated-pack consumption without widening into a broader suspension sweep in `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/app/Filament/Pages/Reviews/CustomerReviewWorkspace.php`, `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/app/Filament/Resources/TenantReviewResource/Pages/ViewTenantReview.php`, `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/app/Filament/Resources/ReviewPackResource/Pages/ViewReviewPack.php`, and `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/app/Filament/Resources/EvidenceSnapshotResource/Pages/ViewEvidenceSnapshot.php`
**Checkpoint**: User Story 3 is independently functional when all in-scope start actions share one lifecycle gate and suspended workspaces still retain safe read-only access to existing history and evidence.
---
## Phase 6: Polish & Cross-Cutting Concerns
**Purpose**: Run the narrow validation lanes, format touched files, and capture the feature-local close-out without widening scope.
- [x] T022 Run the targeted unit Sail/Pest command from `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/251-commercial-entitlements-billing-state/plan.md` and `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/251-commercial-entitlements-billing-state/quickstart.md` against `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/tests/Unit/Entitlements/WorkspaceCommercialLifecycleResolverTest.php`
- [x] T023 Run the targeted system-plane and onboarding Sail/Pest command from `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/251-commercial-entitlements-billing-state/plan.md` and `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/251-commercial-entitlements-billing-state/quickstart.md` against `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/tests/Feature/System/ViewWorkspaceEntitlementsTest.php`, `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/tests/Feature/System/Spec113/AuthorizationSemanticsTest.php`, `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/tests/Feature/Onboarding/ManagedTenantOnboardingEntitlementTest.php`, and `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/tests/Feature/Onboarding/OnboardingRbacSemanticsTest.php`
- [x] T024 Run the targeted review-pack, blocked-start no-notification, in-flight-boundary, and preserved-read-only Sail/Pest command from `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/251-commercial-entitlements-billing-state/plan.md` and `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/251-commercial-entitlements-billing-state/quickstart.md` against `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/tests/Feature/ReviewPack/ReviewPackEntitlementEnforcementTest.php`, `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/tests/Feature/ReviewPack/ReviewPackGenerationTest.php`, `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/tests/Feature/ReviewPack/ReviewPackDownloadTest.php`, `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/tests/Feature/Reviews/CustomerReviewWorkspacePageTest.php`, `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/tests/Feature/Reviews/CustomerReviewWorkspacePackAccessTest.php`, and `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/tests/Feature/Evidence/EvidenceSnapshotResourceTest.php`
- [x] T025 Run dirty-only Pint through Sail for touched platform files using the command recorded in `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/251-commercial-entitlements-billing-state/quickstart.md`
- [x] T026 Record the final guardrail close-out, lane results, workflow outcome (`keep` unless implementation proves otherwise), and any bounded `document-in-feature` or `follow-up-spec` note for blocked-decision transport or preserved read-only scope in `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/251-commercial-entitlements-billing-state/plan.md` and `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/251-commercial-entitlements-billing-state/checklists/requirements.md`
---
## Dependencies & Execution Order
### Phase Dependencies
- **Phase 1 (Setup)**: starts immediately.
- **Phase 2 (Foundational)**: depends on Phase 1 and blocks all user stories.
- **Phase 3 (US1)**, **Phase 4 (US2)**, and **Phase 5 (US3)**: each depends on Phase 2 and is independently testable after the shared lifecycle setting and resolver primitives exist.
- **Phase 6 (Polish)**: depends on all desired user stories being complete.
### User Story Dependencies
- **US1 (P1)**: first shippable increment once Phase 2 is complete.
- **US2 (P1)**: independently testable after Phase 2 and should follow US1 in the main implementation loop because the system-plane lifecycle vocabulary and audit semantics become canonical there.
- **US3 (P2)**: independently testable after Phase 2 and should merge after US1 because review-pack surfaces must reuse the same lifecycle vocabulary and blocked-decision transport.
### Within Each User Story
- Write the listed Pest coverage first and make it fail for the intended gap before implementation.
- Complete the shared service or enforcement seam before wiring multiple UI entry points that depend on it.
- Re-run the narrowest relevant proof command after each story checkpoint before moving to the next story.
---
## Parallel Opportunities
### Phase 1
- T002 and T003 can run in parallel after T001 confirms the bounded slice.
### Phase 2
- T004 and T005 can run in parallel.
- T006 should follow once the lifecycle setting keys and resolver shape exist.
### User Story 1
- T007 and T008 can run in parallel.
- T009 can proceed before T010 and T011, but T010 and T011 should stay coordinated because both touch `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/app/Filament/System/Pages/Directory/ViewWorkspace.php`.
### User Story 2
- T012 can run in parallel with any remaining US1 validation once Phase 2 is complete.
- T013, T014, and T015 should stay sequential because they all tighten the same onboarding completion boundary.
### User Story 3
- T016 and T017 can run in parallel.
- After T018 establishes the service-level gate, T019 and T020 can run in parallel.
- T021 should follow the shared start-gate work so preserved read-only semantics stay bounded to existing consumption surfaces.
---
## Implementation Strategy
### Suggested MVP Scope
- MVP = **Phase 2 + User Story 1 + User Story 2**. This is the smallest slice that creates canonical lifecycle truth, exposes the one platform-side mutation surface, and proves a real business-state consequence (`grace` / `suspended_read_only` onboarding activation gating) without yet widening into review-pack and preserved-history follow-up.
### Incremental Delivery
1. Complete Phase 1 and Phase 2.
2. Deliver US1 and validate system-plane lifecycle mutation plus audit semantics.
3. Deliver US2 and validate onboarding business-state gating.
4. Deliver US3 and validate review-pack start blocking plus preserved suspended read-only history/evidence/download access.
5. Finish with Phase 6 validation, formatting, and feature-local close-out recording.