feat(reviews): add CustomerReviewWorkspace with audit logging and RBAC enforcement
Some checks failed
PR Fast Feedback / fast-feedback (pull_request) Failing after 5m43s

- Add CustomerReviewWorkspace page for tenant pre-filtered reviews
- Add customer workspace links to EvidenceSnapshotResource, ReviewPackResource, and TenantReviewResource
- Implement audit logging for TenantReviewOpened and ReviewPackDownloaded actions
- Update ReviewPack download controller to enforce tenant-scoped RBAC
- Add tests for ReviewPack download authorization and audit logging
This commit is contained in:
Ahmed Darrazi 2026-04-28 09:10:56 +02:00
parent f7bc4f2787
commit dfc91d055a
29 changed files with 2969 additions and 10 deletions

View File

@ -260,6 +260,8 @@ ## 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.15 (feat/005-bulk-operations)
@ -294,9 +296,9 @@ ## Code Style
PHP 8.4.15: Follow standard conventions
## Recent Changes
- 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

@ -0,0 +1,497 @@
<?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

@ -6,6 +6,7 @@
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;
@ -267,6 +268,20 @@ 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,6 +5,7 @@
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;
@ -195,6 +196,13 @@ 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()

View File

@ -6,6 +6,7 @@
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;
@ -649,6 +650,15 @@ 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,12 +4,15 @@
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;
@ -24,6 +27,13 @@ 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);
@ -69,7 +79,7 @@ protected function getHeaderActions(): array
->label('Danger')
->icon('heroicon-o-archive-box')
->color('danger')
->visible(fn (): bool => ! $this->record->statusEnum()->isTerminal()),
->visible(fn (): bool => ! $this->isCustomerWorkspaceView() && ! $this->record->statusEnum()->isTerminal()),
]));
}
@ -85,6 +95,10 @@ 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';
}
@ -122,6 +136,10 @@ private function secondaryLifecycleActions(): array
*/
private function secondaryLifecycleActionNames(): array
{
if ($this->isCustomerWorkspaceView()) {
return [];
}
$names = [];
if ($this->record->isMutable()) {
@ -178,7 +196,6 @@ private function refreshReviewAction(): Actions\Action
}),
)
->requireCapability(Capabilities::TENANT_REVIEW_MANAGE)
->preserveVisibility()
->apply();
}
@ -325,4 +342,39 @@ 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

@ -5,6 +5,7 @@
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;
@ -180,6 +181,7 @@ protected function getViewData(): array
'canManage' => $canManage,
'generationBlocked' => $generationBlocked,
'generationBlockReason' => $generationBlockReason,
'customerWorkspaceUrl' => $canView ? CustomerReviewWorkspace::tenantPrefilterUrl($tenant) : null,
'downloadUrl' => null,
'failedReason' => null,
'reviewUrl' => null,
@ -230,6 +232,7 @@ protected function getViewData(): array
'canManage' => $canManage,
'generationBlocked' => $generationBlocked,
'generationBlockReason' => $generationBlockReason,
'customerWorkspaceUrl' => $canView ? CustomerReviewWorkspace::tenantPrefilterUrl($tenant) : null,
'downloadUrl' => $downloadUrl,
'failedReason' => $failedReason,
'failedReasonDetail' => $failedReasonDetail,
@ -262,6 +265,7 @@ private function emptyState(): array
'canManage' => false,
'generationBlocked' => false,
'generationBlockReason' => null,
'customerWorkspaceUrl' => null,
'downloadUrl' => null,
'failedReason' => null,
'failedReasonDetail' => null,

View File

@ -4,7 +4,12 @@
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;
@ -15,6 +20,21 @@ 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;
}
@ -29,7 +49,26 @@ public function __invoke(Request $request, ReviewPack $reviewPack): StreamedResp
throw new NotFoundHttpException;
}
$tenant = $reviewPack->tenant;
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,
);
$filename = sprintf(
'review-pack-%s-%s.zip',
$tenant?->external_id ?? 'unknown',

View File

@ -12,6 +12,7 @@
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;
@ -183,6 +184,7 @@ public function panel(Panel $panel): Panel
FindingsIntakeQueue::class,
MyFindingsInbox::class,
FindingExceptionsQueue::class,
CustomerReviewWorkspace::class,
ReviewRegister::class,
])
->widgets([

View File

@ -234,14 +234,16 @@ 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): string
public function generateDownloadUrl(ReviewPack $pack, array $parameters = []): string
{
$ttlMinutes = (int) config('tenantpilot.review_pack.download_url_ttl_minutes', 60);
return URL::signedRoute(
'admin.review-packs.download',
['reviewPack' => $pack->getKey()],
array_merge(['reviewPack' => $pack->getKey()], $parameters),
now()->addMinutes($ttlMinutes),
);
}

View File

@ -12,6 +12,7 @@
use App\Services\Auth\RoleCapabilityMap;
use App\Support\Auth\Capabilities;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Facades\DB;
final class TenantReviewRegisterService
{
@ -43,6 +44,55 @@ 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,8 +94,10 @@ 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';
@ -238,8 +240,10 @@ 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',
@ -328,8 +332,10 @@ 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

@ -0,0 +1,19 @@
<x-filament-panels::page>
<x-filament::section>
<div class="flex flex-col gap-3">
<div class="text-lg font-semibold text-gray-900 dark:text-gray-100">
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

@ -11,6 +11,7 @@
/** @var bool $canManage */
/** @var bool $generationBlocked */
/** @var ?string $generationBlockReason */
/** @var ?string $customerWorkspaceUrl */
/** @var ?string $downloadUrl */
/** @var ?string $failedReason */
/** @var ?string $failedReasonDetail */
@ -215,5 +216,18 @@
@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

@ -0,0 +1,100 @@
<?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

@ -3,7 +3,9 @@
declare(strict_types=1);
use App\Models\ReviewPack;
use App\Models\AuditLog;
use App\Services\ReviewPackService;
use App\Support\Audit\AuditActionId;
use App\Support\ReviewPackStatus;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Storage;
@ -41,13 +43,25 @@ function createReadyPackWithFile(?array $packOverrides = []): array
it('downloads a ready pack via signed URL with correct headers', function (): void {
[$user, $tenant, $pack] = createReadyPackWithFile();
$signedUrl = app(ReviewPackService::class)->generateDownloadUrl($pack);
$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');
});
// ─── Expired Signature → 403 ────────────────────────────────

View File

@ -77,11 +77,9 @@ 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)->assertOk();
$this->actingAs($user)->get($signedUrl)->assertNotFound();
});
// ─── REVIEW_PACK_VIEW Member ────────────────────────────────

View File

@ -0,0 +1,66 @@
<?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

@ -0,0 +1,160 @@
<?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

@ -0,0 +1,97 @@
<?php
declare(strict_types=1);
use App\Filament\Pages\Reviews\CustomerReviewWorkspace;
use App\Models\ReviewPack;
use App\Models\Tenant;
use App\Support\TenantReviewStatus;
use App\Support\Workspaces\WorkspaceContext;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Livewire\Livewire;
uses(RefreshDatabase::class);
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('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

@ -0,0 +1,222 @@
<?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

@ -0,0 +1,72 @@
# 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

@ -0,0 +1,261 @@
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

@ -0,0 +1,210 @@
# 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

@ -0,0 +1,310 @@
# 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

@ -0,0 +1,59 @@
# 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

@ -0,0 +1,166 @@
# 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

@ -0,0 +1,299 @@
# 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

@ -0,0 +1,205 @@
---
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.