feat: review pack pdf and html renderer v1 (spec 356)
Some checks failed
PR Fast Feedback / fast-feedback (pull_request) Failing after 2m1s

Implemented the first version of the PDF and HTML renderer for review packs. Added ReviewPackRenderedReportController and related blade views to render reports. Updated EnvironmentReviewResource, ReviewPackResource, ReviewPackService, and routing. Added new tests for the renderer and download actions, and updated UI documentation.
This commit is contained in:
Ahmed Darrazi 2026-06-05 22:38:10 +02:00
parent f35782a163
commit 54f81d4a50
39 changed files with 3168 additions and 71 deletions

View File

@ -880,6 +880,17 @@ private static function summaryContextLinks(EnvironmentReview $record, bool $cus
}
if (! $customerWorkspaceMode && $record->currentExportReviewPack && $record->tenant) {
$renderedReportUrl = static::currentRenderedReportUrlFor($record);
if ($renderedReportUrl !== null) {
$links[] = [
'title' => __('localization.review.rendered_report'),
'label' => static::renderedReportActionLabelFor($record),
'url' => $renderedReportUrl,
'description' => __('localization.review.rendered_report_description'),
];
}
$links[] = [
'title' => __('localization.review.executive_pack'),
'label' => __('localization.review.view_executive_pack'),
@ -1206,4 +1217,66 @@ public static function currentReviewPackDownloadUrlFor(EnvironmentReview $record
'interpretation_version' => $record->controlInterpretationVersion(),
]);
}
public static function currentRenderedReportUrlFor(EnvironmentReview $record): ?string
{
$pack = $record->currentExportReviewPack;
$tenant = $record->tenant;
$user = auth()->user();
if (! $pack instanceof ReviewPack || ! $tenant instanceof ManagedEnvironment || ! $user instanceof User) {
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;
}
if ((int) ($record->current_export_review_pack_id ?? 0) !== (int) $pack->getKey()) {
return null;
}
$parameters = [
'source_surface' => static::isCustomerWorkspaceMode()
? CustomerReviewWorkspace::SOURCE_SURFACE
: 'environment_review_detail',
'review_id' => (int) $record->getKey(),
'tenant_filter_id' => request()->query('tenant_filter_id'),
'interpretation_version' => $record->controlInterpretationVersion(),
];
if (static::isCustomerWorkspaceMode()) {
$parameters[CustomerReviewWorkspace::DETAIL_CONTEXT_QUERY_KEY] = 1;
}
return app(ReviewPackService::class)->generateRenderedReportUrl(
$pack,
array_filter($parameters, static fn (mixed $value): bool => $value !== null && $value !== ''),
);
}
public static function renderedReportActionLabelFor(?EnvironmentReview $record): string
{
if (! $record instanceof EnvironmentReview) {
return __('localization.review.view_report_with_limitations');
}
$guidance = ReviewPackOutputResolutionGuidance::fromReadiness(
ReviewPackOutputResolutionGuidance::readinessForReview($record),
);
return match ((string) ($guidance['state'] ?? ReviewPackOutputResolutionGuidance::STATE_UNKNOWN)) {
ReviewPackOutputResolutionGuidance::STATE_CUSTOMER_SAFE_READY => __('localization.review.view_customer_safe_report'),
ReviewPackOutputResolutionGuidance::STATE_INTERNAL_ONLY => __('localization.review.view_internal_report'),
default => __('localization.review.view_report_with_limitations'),
};
}
}

View File

@ -75,7 +75,7 @@ protected function getHeaderActions(): array
{
if ($this->isCustomerWorkspaceView()) {
return [
$this->downloadCurrentReviewPackAction(),
$this->openCurrentRenderedReportAction(),
];
}
@ -397,30 +397,26 @@ private function archiveReviewAction(): Actions\Action
->apply();
}
private function downloadCurrentReviewPackAction(): Actions\Action
private function openCurrentRenderedReportAction(): Actions\Action
{
return Actions\Action::make('download_current_review_pack')
->label(function (): string {
$guidance = EnvironmentReviewResource::outputGuidanceState($this->record);
return (string) ($guidance['qualified_download_label'] ?? __('localization.review.download_governance_package'));
})
->icon('heroicon-o-arrow-down-tray')
return Actions\Action::make('open_current_rendered_report')
->label(fn (): string => EnvironmentReviewResource::renderedReportActionLabelFor($this->record))
->icon('heroicon-o-document-text')
->color('primary')
->disabled(fn (): bool => $this->currentReviewPackDownloadUrl() === null)
->tooltip(fn (): ?string => $this->currentReviewPackUnavailableReason())
->url(fn (): ?string => $this->currentReviewPackDownloadUrl())
->disabled(fn (): bool => $this->currentRenderedReportUrl() === null)
->tooltip(fn (): ?string => $this->currentRenderedReportUnavailableReason())
->url(fn (): ?string => $this->currentRenderedReportUrl())
->openUrlInNewTab();
}
private function currentReviewPackDownloadUrl(): ?string
private function currentRenderedReportUrl(): ?string
{
return EnvironmentReviewResource::currentReviewPackDownloadUrlFor($this->record);
return EnvironmentReviewResource::currentRenderedReportUrlFor($this->record);
}
private function currentReviewPackUnavailableReason(): ?string
private function currentRenderedReportUnavailableReason(): ?string
{
if ($this->currentReviewPackDownloadUrl() !== null) {
if ($this->currentRenderedReportUrl() !== null) {
return null;
}

View File

@ -9,6 +9,7 @@
use App\Filament\Pages\Reviews\CustomerReviewWorkspace;
use App\Filament\Resources\EvidenceSnapshotResource as TenantEvidenceSnapshotResource;
use App\Filament\Resources\ReviewPackResource\Pages;
use App\Models\EnvironmentReview;
use App\Models\ManagedEnvironment;
use App\Models\ReviewPack;
use App\Models\User;
@ -21,6 +22,7 @@
use App\Support\OperationRunLinks;
use App\Support\OpsUx\OperationUxPresenter;
use App\Support\Rbac\UiEnforcement;
use App\Support\ReviewPacks\ReviewPackOutputResolutionGuidance;
use App\Support\ReviewPackStatus;
use App\Support\Ui\ActionSurface\ActionSurfaceDeclaration;
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceInspectAffordance;
@ -120,7 +122,7 @@ public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration
->satisfy(ActionSurfaceSlot::ListEmptyState, 'Empty state carries the single Generate CTA while the registry is empty.')
->satisfy(ActionSurfaceSlot::ListRowMoreMenu, 'Clickable-row inspection stays primary while Download remains the only direct row shortcut and Expire is grouped under More.')
->exempt(ActionSurfaceSlot::ListBulkMoreGroup, 'No bulk operations are supported for review packs.')
->satisfy(ActionSurfaceSlot::DetailHeader, 'Download and Regenerate actions in ViewReviewPack header.');
->satisfy(ActionSurfaceSlot::DetailHeader, 'Rendered-report preview stays primary while Download and Regenerate remain available in the ViewReviewPack header.');
}
public static function form(Schema $schema): Schema
@ -333,9 +335,9 @@ public static function table(Table $table): Table
])
->actions([
Actions\Action::make('download')
->label('Download')
->label(fn (ReviewPack $record): string => static::downloadActionLabelFor($record))
->icon('heroicon-o-arrow-down-tray')
->color('success')
->color('gray')
->visible(fn (ReviewPack $record): bool => $record->status === ReviewPackStatus::Ready->value)
->url(function (ReviewPack $record): string {
return app(ReviewPackService::class)->generateDownloadUrl($record);
@ -439,6 +441,25 @@ public static function getPages(): array
];
}
public static function downloadActionLabelFor(ReviewPack $record): string
{
$review = $record->environmentReview;
if (! $review instanceof EnvironmentReview) {
return __('localization.review.download_current_review_pack');
}
$guidance = ReviewPackOutputResolutionGuidance::fromReadiness(
ReviewPackOutputResolutionGuidance::readinessForReview($review),
);
return match ((string) ($guidance['state'] ?? ReviewPackOutputResolutionGuidance::STATE_UNKNOWN)) {
ReviewPackOutputResolutionGuidance::STATE_CUSTOMER_SAFE_READY => __('localization.review.download_customer_safe_review_pack'),
ReviewPackOutputResolutionGuidance::STATE_INTERNAL_ONLY => __('localization.review.download_internal_review_pack'),
default => __('localization.review.download_review_pack_with_limitations'),
};
}
public static function isCustomerWorkspaceFlow(): bool
{
return request()->query('source_surface') === CustomerReviewWorkspace::SOURCE_SURFACE;

View File

@ -2,6 +2,7 @@
namespace App\Filament\Resources\ReviewPackResource\Pages;
use App\Filament\Pages\Reviews\CustomerReviewWorkspace;
use App\Filament\Resources\ReviewPackResource;
use App\Models\ReviewPack;
use App\Services\ReviewPackService;
@ -21,13 +22,20 @@ protected function getHeaderActions(): array
{
if (ReviewPackResource::isCustomerWorkspaceFlow()) {
return [
$this->openRenderedReportAction([
'source_surface' => CustomerReviewWorkspace::SOURCE_SURFACE,
CustomerReviewWorkspace::DETAIL_CONTEXT_QUERY_KEY => 1,
'review_id' => $this->record->environment_review_id,
'tenant_filter_id' => request()->query('tenant_filter_id'),
'interpretation_version' => $this->record->environmentReview?->controlInterpretationVersion(),
], 'primary'),
Actions\Action::make('download')
->label('Download')
->label(fn (): string => ReviewPackResource::downloadActionLabelFor($this->record))
->icon('heroicon-o-arrow-down-tray')
->color('primary')
->color('gray')
->visible(fn (): bool => $this->record->status === ReviewPackStatus::Ready->value)
->url(fn (): string => app(ReviewPackService::class)->generateDownloadUrl($this->record, [
'source_surface' => \App\Filament\Pages\Reviews\CustomerReviewWorkspace::SOURCE_SURFACE,
'source_surface' => CustomerReviewWorkspace::SOURCE_SURFACE,
]))
->openUrlInNewTab(),
];
@ -79,10 +87,16 @@ protected function getHeaderActions(): array
$regenerateAction->tooltip(fn (): ?string => ReviewPackResource::reviewPackGenerationActionTooltip($this->record->tenant));
return [
$this->openRenderedReportAction([
'source_surface' => 'review_pack',
'review_id' => $this->record->environment_review_id,
'tenant_filter_id' => request()->query('tenant_filter_id'),
'interpretation_version' => $this->record->environmentReview?->controlInterpretationVersion(),
]),
Actions\Action::make('download')
->label('Download')
->label(fn (): string => ReviewPackResource::downloadActionLabelFor($this->record))
->icon('heroicon-o-arrow-down-tray')
->color('success')
->color('gray')
->visible(fn (): bool => $this->record->status === ReviewPackStatus::Ready->value)
->url(fn (): string => app(ReviewPackService::class)->generateDownloadUrl($this->record))
->openUrlInNewTab(),
@ -90,4 +104,41 @@ protected function getHeaderActions(): array
$regenerateAction,
];
}
/**
* @param array<string, scalar|null> $parameters
*/
private function openRenderedReportAction(array $parameters = [], string $color = 'primary'): Actions\Action
{
return Actions\Action::make('open_rendered_report')
->label(fn (): string => \App\Filament\Resources\EnvironmentReviewResource::renderedReportActionLabelFor($this->record->environmentReview))
->icon('heroicon-o-document-text')
->color($color)
->visible(fn (): bool => $this->canOpenRenderedReport())
->url(fn (): string => app(ReviewPackService::class)->generateRenderedReportUrl(
$this->record,
array_filter($parameters, static fn (mixed $value): bool => $value !== null && $value !== ''),
))
->openUrlInNewTab();
}
private function canOpenRenderedReport(): bool
{
/** @var ReviewPack $record */
$record = $this->record;
if ($record->status !== ReviewPackStatus::Ready->value) {
return false;
}
if ($record->expires_at !== null && $record->expires_at->isPast()) {
return false;
}
if (! $record->environmentReview instanceof \App\Models\EnvironmentReview) {
return false;
}
return (int) ($record->environmentReview->current_export_review_pack_id ?? 0) === (int) $record->getKey();
}
}

View File

@ -0,0 +1,584 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers;
use App\Filament\Pages\Reviews\CustomerReviewWorkspace;
use App\Filament\Resources\EnvironmentReviewResource;
use App\Filament\Resources\ReviewPackResource;
use App\Models\EnvironmentReview;
use App\Models\EnvironmentReviewSection;
use App\Models\ManagedEnvironment;
use App\Models\ReviewPack;
use App\Models\User;
use App\Models\Workspace;
use App\Services\ReviewPackService;
use App\Support\Auth\Capabilities;
use App\Support\ReviewPacks\ReviewPackOutputResolutionGuidance;
use App\Support\ReviewPackStatus;
use Illuminate\Http\Request;
use Illuminate\Http\Response;
use Illuminate\Support\Str;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
class ReviewPackRenderedReportController extends Controller
{
public function __invoke(Request $request, ReviewPack $reviewPack): Response
{
$reviewPack->loadMissing([
'tenant.workspace',
'environmentReview.evidenceSnapshot',
'environmentReview.currentExportReviewPack',
'environmentReview.sections',
]);
$user = $request->user();
$tenant = $reviewPack->tenant;
$review = $reviewPack->environmentReview;
if (! $user instanceof User || ! $tenant instanceof ManagedEnvironment || ! $review instanceof EnvironmentReview) {
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;
}
if ($reviewPack->expires_at !== null && $reviewPack->expires_at->isPast()) {
throw new NotFoundHttpException;
}
if ((int) $review->managed_environment_id !== (int) $tenant->getKey()) {
throw new NotFoundHttpException;
}
if ((int) ($review->current_export_review_pack_id ?? 0) !== (int) $reviewPack->getKey()) {
throw new NotFoundHttpException;
}
return response()->view('review-packs.rendered-report', [
'report' => $this->reportState($request, $reviewPack, $review, $tenant),
]);
}
/**
* @return array<string, mixed>
*/
private function reportState(
Request $request,
ReviewPack $reviewPack,
EnvironmentReview $review,
ManagedEnvironment $tenant,
): array {
$summary = is_array($reviewPack->summary) ? $reviewPack->summary : [];
$governancePackage = is_array($summary['governance_package'] ?? null) ? $summary['governance_package'] : [];
$controlInterpretation = is_array($summary['control_interpretation'] ?? null) ? $summary['control_interpretation'] : [];
$downloadUrl = $this->downloadUrl($request, $reviewPack, $review);
$reviewUrl = $this->reviewUrl($request, $review, $tenant);
$reviewPackUrl = $this->reviewPackUrl($request, $reviewPack, $tenant);
$readiness = ReviewPackOutputResolutionGuidance::readinessForReview($review);
$guidance = ReviewPackOutputResolutionGuidance::fromReadiness($readiness, [
'download' => $downloadUrl,
'review' => $reviewUrl,
]);
$decisionSummary = is_array($governancePackage['decision_summary'] ?? null)
? $governancePackage['decision_summary']
: [];
$state = (string) ($guidance['state'] ?? ReviewPackOutputResolutionGuidance::STATE_UNKNOWN);
$limitations = $this->managementLimitations(is_array($guidance['limitations'] ?? null) ? $guidance['limitations'] : []);
$evidenceBasis = $this->evidenceBasisState($review);
return [
'title' => __('localization.review.rendered_report'),
'guidance' => $guidance,
'state' => $state,
'hero' => $this->heroState($state, $guidance),
'branding' => $this->brandingState($tenant),
'tenant_name' => $tenant->name,
'review_id' => (int) $review->getKey(),
'pack_id' => (int) $reviewPack->getKey(),
'review_status' => (string) $review->status,
'review_completeness_state' => (string) $review->completeness_state,
'review_status_label' => Str::headline((string) $review->status),
'review_completeness_label' => Str::headline((string) $review->completeness_state),
'generated_at' => $reviewPack->generated_at,
'published_at' => $review->published_at,
'download_url' => $downloadUrl,
'download_label' => $this->downloadLabel($state),
'review_url' => $reviewUrl,
'review_pack_url' => $reviewPackUrl,
'html_only' => true,
'executive_summary' => $this->plainText(
$governancePackage['executive_summary'] ?? null,
__('localization.review.rendered_report_summary_fallback'),
),
'management_summary' => $this->managementSummary(
state: $state,
guidance: $guidance,
limitations: $limitations,
storedExecutiveSummary: $governancePackage['executive_summary'] ?? null,
),
'evidence_basis' => $evidenceBasis,
'evidence_basis_summary' => $evidenceBasis['description'],
'limitations' => $limitations,
'top_findings' => is_array($governancePackage['top_findings'] ?? null) ? $governancePackage['top_findings'] : [],
'accepted_risks' => $this->acceptedRiskItems(
is_array($governancePackage['accepted_risks'] ?? null) ? $governancePackage['accepted_risks'] : [],
$state !== ReviewPackOutputResolutionGuidance::STATE_INTERNAL_ONLY,
),
'decision_summary' => $decisionSummary,
'governance_decisions' => is_array($decisionSummary['entries'] ?? null)
? $decisionSummary['entries']
: (is_array($governancePackage['governance_decisions'] ?? null) ? $governancePackage['governance_decisions'] : []),
'next_actions' => is_array($summary['recommended_next_actions'] ?? null) ? $summary['recommended_next_actions'] : [],
'non_certification_disclosure' => $this->plainText(
$controlInterpretation['non_certification_disclosure'] ?? null,
__('localization.review.non_certification_disclosure_text'),
),
'section_appendix' => $this->sectionAppendix($reviewPack, $review),
'technical_details' => is_array($guidance['technical_details'] ?? null) ? $guidance['technical_details'] : [],
'entrypoint_file' => (string) data_get($summary, 'delivery_bundle.executive_entrypoint_file', ReviewPackService::EXECUTIVE_ENTRYPOINT_FILENAME),
'appendix_files' => is_array(data_get($summary, 'delivery_bundle.appendix_files')) ? data_get($summary, 'delivery_bundle.appendix_files') : [],
];
}
/**
* @return array{prepared_by:string,prepared_for:string,generated_by:string}
*/
private function brandingState(ManagedEnvironment $tenant): array
{
$workspace = $tenant->workspace;
return [
'prepared_by' => $workspace instanceof Workspace && filled($workspace->name)
? (string) $workspace->name
: 'TenantPilot',
'prepared_for' => filled($tenant->name) ? (string) $tenant->name : __('localization.review.tenant'),
'generated_by' => 'TenantPilot',
];
}
/**
* @param array<string, mixed> $guidance
* @return array{title:string,badge:string,summary:string,warning:?string,color:string,customer_safe:bool}
*/
private function heroState(string $state, array $guidance): array
{
$customerSafe = $state === ReviewPackOutputResolutionGuidance::STATE_CUSTOMER_SAFE_READY;
return [
'title' => match ($state) {
ReviewPackOutputResolutionGuidance::STATE_CUSTOMER_SAFE_READY => __('localization.review.report_state_customer_safe_ready'),
ReviewPackOutputResolutionGuidance::STATE_PUBLISHED_WITH_LIMITATIONS => __('localization.review.report_state_with_limitations'),
ReviewPackOutputResolutionGuidance::STATE_INTERNAL_ONLY => __('localization.review.report_state_internal_with_limitations'),
default => __('localization.review.report_state_not_customer_ready'),
},
'badge' => $customerSafe
? __('localization.review.customer_safe')
: (string) ($guidance['boundary_label'] ?? __('localization.review.requires_review')),
'summary' => match ($state) {
ReviewPackOutputResolutionGuidance::STATE_CUSTOMER_SAFE_READY => __('localization.review.report_state_customer_safe_ready_summary'),
ReviewPackOutputResolutionGuidance::STATE_INTERNAL_ONLY => __('localization.review.report_state_internal_summary'),
ReviewPackOutputResolutionGuidance::STATE_PUBLISHED_WITH_LIMITATIONS => __('localization.review.report_state_limitations_summary'),
default => __('localization.review.report_state_not_ready_summary'),
},
'warning' => $customerSafe ? null : __('localization.review.report_external_sharing_warning'),
'color' => $customerSafe ? 'success' : ($state === ReviewPackOutputResolutionGuidance::STATE_PUBLICATION_BLOCKED ? 'danger' : 'warning'),
'customer_safe' => $customerSafe,
];
}
/**
* @param array<string, mixed> $guidance
* @param list<array<string, string>> $limitations
* @return array{overall_state:string,reason:string,impact:string,next_action:string,top_limitations:list<string>}
*/
private function managementSummary(string $state, array $guidance, array $limitations, mixed $storedExecutiveSummary): array
{
$topLimitations = collect($limitations)
->pluck('summary')
->filter(static fn (mixed $summary): bool => is_string($summary) && trim($summary) !== '')
->take(3)
->values()
->all();
return [
'overall_state' => (string) ($this->heroState($state, $guidance)['title']),
'reason' => $this->managementReason($state, $guidance, $storedExecutiveSummary),
'impact' => $this->plainText($guidance['impact'] ?? null, __('localization.review.report_summary_default_impact')),
'next_action' => $this->managementNextAction($state, $limitations),
'top_limitations' => $topLimitations,
];
}
/**
* @param array<string, mixed> $guidance
*/
private function managementReason(string $state, array $guidance, mixed $storedExecutiveSummary): string
{
if ($state === ReviewPackOutputResolutionGuidance::STATE_CUSTOMER_SAFE_READY) {
return $this->plainText($storedExecutiveSummary, __('localization.review.report_summary_ready_reason'));
}
return $this->plainText($guidance['primary_reason'] ?? null, __('localization.review.report_summary_limited_reason'));
}
/**
* @param list<array<string, string>> $limitations
*/
private function managementNextAction(string $state, array $limitations): string
{
$firstKey = $limitations[0]['key'] ?? null;
return match (true) {
$state === ReviewPackOutputResolutionGuidance::STATE_CUSTOMER_SAFE_READY => __('localization.review.report_next_action_ready'),
$state === ReviewPackOutputResolutionGuidance::STATE_INTERNAL_ONLY => __('localization.review.report_next_action_internal'),
$firstKey === 'evidence_basis_missing',
$firstKey === 'evidence_basis_stale',
$firstKey === 'evidence_basis_incomplete' => __('localization.review.report_next_action_evidence'),
$firstKey === 'required_sections_incomplete' => __('localization.review.report_next_action_sections'),
$firstKey === 'publish_blockers_present' => __('localization.review.report_next_action_blockers'),
default => __('localization.review.report_next_action_limited'),
};
}
/**
* @param list<array<string, mixed>> $limitations
* @return list<array{key:string,title:string,summary:string,next_action:string,severity:string}>
*/
private function managementLimitations(array $limitations): array
{
return collect($limitations)
->map(function (array $limitation): array {
$key = (string) ($limitation['key'] ?? 'unknown');
return [
'key' => $key,
'title' => $this->plainText($limitation['label'] ?? null, __('localization.review.output_limitations')),
'summary' => match ($key) {
'evidence_basis_missing',
'evidence_basis_stale',
'evidence_basis_incomplete' => __('localization.review.report_limitation_evidence_summary'),
'required_sections_incomplete' => __('localization.review.report_limitation_sections_summary'),
'contains_pii' => __('localization.review.report_limitation_pii_summary'),
'publish_blockers_present' => __('localization.review.report_limitation_blockers_summary'),
'export_not_ready' => __('localization.review.report_limitation_export_summary'),
'disclosure_missing' => __('localization.review.report_limitation_disclosure_summary'),
default => $this->plainText($limitation['reason'] ?? null, __('localization.review.report_limitation_default_summary')),
},
'next_action' => match ($key) {
'evidence_basis_missing',
'evidence_basis_stale',
'evidence_basis_incomplete' => __('localization.review.report_next_action_evidence'),
'required_sections_incomplete' => __('localization.review.report_next_action_sections'),
'contains_pii' => __('localization.review.report_next_action_internal'),
'publish_blockers_present' => __('localization.review.report_next_action_blockers'),
default => __('localization.review.report_next_action_limited'),
},
'severity' => (string) ($limitation['severity'] ?? 'warning'),
];
})
->values()
->all();
}
/**
* @return array{label:string,description:string,operator_action:string,snapshot_label:string}
*/
private function evidenceBasisState(EnvironmentReview $review): array
{
$snapshot = $review->evidenceSnapshot;
$state = (string) ($snapshot?->completeness_state ?? 'missing');
$snapshotLabel = $snapshot instanceof \App\Models\EvidenceSnapshot
? __('localization.review.evidence_snapshot_number', ['id' => (int) $snapshot->getKey()])
: __('localization.review.evidence_snapshot_missing');
return [
'label' => Str::headline($state),
'description' => match ($state) {
'complete' => __('localization.review.report_evidence_complete_description', ['snapshot' => $snapshotLabel]),
'stale' => __('localization.review.report_evidence_stale_description', ['snapshot' => $snapshotLabel]),
'partial' => __('localization.review.report_evidence_partial_description', ['snapshot' => $snapshotLabel]),
default => __('localization.review.report_evidence_missing_description'),
},
'operator_action' => match ($state) {
'complete' => __('localization.review.report_evidence_complete_action'),
default => __('localization.review.report_next_action_evidence'),
},
'snapshot_label' => $snapshotLabel,
];
}
/**
* @param list<array<string, mixed>> $acceptedRisks
* @return list<array{title:string,status:string,summary:?string,limitation:?string,owner:?string,review_state:string}>
*/
private function acceptedRiskItems(array $acceptedRisks, bool $showOwner): array
{
return collect($acceptedRisks)
->filter(static fn (mixed $risk): bool => is_array($risk))
->map(function (array $risk) use ($showOwner): array {
$customerSafeSummary = $this->plainText(
$risk['customer_safe_summary'] ?? ($risk['customer_summary'] ?? null),
'',
);
$status = $this->acceptedRiskStatusLabel((string) ($risk['governance_state'] ?? ($risk['status'] ?? 'unknown')));
$owner = $showOwner
? $this->plainText($risk['owner_label'] ?? data_get($risk, 'owner.name'), '')
: '';
return [
'title' => $this->plainText($risk['title'] ?? null, __('localization.review.accepted_risks')),
'status' => $status,
'summary' => $customerSafeSummary !== '' ? $customerSafeSummary : null,
'limitation' => $customerSafeSummary === '' ? __('localization.review.accepted_risk_customer_safe_summary_missing') : null,
'owner' => $owner !== '' ? $owner : null,
'review_state' => $this->acceptedRiskReviewState($risk),
];
})
->values()
->all();
}
private function acceptedRiskStatusLabel(string $state): string
{
return match ($state) {
'valid_exception' => __('localization.review.accepted_risk_state_current'),
'expiring_exception' => __('localization.review.accepted_risks_expiring_soon'),
'expired_exception' => __('localization.review.accepted_risks_expired'),
'revoked_exception', 'rejected_exception' => __('localization.review.accepted_risks_needs_review'),
'risk_accepted_without_valid_exception' => __('localization.review.accepted_risks_needs_review'),
default => Str::headline($state),
};
}
/**
* @param array<string, mixed> $risk
*/
private function acceptedRiskReviewState(array $risk): string
{
$reviewDueAt = $this->plainText($risk['review_due_at'] ?? null, '');
$expiresAt = $this->plainText($risk['expires_at'] ?? null, '');
if ($reviewDueAt !== '') {
return __('localization.review.accepted_risk_review_due_on', ['date' => $reviewDueAt]);
}
if ($expiresAt !== '') {
return __('localization.review.accepted_risk_expires_on', ['date' => $expiresAt]);
}
return __('localization.review.review_date_not_recorded');
}
private function downloadLabel(string $state): string
{
return match ($state) {
ReviewPackOutputResolutionGuidance::STATE_CUSTOMER_SAFE_READY => __('localization.review.download_customer_safe_review_pack'),
ReviewPackOutputResolutionGuidance::STATE_INTERNAL_ONLY => __('localization.review.download_internal_review_pack'),
default => __('localization.review.download_review_pack_with_limitations'),
};
}
/**
* @return list<array<string, mixed>>
*/
private function sectionAppendix(ReviewPack $reviewPack, EnvironmentReview $review): array
{
$includeOperations = (bool) (($reviewPack->options ?? [])['include_operations'] ?? true);
return $review->sections
->filter(fn (EnvironmentReviewSection $section): bool => $includeOperations || $section->section_key !== 'operations_health')
->sortBy('sort_order')
->values()
->map(function (EnvironmentReviewSection $section): array {
$summaryPayload = is_array($section->summary_payload) ? $section->summary_payload : [];
$renderPayload = is_array($section->render_payload) ? $section->render_payload : [];
return [
'title' => (string) $section->title,
'completeness_label' => Str::headline((string) $section->completeness_state),
'summary_items' => collect($summaryPayload)
->map(function (mixed $value, string $key): ?array {
if (is_array($value) || $value === null || $value === '') {
return null;
}
return [
'label' => Str::headline($key),
'value' => (string) $value,
];
})
->filter()
->take(6)
->values()
->all(),
'highlights' => collect($renderPayload['highlights'] ?? [])
->filter(fn (mixed $value): bool => is_string($value) && trim($value) !== '')
->take(5)
->values()
->all(),
'entries' => $this->sectionEntries($renderPayload, $section->isControlInterpretation()),
'next_actions' => collect($renderPayload['next_actions'] ?? [])
->filter(fn (mixed $value): bool => is_string($value) && trim($value) !== '')
->take(4)
->values()
->all(),
'disclosure' => is_string($renderPayload['disclosure'] ?? null) ? $renderPayload['disclosure'] : null,
];
})
->all();
}
/**
* @param array<string, mixed> $renderPayload
* @return list<array{title:string,summary:?string}>
*/
private function sectionEntries(array $renderPayload, bool $isControlInterpretation): array
{
return collect($renderPayload['entries'] ?? [])
->filter(fn (mixed $entry): bool => is_array($entry))
->take($isControlInterpretation ? 3 : 4)
->map(function (array $entry) use ($isControlInterpretation): array {
if ($isControlInterpretation) {
$basisItems = collect($entry['evidence_basis_items'] ?? [])
->filter(fn (mixed $value): bool => is_string($value) && trim($value) !== '')
->take(2)
->values()
->all();
$summary = $this->plainText($entry['explanation_text'] ?? null, '');
if ($summary === '' && $basisItems !== []) {
$summary = implode(' ', $basisItems);
}
return [
'title' => $this->plainText($entry['control_name'] ?? ($entry['title'] ?? null), __('localization.review.control')),
'summary' => $summary !== '' ? $summary : null,
];
}
$detailParts = collect([
$entry['summary'] ?? null,
isset($entry['severity']) ? Str::headline((string) $entry['severity']) : null,
isset($entry['status']) ? Str::headline((string) $entry['status']) : null,
isset($entry['outcome']) ? Str::headline((string) $entry['outcome']) : null,
])
->filter(fn (mixed $value): bool => is_string($value) && trim($value) !== '')
->values()
->all();
return [
'title' => $this->plainText($entry['title'] ?? ($entry['displayName'] ?? null), __('localization.review.entry')),
'summary' => $detailParts === [] ? null : implode(' · ', $detailParts),
];
})
->values()
->all();
}
private function downloadUrl(Request $request, ReviewPack $reviewPack, EnvironmentReview $review): ?string
{
if (! filled($reviewPack->file_path) || ! filled($reviewPack->file_disk)) {
return null;
}
return app(ReviewPackService::class)->generateDownloadUrl(
$reviewPack,
$this->routeParameters($request, $review),
);
}
private function reviewUrl(Request $request, EnvironmentReview $review, ManagedEnvironment $tenant): string
{
$url = EnvironmentReviewResource::environmentScopedUrl('view', ['record' => $review], $tenant);
if (! $request->boolean(CustomerReviewWorkspace::DETAIL_CONTEXT_QUERY_KEY)) {
return $url;
}
return $this->appendQuery($url, [
CustomerReviewWorkspace::DETAIL_CONTEXT_QUERY_KEY => 1,
'source_surface' => CustomerReviewWorkspace::SOURCE_SURFACE,
'tenant_filter_id' => $request->query('tenant_filter_id'),
]);
}
private function reviewPackUrl(Request $request, ReviewPack $reviewPack, ManagedEnvironment $tenant): string
{
$url = ReviewPackResource::getUrl('view', ['record' => $reviewPack], tenant: $tenant, panel: 'admin');
if ((string) $request->query('source_surface') !== CustomerReviewWorkspace::SOURCE_SURFACE) {
return $url;
}
return $this->appendQuery($url, [
'source_surface' => CustomerReviewWorkspace::SOURCE_SURFACE,
'tenant_filter_id' => $request->query('tenant_filter_id'),
]);
}
/**
* @return array<string, scalar|null>
*/
private function routeParameters(Request $request, EnvironmentReview $review): array
{
$parameters = [
'source_surface' => is_string($request->query('source_surface'))
? (string) $request->query('source_surface')
: 'review_pack',
'review_id' => (int) $review->getKey(),
'tenant_filter_id' => $request->query('tenant_filter_id'),
'interpretation_version' => $review->controlInterpretationVersion(),
];
if ($request->boolean(CustomerReviewWorkspace::DETAIL_CONTEXT_QUERY_KEY)) {
$parameters[CustomerReviewWorkspace::DETAIL_CONTEXT_QUERY_KEY] = 1;
}
return array_filter(
$parameters,
static fn (mixed $value): bool => $value !== null && $value !== '',
);
}
/**
* @param array<string, scalar|null> $query
*/
private function appendQuery(string $url, array $query): string
{
if ($query === []) {
return $url;
}
return $url.(str_contains($url, '?') ? '&' : '?').http_build_query(array_filter(
$query,
static fn (mixed $value): bool => $value !== null && $value !== '',
));
}
private function plainText(mixed $value, string $fallback): string
{
if (! is_scalar($value) && $value !== null) {
return $fallback;
}
$text = preg_replace('/\s+/', ' ', trim((string) $value));
if (! is_string($text) || $text === '') {
return $fallback;
}
return str_starts_with($text, 'localization.') ? $fallback : $text;
}
}

View File

@ -214,7 +214,11 @@ private function packageAcceptedRiskEntry(array $entry): array
'title' => $this->entryTitle($entry, 'Accepted risk'),
'governance_state' => is_string($entry['governance_state'] ?? null) ? $entry['governance_state'] : 'unknown',
'summary' => $this->entrySummary($entry, 'This accepted-risk entry qualifies the current governance position for stakeholder delivery.'),
'customer_safe_summary' => $this->customerSafeSummary($entry),
'owner_label' => $this->ownerLabel($entry),
'status' => is_string($entry['exception_status'] ?? null) ? $entry['exception_status'] : null,
'review_due_at' => is_string($entry['review_due_at'] ?? null) ? $entry['review_due_at'] : null,
'expires_at' => is_string($entry['expires_at'] ?? null) ? $entry['expires_at'] : null,
];
}
@ -426,6 +430,22 @@ private function entrySummary(array $entry, string $fallback): string
return $fallback;
}
/**
* @param array<string, mixed> $entry
*/
private function customerSafeSummary(array $entry): ?string
{
foreach (['customer_safe_summary', 'customer_summary'] as $key) {
$value = $entry[$key] ?? null;
if (is_string($value) && trim($value) !== '') {
return $value;
}
}
return null;
}
/**
* @param array<string, mixed> $entry
*/

View File

@ -29,12 +29,13 @@ public function collect(ManagedEnvironment $tenant): array
{
$findings = Finding::query()
->where('managed_environment_id', (int) $tenant->getKey())
->with('findingException.currentDecision')
->with(['findingException.currentDecision', 'findingException.owner'])
->orderByDesc('updated_at')
->get();
$latest = $findings->max('updated_at') ?? $findings->max('created_at');
$entries = $findings->map(function (Finding $finding): array {
$exception = $finding->findingException;
$governanceState = $this->governanceResolver->resolveFindingState($finding, $finding->findingException);
$governanceWarning = $this->governanceResolver->resolveWarningMessage($finding, $finding->findingException);
$outcome = $this->findingOutcomeSemantics->describe($finding);
@ -68,6 +69,16 @@ public function collect(ManagedEnvironment $tenant): array
'canonical_control_resolution' => $canonicalControlResolution,
'governance_state' => $governanceState,
'governance_warning' => $governanceWarning,
'customer_summary' => is_string($finding->evidence_jsonb['customer_summary'] ?? null)
? (string) $finding->evidence_jsonb['customer_summary']
: null,
'exception_status' => $exception !== null ? (string) $exception->status : null,
'review_due_at' => $exception?->review_due_at?->toDateString(),
'expires_at' => $exception?->expires_at?->toDateString(),
'owner' => $exception?->owner !== null ? [
'id' => (int) $exception->owner->getKey(),
'name' => (string) $exception->owner->name,
] : null,
];
});
$outcomeCounts = array_fill_keys($this->findingOutcomeSemantics->orderedOutcomeKeys(), 0);

View File

@ -257,6 +257,22 @@ public function generateDownloadUrl(ReviewPack $pack, array $parameters = []): s
);
}
/**
* Generate a signed rendered-report URL for a review pack.
*
* @param array<string, scalar|null> $parameters
*/
public function generateRenderedReportUrl(ReviewPack $pack, array $parameters = []): string
{
$ttlMinutes = (int) config('tenantpilot.review_pack.download_url_ttl_minutes', 60);
return URL::signedRoute(
'admin.review-packs.report',
array_merge(['reviewPack' => $pack->getKey()], $parameters),
now()->addMinutes($ttlMinutes),
);
}
/**
* @return array<string, mixed>
*/

View File

@ -716,10 +716,14 @@
'evidence_proof' => 'Evidence-Nachweis',
'evidence_status' => 'Nachweise',
'published' => 'Veröffentlicht',
'published_at' => 'Veröffentlicht am',
'generated_at' => 'Erstellt am',
'review_pack' => 'Review-Pack',
'rendered_report' => 'Gerenderter Review-Bericht',
'open_latest_review' => 'Letztes Review öffnen',
'open' => 'Öffnen',
'open_review' => 'Review öffnen',
'open_rendered_review_report' => 'Gerenderten Bericht öffnen',
'open_draft_review' => 'Draft-Review öffnen',
'open_successor_review' => 'Nachfolge-Review öffnen',
'last_review' => 'Letztes Review',
@ -788,6 +792,63 @@
'executive_entrypoint_description' => 'Beginnen Sie im heruntergeladenen Paket mit executive-summary.md.',
'auditor_appendix' => 'Strukturierter Auditor-Anhang',
'auditor_appendix_description' => 'metadata.json, summary.json und sections.json bleiben als sekundärer strukturierter Anhang enthalten.',
'rendered_report_description' => 'Öffnet den gerenderten Stakeholder-Bericht, der aus dem aktuellen Review-Pack-Vertrag abgeleitet ist.',
'rendered_report_summary_fallback' => 'Für dieses veröffentlichte Review ist keine Executive-Zusammenfassung verfügbar.',
'rendered_report_html_only' => 'Dieser v1-Bericht bleibt HTML-first. Verwenden Sie bei Bedarf den Browser-Druckdialog für eine PDF-Übergabe.',
'rendered_report_appendix_note' => 'Dieser gerenderte Bericht ist aus dem aktuellen Review-Pack-Vertrag abgeleitet. Das ZIP-Paket bleibt der strukturierte Anhang und das herunterladbare Artefakt.',
'print_rendered_report' => 'Bericht drucken',
'return_to_review_detail' => 'Review-Detail öffnen',
'return_to_review_pack_detail' => 'Review-Pack-Detail öffnen',
'view_customer_safe_report' => 'Kundensicheren Bericht anzeigen',
'view_report_with_limitations' => 'Bericht mit Einschränkungen anzeigen',
'view_internal_report' => 'Internen Bericht anzeigen',
'governance_review_report' => 'Governance-Review-Bericht',
'prepared_by_for' => 'Erstellt von :prepared_by für :prepared_for',
'generated_by' => 'Erzeugt durch :generated_by',
'report_state_customer_safe_ready' => 'Kundensicherer Bericht bereit',
'report_state_with_limitations' => 'Bericht mit Einschränkungen',
'report_state_internal_with_limitations' => 'Interner Bericht mit Einschränkungen',
'report_state_not_customer_ready' => 'Output nicht kundenbereit',
'report_state_customer_safe_ready_summary' => 'Dieses veröffentlichte Review ist durch die aktuelle Evidence- und Export-Bereitschaft gestützt. Es kann als kundensicherer Governance-Review-Bericht genutzt werden.',
'report_state_limitations_summary' => 'Dieser Bericht ist lesbar, aber Evidence-, Abschnitts- oder Offenlegungsbasis enthalten Einschränkungen, die vor externer Weitergabe geprüft werden müssen.',
'report_state_internal_summary' => 'Dieser Bericht enthält interne oder PII-tragende Details und muss intern bleiben, bis Redaktion und Bereitschaft geprüft sind.',
'report_state_not_ready_summary' => 'Dieser Output ist nicht bereit für externe Kundenweitergabe. Lösen Sie die angezeigten Blocker, bevor Sie ihn als Bericht behandeln.',
'report_external_sharing_warning' => 'Nicht extern weitergeben, bevor der Bericht geprüft wurde.',
'executive_summary' => 'Executive-Zusammenfassung',
'overall_state' => 'Gesamtstatus',
'reason' => 'Begründung',
'report_summary_default_impact' => 'Die Teilbarkeit des Berichts hängt von Bereitschaft, Einschränkungen, Evidence-Basis und Offenlegungsstatus ab.',
'report_summary_ready_reason' => 'Der Bericht ist durch eine vollständige Evidence-Basis und den aktuellen veröffentlichten Review-Pack-Vertrag gestützt.',
'report_summary_limited_reason' => 'Der Bericht enthält Einschränkungen, die vor externer Nutzung geprüft werden sollten.',
'report_next_action_ready' => 'Nutzen Sie den Bericht für kundensichere Governance-Reviews und behalten Sie das ZIP-Paket als strukturierten Anhang bei.',
'report_next_action_internal' => 'Prüfen Sie Redaktion und PII-Umfang vor jeder externen Kundenübergabe.',
'report_next_action_evidence' => 'Prüfen oder aktualisieren Sie die Evidence-Basis vor externer Weitergabe.',
'report_next_action_sections' => 'Prüfen Sie die unvollständigen Abschnitte und aktualisieren Sie die Berichtsbasis vor externer Weitergabe.',
'report_next_action_blockers' => 'Lösen Sie die Publish-Blocker, bevor dieser Bericht als kundenbereit behandelt wird.',
'report_next_action_limited' => 'Prüfen Sie die gelisteten Einschränkungen vor externer Weitergabe.',
'report_limitation_evidence_summary' => 'Die Evidence-Basis ist unvollständig, veraltet oder fehlt. Einige Review-Aussagen können erst als kundensicher gelten, wenn die Evidence-Basis geprüft oder aktualisiert wurde.',
'report_limitation_sections_summary' => 'Mehrere erforderliche Abschnitte sind unvollständig oder eingeschränkt. Prüfen Sie den unterstützenden Anhang vor externer Weitergabe.',
'report_limitation_pii_summary' => 'Dieser Output enthält interne oder PII-tragende Details. Er ist nicht kundensicher, bis Redaktion und Weitergabegrenzen geprüft wurden.',
'report_limitation_blockers_summary' => 'Für diesen Output sind weiterhin Publish-Blocker erfasst. Lösen Sie diese, bevor der Bericht als kundenbereiter Governance-Bericht präsentiert wird.',
'report_limitation_export_summary' => 'Der Export-Bereitschaftsvertrag wurde nicht erfüllt. Behandeln Sie diesen Bericht als interne Vorschau, bis der Export bereit ist.',
'report_limitation_disclosure_summary' => 'Die erforderliche Nicht-Zertifizierungs-Offenlegung fehlt oder ist unvollständig. Ergänzen Sie die Offenlegung vor externer Weitergabe.',
'report_limitation_default_summary' => 'Dieser Bericht enthält eine Einschränkung, die vor externer Weitergabe geprüft werden sollte.',
'evidence_snapshot_number' => 'Evidence-Snapshot #:id',
'evidence_snapshot_missing' => 'Kein Evidence-Snapshot',
'report_evidence_complete_description' => 'Dieser Bericht ist an :snapshot mit vollständiger Evidence-Basis gebunden.',
'report_evidence_stale_description' => 'Dieser Bericht ist an :snapshot gebunden, aber die Evidence-Basis ist veraltet. Prüfen oder aktualisieren Sie Evidence vor externer Weitergabe.',
'report_evidence_partial_description' => 'Dieser Bericht ist an :snapshot mit unvollständiger Evidence gebunden. Prüfen Sie die Evidence-Basis vor externer Weitergabe.',
'report_evidence_missing_description' => 'Dieser Bericht ist nicht an eine vollständige Evidence-Basis gebunden. Prüfen oder aktualisieren Sie Evidence vor externer Weitergabe.',
'report_evidence_complete_action' => 'Halten Sie Evidence-Snapshot und Review-Pack bei der Übergabe dieses Berichts zusammen.',
'findings_and_open_risks' => 'Findings und offene Risiken',
'no_open_risks_listed' => 'Für dieses Review sind keine offenen Risiken gelistet.',
'no_accepted_risks_listed_for_review' => 'Für dieses Review sind keine akzeptierten Risiken gelistet.',
'accepted_risk_customer_safe_summary_missing' => 'Eine kundensichere Accepted-Risk-Zusammenfassung ist nicht erfasst. Interne Begründungen dürfen nicht als kundensicherer Kontext ausgegeben werden.',
'accepted_risk_review_due_on' => 'Review fällig am :date',
'accepted_risk_expires_on' => 'Läuft ab am :date',
'non_certification_disclosure' => 'Nicht-Zertifizierungs-Offenlegung',
'non_certification_disclosure_text' => 'TenantPilot fasst verfügbare Service-Delivery-Evidence für Governance-Reviews zusammen. Dieser Bericht ist keine Zertifizierung, rechtliche Attestierung, Auditmeinung oder Compliance-Garantie.',
'supporting_appendix' => 'Unterstützender Anhang',
'governance_package_available' => 'Governance-Paket verfügbar',
'governance_package_available_description' => 'Das aktuelle Export-Review-Pack ist aus diesem veröffentlichten Review für die Stakeholder-Auslieferung bereit.',
'governance_package_partial' => 'Governance-Paket partiell',
@ -829,7 +890,7 @@
'preparing' => 'In Vorbereitung',
'review_pack_available' => 'Aktuelles Review-Pack verfügbar',
'review_pack_available_customer_description' => 'Das aktuelle Review-Pack ist zum Download bereit.',
'review_pack_customer_safe_ready_description' => 'Das aktuelle Review-Paket ist verfügbar und erfüllt den kundensicheren Output-Vertrag.',
'review_pack_customer_safe_ready_description' => 'Das aktuelle Review-Paket ist verfügbar, erfüllt den kundensicheren Output-Vertrag und kann aus dem Review-Detail als gerenderter Bericht geöffnet werden.',
'review_pack_with_limitations_description' => 'Das Review-Paket existiert, aber Evidence-, Abschnitts- oder Veröffentlichungsgrenzen müssen noch geprüft werden.',
'review_pack_internal_review_description' => 'Das Review-Paket existiert, enthält aber interne oder PII-tragende Details, die vor externer Weitergabe geprüft werden sollten.',
'review_pack_export_not_ready_description' => 'Das Review-Paket existiert, aber der Exportvertrag ist noch nicht bereit.',
@ -844,6 +905,8 @@
'review_pack_access_unavailable' => 'Review-Pack-Zugriff ist für dieses Konto nicht verfügbar',
'review_pack_unavailable' => 'Review-Pack ist noch nicht bereit',
'review_pack_expired' => 'Review-Pack abgelaufen',
'no_key_findings_listed' => 'Für dieses veröffentlichte Review sind keine wichtigen Findings gelistet.',
'no_next_action_listed' => 'Für dieses veröffentlichte Review ist keine nächste Aktion gelistet.',
'evidence_proof_available' => 'Nachweiszusammenfassung verfügbar',
'evidence_proof_absent' => 'Noch keine Nachweiszusammenfassung verknüpft',
'evidence_proof_access_unavailable' => 'Nachweiszugriff ist für dieses Konto nicht verfügbar',

View File

@ -716,10 +716,14 @@
'evidence_proof' => 'Evidence proof',
'evidence_status' => 'Evidence',
'published' => 'Published',
'published_at' => 'Published at',
'generated_at' => 'Generated at',
'review_pack' => 'Review pack',
'rendered_report' => 'Rendered review report',
'open_latest_review' => 'Open latest review',
'open' => 'Open',
'open_review' => 'Open review',
'open_rendered_review_report' => 'Open rendered report',
'open_draft_review' => 'Open draft review',
'open_successor_review' => 'Open successor review',
'last_review' => 'Last review',
@ -788,6 +792,63 @@
'executive_entrypoint_description' => 'Start with executive-summary.md in the downloaded package.',
'auditor_appendix' => 'Structured auditor appendix',
'auditor_appendix_description' => 'metadata.json, summary.json, and sections.json remain included as the secondary structured appendix.',
'rendered_report_description' => 'Open the rendered stakeholder report derived from the current review-pack contract.',
'rendered_report_summary_fallback' => 'No executive summary is available for this released review.',
'rendered_report_html_only' => 'This v1 report is HTML-first. Use your browser print dialog when you need a PDF handoff.',
'rendered_report_appendix_note' => 'This rendered report is derived from the current review-pack contract. The ZIP package remains the structured appendix and downloadable artifact.',
'print_rendered_report' => 'Print report',
'return_to_review_detail' => 'Open review detail',
'return_to_review_pack_detail' => 'Open review pack detail',
'view_customer_safe_report' => 'View customer-safe report',
'view_report_with_limitations' => 'View report with limitations',
'view_internal_report' => 'View internal report',
'governance_review_report' => 'Governance review report',
'prepared_by_for' => 'Prepared by :prepared_by for :prepared_for',
'generated_by' => 'Generated by :generated_by',
'report_state_customer_safe_ready' => 'Customer-safe report ready',
'report_state_with_limitations' => 'Report with limitations',
'report_state_internal_with_limitations' => 'Internal report with limitations',
'report_state_not_customer_ready' => 'Output not customer-ready',
'report_state_customer_safe_ready_summary' => 'This released review is backed by the current evidence and export-readiness contract. It can be used as a customer-safe governance review report.',
'report_state_limitations_summary' => 'This report is readable, but the evidence, section, or disclosure basis still carries limitations that must be reviewed before external sharing.',
'report_state_internal_summary' => 'This report includes internal or PII-bearing detail and must stay internal until redaction and readiness are reviewed.',
'report_state_not_ready_summary' => 'This output is not ready for external customer sharing. Resolve the shown blockers before treating it as a report.',
'report_external_sharing_warning' => 'Do not share externally before review.',
'executive_summary' => 'Executive summary',
'overall_state' => 'Overall state',
'reason' => 'Reason',
'report_summary_default_impact' => 'Report shareability depends on the readiness, limitations, evidence basis, and disclosure state shown in this report.',
'report_summary_ready_reason' => 'The report is backed by a complete evidence basis and current released review-pack contract.',
'report_summary_limited_reason' => 'The report has limitations that should be reviewed before relying on it externally.',
'report_next_action_ready' => 'Use the report for customer-safe governance review and retain the ZIP package as the structured appendix.',
'report_next_action_internal' => 'Review redaction and PII scope before any external customer handoff.',
'report_next_action_evidence' => 'Review or refresh the evidence basis before external sharing.',
'report_next_action_sections' => 'Review the incomplete sections and refresh the report basis before external sharing.',
'report_next_action_blockers' => 'Resolve the publish blockers before treating this report as customer-ready.',
'report_next_action_limited' => 'Review the listed limitations before external sharing.',
'report_limitation_evidence_summary' => 'The evidence basis is incomplete, stale, or missing. Some review claims cannot be treated as customer-ready until the evidence basis is reviewed or refreshed.',
'report_limitation_sections_summary' => 'Several required sections are incomplete or limited. Review the supporting appendix before external sharing.',
'report_limitation_pii_summary' => 'This output includes internal or PII-bearing detail. It is not customer-safe until redaction and sharing boundaries are reviewed.',
'report_limitation_blockers_summary' => 'Publish blockers are still recorded for this output. Resolve them before presenting this as a customer-ready governance report.',
'report_limitation_export_summary' => 'The export-readiness contract has not passed. Treat this report as an internal preview until the export is ready.',
'report_limitation_disclosure_summary' => 'The required non-certification disclosure is missing or incomplete. Add the disclosure before external sharing.',
'report_limitation_default_summary' => 'This report has a limitation that should be reviewed before external sharing.',
'evidence_snapshot_number' => 'Evidence snapshot #:id',
'evidence_snapshot_missing' => 'No evidence snapshot',
'report_evidence_complete_description' => 'This report is anchored to :snapshot with a complete evidence basis.',
'report_evidence_stale_description' => 'This report is anchored to :snapshot, but the evidence basis is stale. Review or refresh the evidence before external sharing.',
'report_evidence_partial_description' => 'This report is anchored to :snapshot with incomplete evidence. Review the evidence basis before external sharing.',
'report_evidence_missing_description' => 'This report is not anchored to a complete evidence basis. Review or refresh evidence before external sharing.',
'report_evidence_complete_action' => 'Keep the evidence snapshot and review pack together when handing off this report.',
'findings_and_open_risks' => 'Findings and open risks',
'no_open_risks_listed' => 'No open risks are listed for this review.',
'no_accepted_risks_listed_for_review' => 'No accepted risks are listed for this review.',
'accepted_risk_customer_safe_summary_missing' => 'A customer-safe accepted-risk summary is not recorded. Do not expose internal rationale as customer-safe context.',
'accepted_risk_review_due_on' => 'Review due on :date',
'accepted_risk_expires_on' => 'Expires on :date',
'non_certification_disclosure' => 'Non-certification disclosure',
'non_certification_disclosure_text' => 'TenantPilot summarizes available service-delivery evidence for governance review. This report is not a certification, legal attestation, audit opinion, or compliance guarantee.',
'supporting_appendix' => 'Supporting appendix',
'governance_package_available' => 'Governance package available',
'governance_package_available_description' => 'The current export review pack is ready for stakeholder delivery from this released review.',
'governance_package_partial' => 'Governance package partial',
@ -829,7 +890,7 @@
'preparing' => 'Preparing',
'review_pack_available' => 'Current review pack available',
'review_pack_available_customer_description' => 'Current review pack is ready to download.',
'review_pack_customer_safe_ready_description' => 'The current review package is available and meets the customer-safe output contract.',
'review_pack_customer_safe_ready_description' => 'The current review package is available, meets the customer-safe output contract, and can be opened as a rendered report from the review detail.',
'review_pack_with_limitations_description' => 'The review package exists, but evidence, section completeness, or publication limitations still need review.',
'review_pack_internal_review_description' => 'The review package exists, but it includes internal or PII-bearing detail that should be reviewed before external sharing.',
'review_pack_export_not_ready_description' => 'The review package exists, but the export contract is not ready yet.',
@ -844,6 +905,8 @@
'review_pack_access_unavailable' => 'Review pack access is unavailable for this actor',
'review_pack_unavailable' => 'Review pack is not ready yet',
'review_pack_expired' => 'Review pack expired',
'no_key_findings_listed' => 'No key findings are listed for this released review.',
'no_next_action_listed' => 'No next action is listed for this released review.',
'evidence_proof_available' => 'Proof summary available',
'evidence_proof_absent' => 'No proof summary linked yet',
'evidence_proof_access_unavailable' => 'Proof access is unavailable for this actor',

View File

@ -0,0 +1,575 @@
@php
/** @var array<string, mixed> $report */
$badgeClasses = [
'success' => 'background:#ecfdf3;color:#166534;border-color:#bbf7d0;',
'warning' => 'background:#fffbeb;color:#92400e;border-color:#fde68a;',
'danger' => 'background:#fef2f2;color:#991b1b;border-color:#fecaca;',
'gray' => 'background:#f3f4f6;color:#374151;border-color:#d1d5db;',
];
$hero = is_array($report['hero'] ?? null) ? $report['hero'] : [];
$branding = is_array($report['branding'] ?? null) ? $report['branding'] : [];
$managementSummary = is_array($report['management_summary'] ?? null) ? $report['management_summary'] : [];
$evidenceBasis = is_array($report['evidence_basis'] ?? null) ? $report['evidence_basis'] : [];
$heroBadgeStyle = $badgeClasses[$hero['color'] ?? 'gray'] ?? $badgeClasses['gray'];
$boundaryBadgeStyle = $badgeClasses[$report['guidance']['boundary_color'] ?? 'gray'] ?? $badgeClasses['gray'];
$generatedAt = $report['generated_at'] ?? null;
$publishedAt = $report['published_at'] ?? null;
@endphp
<!DOCTYPE html>
<html lang="{{ str_replace('_', '-', app()->getLocale()) }}">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{{ $report['title'] }} · {{ $report['tenant_name'] }}</title>
<style>
:root {
color-scheme: light;
--ink: #111827;
--muted: #4b5563;
--soft: #64748b;
--line: #d9e2ec;
--paper: #fffdf8;
--panel: #f8fafc;
--accent: #0f766e;
--accent-soft: #ccfbf1;
}
* { box-sizing: border-box; }
body {
margin: 0;
color: var(--ink);
background:
radial-gradient(circle at 8% 8%, rgba(20, 83, 45, 0.08), transparent 34%),
radial-gradient(circle at 90% 4%, rgba(15, 118, 110, 0.12), transparent 28%),
linear-gradient(180deg, #eef7f4 0%, #f8fafc 24%, #e5e7eb 100%);
font-family: Avenir, "Avenir Next", "Segoe UI", sans-serif;
}
a { color: inherit; }
.page {
width: min(1080px, calc(100% - 32px));
margin: 0 auto;
padding: 28px 0 64px;
}
.report-toolbar {
display: flex;
flex-wrap: wrap;
gap: 12px;
justify-content: space-between;
align-items: center;
margin-bottom: 18px;
color: var(--soft);
font-size: 13px;
}
.toolbar-actions {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.app-action {
display: inline-flex;
align-items: center;
justify-content: center;
border: 1px solid rgba(148, 163, 184, 0.5);
border-radius: 999px;
padding: 8px 12px;
background: rgba(255, 255, 255, 0.82);
color: #334155;
text-decoration: none;
font: 700 12px/1.2 Avenir, "Avenir Next", "Segoe UI", sans-serif;
cursor: pointer;
}
.app-action--primary {
background: #0f172a;
border-color: #0f172a;
color: #fff;
}
.report-canvas {
overflow: hidden;
border: 1px solid rgba(148, 163, 184, 0.42);
border-radius: 30px;
background: var(--paper);
box-shadow: 0 28px 70px rgba(15, 23, 42, 0.14);
}
.cover {
padding: clamp(28px, 5vw, 56px);
background:
linear-gradient(135deg, rgba(15, 118, 110, 0.14), transparent 46%),
linear-gradient(180deg, #ffffff 0%, #fffdf8 100%);
border-bottom: 1px solid rgba(148, 163, 184, 0.35);
}
.co-brand {
display: flex;
flex-wrap: wrap;
gap: 10px 18px;
justify-content: space-between;
color: var(--soft);
font: 700 12px/1.4 Avenir, "Avenir Next", "Segoe UI", sans-serif;
letter-spacing: 0.06em;
text-transform: uppercase;
}
.eyebrow {
margin-top: 46px;
color: var(--accent);
font: 800 12px/1.4 Avenir, "Avenir Next", "Segoe UI", sans-serif;
letter-spacing: 0.14em;
text-transform: uppercase;
}
h1 {
max-width: 12ch;
margin: 12px 0 14px;
font-family: Georgia, Cambria, "Times New Roman", serif;
font-size: clamp(2.6rem, 6vw, 5.2rem);
line-height: 0.93;
letter-spacing: -0.05em;
}
.hero-summary {
max-width: 76ch;
margin: 0;
color: var(--muted);
font: 500 17px/1.7 Avenir, "Avenir Next", "Segoe UI", sans-serif;
}
.badges {
display: flex;
flex-wrap: wrap;
gap: 10px;
margin: 22px 0 0;
}
.badge {
display: inline-flex;
align-items: center;
padding: 8px 12px;
border: 1px solid;
border-radius: 999px;
font: 800 11px/1.2 Avenir, "Avenir Next", "Segoe UI", sans-serif;
letter-spacing: 0.04em;
text-transform: uppercase;
}
.share-warning {
margin-top: 22px;
border: 1px solid #fbbf24;
border-left: 5px solid #d97706;
border-radius: 18px;
background: #fffbeb;
padding: 15px 18px;
color: #78350f;
font: 800 14px/1.55 Avenir, "Avenir Next", "Segoe UI", sans-serif;
}
.meta-row {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
gap: 12px;
margin-top: 26px;
}
.meta-item {
border-top: 1px solid rgba(148, 163, 184, 0.5);
padding-top: 12px;
}
.meta-label, .field-label {
margin: 0;
color: var(--soft);
font: 800 10px/1.2 Avenir, "Avenir Next", "Segoe UI", sans-serif;
letter-spacing: 0.1em;
text-transform: uppercase;
}
.meta-value, .field-value {
margin: 7px 0 0;
color: var(--ink);
font: 700 14px/1.45 Avenir, "Avenir Next", "Segoe UI", sans-serif;
}
.content {
display: grid;
gap: 24px;
padding: clamp(24px, 4vw, 44px);
}
.section {
padding-bottom: 24px;
border-bottom: 1px solid rgba(148, 163, 184, 0.32);
}
.section:last-child {
border-bottom: 0;
padding-bottom: 0;
}
h2 {
margin: 0 0 12px;
font-family: Georgia, Cambria, "Times New Roman", serif;
font-size: clamp(1.55rem, 3vw, 2.15rem);
line-height: 1.05;
letter-spacing: -0.025em;
}
h3 {
margin: 0;
font-size: 1rem;
line-height: 1.35;
}
.copy, .empty-state, .list {
color: var(--ink);
font: 500 15px/1.75 Avenir, "Avenir Next", "Segoe UI", sans-serif;
}
.empty-state {
margin: 0;
color: var(--muted);
}
.summary-grid, .risk-grid, .appendix-grid {
display: grid;
gap: 14px;
}
.summary-grid {
grid-template-columns: repeat(auto-fit, minmax(230px, 1fr));
margin-top: 14px;
}
.summary-field, .risk-card, .appendix-card, .technical-card {
border: 1px solid rgba(148, 163, 184, 0.32);
border-radius: 18px;
background: rgba(248, 250, 252, 0.72);
padding: 16px;
}
.limitations {
display: grid;
gap: 12px;
margin-top: 14px;
}
.limitation {
border: 1px solid #fde68a;
border-left: 5px solid #d97706;
border-radius: 18px;
background: #fffbeb;
padding: 16px 18px;
}
.limitation p, .risk-card p, .appendix-card p {
margin: 8px 0 0;
}
.note {
color: var(--muted);
font: 500 13px/1.65 Avenir, "Avenir Next", "Segoe UI", sans-serif;
}
.list {
margin: 0;
padding-left: 20px;
}
.list li + li { margin-top: 10px; }
.risk-grid {
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
}
.supporting {
background: #f8fafc;
border-radius: 24px;
padding: 22px;
}
.appendix-grid {
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
margin-top: 14px;
}
.appendix-card + .appendix-card { margin-top: 14px; }
.technical-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
gap: 10px;
margin-top: 14px;
}
.screen-only { display: block; }
body.print-preview-smoke .report-toolbar,
body.print-preview-smoke .screen-only {
display: none !important;
}
@media print {
body { background: #fff; }
.page { width: 100%; padding: 0; }
.report-toolbar, .screen-only { display: none !important; }
.report-canvas {
border: 0;
border-radius: 0;
box-shadow: none;
}
.cover, .section, .appendix-card, .risk-card, .summary-field, .technical-card {
break-inside: avoid;
}
}
</style>
</head>
<body>
<div class="page">
<div class="report-toolbar screen-only" data-testid="rendered-report-toolbar">
<div>{{ __('localization.review.rendered_report_html_only') }}</div>
<div class="toolbar-actions">
@if (filled($report['review_url'] ?? null))
<a class="app-action" href="{{ $report['review_url'] }}" target="_blank" rel="noreferrer">
{{ __('localization.review.return_to_review_detail') }}
</a>
@endif
@if (filled($report['review_pack_url'] ?? null))
<a class="app-action" href="{{ $report['review_pack_url'] }}" target="_blank" rel="noreferrer">
{{ __('localization.review.return_to_review_pack_detail') }}
</a>
@endif
@if (filled($report['download_url'] ?? null))
<a class="app-action" href="{{ $report['download_url'] }}" target="_blank" rel="noreferrer">
{{ $report['download_label'] ?? __('localization.review.download_review_pack_with_limitations') }}
</a>
@endif
<button class="app-action app-action--primary" type="button" onclick="window.print()">
{{ __('localization.review.print_rendered_report') }}
</button>
</div>
</div>
<main class="report-canvas" data-testid="rendered-report-canvas">
<section class="cover" data-testid="rendered-report-hero">
<div class="co-brand">
<span>{{ __('localization.review.prepared_by_for', [
'prepared_by' => $branding['prepared_by'] ?? 'TenantPilot',
'prepared_for' => $branding['prepared_for'] ?? $report['tenant_name'],
]) }}</span>
<span>{{ __('localization.review.generated_by', ['generated_by' => $branding['generated_by'] ?? 'TenantPilot']) }}</span>
</div>
<div class="eyebrow">{{ __('localization.review.governance_review_report') }}</div>
<h1>{{ $hero['title'] ?? $report['title'] }}</h1>
<p class="hero-summary">{{ $hero['summary'] ?? $report['executive_summary'] }}</p>
<div class="badges">
<span class="badge" style="{{ $heroBadgeStyle }}">{{ $hero['badge'] ?? __('localization.review.requires_review') }}</span>
<span class="badge" style="{{ $boundaryBadgeStyle }}">{{ $report['guidance']['boundary_label'] ?? __('localization.review.requires_review') }}</span>
</div>
@if (filled($hero['warning'] ?? null))
<div class="share-warning" data-testid="rendered-report-sharing-warning">{{ $hero['warning'] }}</div>
@endif
<div class="meta-row">
<div class="meta-item">
<p class="meta-label">{{ __('localization.review.review_status') }}</p>
<p class="meta-value">#{{ $report['review_id'] }} · {{ $report['review_status_label'] }}</p>
</div>
<div class="meta-item">
<p class="meta-label">{{ __('localization.review.evidence_basis') }}</p>
<p class="meta-value">{{ $evidenceBasis['label'] ?? __('localization.review.unavailable') }}</p>
</div>
<div class="meta-item">
<p class="meta-label">{{ __('localization.review.generated_at') }}</p>
<p class="meta-value">{{ $generatedAt?->format('Y-m-d H:i') ?? '—' }}</p>
</div>
<div class="meta-item">
<p class="meta-label">{{ __('localization.review.published_at') }}</p>
<p class="meta-value">{{ $publishedAt?->format('Y-m-d H:i') ?? '—' }}</p>
</div>
</div>
</section>
<div class="content">
<section class="section" data-testid="rendered-report-executive-summary">
<h2>{{ __('localization.review.executive_summary') }}</h2>
<p class="copy">{{ $report['executive_summary'] }}</p>
<div class="summary-grid">
<div class="summary-field">
<p class="field-label">{{ __('localization.review.overall_state') }}</p>
<p class="field-value">{{ $managementSummary['overall_state'] ?? ($hero['title'] ?? '') }}</p>
</div>
<div class="summary-field">
<p class="field-label">{{ __('localization.review.reason') }}</p>
<p class="field-value">{{ $managementSummary['reason'] ?? '' }}</p>
</div>
<div class="summary-field">
<p class="field-label">{{ __('localization.review.impact') }}</p>
<p class="field-value">{{ $managementSummary['impact'] ?? '' }}</p>
</div>
<div class="summary-field">
<p class="field-label">{{ __('localization.review.recommended_next_action') }}</p>
<p class="field-value">{{ $managementSummary['next_action'] ?? '' }}</p>
</div>
</div>
@if (($managementSummary['top_limitations'] ?? []) !== [])
<ul class="list" style="margin-top:16px;">
@foreach (($managementSummary['top_limitations'] ?? []) as $limitation)
<li>{{ $limitation }}</li>
@endforeach
</ul>
@endif
</section>
@if (($report['limitations'] ?? []) !== [])
<section class="section" data-testid="rendered-report-output-limitations">
<h2>{{ __('localization.review.output_limitations') }}</h2>
<div class="limitations">
@foreach (($report['limitations'] ?? []) as $limitation)
<article class="limitation">
<h3>{{ $limitation['title'] }}</h3>
<p class="copy">{{ $limitation['summary'] }}</p>
<p class="note">{{ $limitation['next_action'] }}</p>
</article>
@endforeach
</div>
</section>
@endif
<section class="section" data-testid="rendered-report-findings">
<h2>{{ __('localization.review.findings_and_open_risks') }}</h2>
@if (($report['top_findings'] ?? []) === [])
<p class="empty-state">{{ __('localization.review.no_open_risks_listed') }}</p>
@else
<ul class="list">
@foreach (($report['top_findings'] ?? []) as $finding)
<li>
<strong>{{ $finding['title'] ?? __('localization.review.control') }}</strong>
@if (filled($finding['summary'] ?? null))
: {{ $finding['summary'] }}
@endif
</li>
@endforeach
</ul>
@endif
</section>
<section class="section" data-testid="rendered-report-accepted-risks">
<h2>{{ __('localization.review.accepted_risks') }}</h2>
@if (($report['accepted_risks'] ?? []) === [])
<p class="empty-state">{{ __('localization.review.no_accepted_risks_listed_for_review') }}</p>
@else
<div class="risk-grid">
@foreach (($report['accepted_risks'] ?? []) as $risk)
<article class="risk-card">
<h3>{{ $risk['title'] }}</h3>
<p class="note">{{ __('localization.review.status') }}: {{ $risk['status'] }} · {{ $risk['review_state'] }}</p>
@if (filled($risk['owner'] ?? null))
<p class="note">{{ __('localization.review.accepted_risk_owner') }}: {{ $risk['owner'] }}</p>
@endif
@if (filled($risk['summary'] ?? null))
<p class="copy">{{ $risk['summary'] }}</p>
@elseif (filled($risk['limitation'] ?? null))
<p class="copy">{{ $risk['limitation'] }}</p>
@endif
</article>
@endforeach
</div>
@endif
</section>
<section class="section" data-testid="rendered-report-governance-decisions">
<h2>{{ __('localization.review.governance_decisions_requiring_awareness') }}</h2>
@if (filled($report['decision_summary']['summary'] ?? null))
<p class="copy">{{ $report['decision_summary']['summary'] }}</p>
@endif
@if (($report['governance_decisions'] ?? []) === [])
<p class="empty-state">{{ __('localization.review.no_decisions_require_awareness_description') }}</p>
@else
<ul class="list">
@foreach (($report['governance_decisions'] ?? []) as $decision)
<li>
<strong>{{ $decision['title'] ?? __('localization.review.governance_decisions') }}</strong>
@if (filled($decision['summary'] ?? null))
: {{ $decision['summary'] }}
@endif
@if (filled($decision['next_action'] ?? null))
<div class="note">{{ $decision['next_action'] }}</div>
@endif
</li>
@endforeach
</ul>
@endif
</section>
<section class="section" data-testid="rendered-report-evidence-basis">
<h2>{{ __('localization.review.evidence_basis') }}</h2>
<p class="copy">{{ $evidenceBasis['description'] ?? $report['evidence_basis_summary'] }}</p>
<p class="note">{{ $evidenceBasis['operator_action'] ?? '' }}</p>
</section>
<section class="section">
<h2>{{ __('localization.review.next_actions') }}</h2>
@if (($report['next_actions'] ?? []) === [])
<p class="empty-state">{{ __('localization.review.no_next_action_listed') }}</p>
@else
<ul class="list">
@foreach (($report['next_actions'] ?? []) as $nextAction)
<li>{{ $nextAction }}</li>
@endforeach
</ul>
@endif
</section>
<section class="section" data-testid="rendered-report-disclosure">
<h2>{{ __('localization.review.non_certification_disclosure') }}</h2>
<p class="copy">{{ $report['non_certification_disclosure'] }}</p>
</section>
<section class="section supporting" data-testid="rendered-report-supporting-appendix">
<h2>{{ __('localization.review.supporting_appendix') }}</h2>
<p class="copy">{{ __('localization.review.rendered_report_appendix_note') }}</p>
<div class="technical-grid" data-testid="rendered-report-technical-details">
<div class="technical-card">
<p class="field-label">{{ __('localization.review.executive_entrypoint') }}</p>
<p class="field-value">{{ $report['entrypoint_file'] }}</p>
</div>
<div class="technical-card">
<p class="field-label">{{ __('localization.review.auditor_appendix') }}</p>
<p class="field-value">{{ implode(', ', $report['appendix_files'] ?? []) ?: '—' }}</p>
</div>
@foreach (($report['technical_details'] ?? []) as $label => $value)
<div class="technical-card">
<p class="field-label">{{ $label }}</p>
<p class="field-value">{{ $value }}</p>
</div>
@endforeach
</div>
@foreach (($report['section_appendix'] ?? []) as $section)
<article class="appendix-card">
<div style="display:flex;flex-wrap:wrap;justify-content:space-between;gap:10px;align-items:baseline;">
<h3>{{ $section['title'] }}</h3>
<span class="badge" style="{{ $badgeClasses['gray'] }}">{{ $section['completeness_label'] }}</span>
</div>
@if (($section['highlights'] ?? []) !== [])
<p class="copy">{{ implode(' ', $section['highlights']) }}</p>
@endif
@if (($section['entries'] ?? []) !== [])
<div class="appendix-grid">
@foreach (($section['entries'] ?? []) as $entry)
<div class="technical-card">
<p class="field-value">{{ $entry['title'] }}</p>
@if (filled($entry['summary'] ?? null))
<p class="note">{{ $entry['summary'] }}</p>
@endif
</div>
@endforeach
</div>
@endif
@if (($section['summary_items'] ?? []) !== [])
<div class="appendix-grid">
@foreach (($section['summary_items'] ?? []) as $item)
<div class="technical-card">
<p class="field-label">{{ $item['label'] }}</p>
<p class="field-value">{{ $item['value'] }}</p>
</div>
@endforeach
</div>
@endif
@if (($section['next_actions'] ?? []) !== [])
<ul class="list" style="margin-top:16px;">
@foreach (($section['next_actions'] ?? []) as $nextAction)
<li>{{ $nextAction }}</li>
@endforeach
</ul>
@endif
@if (filled($section['disclosure'] ?? null))
<p class="note">{{ $section['disclosure'] }}</p>
@endif
</article>
@endforeach
</section>
</div>
</main>
</div>
</body>
</html>

View File

@ -9,6 +9,7 @@
use App\Http\Controllers\OpenFindingExceptionsQueueController;
use App\Http\Controllers\RbacDelegatedAuthController;
use App\Http\Controllers\ReviewPackDownloadController;
use App\Http\Controllers\ReviewPackRenderedReportController;
use App\Http\Controllers\SelectEnvironmentController;
use App\Http\Controllers\SwitchWorkspaceController;
use App\Http\Middleware\SuppressDebugbarForSmokeRequests;
@ -529,6 +530,10 @@
->get('/admin/review-packs/{reviewPack}/download', ReviewPackDownloadController::class)
->name('admin.review-packs.download');
Route::middleware(['signed', 'apply-resolved-locale:admin'])
->get('/admin/review-packs/{reviewPack}/report', ReviewPackRenderedReportController::class)
->name('admin.review-packs.report');
if (app()->runningUnitTests()) {
Route::middleware(['web', 'auth', 'ensure-workspace-selected'])
->get('/admin/_test/workspace-context', function (Request $request) {

View File

@ -3,9 +3,13 @@
declare(strict_types=1);
use App\Filament\Resources\EnvironmentReviewResource;
use App\Filament\Pages\Reviews\CustomerReviewWorkspace;
use App\Models\EvidenceSnapshot;
use App\Models\ManagedEnvironment;
use App\Models\ReviewPack;
use App\Services\ReviewPackService;
use App\Support\EnvironmentReviewCompletenessState;
use App\Support\Evidence\EvidenceCompletenessState;
use App\Support\EnvironmentReviewStatus;
use App\Support\Governance\Controls\ComplianceEvidenceMappingV1;
use App\Support\Workspaces\WorkspaceContext;
@ -14,7 +18,7 @@
uses(RefreshDatabase::class);
pest()->browser()->timeout(20_000);
pest()->browser()->timeout(60_000);
beforeEach(function (): void {
Storage::fake('exports');
@ -98,12 +102,81 @@
'include_pii' => false,
'include_operations' => true,
],
'summary' => [
'governance_package' => [
'executive_summary' => 'Customer steering summary for the rendered browser smoke report.',
'evidence_basis_summary' => 'Evidence basis is complete for the published smoke review.',
'top_findings' => [
[
'title' => 'Conditional access drift requires review',
'summary' => 'An inherited drift signal should stay visible in the rendered report.',
],
],
'accepted_risks' => [
[
'title' => 'Temporary exception remains accepted',
'customer_safe_summary' => 'The accepted risk stays visible as stakeholder context.',
],
],
'decision_summary' => [
'status' => 'attention_required',
'summary' => 'A governance decision still requires stakeholder awareness.',
'next_action' => 'Review the accepted exception before external sharing.',
'entries' => [
[
'title' => 'Conditional access exception',
'summary' => 'Temporary risk is accepted until remediation finishes.',
'next_action' => 'Confirm the remediation timeline before customer handoff.',
],
],
],
],
'control_interpretation' => [
'non_certification_disclosure' => 'Nur Service-Delivery-Zusammenfassung. Ersetzt weder formales Auditurteil noch Zertifizierung oder rechtliche Attestierung.',
],
'recommended_next_actions' => [
'Share the rendered report with the customer steering group.',
],
'delivery_bundle' => [
'executive_entrypoint_file' => 'executive-summary.md',
'appendix_files' => ['metadata.json', 'summary.json', 'sections.json'],
],
],
'file_path' => 'review-packs/customer-review-workspace-smoke.zip',
'file_disk' => 'exports',
]);
$publishedReview->forceFill(['current_export_review_pack_id' => (int) $pack->getKey()])->save();
$limitedEnvironment = ManagedEnvironment::factory()->active()->create([
'workspace_id' => (int) $tenantPublished->workspace_id,
'name' => 'Limited Report ManagedEnvironment',
]);
createUserWithTenant(tenant: $limitedEnvironment, user: $user, role: 'owner', workspaceRole: 'manager');
[$limitedReview, $limitedPack] = spec356BrowserCreateRenderedReportPack(
environment: $limitedEnvironment,
user: $user,
snapshot: seedEnvironmentReviewEvidence($limitedEnvironment, findingCount: 0, driftCount: 0),
filePath: 'review-packs/spec356-browser-limited.zip',
evidenceOverride: EvidenceCompletenessState::Partial,
);
$internalEnvironment = ManagedEnvironment::factory()->active()->create([
'workspace_id' => (int) $tenantPublished->workspace_id,
'name' => 'Internal PII Report ManagedEnvironment',
]);
createUserWithTenant(tenant: $internalEnvironment, user: $user, role: 'owner', workspaceRole: 'manager');
[$internalReview, $internalPack] = spec356BrowserCreateRenderedReportPack(
environment: $internalEnvironment,
user: $user,
snapshot: seedEnvironmentReviewEvidence($internalEnvironment, findingCount: 0, driftCount: 0),
packOptions: [
'include_pii' => true,
'include_operations' => true,
],
filePath: 'review-packs/spec356-browser-internal-pii.zip',
);
$this->actingAs($user)->withSession([
WorkspaceContext::SESSION_KEY => (int) $tenantPublished->workspace_id,
WorkspaceContext::LAST_ENVIRONMENT_IDS_SESSION_KEY => [
@ -111,12 +184,16 @@
],
]);
visit(EnvironmentReviewResource::environmentScopedUrl('view', ['record' => $publishedReview], $tenantPublished))
$environmentReviewPage = visit(EnvironmentReviewResource::environmentScopedUrl('view', ['record' => $publishedReview], $tenantPublished))
->waitForText('Verwandter Kontext')
->assertSee('Kunden-Workspace öffnen')
->assertNoJavaScriptErrors()
->assertNoConsoleLogs()
->click('a.fi-link[href*="/admin/reviews/workspace?environment_id="]')
->assertNoConsoleLogs();
$environmentReviewPage
->assertScript('document.querySelector(\'a.fi-link[href*="/admin/reviews/workspace?environment_id="]\') instanceof HTMLAnchorElement', true);
$workspacePage = visit(CustomerReviewWorkspace::environmentFilterUrl($tenantPublished))
->waitForText('Kundensichere Review-Pakete')
->assertSee('Filter löschen')
->assertSee('Review öffnen')
@ -130,7 +207,7 @@
->assertSee('Offenlegungsregel')
->assertSee('Eingeklappt')
->assertSee('Kundensicheres Review-Paket herunterladen')
->assertSee('Das aktuelle Review-Paket ist verfügbar und erfüllt den kundensicheren Output-Vertrag.')
->assertSee('Das aktuelle Review-Paket ist verfügbar, erfüllt den kundensicheren Output-Vertrag und kann aus dem Review-Detail als gerenderter Bericht geöffnet werden.')
->assertSee('In diesem veröffentlichten Review benötigen keine Governance-Entscheidungen Kundenaufmerksamkeit.')
->assertSee('Kundensicheres Review-Paket bereit')
->assertSee('Verfügbar')
@ -140,15 +217,27 @@
->assertDontSee('No mapped controls')
->assertDontSee('Compliance evidence mapping v1')
->assertDontSee('Publish review')
->assertDontSee('Refresh review')
->click('Filter löschen')
->assertDontSee('Refresh review');
$workspacePage
->assertScript('document.querySelector(\'[data-testid="workspace-hub-environment-filter-clear"]\') instanceof HTMLAnchorElement', true);
visit(CustomerReviewWorkspace::getUrl(panel: 'admin'))
->waitForText('Published ManagedEnvironment')
->assertDontSee('No Published ManagedEnvironment')
->assertDontSee('No published review available yet')
->assertSeeIn('tbody tr.fi-ta-row:first-of-type td:last-child', 'Review öffnen')
->click('tbody tr.fi-ta-row:first-of-type td:last-child a')
->assertSeeIn('tbody tr.fi-ta-row:first-of-type td:last-child', 'Review öffnen');
$customerContextReviewUrl = EnvironmentReviewResource::environmentScopedUrl('view', ['record' => $publishedReview], $tenantPublished)
.'?'.http_build_query([
CustomerReviewWorkspace::DETAIL_CONTEXT_QUERY_KEY => 1,
'source_surface' => CustomerReviewWorkspace::SOURCE_SURFACE,
'tenant_filter_id' => (int) $tenantPublished->getKey(),
]);
$customerReviewDetailPage = visit($customerContextReviewUrl)
->waitForText('Ergebniszusammenfassung')
->assertSee('Governance-Paket herunterladen')
->assertSee('Kundensicheren Bericht anzeigen')
->assertSee('Governance-Paket')
->assertSee('Veröffentlichter Governance-Nachweis')
->assertSee('Review-Status')
@ -164,6 +253,188 @@
->assertDontSee('Create next review')
->assertDontSee('Export executive pack')
->assertDontSee('Archive review')
->assertScript('document.querySelector(\'a[href*="/admin/review-packs/"][href*="/report"]\') instanceof HTMLAnchorElement', true)
->assertNoJavaScriptErrors()
->assertNoConsoleLogs();
$renderedReportUrl = app(ReviewPackService::class)->generateRenderedReportUrl($pack, [
CustomerReviewWorkspace::DETAIL_CONTEXT_QUERY_KEY => 1,
'source_surface' => CustomerReviewWorkspace::SOURCE_SURFACE,
'tenant_filter_id' => (int) $tenantPublished->getKey(),
'review_id' => (int) $publishedReview->getKey(),
'interpretation_version' => $publishedReview->controlInterpretationVersion(),
]);
$renderedReportPage = visit($renderedReportUrl)
->resize(1280, 1440)
->waitForText('Kundensicherer Bericht bereit')
->assertPathContains('/admin/review-packs/')
->assertPathEndsWith('/report')
->assertSee('Evidence-Basis')
->assertSee('Bericht drucken')
->assertSee('Review-Detail öffnen')
->assertSee('Review-Pack-Detail öffnen')
->assertSee('Conditional access drift requires review')
->assertSee('A governance decision still requires stakeholder awareness.')
->assertSee('Unterstützender Anhang')
->assertSee('Nur Service-Delivery-Zusammenfassung. Ersetzt weder formales Auditurteil noch Zertifizierung oder rechtliche Attestierung.')
->assertDontSee('localization.')
->assertNoJavaScriptErrors()
->assertNoConsoleLogs();
$renderedReportPage->screenshot(true, spec356BrowserScreenshotName('report-customer-safe-ready'));
spec356CopyBrowserScreenshot('report-customer-safe-ready');
$renderedReportPage->script('document.body.classList.add("print-preview-smoke"); window.scrollTo(0, 0);');
$renderedReportPage
->assertScript('window.getComputedStyle(document.querySelector("[data-testid=\"rendered-report-toolbar\"]")).display === "none"', true);
$renderedReportPage->screenshot(true, spec356BrowserScreenshotName('report-print-view'));
spec356CopyBrowserScreenshot('report-print-view');
$renderedReportPage->script('document.body.classList.remove("print-preview-smoke"); document.querySelector("[data-testid=\"rendered-report-supporting-appendix\"]")?.scrollIntoView({ block: "start" });');
$renderedReportPage->screenshot(false, spec356BrowserScreenshotName('report-appendix'));
spec356CopyBrowserScreenshot('report-appendix');
$limitedReportUrl = app(ReviewPackService::class)->generateRenderedReportUrl($limitedPack, [
CustomerReviewWorkspace::DETAIL_CONTEXT_QUERY_KEY => 1,
'source_surface' => CustomerReviewWorkspace::SOURCE_SURFACE,
'tenant_filter_id' => (int) $limitedEnvironment->getKey(),
'review_id' => (int) $limitedReview->getKey(),
'interpretation_version' => $limitedReview->controlInterpretationVersion(),
]);
$limitedReportPage = visit($limitedReportUrl)
->resize(1280, 1440)
->waitForText('Bericht mit Einschränkungen')
->assertSee('Nicht extern weitergeben, bevor der Bericht geprüft wurde.')
->assertSee('Output-Einschränkungen')
->assertSee('Die Evidence-Basis ist unvollständig')
->assertDontSee('Kundensicherer Bericht bereit')
->assertDontSee('localization.')
->assertNoJavaScriptErrors()
->assertNoConsoleLogs();
$limitedReportPage->screenshot(true, spec356BrowserScreenshotName('report-with-limitations'));
spec356CopyBrowserScreenshot('report-with-limitations');
$internalReportUrl = app(ReviewPackService::class)->generateRenderedReportUrl($internalPack, [
CustomerReviewWorkspace::DETAIL_CONTEXT_QUERY_KEY => 1,
'source_surface' => CustomerReviewWorkspace::SOURCE_SURFACE,
'tenant_filter_id' => (int) $internalEnvironment->getKey(),
'review_id' => (int) $internalReview->getKey(),
'interpretation_version' => $internalReview->controlInterpretationVersion(),
]);
$internalReportPage = visit($internalReportUrl)
->resize(1280, 1440)
->waitForText('Interner Bericht mit Einschränkungen')
->assertSee('Nicht extern weitergeben, bevor der Bericht geprüft wurde.')
->assertSee('interne oder PII-tragende Details')
->assertSee('Internes Review-Paket herunterladen')
->assertDontSee('Kundensicherer Bericht bereit')
->assertDontSee('localization.')
->assertNoJavaScriptErrors()
->assertNoConsoleLogs();
$internalReportPage->screenshot(true, spec356BrowserScreenshotName('report-internal-pii'));
spec356CopyBrowserScreenshot('report-internal-pii');
});
/**
* @param array<string, mixed> $packOptions
* @return array{0:\App\Models\EnvironmentReview,1:ReviewPack}
*/
function spec356BrowserCreateRenderedReportPack(
ManagedEnvironment $environment,
\App\Models\User $user,
EvidenceSnapshot $snapshot,
array $packOptions = [],
string $filePath = 'review-packs/spec356-browser-report.zip',
?EvidenceCompletenessState $evidenceOverride = null,
): array {
$review = composeEnvironmentReviewForTest($environment, $user, $snapshot);
$review->forceFill([
'status' => EnvironmentReviewStatus::Published->value,
'published_at' => now()->subMinutes(5),
'published_by_user_id' => (int) $user->getKey(),
])->save();
$review = markEnvironmentReviewCustomerSafeReady($review);
if ($evidenceOverride instanceof EvidenceCompletenessState) {
restateEnvironmentReviewEvidenceSnapshot($review->evidenceSnapshot, $evidenceOverride);
$review = $review->fresh(['sections', 'evidenceSnapshot']);
}
Storage::disk('exports')->put($filePath, 'PK-spec356-browser-test');
$pack = ReviewPack::factory()->ready()->create([
'managed_environment_id' => (int) $environment->getKey(),
'workspace_id' => (int) $environment->workspace_id,
'environment_review_id' => (int) $review->getKey(),
'evidence_snapshot_id' => (int) $snapshot->getKey(),
'initiated_by_user_id' => (int) $user->getKey(),
'options' => array_replace([
'include_pii' => false,
'include_operations' => true,
], $packOptions),
'summary' => [
'governance_package' => [
'executive_summary' => 'Management summary for the Spec 356 rendered browser report.',
'evidence_basis_summary' => 'The report is anchored to the stored review evidence basis.',
'top_findings' => [],
'accepted_risks' => [],
'decision_summary' => [
'status' => 'none',
'summary' => '',
'next_action' => '',
'entries' => [],
],
],
'control_interpretation' => [
'non_certification_disclosure' => 'Nur Service-Delivery-Zusammenfassung. Ersetzt weder formales Auditurteil noch Zertifizierung oder rechtliche Attestierung.',
],
'recommended_next_actions' => [],
'delivery_bundle' => [
'executive_entrypoint_file' => 'executive-summary.md',
'appendix_files' => ['metadata.json', 'summary.json', 'sections.json'],
],
],
'file_path' => $filePath,
'file_disk' => 'exports',
'generated_at' => now()->subMinutes(4),
]);
$review->forceFill([
'current_export_review_pack_id' => (int) $pack->getKey(),
])->save();
return [$review->refresh(), $pack->refresh()];
}
function spec356BrowserScreenshotName(string $name): string
{
return 'spec356-review-pack-rendered-report-'.$name;
}
function spec356CopyBrowserScreenshot(string $name): void
{
$filename = spec356BrowserScreenshotName($name).'.png';
$source = base_path('tests/Browser/Screenshots/'.$filename);
$targetDirectory = repo_path('specs/356-review-pack-pdf-html-renderer-v1/artifacts/screenshots');
if (! is_dir($targetDirectory)) {
@mkdir($targetDirectory, 0755, true);
}
if (! is_file($source)) {
$source = \Pest\Browser\Support\Screenshot::path($filename);
}
for ($attempt = 0; $attempt < 10 && ! is_file($source); $attempt++) {
usleep(100_000);
clearstatcache(true, $source);
}
if (is_file($source) && is_dir($targetDirectory) && is_writable($targetDirectory)) {
@copy($source, $targetDirectory.DIRECTORY_SEPARATOR.$name.'.png');
}
}

View File

@ -128,6 +128,18 @@ function spec308SeedExpiredDecisionFinding(ManagedEnvironment $tenant, User $req
Livewire::actingAs($user)
->test(ManagedEnvironmentReviewPackCard::class, ['record' => $tenant])
->assertSee('View review');
$this->actingAs($user)
->get(app(ReviewPackService::class)->generateRenderedReportUrl($pack, [
'source_surface' => 'environment_review_detail',
'review_id' => (int) $review->getKey(),
'interpretation_version' => $review->controlInterpretationVersion(),
]))
->assertOk()
->assertSee('Rendered review report')
->assertSee((string) data_get($summary, 'governance_package.executive_summary'))
->assertSee('Structured auditor appendix')
->assertDontSee('Platform reason family');
});
it('builds an explicit customer-safe decision summary for released review consumption', function (): void {

View File

@ -91,6 +91,7 @@
->assertOk()
->assertSee('Released governance record')
->assertSee('This released review is available for customer-safe governance consumption.')
->assertSee('View report with limitations')
->assertSee('Governance package')
->assertSee('Review status')
->assertSee('Evidence')

View File

@ -219,23 +219,18 @@ function environmentReviewContractHeaderActions(Testable $component): array
$component = Livewire::withQueryParams([CustomerReviewWorkspace::DETAIL_CONTEXT_QUERY_KEY => 1])
->actingAs($user)
->test(ViewEnvironmentReview::class, ['record' => $review->getKey()])
->assertActionVisible('download_current_review_pack')
->assertActionEnabled('download_current_review_pack')
->assertActionVisible('open_current_rendered_report')
->assertActionEnabled('open_current_rendered_report')
->assertActionDoesNotExist('publish_review')
->assertActionDoesNotExist('refresh_review')
->assertActionDoesNotExist('create_next_review')
->assertActionDoesNotExist('export_executive_pack')
->assertActionDoesNotExist('archive_review');
$component->assertActionExists('download_current_review_pack', function (Action $action): bool {
$component->assertActionExists('open_current_rendered_report', function (Action $action): bool {
$label = $action->getLabel();
return $label !== 'Download governance package'
&& in_array($label, [
'Download customer-safe review pack',
'Download review pack with limitations',
'Download internal review pack',
], true);
return $label === 'View internal report';
});
$topLevelActionNames = collect(environmentReviewContractHeaderActions($component))
@ -244,7 +239,7 @@ function environmentReviewContractHeaderActions(Testable $component): array
->values()
->all();
expect($topLevelActionNames)->toBe(['download_current_review_pack']);
expect($topLevelActionNames)->toBe(['open_current_rendered_report']);
});
it('shows publication truth and next-step guidance when a review is not yet publishable', function (): void {

View File

@ -155,7 +155,7 @@
Livewire::withQueryParams([CustomerReviewWorkspace::DETAIL_CONTEXT_QUERY_KEY => 1])
->actingAs($user)
->test(ViewEnvironmentReview::class, ['record' => $review->getKey()])
->assertActionVisible('download_current_review_pack')
->assertActionEnabled('download_current_review_pack')
->assertActionExists('download_current_review_pack', fn (\Filament\Actions\Action $action): bool => $action->getLabel() === 'Download internal review pack');
->assertActionVisible('open_current_rendered_report')
->assertActionEnabled('open_current_rendered_report')
->assertActionExists('open_current_rendered_report', fn (\Filament\Actions\Action $action): bool => $action->getLabel() === 'View internal report');
});

View File

@ -93,6 +93,7 @@
->assertOk()
->assertSee('Veröffentlichter Governance-Nachweis')
->assertSee('Primäre Aktion')
->assertSee('Internen Bericht anzeigen')
->assertSee('Executive-Einstieg')
->assertSee('Strukturierter Auditor-Anhang')
->assertDontSee('Released governance record');
@ -100,8 +101,8 @@
$component = localizedEnvironmentReviewComponent($user, $review->getKey());
$component->assertActionExists(
'download_current_review_pack',
fn (\Filament\Actions\Action $action): bool => $action->getLabel() === 'Governance-Paket herunterladen',
'open_current_rendered_report',
fn (\Filament\Actions\Action $action): bool => $action->getLabel() === 'Internen Bericht anzeigen',
);
});

View File

@ -114,6 +114,21 @@ function spec308SeedPackDecisionFinding(ManagedEnvironment $tenant, User $reques
->assertDontSee('Artifact truth')
->assertSee('#'.$review->getKey())
->assertSee('Review status');
$this->actingAs($user)
->get(app(ReviewPackService::class)->generateRenderedReportUrl($pack, [
'source_surface' => 'review_pack',
'review_id' => (int) $review->getKey(),
'interpretation_version' => $review->controlInterpretationVersion(),
]))
->assertOk()
->assertSee('Rendered review report')
->assertSee('Executive summary')
->assertSee('Evidence basis')
->assertSee('Output limitations')
->assertSee('Supporting appendix')
->assertDontSee((string) data_get($summary, 'governance_package.evidence_basis_summary'))
->assertDontSee('localization.');
});
it('includes the customer-safe decision summary in review-derived pack JSON and markdown', function (): void {

View File

@ -6,15 +6,18 @@
use App\Models\AuditLog;
use App\Models\OperationRun;
use App\Models\PlatformUser;
use App\Services\Graph\GraphClientInterface;
use App\Services\Entitlements\WorkspaceCommercialLifecycleResolver;
use App\Services\ReviewPackService;
use App\Services\Settings\SettingsWriter;
use App\Support\Evidence\EvidenceCompletenessState;
use App\Support\Audit\AuditActionId;
use App\Support\Auth\PlatformCapabilities;
use App\Support\ReviewPackStatus;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Facades\URL;
use Tests\Support\FailHardGraphClient;
uses(RefreshDatabase::class);
@ -43,6 +46,80 @@ function createReadyPackWithFile(?array $packOverrides = []): array
return [$user, $tenant, $pack];
}
function createCurrentReviewPackForRenderedReport(
?array $packOverrides = [],
bool $customerSafeReady = false,
?\App\Models\EvidenceSnapshot $snapshot = null,
): array
{
$packOverrides ??= [];
$tenant = \App\Models\ManagedEnvironment::factory()->create();
[$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'owner');
$snapshot ??= seedEnvironmentReviewEvidence($tenant, findingCount: 0, driftCount: 0);
$review = composeEnvironmentReviewForTest($tenant, $user, $snapshot);
$review->forceFill([
'status' => 'published',
'published_at' => now(),
'published_by_user_id' => (int) $user->getKey(),
])->save();
if ($customerSafeReady) {
$review = markEnvironmentReviewCustomerSafeReady($review);
}
$filePath = 'review-packs/'.$tenant->external_id.'/rendered-report.zip';
Storage::disk('exports')->put($filePath, 'PK-rendered-report-content');
$summary = array_replace_recursive([
'governance_package' => [
'executive_summary' => 'The released review is ready for management handoff.',
'evidence_basis_summary' => 'The report is anchored to the current released evidence snapshot.',
'top_findings' => [],
'accepted_risks' => [],
'decision_summary' => [
'status' => 'none',
'summary' => '',
'next_action' => '',
'entries' => [],
],
],
'control_interpretation' => [
'non_certification_disclosure' => 'TenantPilot summarizes available service-delivery evidence for governance review. This report is not a certification, legal attestation, audit opinion, or compliance guarantee.',
],
'recommended_next_actions' => [],
'delivery_bundle' => [
'executive_entrypoint_file' => 'executive-summary.md',
'appendix_files' => ['metadata.json', 'summary.json', 'sections.json'],
],
], is_array($packOverrides['summary'] ?? null) ? $packOverrides['summary'] : []);
$packAttributes = array_merge([
'managed_environment_id' => (int) $tenant->getKey(),
'workspace_id' => (int) $tenant->workspace_id,
'environment_review_id' => (int) $review->getKey(),
'evidence_snapshot_id' => (int) $snapshot->getKey(),
'initiated_by_user_id' => (int) $user->getKey(),
'options' => [
'include_pii' => false,
'include_operations' => true,
],
'summary' => $summary,
'file_path' => $filePath,
'file_disk' => 'exports',
'sha256' => hash('sha256', 'PK-rendered-report-content'),
'expires_at' => now()->addDay(),
], $packOverrides);
$packAttributes['summary'] = $summary;
$pack = ReviewPack::factory()->ready()->create($packAttributes);
$review->forceFill([
'current_export_review_pack_id' => (int) $pack->getKey(),
])->save();
return [$user, $tenant, $review->fresh(['sections', 'evidenceSnapshot', 'currentExportReviewPack']), $pack->fresh()];
}
function suspendReadyPackWorkspaceForDownloadTest(ReviewPack $pack): void
{
app(SettingsWriter::class)->updateWorkspaceCommercialLifecycle(
@ -111,6 +188,248 @@ function suspendReadyPackWorkspaceForDownloadTest(ReviewPack $pack): void
$response->assertDownload();
});
it('renders the current review pack as a customer-safe management report via signed URL without creating a download audit event or provider calls', function (): void {
[$user, $tenant, $review, $pack] = createCurrentReviewPackForRenderedReport(customerSafeReady: true);
$signedUrl = app(ReviewPackService::class)->generateRenderedReportUrl($pack, [
'source_surface' => 'review_pack',
'review_id' => (int) $review->getKey(),
'interpretation_version' => $review->controlInterpretationVersion(),
]);
$this->app->instance(GraphClientInterface::class, new FailHardGraphClient());
$packCount = ReviewPack::query()->count();
$operationRunCount = OperationRun::query()->count();
expect(\App\Filament\Resources\EnvironmentReviewResource::renderedReportActionLabelFor($review))->toBe('View customer-safe report')
->and(\App\Filament\Resources\ReviewPackResource::downloadActionLabelFor($pack))->toBe('Download customer-safe review pack');
$response = $this->actingAs($user)->get($signedUrl);
$content = (string) $response->getContent();
$toolbarPosition = strpos($content, 'data-testid="rendered-report-toolbar"');
$canvasPosition = strpos($content, 'data-testid="rendered-report-canvas"');
$response->assertOk()
->assertSee('Rendered review report')
->assertSee('Customer-safe report ready')
->assertSee('Executive summary')
->assertSee('Overall state')
->assertSee('Reason')
->assertSee('Impact')
->assertSee('Recommended next action')
->assertSee('Prepared by '.$tenant->workspace->name.' for '.$tenant->name)
->assertSee('Generated by TenantPilot')
->assertSee('Download customer-safe review pack')
->assertSee('No open risks are listed for this review.')
->assertSee('No accepted risks are listed for this review.')
->assertSee('No governance decisions require customer awareness in this released review.')
->assertSee('Evidence basis')
->assertSee('Supporting appendix')
->assertSee('Non-certification disclosure')
->assertSee('@media print', false)
->assertSee('.report-toolbar, .screen-only { display: none !important; }', false)
->assertSee((string) data_get($pack->summary, 'governance_package.executive_summary'))
->assertDontSee('Platform reason family')
->assertDontSee('localization.')
->assertDontSee('Evidence state:')
->assertDontSee('Section completeness:')
->assertDontSee('Total findings')
->assertDontSee('Certified report')
->assertDontSee('Approved compliance report')
->assertDontSee('Share with customer')
->assertDontSee('Do not share externally before review.')
->assertDontSee((string) $review->fingerprint);
expect($toolbarPosition)->not->toBeFalse()
->and($canvasPosition)->not->toBeFalse()
->and($toolbarPosition)->toBeLessThan($canvasPosition)
->and(AuditLog::query()->where('action', AuditActionId::ReviewPackDownloaded->value)->count())->toBe(0)
->and(ReviewPack::query()->count())->toBe($packCount)
->and(OperationRun::query()->count())->toBe($operationRunCount);
});
it('renders limitations near the top without claiming customer-safe readiness', function (): void {
[$user, $tenant, $review, $pack] = createCurrentReviewPackForRenderedReport(customerSafeReady: true);
restateEnvironmentReviewEvidenceSnapshot($review->evidenceSnapshot, EvidenceCompletenessState::Partial);
$review = $review->fresh(['sections', 'evidenceSnapshot', 'currentExportReviewPack']);
$pack = $pack->fresh(['environmentReview']);
expect(\App\Filament\Resources\EnvironmentReviewResource::renderedReportActionLabelFor($review))->toBe('View report with limitations')
->and(\App\Filament\Resources\ReviewPackResource::downloadActionLabelFor($pack))->toBe('Download review pack with limitations');
$signedUrl = app(ReviewPackService::class)->generateRenderedReportUrl($pack, [
'source_surface' => 'review_pack',
'review_id' => (int) $review->getKey(),
'interpretation_version' => $review->controlInterpretationVersion(),
]);
$response = $this->actingAs($user)->get($signedUrl);
$content = (string) $response->getContent();
$response->assertOk()
->assertSee('Report with limitations')
->assertSee('Do not share externally before review.')
->assertSee('Output limitations')
->assertSee('Download review pack with limitations')
->assertSee('The evidence basis is incomplete, stale, or missing.')
->assertSee('Review or refresh the evidence basis before external sharing.')
->assertSee('This report is anchored to Evidence snapshot #')
->assertDontSee('Customer-safe report ready')
->assertDontSee('Customer-ready report')
->assertDontSee('Certified report')
->assertDontSee('Approved compliance report')
->assertDontSee('Share with customer')
->assertDontSee('localization.');
expect(strpos($content, 'data-testid="rendered-report-output-limitations"'))
->toBeLessThan(strpos($content, 'data-testid="rendered-report-evidence-basis"'));
});
it('renders internal pii reports with a visible internal warning and qualified labels', function (): void {
[$user, $tenant, $review, $pack] = createCurrentReviewPackForRenderedReport(
packOverrides: [
'options' => [
'include_pii' => true,
'include_operations' => true,
],
],
customerSafeReady: true,
);
expect(\App\Filament\Resources\EnvironmentReviewResource::renderedReportActionLabelFor($review))->toBe('View internal report')
->and(\App\Filament\Resources\ReviewPackResource::downloadActionLabelFor($pack))->toBe('Download internal review pack');
$signedUrl = app(ReviewPackService::class)->generateRenderedReportUrl($pack, [
'source_surface' => 'review_pack',
'review_id' => (int) $review->getKey(),
'interpretation_version' => $review->controlInterpretationVersion(),
]);
$this->actingAs($user)
->get($signedUrl)
->assertOk()
->assertSee('Internal report with limitations')
->assertSee('Do not share externally before review.')
->assertSee('This output includes internal or PII-bearing detail.')
->assertSee('Download internal review pack')
->assertDontSee('Customer-safe report ready')
->assertDontSee('Customer-ready report')
->assertDontSee('Certified report')
->assertDontSee('Approved compliance report')
->assertDontSee('Share with customer')
->assertDontSee('localization.');
});
it('renders localized report chrome and copy without exposing localization keys', function (): void {
[$user, $tenant, $review, $pack] = createCurrentReviewPackForRenderedReport(customerSafeReady: true);
$user->forceFill(['preferred_locale' => 'de'])->save();
$signedUrl = app(ReviewPackService::class)->generateRenderedReportUrl($pack, [
'source_surface' => 'review_pack',
'review_id' => (int) $review->getKey(),
'interpretation_version' => $review->controlInterpretationVersion(),
]);
$this->actingAs($user)
->get($signedUrl)
->assertOk()
->assertSee('Kundensicherer Bericht bereit')
->assertSee('Executive-Zusammenfassung')
->assertSee('Erstellt von '.$tenant->workspace->name.' für '.$tenant->name)
->assertSee('Unterstützender Anhang')
->assertSee('Nicht-Zertifizierungs-Offenlegung')
->assertDontSee('localization.');
});
it('renders accepted risks from customer-safe summaries without leaking internal rationale', function (): void {
[$user, $tenant, $review, $pack] = createCurrentReviewPackForRenderedReport(
packOverrides: [
'summary' => [
'governance_package' => [
'accepted_risks' => [
[
'title' => 'MFA exception accepted during migration',
'governance_state' => 'expiring_exception',
'customer_safe_summary' => 'The exception is time-bound and tracked for customer awareness.',
'summary' => 'Internal committee rationale must stay internal.',
'owner_label' => 'Customer Success Lead',
'expires_at' => '2026-07-15',
],
[
'title' => 'Legacy device exception',
'governance_state' => 'expired_exception',
'summary' => 'Internal rationale without customer-safe copy.',
'owner_label' => 'Security Operations',
'review_due_at' => '2026-06-01',
],
],
],
],
],
customerSafeReady: true,
);
$signedUrl = app(ReviewPackService::class)->generateRenderedReportUrl($pack, [
'source_surface' => 'review_pack',
'review_id' => (int) $review->getKey(),
'interpretation_version' => $review->controlInterpretationVersion(),
]);
$this->actingAs($user)
->get($signedUrl)
->assertOk()
->assertSee('MFA exception accepted during migration')
->assertSee('Expiring soon')
->assertSee('Expires on 2026-07-15')
->assertSee('Owner: Customer Success Lead')
->assertSee('The exception is time-bound and tracked for customer awareness.')
->assertSee('Legacy device exception')
->assertSee('Expired')
->assertSee('Review due on 2026-06-01')
->assertSee('A customer-safe accepted-risk summary is not recorded.')
->assertDontSee('Internal committee rationale must stay internal.')
->assertDontSee('Internal rationale without customer-safe copy.')
->assertDontSee('Certified report')
->assertDontSee('Approved compliance report')
->assertDontSee('localization.');
});
it('returns 404 for a rendered report when the pack is no longer the current export', function (): void {
[$user, $tenant, $review] = createCurrentReviewPackForRenderedReport();
$oldPack = ReviewPack::factory()->ready()->create([
'managed_environment_id' => (int) $tenant->getKey(),
'workspace_id' => (int) $tenant->workspace_id,
'environment_review_id' => (int) $review->getKey(),
'evidence_snapshot_id' => (int) $review->evidence_snapshot_id,
'initiated_by_user_id' => (int) $user->getKey(),
'expires_at' => now()->addDay(),
]);
$signedUrl = app(ReviewPackService::class)->generateRenderedReportUrl($oldPack, [
'source_surface' => 'review_pack',
'review_id' => (int) $review->getKey(),
]);
$this->actingAs($user)
->get($signedUrl)
->assertNotFound();
});
it('returns 404 for a rendered report when the user is not a tenant member', function (): void {
[$owner, $tenant, $review, $pack] = createCurrentReviewPackForRenderedReport();
$otherTenant = \App\Models\ManagedEnvironment::factory()->create();
[$otherUser] = createUserWithTenant(tenant: $otherTenant, role: 'owner');
$signedUrl = app(ReviewPackService::class)->generateRenderedReportUrl($pack, [
'source_surface' => 'review_pack',
'review_id' => (int) $review->getKey(),
]);
$this->actingAs($otherUser)
->get($signedUrl)
->assertNotFound();
});
// ─── Expired Signature → 403 ────────────────────────────────
it('rejects requests with an expired signature', function (): void {

View File

@ -135,6 +135,32 @@ function seedReviewPackEvidence(ManagedEnvironment $tenant): EvidenceSnapshot
return $snapshot->load('items');
}
function createCurrentReviewPackForResourcePreview(ManagedEnvironment $tenant, \App\Models\User $user): array
{
$snapshot = seedEnvironmentReviewEvidence($tenant, findingCount: 0, driftCount: 0);
$review = composeEnvironmentReviewForTest($tenant, $user, $snapshot);
$review->forceFill([
'status' => 'published',
'published_at' => now(),
'published_by_user_id' => (int) $user->getKey(),
])->save();
$pack = ReviewPack::factory()->ready()->create([
'managed_environment_id' => (int) $tenant->getKey(),
'workspace_id' => (int) $tenant->workspace_id,
'environment_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();
return [$review->fresh(), $pack->fresh(['environmentReview'])];
}
// ─── List Page ───────────────────────────────────────────────
it('renders the list page for an authorized user', function (): void {
@ -636,6 +662,20 @@ function seedReviewPackEvidence(ManagedEnvironment $tenant): EvidenceSnapshot
->assertActionVisible('download');
});
it('shows rendered report header action on view page for the current review-derived pack', function (): void {
$tenant = ManagedEnvironment::factory()->create();
[$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'owner');
[, $pack] = createCurrentReviewPackForResourcePreview($tenant, $user);
$tenant->makeCurrent();
Filament::setTenant($tenant, true);
Livewire::actingAs($user)
->test(ViewReviewPack::class, ['record' => $pack->getKey()])
->assertActionVisible('open_rendered_report')
->assertActionVisible('download');
});
it('shows regenerate header action on view page', function (): void {
$tenant = ManagedEnvironment::factory()->create();
[$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'owner');
@ -658,13 +698,12 @@ function seedReviewPackEvidence(ManagedEnvironment $tenant): EvidenceSnapshot
$tenant = ManagedEnvironment::factory()->create();
[$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'readonly');
$pack = ReviewPack::factory()->ready()->create([
'managed_environment_id' => (int) $tenant->getKey(),
'workspace_id' => (int) $tenant->workspace_id,
'initiated_by_user_id' => (int) $user->getKey(),
[, $pack] = createCurrentReviewPackForResourcePreview($tenant, $user);
$pack->forceFill([
'sha256' => hash('sha256', 'customer-pack-flow'),
'fingerprint' => hash('sha256', 'customer-pack-fingerprint'),
]);
])->save();
$pack = $pack->fresh(['environmentReview']);
$this->actingAs($user)
->get(ReviewPackResource::getUrl('view', ['record' => $pack], tenant: $tenant, panel: 'admin').'?'.http_build_query([
@ -684,6 +723,7 @@ function seedReviewPackEvidence(ManagedEnvironment $tenant): EvidenceSnapshot
Livewire::withQueryParams(['source_surface' => CustomerReviewWorkspace::SOURCE_SURFACE])
->actingAs($user)
->test(ViewReviewPack::class, ['record' => $pack->getKey()])
->assertActionVisible('open_rendered_report')
->assertActionVisible('download')
->assertActionDoesNotExist('regenerate');
});

View File

@ -76,7 +76,7 @@ function suspendCustomerReviewWorkspacePackAccessWorkspace(ManagedEnvironment $t
->assertSee(EnvironmentReviewResource::environmentScopedUrl('view', ['record' => $review->fresh()], $tenant), false)
->assertSee('Review pack')
->assertSee('Available')
->assertSee('The current review package is available and meets the customer-safe output contract.')
->assertSee('The current review package is available, meets the customer-safe output contract, and can be opened as a rendered report from the review detail.')
->assertSee('Customer-safe review pack ready')
->assertSee('Download customer-safe review pack')
->assertSee('source_surface=customer_review_workspace', false)

View File

@ -6,17 +6,17 @@ ## Summary
| Metric | Count | Notes |
| --- | ---: | --- |
| UI route/page inventory rows | 98 | Includes dynamic route families and utility/auth endpoints. |
| Unique page reports | 18 | `page-reports/*.md`; some inventory rows intentionally share existing reports where routes resolve to the same surface. |
| UI route/page inventory rows | 99 | Includes dynamic route families and utility/auth endpoints. |
| Unique page reports | 20 | `page-reports/*.md`; some inventory rows intentionally share existing reports where routes resolve to the same surface. |
| Desktop screenshots | 15 | Route-inventory-linked desktop evidence, including strategic runtime captures and blocker evidence screenshots. |
| Tablet screenshots | 0 | Deferred to later strategic mockup/implementation specs. |
| Mobile screenshots | 0 | Deferred to later strategic mockup/implementation specs. |
| Strategic Surface rows | 44 | Individual target treatment or explicit product decision required. |
| Strategic Surface rows | 45 | Individual target treatment or explicit product decision required. |
| Domain Pattern Surface rows | 45 | Can be handled through grouped pattern specs unless later evidence raises risk. |
| Design-System Cleanup Surface rows | 7 | Tables/forms/states/copy cleanup, no individual target mockup expected by default. |
| Internal / Deprecated / Hidden rows | 1 | Local-only smoke login routes. |
| Manual Review Required rows | 1 | File-discovered break-glass page without confirmed route. |
| High-priority unresolved/manual-review entries | 30 | Recorded in `unresolved-pages.md`. |
| High-priority unresolved/manual-review entries | 28 | Recorded in `unresolved-pages.md`. |
## Spec 325 Target Image Coverage
@ -53,7 +53,7 @@ ## Coverage By Area
| Monitoring | 9 | Operations hub and alert delivery landing captured; record details and config forms remain pattern/manual review. |
| Inventory | 8 | Route-discovered only; coverage, policy version detail, and raw-data exposure need later review. |
| Evidence / audit | 8 | Audit log captured; evidence/report detail routes need customer-safe progressive-disclosure review. |
| Reviews | 6 | Review register and customer workspace captured; review pack/detail routes remain unresolved. |
| Reviews | 7 | Review register, customer workspace, review pack detail, and the rendered-report route now have bounded browser evidence; deeper evidence/report surfaces still remain open elsewhere. |
| Backup / restore | 6 | High-risk area; backup sets and restore runs were blocked by fixture capability. |
| Settings / admin | 5 | Workspace and environment access are RBAC-sensitive and need later review. |
| Provider / integration | 5 | Provider connections and required permissions are captured; create/edit/onboarding remain high-risk unresolved surfaces. |
@ -78,7 +78,7 @@ ## Coverage By Primary Archetype
| Inventory | 8 | Needs raw provider payload disclosure rules and confidence/status language. |
| Drift / Diff | 8 | Needs assignment, comparison, snapshot, and evidence-gap hierarchy. |
| Provider / Integration | 7 | Consent, credentials, permissions, and disconnect states require high trust clarity. |
| Reviews | 6 | Customer/auditor language, export context, and proof links are central. |
| Reviews | 7 | Customer/auditor language, export context, and proof links are central. |
| Findings / Inbox | 6 | Needs triage, owner, SLA, exception, and close-state clarity. |
| Backup / Restore | 6 | Highest safety burden: dry-run, confirmation, audit, and restore-point truth. |
| Auth / Access | 6 | Guard, denial, external auth, and smoke/local flows should stay explicit. |
@ -93,7 +93,7 @@ ## Coverage By Design Depth
| Design Depth | Rows | Gate Treatment |
| --- | ---: | --- |
| Strategic Surface | 44 | Requires individual target artifact or explicit product decision before substantive UI implementation. |
| Strategic Surface | 45 | Requires individual target artifact or explicit product decision before substantive UI implementation. |
| Domain Pattern Surface | 45 | Can be handled by grouped pattern specs and shared components. |
| Design-System Cleanup Surface | 7 | Table/form/action/state cleanup can be folded into implementation waves. |
| Manual Review Required | 1 | Must not be treated as product-ready until route/auth state is confirmed. |
@ -101,7 +101,7 @@ ## Coverage By Design Depth
## Missing Or Unclear Coverage
The largest open gaps are strategic detail/workflow surfaces, system-plane routes, and high-risk restore/backup flows that need seeded capability states. `unresolved-pages.md` records 30 high-priority entries.
The largest open gaps are strategic detail/workflow surfaces, system-plane routes, and high-risk restore/backup flows that need seeded capability states. `unresolved-pages.md` records 28 high-priority entries.
Tablet and mobile coverage is intentionally absent from this baseline. Later target specs should add responsive evidence for the app shell, workspace overview, environment dashboard, customer review workspace, governance inbox, operations, evidence, backup/restore, and critical forms.

View File

@ -136,3 +136,11 @@ ### Browser proof
- Verified states:
- `01-published-blocked.png` shows the executable `Create next review` CTA with confirmation, the `Supporting actions` group, and the split review-pack state semantics
- `04-fallback.png` shows the readonly/non-executable fallback to `Inspect review blockers`
## Spec 356 Follow-up
Spec 356 keeps the workspace read-first and avoids adding a second dominant rendered-report CTA at the hub level.
- the workspace continues to state review-pack readiness and download truth directly
- rendered stakeholder-report launch is deferred to the released-review detail surface so the hub does not duplicate the dominant handoff
- customer-safe wording now states explicitly that the report can be opened from review detail when the current pack supports it

View File

@ -64,6 +64,14 @@ ### Browser proof
- `02-mutable-blocked.png` shows the `Refresh review` confirmation path
- `03-ready-draft.png` shows the `Publish review` modal with the existing reason field
## Spec 356 Follow-up
Spec 356 keeps this detail page as the owner-side explanation surface but replaces the customer-workspace detail CTA with one dominant rendered-report handoff.
- customer-workspace detail mode now opens the rendered stakeholder report instead of leading with ZIP download
- the rendered-report affordance only appears when the current export review pack is ready, current, and not expired
- ZIP contract truth and appendix semantics remain secondary proof, not a second competing primary CTA
## Target Direction
Keep this surface audit- and evidence-oriented. If future work broadens it beyond the review-output path, that should happen through a dedicated detail-surface spec rather than hidden incremental drift.

View File

@ -0,0 +1,51 @@
# UI-042 Review Pack Detail
| Field | Value |
| --- | --- |
| Route | `/admin/workspaces/{workspace}/environments/{environment}/review-packs/{record}` |
| Source | `ReviewPackResource::view` |
| Area / scope | Reviews / environment artifact detail |
| Archetype | Evidence / Audit |
| Design depth | Strategic Surface |
| Repo truth | repo-verified |
| Screenshot | `-` |
| Browser status | Reached in the live in-app browser on 2026-06-05 via the Spec 351 review-output fixture; verified the preview-first action model, ZIP download secondary action, and customer-workspace return context. |
## First Five Seconds
The page should answer three questions immediately:
1. is this pack the current stakeholder-safe export or only a historical artifact
2. should the actor open the rendered report, download the ZIP, or stop
3. does this surface permit operator mutation or only read-first inspection
## Productization Review
- Decision-first: Spec 356 moves the primary inspect path to the rendered report instead of treating ZIP download as the first read.
- Evidence-first: status, expiry, evidence snapshot linkage, and package contract stay visible as artifact truth.
- Context: environment-bound artifact detail with optional customer-workspace return context.
- Capability/RBAC awareness: preview and download remain view-authorized; regenerate stays manage-only and confirmation-gated.
- Customer/auditor safety: rendered preview is only available for the current ready non-expired review-derived pack.
- Diagnostics/default hierarchy: the ZIP remains the structured appendix and downloadable artifact, not the first-read surface.
## Information Inventory
Default-visible content should show pack status, generated/expiry timing, linked review/evidence context, sharing boundary, executive entrypoint guidance, and the current rendered-report launch affordance.
## Dangerous Actions
- Dangerous or high-impact actions: `regenerate` on the operator detail surface.
- Current confirmation/evidence posture: `regenerate` is capability-gated and `->requiresConfirmation()`; customer-workspace flow suppresses it entirely.
- Target handling: keep preview and download read-only; do not let historical/expired packs impersonate the current report path.
## Spec 356 Follow-up
Spec 356 productizes this page as the owner-side artifact detail:
- `Open rendered report` is now the primary action for current ready packs.
- ZIP download remains available as the structured appendix artifact.
- Customer-workspace detail flow keeps `regenerate` hidden so the page does not compete with read-first stakeholder handoff.
## Target Direction
Keep this surface artifact-truth-first and narrowly scoped. Future work should deepen proof hierarchy and browser evidence, not invent a second portal or artifact family.

View File

@ -0,0 +1,52 @@
# UI-099 Rendered Review Report
| Field | Value |
| --- | --- |
| Route | `/admin/review-packs/{reviewPack}/report` |
| Source | `ReviewPackRenderedReportController` |
| Area / scope | Reviews / signed stakeholder report |
| Archetype | Reviews |
| Design depth | Strategic Surface |
| Repo truth | repo-verified |
| Screenshot | `-` |
| Browser status | Reached in the live in-app browser on 2026-06-05 via the Spec 351 review-output fixture; verified the HTML-first toolbar, signed route, evidence/technical-detail sections, and structured appendix rendering. |
## First Five Seconds
This route should answer four questions without exposing raw appendix files first:
1. what can a stakeholder trust right now
2. what evidence basis supports that conclusion
3. what limitations or accepted risks still matter
4. where can the operator return for review detail or artifact detail
## Productization Review
- Decision-first: the hero and guidance badges summarize stakeholder-safe posture before appendix detail.
- Evidence-first: evidence basis, governance decisions, accepted risks, and technical details stay visible in bounded sections.
- Context: the route is signed, read-only, and anchored to one current review pack plus one released review.
- Capability/RBAC awareness: the controller enforces tenant membership, `review_pack.view`, current-export authority, ready state, and expiry.
- Customer/auditor safety: diagnostics remain appendix-level; the route does not expose raw ZIP internals as the first screen.
- Diagnostics/default hierarchy: HTML-first rendering leads, with ZIP download and print as secondary utilities.
## Information Inventory
Default-visible content should include executive summary, evidence basis, limitations, key findings, accepted risks, governance decisions requiring awareness, next actions, non-certification disclosure, technical details, and a structured auditor appendix derived from `EnvironmentReviewSection` truth.
## Dangerous Actions
- Dangerous or high-impact actions: none. This is a read-only route.
- Current confirmation/evidence posture: toolbar actions only open review detail, review-pack detail, ZIP download, or browser print.
- Target handling: keep the route signed and current-pack-only; do not widen it into a multi-review delivery surface or implicit PDF engine.
## Spec 356 Follow-up
Spec 356 introduces this route as an HTML-first stakeholder handoff:
- it is derived from the current review-pack contract rather than archive re-parsing
- it keeps the ZIP as the structured appendix and downloadable artifact
- it preserves owner-surface backlinks so operators can inspect the released review or pack detail without losing context
## Target Direction
Keep this report calm, bounded, and print-friendly. Future follow-up should focus on browser evidence and hierarchy polish, not on a second rendering runtime or a broader delivery taxonomy.

View File

@ -47,8 +47,9 @@ # Route Inventory
| UI-039 | `/admin/workspaces/{workspace}/environments/{environment}/environment-reviews` | resource | Environment Reviews | Reviews | environment-bound | route exists | environment entitlement | Reviews | Evidence / Audit | Domain Pattern Surface | repo-verified | - | - | Environment-scoped review list. |
| UI-040 | `/admin/workspaces/{workspace}/environments/{environment}/environment-reviews/{record}` | resource | Environment Review Detail | Reviews | environment record | route exists | environment + record entitlement | Reviews | Evidence / Audit | Strategic Surface | repo-verified | - | [report](page-reports/ui-040-environment-review-detail.md) | Customer/auditor-facing evidence risk. |
| UI-041 | `/admin/workspaces/{workspace}/environments/{environment}/review-packs` | resource | Review Packs | Reviews | environment-bound | route exists | environment entitlement | Reviews | Evidence / Audit | Domain Pattern Surface | repo-verified | - | - | Export artifact list. |
| UI-042 | `/admin/workspaces/{workspace}/environments/{environment}/review-packs/{record}` | resource | Review Pack Detail | Reviews | environment record | route exists | environment + record entitlement | Reviews | Evidence / Audit | Strategic Surface | repo-verified | - | - | Export/evidence artifact detail. |
| UI-042 | `/admin/workspaces/{workspace}/environments/{environment}/review-packs/{record}` | resource | Review Pack Detail | Reviews | environment record | route exists | environment + record entitlement | Reviews | Evidence / Audit | Strategic Surface | repo-verified | - | [report](page-reports/ui-042-review-pack-detail.md) | Spec 356 makes rendered-report preview the primary inspect affordance while ZIP download and regenerate remain secondary/operator-scoped. |
| UI-043 | `/admin/review-packs/{reviewPack}/download` | controller | Review Pack Download | Reviews | workspace/environment artifact | route exists | download authorization expected | Reviews | Evidence / Audit | Design-System Cleanup Surface | repo-verified | - | - | Action endpoint, not page; include in coverage due customer artifact impact. |
| UI-099 | `/admin/review-packs/{reviewPack}/report` | controller | Rendered Review Report | Reviews | workspace/environment artifact | route exists | signed review-pack view access plus current-export / ready / not-expired authority | Reviews | Evidence / Audit | Strategic Surface | repo-verified | - | [report](page-reports/ui-099-rendered-review-report.md) | Spec 356 adds an HTML-first stakeholder report route derived from the current review-pack contract; it is read-only and current-pack-only. |
| UI-044 | `/admin/evidence/overview` | route + page | Evidence Overview | Evidence / audit | workspace hub | route exists | workspace member | Evidence / Audit | Reviews | Strategic Surface | repo-verified | - | - | Workspace-wide evidence landing. |
| UI-045 | `/admin/workspaces/{workspace}/environments/{environment}/evidence` | resource | Evidence Snapshots | Evidence / audit | environment-bound | route exists | environment entitlement | Evidence / Audit | Reviews | Domain Pattern Surface | repo-verified | - | - | Environment evidence list. |
| UI-046 | `/admin/workspaces/{workspace}/environments/{environment}/evidence/{record}` | resource | Evidence Snapshot Detail | Evidence / audit | environment record | route exists | environment + record entitlement | Evidence / Audit | Support / Diagnostics | Strategic Surface | repo-verified | - | - | Raw/support evidence must stay progressively disclosed. |

View File

@ -31,7 +31,7 @@ ### Deferred By Spec 325
| Deferred rows | Deferral reason | Later coverage |
| --- | --- | --- |
| UI-029, UI-034, UI-036, UI-076 | Governance/detail variants need seeded records after inbox pattern is accepted. | Governance Inbox decision experience and Drift/Baseline decision experience. |
| UI-037, UI-040, UI-042, UI-044, UI-046, UI-048 | Evidence/review detail and export surfaces need customer-safe pattern work after the customer workspace and audit anchors. | Evidence and review pack consumption productization. |
| UI-037, UI-040, UI-042, UI-044, UI-046, UI-048, UI-099 | Evidence/review detail and export surfaces need customer-safe pattern work after the customer workspace and audit anchors. | Evidence and review pack consumption productization. |
| UI-049, UI-051, UI-052 | Backup pages need capability-backed fixtures; restore safety is the first high-risk anchor. | Backup/Restore safety workflow spec. |
| UI-055, UI-057, UI-058, UI-063, UI-069 | Baseline/library/inventory detail pages should follow after baseline compare/drift hierarchy is verified. | Drift/Baseline and inventory proof patterns. |
| UI-007, UI-010, UI-013, UI-014 | Admin/access/onboarding surfaces are important but outside the first target-image wave. | Admin/settings and provider onboarding specs. |
@ -68,6 +68,7 @@ ### Deferred By Spec 325
| P1 | UI-037 | Review Register | `/admin/reviews` | Review planning and proof register. | Needs timeline and customer/auditor framing. | Review pattern target. |
| P1 | UI-040 | Environment Review Detail | `/admin/workspaces/{workspace}/environments/{environment}/environment-reviews/{record}` | Customer/auditor review detail. | Dynamic detail not reviewed. | Review detail target. |
| P1 | UI-042 | Review Pack Detail | `/admin/workspaces/{workspace}/environments/{environment}/review-packs/{record}` | Export/evidence artifact detail. | Export context and proof trust need review. | Review-pack target. |
| P1 | UI-099 | Rendered Review Report | `/admin/review-packs/{reviewPack}/report` | Signed stakeholder report derived from the current review-pack contract. | New read-first route needs browser evidence and hierarchy validation. | Rendered-report target. |
| P1 | UI-044 | Evidence Overview | `/admin/evidence/overview` | Workspace-wide evidence landing. | Not captured; evidence taxonomy unknown. | Evidence overview target. |
| P1 | UI-046 | Evidence Snapshot Detail | `/admin/workspaces/{workspace}/environments/{environment}/evidence/{record}` | Raw/support evidence detail. | Raw data exposure risk. | Evidence detail pattern. |
| P1 | UI-048 | Stored Report Detail | `/admin/workspaces/{workspace}/environments/{environment}/stored-reports/{record}` | Customer-readable report artifact. | Claims, freshness, and export context need review. | Stored report target. |

View File

@ -4,9 +4,9 @@ # Unresolved Pages
Summary:
- High-priority unresolved/manual-review entries: 29.
- High-priority unresolved/manual-review entries: 28.
- Capability/fixture blockers with desktop evidence: UI-051, UI-053, UI-061.
- Strategic routes not browser-captured in this bounded pass: 25.
- Strategic routes not browser-captured in this bounded pass: 24.
- Hidden/file-discovered manual-review surface: UI-080.
| ID | Page | Blocker / Reason | Needed Evidence | Next Action |
@ -16,7 +16,6 @@ # Unresolved Pages
| UI-014 | Environment Onboarding | Provider setup wizard route exists but was not captured. | Draft/onboarding fixture with consent and permission states. | Include in provider onboarding target pass. |
| UI-017 | Operation Detail | Dynamic operation record route requires a run fixture. | OperationRun records covering success, failure, running, retryable states. | Add operation detail report later. |
| UI-034 | Finding Detail | Dynamic finding detail requires seeded finding state. | Finding records with owner, severity, exception, and close state. | Add strategic finding detail mockup. |
| UI-042 | Review Pack Detail | Export/evidence artifact detail requires seeded review pack. | Review pack with files, freshness, and download state. | Add review-pack target artifact. |
| UI-044 | Evidence Overview | Workspace evidence landing was not captured. | Workspace with evidence sources, gaps, and stale states. | Add evidence overview report. |
| UI-046 | Evidence Snapshot Detail | Dynamic raw/support evidence detail requires snapshot record. | Snapshot with normalized summary and raw payload. | Add progressive-disclosure review. |
| UI-048 | Stored Report Detail | Dynamic report artifact requires stored report record. | Stored report with customer-facing summary and export metadata. | Add stored-report target pass. |

Binary file not shown.

After

Width:  |  Height:  |  Size: 209 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 957 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1002 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 902 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 997 KiB

View File

@ -0,0 +1,53 @@
# Requirements Checklist: Review Pack PDF/HTML Renderer v1
**Purpose**: Validate that Spec 356 is bounded, repo-based, constitution-aligned, and ready for a later implementation loop.
**Created**: 2026-06-05
**Feature**: `specs/356-review-pack-pdf-html-renderer-v1/spec.md`
## Candidate Selection Gate
- [x] CHK001 The package names the direct user-provided Spec 356 draft as the starting input and ties it to the renderer follow-up explicitly deferred by Spec 355.
- [x] CHK002 The active auto-prep queue in `docs/product/spec-candidates.md` was reviewed and found intentionally empty, so this package proceeds only as a direct manual promotion rather than an auto-selected backlog item.
- [x] CHK003 Completed-spec guardrails were applied to Specs 263, 347, 349, 351, and 355; they are treated as historical/runtime context only and are not rewritten.
- [x] CHK004 The selected slice is narrowed to current review-pack rendered delivery only; customer portal, broader workspace completion, localization-wide cleanup, governance inbox follow-up, and PDF dependency work stay deferred.
## Repo Truth And Architecture
- [x] CHK005 The spec and plan anchor the work to current review-pack generation, current customer-safe detail surfaces, and the current signed download seam.
- [x] CHK006 The artifacts state that rendered HTML/PDF remains derived-only and must not introduce a new persisted artifact family or a new database shape.
- [x] CHK007 The plan forbids live provider calls, a second report engine, a second `OperationRun`, or a new package-backed PDF stack.
- [x] CHK008 The prep explicitly records the repo-truth deviation from the user draft: `executive-summary.md` already exists today, so the gap is rendered delivery, not executive-entrypoint creation.
- [x] CHK009 The prep explicitly records that PDF is conditional on current repo support and must not be forced with a new dependency.
## UI/Productization Coverage
- [x] CHK010 UI Surface Impact is explicit and consistent with changing current owner surfaces plus adding one new rendered report route if needed.
- [x] CHK011 UI/Productization Coverage keeps `CustomerReviewWorkspace`, released-review detail, and review-pack detail as the current owner surfaces instead of inventing a portal taxonomy.
- [x] CHK012 The spec requires one dominant rendered-output action and keeps diagnostics/appendix detail secondary.
- [x] CHK013 Customer-safe default disclosure remains an explicit invariant across the rendered report and current owner surfaces.
## Testing And Validation
- [x] CHK014 Planned tests cover rendered-contract truth, disclosure, authorization, audit continuity, and one bounded browser smoke.
- [x] CHK015 Validation commands explicitly stay on current `EnvironmentReview`, `ReviewPack`, `Reviews`, and customer-review browser coverage rather than widening into unrelated suites.
- [x] CHK016 The artifacts name `pint --dirty` and `git diff --check` as final validation steps.
## Readiness Gate
- [x] CHK017 Candidate Selection Gate passes.
- [x] CHK018 Spec Readiness Gate passes.
- [x] CHK019 No blocking product question remains; the only implementation-time branch is whether the current repo can support PDF without a new package.
- [x] CHK020 No application implementation has been performed in this preparation step.
- [x] CHK021 Preparation analyze result: pass via repo-based artifact review checklist; no standalone local `speckit.analyze` command is available in this repo surface.
## Review Outcome
- [x] CHK022 Review outcome class: `acceptable-special-case`
- [x] CHK023 Workflow outcome: `keep`
- [x] CHK024 Final note location is the active feature PR close-out entry `Smoke Coverage`.
## Notes
- This checklist validates preparation readiness only. No application implementation, runtime test execution, or browser smoke has been performed in this prep step.
- The repository provides Bash helpers for feature creation but no local executable `speckit.tasks` or `speckit.analyze` command. Tasks and analysis were therefore produced repo-conformantly from the templates and checked manually here.
- The HTML-first / PDF-conditional boundary is intentional repo truth, not an unfinished spec hole.

View File

@ -0,0 +1,264 @@
# Implementation Plan: Review Pack PDF/HTML Renderer v1
**Branch**: `356-review-pack-pdf-html-renderer-v1` | **Date**: 2026-06-05 | **Spec**: `specs/356-review-pack-pdf-html-renderer-v1/spec.md`
**Input**: Feature specification from `specs/356-review-pack-pdf-html-renderer-v1/spec.md`
## Summary
Implement a rendered review-output follow-up that reuses the current review-derived `ReviewPack` contract to provide one calm HTML report, surfaces that report through the current released-review/review-pack seams, and keeps PDF strictly conditional on current repo-supported capabilities. The slice must preserve the current `ReviewPackGenerate` authority path, current signed download seam, current customer-safe disclosure contract, and current audit boundaries without introducing a second artifact family or a new dependency.
Spec 263 remains the bundle-contract baseline and must not be reopened. Spec 355 is the gating proof that the operator/productization flow is coherent enough for this delivery-format follow-up.
## Technical Context
**Language/Version**: PHP 8.4.15, Laravel 12.52, Filament 5.2.1, Livewire 4.1.4
**Primary Dependencies**: Filament admin panel, current `ReviewPackService`, `GenerateReviewPackJob`, `ReviewPackDownloadController`, current `EnvironmentReview` summary/composer truth, Blade views, Pest 4.3
**Storage**: PostgreSQL plus current `exports` disk; no schema change or new persistence family planned
**Testing**: Pest Feature tests plus one bounded Browser smoke
**Validation Lanes**: confidence, browser, `git diff --check`
**Target Platform**: Laravel monolith in `apps/platform`
**Project Type**: web application
**Performance Goals**: render from current stored review-pack/review truth only; no live provider calls; no new queue family; no wide per-request recomposition beyond one current pack/review
**Constraints**: no new package without approval, no new artifact family, no new `OperationRun`, no Graph calls during render, no customer portal, PDF conditional on current support only
**Scale/Scope**: one released review and one current review pack, surfaced through current admin/operator and customer-safe read-only paths
## UI / Surface Guardrail Plan
- **Guardrail scope**: new rendered delivery surface plus changed customer-safe and review-pack owner surfaces
- **Affected routes/pages/actions/states/navigation/panel/provider surfaces**:
- `CustomerReviewWorkspace`
- `ViewEnvironmentReview` in customer-workspace mode
- `ReviewPackResource` detail/download surface
- one new read-only rendered report route under `/admin/review-packs/{reviewPack}/...` for preview/print delivery
- **No-impact class, if applicable**: N/A
- **Native vs custom classification summary**: mixed; native Filament owner surfaces plus one bounded custom rendered report view
- **Shared-family relevance**: delivery-status messaging, review/report viewers, download actions, customer-safe disclosure
- **State layers in scope**: page, detail, URL/route
- **Audience modes in scope**: operator-MSP, customer-admin, customer-read-only, auditor-read-only where current review-pack access already allows it
- **Decision/diagnostic/raw hierarchy plan**: decision-first rendered report and owner-surface summary; appendix/raw ZIP detail stays secondary
- **Raw/support gating plan**: raw pack files, fingerprints, and technical context stay collapsed or secondary behind current authorized seams
- **One-primary-action / duplicate-truth control**: `CustomerReviewWorkspace` keeps `Open review` as the list inspect model; released-review detail gets one dominant rendered-output affordance; review-pack detail does not compete with the owner-surface summary
- **Handling modes by drift class or surface**: customer-safe overclaim and false PDF availability are hard-stop candidates; copy-only drift is review-mandatory
- **Repository-signal treatment**: review-mandatory for the new rendered route and for any changed customer-safe surface copy
- **Special surface test profiles**: shared-detail-family
- **Required tests or manual smoke**: focused Feature disclosure and authorization tests plus one bounded browser smoke through the current workspace/detail flow
- **Exception path and spread control**: if PDF support is absent without a package, record `document-in-feature` and keep HTML as the shipped floor instead of widening scope
- **Active feature PR close-out entry**: Smoke Coverage
- **UI/Productization coverage decision**: reachable UI changes require route inventory, design coverage, strategic-surface, unresolved-page, and page-report follow-through
- **Coverage artifacts to update**: `route-inventory.md`, `design-coverage-matrix.md`, `strategic-surfaces.md`, `unresolved-pages.md`, `page-reports/...`
- **No-impact rationale**: N/A
- **Navigation / Filament provider-panel handling**: no navigation or provider-panel change is planned
- **Screenshot or page-report need**: yes; the rendered report is a new reachable customer-facing surface
## Shared Pattern & System Fit
- **Cross-cutting feature marker**: yes
- **Systems touched**:
- `apps/platform/app/Services/ReviewPackService.php`
- `apps/platform/app/Jobs/GenerateReviewPackJob.php`
- `apps/platform/app/Http/Controllers/ReviewPackDownloadController.php`
- `apps/platform/routes/web.php`
- `apps/platform/app/Filament/Pages/Reviews/CustomerReviewWorkspace.php`
- `apps/platform/app/Filament/Resources/EnvironmentReviewResource/Pages/ViewEnvironmentReview.php`
- `apps/platform/app/Filament/Resources/ReviewPackResource.php`
- current localization files and current review summary/artifact-truth views
- **Shared abstractions reused**: current `ReviewPack` delivery contract, `ReviewPackOutputReadiness`, `ArtifactTruthPresenter`, current review summary and `EnvironmentReviewSection` truth, current export/dedupe/download seams
- **New abstraction introduced? why?**: none by default; any render helper should stay local to the renderer/view seam if needed to keep view logic thin
- **Why the existing abstraction was sufficient or insufficient**: current services and presenters already own truth and safe delivery semantics, but no current surface renders that truth as a calm human-readable report
- **Bounded deviation / spread control**: no second renderer stack, no second artifact family, no package-backed PDF engine
## OperationRun UX Impact
- **Touches OperationRun start/completion/link UX?**: yes, reuse-only
- **Central contract reused**: current `ReviewPackGenerate` path and current `OperationRunLinks` follow-up behavior
- **Delegated UX behaviors**: queued toast, already-available pack reuse, active-run dedupe, current operation link continuity, and current terminal notification behavior remain unchanged
- **Surface-owned behavior kept local**: preview/download of already-ready rendered output only
- **Queued DB-notification policy**: unchanged
- **Terminal notification path**: unchanged
- **Exception path**: none
## Provider Boundary & Portability Fit
- **Shared provider/platform boundary touched?**: no
- **Provider-owned seams**: N/A
- **Platform-core seams**: read-only report delivery over existing review-pack truth
- **Neutral platform terms / contracts preserved**: review pack, rendered report, evidence basis, accepted risk, governance decision, limitation state
- **Retained provider-specific semantics and why**: provider-specific appendix content may remain inside the structured appendix because it already exists in current stored truth; it does not become the primary rendered language
- **Bounded extraction or follow-up path**: none
## Constitution Check
- Inventory-first: no inventory truth changes; the renderer is a delivery view over current stored review/review-pack truth.
- Read/write separation: preview/download is read-only; current export initiation remains the only write/queue path.
- Graph contract path: no Graph calls.
- Deterministic capabilities: current capability and entitlement logic remains authoritative.
- RBAC-UX: non-members remain `404`; in-scope viewers stay on current review/review-pack permission paths.
- Workspace isolation: current workspace membership remains the first boundary.
- Tenant isolation: rendered access stays environment-scoped through current review-pack/review seams.
- Run observability: current `ReviewPackGenerate` remains the only `OperationRun`; no renderer-specific run is added.
- Automation: no new scheduled or queued work is added beyond the current export path.
- Data minimization: rendered output stays customer-safe by default and does not surface raw/debug detail.
- Test governance: Feature plus one bounded browser smoke is the narrowest honest proof.
- Proportionality: no new persistence, no new package, no second artifact family, and no new workflow engine.
- No premature abstraction: render helpers stay local; no generic reporting engine is allowed.
- Persisted truth: unchanged.
- Behavioral state: unchanged.
- UI semantics: direct domain-to-report mapping over current truth; no new status taxonomy.
- Shared pattern first: extend the current review/review-pack/report seams instead of adding a parallel delivery subsystem.
- Provider boundary: unchanged.
- V1 explicitness / few layers: HTML-first and current-contract-first.
- Spec discipline / bloat check: PDF stays conditional rather than forcing broad infrastructure.
- Filament-native UI: existing owner surfaces stay native; the custom report view remains a bounded read-only delivery surface.
## Test Governance Check
- **Test purpose / classification by changed surface**:
- Feature: rendered contract truth, authorization, audit continuity, action hierarchy, honest PDF handling
- Browser: open-path and customer-safe presentation smoke
- **Affected validation lanes**: confidence, browser
- **Why this lane mix is the narrowest sufficient proof**: the slice is a read-only delivery/report follow-up over existing runtime truth; broad PGSQL or heavy-governance lanes are unnecessary
- **Narrowest proving command(s)**:
- `cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/EnvironmentReview/EnvironmentReviewExecutivePackTest.php tests/Feature/EnvironmentReview/EnvironmentReviewExplanationSurfaceTest.php tests/Feature/EnvironmentReview/EnvironmentReviewUiContractTest.php tests/Feature/Reviews/CustomerReviewWorkspacePackAccessTest.php tests/Feature/ReviewPack/EnvironmentReviewDerivedReviewPackTest.php tests/Feature/ReviewPack/ReviewPackDownloadTest.php`
- `cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/ReviewPack/ReviewPackResourceTest.php`
- `cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Browser/Reviews/CustomerReviewWorkspaceSmokeTest.php`
- `git diff --check`
- **Fixture / helper / factory / seed / context cost risks**: existing released-review, review-pack, and entitlement fixtures are sufficient; avoid adding expensive default support/provider setup
- **Expensive defaults or shared helper growth introduced?**: none expected
- **Heavy-family additions, promotions, or visibility changes**: none beyond one explicit browser smoke path
- **Surface-class relief / special coverage rule**: shared-detail-family coverage is required because the rendered report must stay customer-safe and non-duplicative
- **Closing validation and reviewer handoff**: reviewers should verify HTML is the guaranteed floor, PDF is not overclaimed, and no second artifact family or new package appears
- **Budget / baseline / trend follow-up**: none expected
- **Review-stop questions**: false PDF claims, duplicate summary drift, authorization leaks, second artifact family drift
- **Escalation path**: `document-in-feature` if PDF remains unavailable without a new package
- **Active feature PR close-out entry**: Smoke Coverage
- **Why no dedicated follow-up spec is needed**: the HTML rendered-report slice is already the bounded follow-up; PDF-only hardening becomes a follow-up only if the repo cannot support it inside this scope
## Project Structure
### Documentation (this feature)
```text
specs/356-review-pack-pdf-html-renderer-v1/
|-- spec.md
|-- plan.md
|-- tasks.md
`-- checklists/
`-- requirements.md
```
### Source Code (repository root)
Likely runtime surfaces for later implementation:
```text
apps/platform/app/
|-- Services/
| `-- ReviewPackService.php
|-- Jobs/
| `-- GenerateReviewPackJob.php
|-- Http/Controllers/
| |-- ReviewPackDownloadController.php
| `-- ... rendered-report controller for the new read-only preview/print seam
|-- Filament/
| |-- Pages/Reviews/CustomerReviewWorkspace.php
| |-- Resources/EnvironmentReviewResource/Pages/ViewEnvironmentReview.php
| `-- Resources/ReviewPackResource.php
`-- Support/
`-- existing artifact-truth and review-output readiness helpers
apps/platform/resources/
|-- views/
| |-- filament/pages/reviews/customer-review-workspace.blade.php
| |-- filament/infolists/entries/environment-review-summary.blade.php
| |-- filament/infolists/entries/environment-review-section.blade.php
| |-- filament/infolists/entries/review-pack-output-guidance.blade.php
| `-- review-packs/... for the rendered-report preview/print view
`-- lang/
|-- en/localization.php
`-- de/localization.php
apps/platform/routes/
`-- web.php
```
Likely tests for later implementation:
```text
apps/platform/tests/
|-- Feature/EnvironmentReview/
| |-- EnvironmentReviewExecutivePackTest.php
| |-- EnvironmentReviewExplanationSurfaceTest.php
| `-- EnvironmentReviewUiContractTest.php
|-- Feature/ReviewPack/
| |-- EnvironmentReviewDerivedReviewPackTest.php
| |-- ReviewPackDownloadTest.php
| `-- ReviewPackResourceTest.php
|-- Feature/Reviews/
| `-- CustomerReviewWorkspacePackAccessTest.php
`-- Browser/Reviews/
`-- CustomerReviewWorkspaceSmokeTest.php
```
**Structure Decision**: stay inside the current review/review-pack seams. Add exactly one new read-only controller and rendered-report view under the existing review-pack route family so preview/print delivery stays separate from the current signed ZIP download seam.
## Complexity Tracking
| Violation | Why Needed | Simpler Alternative Rejected Because |
|---|---|---|
| One bounded rendered-report surface | The current ZIP plus Markdown entrypoint still feels internal to a stakeholder | Another Markdown-only adjustment would not remove unzip-first friction |
| Conditional PDF handling | The user value includes printable output, but the repo shows no clear PDF stack | Forcing a new PDF package or second renderer would overshoot current release truth |
## Proportionality Review
- **Current operator problem**: the current review pack is accurate but not immediately consumable as a calm stakeholder report.
- **Existing structure is insufficient because**: a ZIP plus Markdown entrypoint still requires explanation and creates productization friction.
- **Narrowest correct implementation**: one HTML report over current review-pack truth and PDF only when the repo can support it from the same contract.
- **Ownership cost created**: maintain a render view, owner-surface launch affordances, and focused customer-safe regression coverage.
- **Alternative intentionally rejected**: a new `AuditorPack` family, a new package-backed PDF engine, or a customer portal.
- **Release truth**: current-release productization follow-through.
## Technical Approach
1. Reconfirm the current review-derived delivery contract.
- Treat Spec 263's runtime as authoritative for `executive-summary.md`, delivery metadata, current review-pack anchoring, and current signed download continuity.
- Keep current review-output readiness truth from Specs 347, 349, 351, and 355.
2. Add one deterministic rendered-report contract over existing truth.
- Render from current stored review, `EnvironmentReviewSection`, and review-pack data only.
- Reuse current executive summary, section ordering/content semantics, evidence basis, readiness, accepted-risk, decision-summary, and non-certification disclosure truth instead of re-parsing archived ZIP contents as the primary source.
- Avoid live provider calls, new persistence, or a second composition engine.
3. Surface the rendered report through current owner seams.
- Keep `CustomerReviewWorkspace` as the first decision surface.
- Add one dominant rendered-output affordance on released-review detail and/or review-pack detail.
- Keep operator export initiation on the current `export_executive_pack` path.
- Keep report chrome outside the report canvas and make rendered/download labels readiness-aware.
- Add controlled repo-backed co-branding slots from existing workspace/environment names only.
- Keep limitations, PII/internal warnings, evidence basis, operation proof, disclosure, and generated metadata visible regardless of branding.
4. Keep printable delivery honest.
- If current repo support can produce PDF from the same rendered contract, allow it without a new package or second renderer.
- If not, keep the slice HTML-first and record the bounded PDF follow-up rather than widening scope.
- Hide toolbar/app actions from print output so the printed report stands alone.
5. Preserve audit and entitlement continuity.
- Reuse current review-pack view/download permissions and current audit events.
- Do not add a new panel, global search surface, asset strategy, package, or queue family.
- Render only from stored review/review-pack/evidence data; no live provider, Graph, operation, refresh, or AI calls during render.
## Implementation Phases
1. Lock the current contract and dependency truth, including current PDF-support reality.
2. Add failing tests for rendered HTML disclosure, authorization, and audit continuity.
3. Implement the bounded HTML report and owner-surface launch affordances.
4. Implement honest PDF handling from the same contract only if current repo support allows it.
5. Re-run focused Feature and Browser proof plus `git diff --check`.
## Rollout And Deployment Impact
- No migrations planned.
- No new env vars planned.
- No new queue family or scheduler change planned.
- No new storage volume or persistence family planned.
- No new Filament asset strategy is planned; `filament:assets` should remain unchanged unless implementation later proves otherwise.
- If implementation stays HTML-first because no PDF support exists, deployment impact remains route/view-only.

View File

@ -0,0 +1,369 @@
# Feature Specification: Review Pack PDF/HTML Renderer v1
**Feature Branch**: `356-review-pack-pdf-html-renderer-v1`
**Created**: 2026-06-05
**Status**: Implemented - close-ready after validation
**Type**: Productization / rendered review output / customer-safe report delivery
**Depends on**: Specs 263, 347, 349, 351, 355
**Runtime posture**: Reuse the current review-derived `ReviewPack` delivery contract. HTML rendering is required. PDF is allowed only if the repo already supports it from the same contract without adding a new package, a new `OperationRun`, or a second artifact family.
**Input**: Direct user-provided Spec 356 draft plus repo truth from the current review-pack export surfaces and Spec 355's documented follow-up queue.
## Dependencies And Repo-Truth Adjustments
- Spec 263 already introduced the current executive entrypoint (`executive-summary.md`) and delivery metadata inside the current review-derived pack. This spec must not reopen or duplicate that bundle-contract work.
- Current customer-safe detail surfaces already direct the reader to start with `executive-summary.md`, and the current review-derived pack is tenant-safe, auditable, and backed by the current signed-download seam.
- Current repo truth before this slice required a ZIP download and Markdown/JSON inspection. This branch adds the calm rendered HTML/print report while preserving the existing ZIP artifact.
- Spec 355 explicitly deferred this renderer until the operator/productization flow was browser-verified as coherent. Spec 355 is now commit-backed on `platform-dev`, so that prerequisite gate is satisfied.
- No dedicated repo-supported report PDF stack is present in the current application dependencies. PDF therefore remains deferred for this slice; the implementation stays HTML-first with browser print support and does not add a package or second rendering subsystem.
## Spec Candidate Check *(mandatory — SPEC-GATE-001)*
- **Problem**: TenantPilot can now produce customer-safe review packs and an executive-first Markdown entrypoint, but the externally deliverable output still feels like an internal ZIP artifact rather than a calm report a customer, executive, or demo stakeholder can open immediately.
- **Today's failure**: Even after Spec 263 and Spec 355, an operator still has to explain that the stakeholder should download a ZIP, open `executive-summary.md`, and ignore the structured JSON appendix unless deeper inspection is needed. That adds friction and weakens the product's demo- and handoff-quality story.
- **User-visible improvement**: An entitled user can open one rendered report from the current released review/current pack context, read the executive story first, understand evidence basis and limitations, and download a printable version when the repo can support it honestly.
- **Smallest enterprise-capable version**: Keep one released-review-bound `ReviewPack` artifact and the current export/download authority model. Add one deterministic HTML report over the existing contract, surface it through the current review/customer-workspace seams, and allow PDF only when it comes from the same render contract without new dependencies.
- **Explicit non-goals**: No new `AuditorPack` model or table, no new customer portal, no public share links, no email delivery, no AI summary generation, no branding or white-label system, no batch multi-review export, no second report engine, and no new review publication workflow.
- **Permanent complexity imported**: One bounded render layer, one or more read-only preview/download surfaces, localized copy updates, and focused Feature/Browser coverage. No new persistence family, no new queue family, no new capability family, and no second artifact taxonomy are allowed.
- **Why now**: Spec 355 explicitly named this as the next narrow follow-up once sellable-flow coherence was proven. The remaining gap is not another operator workflow foundation; it is the presentable customer/demo artifact.
- **Why not local**: A copy-only change or another Markdown file does not remove the ZIP-first friction. A customer portal or generic reporting engine overshoots the current repo truth.
- **Approval class**: Core Enterprise
- **Red flags triggered**: New rendered delivery surface and a conditional PDF branch. Defense: the slice stays derived from the current `ReviewPack` truth, forbids a new package or artifact family, and keeps PDF honest instead of forcing infrastructure.
- **Score**: Nutzen: 2 | Dringlichkeit: 2 | Scope: 2 | Komplexitaet: 1 | Produktnaehe: 2 | Wiederverwendung: 1 | **Gesamt: 10/12**
- **Decision**: approve
## Candidate Source And Completed-Spec Guardrail
- **Candidate source**:
- direct user-provided Spec 356 draft
- explicit deferred follow-up in `specs/355-platform-sellable-smoke-matrix/spec.md`
- current review-pack runtime truth in `ReviewPackService`, `GenerateReviewPackJob`, customer-review surfaces, and their tests
- **Completed-spec guardrail result**:
- `specs/263-auditor-pack-executive-export/` is treated as completed historical/runtime context because its task checklist is fully checked and its runtime/test traces are present; it must not be rewritten.
- `specs/347-*`, `349-*`, `351-*`, and `355-*` are dependency and proof context only and must not be normalized back into preparation wording.
- no `specs/1000-*` package existed before this Spec 356 run.
- **Close alternatives deferred**:
- `customer-review-workspace-v1-completion`: broader workspace productization lane; this renderer is the smaller output-first slice.
- `localization-v1-customer-facing-surfaces`: important follow-through, but it should not hide the output-format gap.
- `customer-portal-boundary-contract`: premature before the current output artifact is rendered and trustworthy.
- `decision-based-governance-inbox-v1`: separate operator-workbench lane, not delivery-format work.
- **Smallest viable implementation slice**: one current-review-bound HTML report plus truthful preview/download affordances on existing review/report surfaces; PDF only when the same render contract can produce it without new infrastructure.
## Spec Scope Fields *(mandatory)*
- **Scope**: canonical-view
- **Primary Routes**:
- existing workspace customer review registry at `/admin/reviews/workspace`
- existing environment review detail route under `EnvironmentReviewResource`
- existing review-pack detail route under `ReviewPackResource`
- existing signed review-pack download route `/admin/review-packs/{reviewPack}/download`
- one new read-only rendered-report route under the current `/admin/review-packs/{reviewPack}/...` family for preview/print delivery, while the current signed route remains ZIP-download-only
- **Data Ownership**:
- `EnvironmentReview`, `EnvironmentReviewSection`, `ReviewPack`, `EvidenceSnapshot`, `StoredReport`, `Finding`, `FindingException`, and `AuditLog` remain the only persisted truth used by this slice
- rendered HTML and any optional PDF remain derived from current review-pack/review truth; if implementation requires a new table, a new stored artifact family, or new DB columns, the scope must stop and split
- **RBAC**:
- workspace membership remains the first isolation boundary and stays `404` for non-members or out-of-scope environment/review targets
- current operator export initiation on published review detail continues to require the existing review-manage capability path used by `export_executive_pack`
- current in-scope review/review-pack view permissions remain authoritative for rendered preview/download
- this slice must not invent a new capability family for rendered delivery
For canonical-view specs, the spec MUST define:
- **Default filter behavior when tenant-context is active**: `CustomerReviewWorkspace` keeps the current managed-environment prefilter launch behavior. The rendered report remains anchored to the selected released review/current pack rather than any hidden shell/session state.
- **Explicit entitlement checks preventing cross-tenant leakage**: rendered preview/download is available only for entitled workspace and managed-environment scope through the current review/review-pack seams; inaccessible targets are omitted from aggregate lists and direct targeting resolves as not found.
## UI Surface Impact *(mandatory — UI-COV-001)*
Does this spec add, remove, rename, or materially change any reachable UI surface?
- [ ] No UI surface impact
- [x] Existing page changed
- [x] New page/route added
- [ ] Navigation changed
- [ ] Filament panel/provider surface changed
- [x] New modal/drawer/wizard/action added
- [ ] New table/form/state added
- [x] Customer-facing surface changed
- [ ] Dangerous action changed
- [x] Status/evidence/review presentation changed
- [ ] Workspace/environment context presentation changed
## UI/Productization Coverage *(mandatory when UI Surface Impact is not "No UI surface impact"; otherwise write `N/A - no reachable UI surface impact` plus rationale)*
- **Route/page/surface**:
- `CustomerReviewWorkspace` governance-package area
- `ViewEnvironmentReview` in customer-workspace mode
- `ReviewPackResource` detail/download surface
- one new read-only rendered report preview/print route under `/admin/review-packs/{reviewPack}/...`
- **Current or new page archetype**: existing customer-safe detail/report surfaces plus one rendered-report viewer route
- **Design depth**: Strategic Surface for customer-review workspace follow-through, released-review detail, review-pack detail, and rendered report preview
- **Repo-truth level**: repo-verified existing surfaces plus one new spec-backed rendered-report route in the current review-pack family
- **Existing pattern reused**: current governance-package truth, `ArtifactTruthPresenter`, current review-pack delivery contract, current customer-safe detail disclosure
- **New pattern required**: one bounded report-view pattern over the existing review-pack truth; no portal, dashboard, or second delivery family
- **Screenshot required**: yes; the rendered report and its launch path need browser-proof screenshots during implementation
- **Page audit required**: yes; the rendered report is a new reachable customer-facing surface
- **Customer-safe review required**: yes; this slice exists to improve customer/demo delivery without leaking internal detail
- **Dangerous-action review required**: no; the slice adds read-only delivery surfaces only
- **Coverage files updated or explicitly not needed**:
- [x] `docs/ui-ux-enterprise-audit/route-inventory.md`
- [x] `docs/ui-ux-enterprise-audit/design-coverage-matrix.md`
- [x] `docs/ui-ux-enterprise-audit/page-reports/...`
- [x] `docs/ui-ux-enterprise-audit/strategic-surfaces.md`
- [ ] `docs/ui-ux-enterprise-audit/grouped-follow-up-candidates.md`
- [x] `docs/ui-ux-enterprise-audit/unresolved-pages.md`
- [ ] `N/A - no reachable UI surface impact`
- **No-impact rationale when applicable**: N/A
- **Audit follow-through note**: implementation must update the Review Pack detail/page coverage so the current `UI-042` unresolved ledger entry no longer conflicts with the changed review-pack/report surfaces.
## 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)**: download actions, preview/report viewers, delivery-status messaging, customer-safe disclosure, review-output guidance
- **Systems touched**: `CustomerReviewWorkspace`, `EnvironmentReviewResource`, `ViewEnvironmentReview`, `ReviewPackResource`, `ReviewPackService`, `GenerateReviewPackJob`, `ReviewPackDownloadController`, localization files, and the current review-pack/report truth presenters
- **Existing pattern(s) to extend**: current `ReviewPack` delivery contract, current `export_executive_pack` path, current signed download route, current customer-safe released-review detail block
- **Shared contract / presenter / builder / renderer to reuse**: `ReviewPackService`, `GenerateReviewPackJob`, `ReviewPackOutputReadiness`, `ArtifactTruthPresenter`, `EnvironmentReview` summary truth, current review-pack authorization/download seams
- **Why the existing shared path is sufficient or insufficient**: the existing path is sufficient for review anchoring, entitlement, export dedupe, audit continuity, and executive-summary truth. It is insufficient only because the current externally consumable output is still ZIP-first and Markdown-first.
- **Allowed deviation and why**: none. The slice must extend the current `ReviewPack` family and current review/report seams instead of creating a new report subsystem.
- **Consistency impact**: rendered copy, package-readiness wording, evidence-basis language, non-certification disclosure, and dominant next-action semantics must stay aligned across workspace rows, released-review detail, review-pack detail, and the rendered report.
- **Review focus**: reviewers must block any second artifact family, any live provider-data render path, any false PDF claim, or any duplicated summary language that competes with the current owner surfaces.
## OperationRun UX Impact *(mandatory when the feature creates, queues, deduplicates, resumes, blocks, completes, or deep-links to an `OperationRun`; otherwise write `N/A - no OperationRun start or link semantics touched`)*
- **Touches OperationRun start/completion/link UX?**: yes, reuse-only
- **Shared OperationRun UX contract/layer reused**: the existing `ReviewPackGenerate` start and completion flow reused by `export_executive_pack` remains the only run path
- **Delegated start/completion UX behaviors**: queued toast, already-available pack reuse, active-run dedupe messaging, current operation link continuity, and current terminal notification behavior remain on the shared review-pack export path
- **Local surface-owned behavior that remains**: preview/download of already-ready rendered output only; the renderer must not invent its own queued/run semantics
- **Queued DB-notification policy**: unchanged from the current review-pack export contract
- **Terminal notification path**: unchanged; the current `OperationRunCompleted` path remains authoritative
- **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. Provider-specific appendix content remains secondary and does not become the primary rendered language.
## 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 governance-package area | yes | Native Filament page plus shared review-package primitives | delivery status, launch actions, customer-safe disclosure | page, URL | no | Existing row-level `Open review` remains the list inspect model |
| Released review detail in customer-workspace mode | yes | Native Filament detail surface plus shared summary/artifact-truth primitives | package meaning, evidence basis, rendered output action hierarchy | detail, URL | no | One rendered-output action must not compete with diagnostics or proof links |
| Review Pack detail and rendered report preview/print surface | yes | Mixed native detail plus bounded custom report surface | evidence/report viewer, read-only output delivery | detail, route | no | The rendered report is read-only and derived from the current pack |
| Published review detail in operator mode | yes | Native Filament detail surface | current pack reuse, export/read-only follow-through | detail | no | Operator export remains current source-owned initiation path |
## 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 governance-package area | Primary Decision Surface | Decide whether the current released review is ready for stakeholder consumption | release/readiness state, limitations, dominant next step | deeper review detail and rendered report after explicit open | Primary because it remains the calm workspace handoff surface | stays on the existing review-consumption workflow | removes the need to explain ZIP mechanics first |
| Released review detail in customer-workspace mode | Secondary Context | Confirm what the rendered report will say and whether it is safe to open/download | executive-ready summary, evidence basis, current readiness, dominant output action | appendix detail, proof links, deeper governance sections | Secondary because the workspace owns the first selection decision | keeps delivery anchored to one released review | avoids duplicate workspace- and detail-level summaries |
| Review Pack detail and rendered report preview | Secondary Context | Inspect or download the current artifact in a presentable format | rendered report, limitation disclosure, appendix relationship | raw ZIP contents and technical metadata stay secondary | Secondary because the report is a delivery artifact, not the primary decision queue | stays on the current review-pack/report flow | removes unzip-plus-Markdown explanation work |
| Published review detail in operator mode | Secondary Context | Prepare or reuse the current pack before external handoff | export readiness and pack reuse status | run detail and review-pack detail after follow-up | Secondary because it is operator-only preparation, not customer-safe consumption | preserves current operator initiation flow | avoids adding a second operator export path |
## 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 | operator-MSP, customer-admin, customer-read-only | release state, package readiness, rendered-report availability, next step | none beyond current review state | raw pack files, fingerprints, internal reasoning | `Open review` | raw/support detail stays off the list surface | workspace rows say whether the handoff is ready; detail owns the explanation |
| Released review detail in customer-workspace mode | operator-MSP, customer-admin, customer-read-only | rendered summary, evidence basis, limitation state, one output action | review lineage and deeper governance detail in secondary sections | raw payloads, fingerprints, platform reason family, internal reason ownership | `Open rendered report` or equivalent safe output action | appendix/raw detail stays secondary | the report intent is stated once and later sections add evidence |
| Review Pack detail and rendered report | operator-MSP, customer-admin, auditor-read-only | rendered story first, appendix relationship second, truthful availability | technical metadata and ZIP details remain secondary | raw JSON files, fingerprints, support-only interpretation | `Open rendered report` or `Download rendered report` | raw bundle detail remains lower priority | the rendered report explains the human-readable path once; ZIP metadata stays supportive only |
| Published review detail in operator mode | operator-MSP | export readiness, reuse truth, current pack availability | run detail and pack metadata after explicit follow-up | raw appendix files remain on the pack/resource seam | `Export executive pack` | customer-safe rendered copy stays off blocked/draft operator states | operator initiation does not duplicate customer-facing delivery text |
## 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 governance-package area | Dashboard / Workbench | Customer-safe workspace review hub | Open the released review that is ready for delivery | existing review-open inspect path | existing | supporting output actions stay off the list row | none | `/admin/reviews/workspace` | existing review detail route | workspace plus optional managed-environment filter | Customer review | whether the current released review is ready for a rendered handoff | none |
| Released review detail in customer-workspace mode | Detail / Report | Read-only detail report | Open the rendered report | sectioned detail with one dominant safe action | forbidden | appendix/proof links remain in-body secondary | none | `/admin/reviews/workspace` | existing review detail route | workspace, managed environment, released review | Governance package | what the rendered handoff means and whether it is available | none |
| Review Pack detail and rendered report | Detail / Report / Export Viewer | Read-only artifact viewer | Inspect or download the presentable output | detail then explicit report open | existing detail row click on pack list stays current | ZIP diagnostics and raw metadata stay secondary | none | current review-pack collection route | current review-pack detail and render route | workspace, managed environment, artifact state | Review pack / rendered report | rendered output readiness and appendix relationship | none |
| Published review detail in operator mode | Detail / Report / Export initiation | Operator export surface | Generate or reuse the current pack | existing detail/open model | existing | pack detail and run links remain secondary | none | existing review collection route | existing review detail route | managed environment, review status, pack status | Executive pack export | whether the current output can be prepared or reused now | 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 | MSP operator | Decide whether a released review is ready to hand over | Read-only workspace report | Is there a presentable current review I can share now? | release state, rendered-output availability, next step | none beyond secondary detail | release readiness, evidence sufficiency, package availability | none | Open review | none |
| Released review detail in customer-workspace mode | MSP operator or entitled customer reader | Understand and open the presentable report | Read-only detail report | What will the stakeholder read first, and what are the limitations? | executive summary, evidence basis, limitations, one output action | deeper governance detail, lineage, proof links | delivery readiness, evidence sufficiency, review status | none | Open rendered report / Download rendered report | none |
| Review Pack detail and rendered report | MSP operator or entitled reviewer | Inspect the current artifact in human-readable form | Read-only artifact viewer | Can I open a calm report from the current pack without unpacking it? | rendered report, appendix explanation, availability | ZIP metadata, fingerprints, technical context | artifact readiness, delivery status | none | Open rendered report | none |
| Published review detail in operator mode | MSP operator | Prepare or reuse the current pack | Export-initiation detail surface | Can I prepare the presentable output now, or is a current pack already available? | review status, reuse truth, pack availability | run detail and pack metadata | review status, pack availability | current `ReviewPackGenerate` only | Export executive pack | none |
## Proportionality Review *(mandatory when structural complexity is introduced)*
- **New source of truth?**: no
- **New persisted entity/table/artifact?**: no new persistence family; current `ReviewPack` remains the source artifact
- **New abstraction?**: no new cross-domain abstraction by default; any helper must stay local to current review-pack/report rendering
- **New enum/state/reason family?**: no
- **New cross-domain UI framework/taxonomy?**: no
- **Current operator problem**: the current output still requires ZIP-first explanation, which weakens customer/demo consumption even though the underlying review truth is already repo-real.
- **Existing structure is insufficient because**: the current Markdown entrypoint and JSON appendix are accurate but not directly consumable as a calm rendered report.
- **Narrowest correct implementation**: render one HTML report from current review-pack/review truth and only allow PDF when the same contract can produce it without new infrastructure.
- **Ownership cost**: maintain one bounded report view, one or more read-only routes/actions, and focused customer-safe regression coverage.
- **Alternative intentionally rejected**: a customer portal, a second report engine, or a new PDF dependency were rejected as broader than current-release truth requires.
- **Release truth**: current-release sellability follow-through, not future delivery automation.
### Compatibility posture
This feature assumes a pre-production environment.
Backward compatibility, legacy aliases, migration shims, historical fixture support, and compatibility-specific tests are out of scope unless explicitly required by this spec.
Current review-pack extension is preferred over a new delivery artifact family.
## Testing / Lane / Runtime Impact *(mandatory for runtime behavior changes)*
- **Test purpose / classification**: Feature, Browser
- **Validation lane(s)**: confidence, browser, `git diff --check`
- **Why this classification and these lanes are sufficient**: focused Feature tests can prove rendered contract truth, authorization, audit continuity, and honest PDF handling. One bounded browser smoke is justified because the rendered report is a customer-facing delivery surface.
- **New or expanded test families**: extend existing `apps/platform/tests/Feature/EnvironmentReview/`, `apps/platform/tests/Feature/ReviewPack/` including `ReviewPackResourceTest.php`, `apps/platform/tests/Feature/Reviews/`, and current customer-review browser smoke coverage
- **Fixture / helper cost impact**: low to moderate; reuse current released-review, review-pack, evidence-snapshot, and entitlement fixtures. No provider-sync, no new queue family, and no heavy-governance lane are needed.
- **Heavy-family visibility / justification**: none beyond one explicit browser smoke or an existing browser family extension
- **Special surface test profile**: shared-detail-family
- **Standard-native relief or required special coverage**: customer-workspace detail plus rendered report need explicit disclosure and action-hierarchy coverage; ordinary review-pack CRUD smoke is not sufficient
- **Reviewer handoff**: reviewers must confirm HTML is the mandatory floor, PDF is not falsely claimed, the renderer stays on the current `ReviewPack` family, and no second artifact family or new package appears
- **Budget / baseline / trend impact**: low feature-local increase only
- **Escalation needed**: `document-in-feature` if the repo cannot support PDF without a package and the slice lands HTML-only
- **Active feature PR close-out entry**: 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/EnvironmentReview/EnvironmentReviewExecutivePackTest.php tests/Feature/EnvironmentReview/EnvironmentReviewExplanationSurfaceTest.php tests/Feature/EnvironmentReview/EnvironmentReviewUiContractTest.php tests/Feature/Reviews/CustomerReviewWorkspacePackAccessTest.php tests/Feature/ReviewPack/EnvironmentReviewDerivedReviewPackTest.php tests/Feature/ReviewPack/ReviewPackDownloadTest.php tests/Feature/ReviewPack/ReviewPackResourceTest.php`
- `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Browser/Reviews/CustomerReviewWorkspaceSmokeTest.php`
## User Scenarios & Testing *(mandatory)*
### User Story 1 - Open A Calm Rendered Review Report (Priority: P1)
As an entitled operator or customer-safe reviewer, I want to open one calm rendered report from the current released review/current pack context so I do not have to unzip a package and explain Markdown/JSON files first.
**Why this priority**: This is the core productization gap. Without a rendered report, the output still feels like an internal artifact.
**Independent Test**: From the current customer-review/review-pack flow, open the rendered report and verify that executive story, evidence basis, limitations, findings, accepted risks, and non-certification disclosure are visible without raw diagnostics by default.
**Acceptance Scenarios**:
1. **Given** a released review with a ready current pack, **When** an entitled user opens the rendered output, **Then** the report shows the executive story, evidence basis, limitations, key findings, accepted risks, and appendix relationship from current stored truth.
2. **Given** a current pack whose output readiness is `partial`, `blocked`, or `expired`, **When** the user opens the owner surfaces, **Then** the product explains the limitation truthfully and does not overclaim a customer-ready rendered report.
---
### User Story 2 - Keep Printable Delivery Honest And Bounded (Priority: P1)
As an MSP operator, I want the same rendered contract to support a printable handoff path when the repo can do so honestly, but I do not want the product to promise PDF when current dependencies cannot support it safely.
**Why this priority**: A printable artifact is part of the draft's user value, but false PDF claims or a second rendering engine would create more debt than value.
**Independent Test**: Verify that the current review/review-pack flow exposes one rendered output contract, that HTML is always available, and that PDF is either supported from the same contract or truthfully unavailable without a dependency addition.
**Acceptance Scenarios**:
1. **Given** the repo can render PDF from the same contract without a new package, **When** an entitled user requests a printable version, **Then** the product serves PDF from the same underlying rendered report and does not introduce a second artifact family.
2. **Given** the repo cannot render PDF without adding a package, **When** an entitled user uses the output surfaces, **Then** the product still serves the HTML report and does not label PDF as available.
---
### User Story 3 - Keep Delivery Tenant-Safe, Auditable, And Derived (Priority: P2)
As a platform owner, I want rendered delivery to stay on the current entitlement, audit, and review-pack truth seams so the presentable report does not become a second uncontrolled export surface.
**Why this priority**: The sellability gain matters only if it stays on the current authorization and audit model.
**Independent Test**: Verify non-members receive `404`, in-scope viewers stay on the current read-only permission paths, and export/download/render actions keep the current audit and `OperationRun` boundaries.
**Acceptance Scenarios**:
1. **Given** a non-member or wrong-environment target, **When** they try to open the rendered report or current pack route, **Then** the response remains deny-as-not-found.
2. **Given** an entitled viewer, **When** they open the rendered report, **Then** the content is derived from existing review-pack/review truth and does not trigger live provider calls or a new queue/run path.
## Edge Cases
- A released review exists but no ready current pack exists yet: the owner surfaces stay truthful about unavailability and do not fabricate a rendered handoff action.
- The current pack is expired: the owner surfaces show `expired`, and the user must rely on the current operator export path rather than a stale rendered link.
- Evidence is partial or stale: the rendered report must carry the limitation state explicitly instead of reading as customer-safe ready.
- Workspace lifecycle blocks new pack generation but an existing ready pack remains readable: export stays blocked while current read-only access remains governed by existing entitlement rules.
- PDF support is absent in current repo dependencies: the feature must still ship HTML cleanly or stop at the documented HTML-only boundary without adding a package.
## Requirements *(mandatory)*
**Constitution alignment:** This feature reuses the current `ReviewPackGenerate` `OperationRun` path and current review-pack authorization/download seams. It must not introduce a second run type, a second artifact family, or live provider calls during render.
**Constitution alignment (PROP-001 / PERSIST-001 / BLOAT-001):** The rendered output must remain derived from current review/review-pack truth. If implementation requires a new table, a second persisted artifact family, or a new dependency-backed rendering engine, the scope fails and must split.
**Constitution alignment (XCUT-001):** Delivery status messaging, rendered report actions, and customer-safe disclosure must extend the current review/review-pack/report seams rather than introducing a parallel local UX language.
### Functional Requirements
- **FR-356-001**: The current review-derived `ReviewPack` remains the source artifact for this slice; no second artifact family may be introduced.
- **FR-356-002**: Operator-side export initiation for published reviews must continue to reuse the current `export_executive_pack` action and current `ReviewPackGenerate` `OperationRun` semantics.
- **FR-356-003**: The renderer must consume the current review-derived runtime truth exposed by `EnvironmentReview`, `EnvironmentReviewSection`, `ReviewPack`, current readiness semantics, and current governance-package summary truth. It must stay semantically consistent with the existing ZIP contract, including the current `executive-summary.md` and section ordering/content, without requiring archive re-parsing as the primary runtime source.
- **FR-356-004**: V1 must provide one rendered HTML report suitable for customer-safe and demo-ready consumption without requiring JSON inspection by default.
- **FR-356-005**: The HTML report must present executive story, evidence basis, limitation state, key findings, accepted risks, governance decisions requiring awareness, next actions, and explicit non-certification disclosure.
- **FR-356-006**: The rendered report must not expose raw provider payloads, fingerprints, internal reason ownership, platform reason families, or operator-only diagnostics by default.
- **FR-356-007**: The rendered output must remain anchored to one released review and one current review pack. No batch, portfolio, or multi-review delivery is allowed in v1.
- **FR-356-008**: `CustomerReviewWorkspace`, released-review detail, review-pack detail, and any render/download actions must keep readiness states truthful and must not imply rendered or PDF readiness when the current truth does not support it.
- **FR-356-009**: PDF is allowed only if the repo can generate it from the same rendered contract without a new package, a second render subsystem, or a second artifact family. Otherwise HTML remains the v1 floor and the product must stay honest about PDF unavailability.
- **FR-356-010**: Existing export/download audit events and metadata must be reused or minimally extended. A new renderer-specific audit family is out of scope.
- **FR-356-011**: This slice must not add a new panel, a new global-search surface, or a new Filament asset strategy.
- **FR-356-012**: This slice must not require live provider calls or Graph calls during render.
- **FR-356-013**: Report chrome and app actions must render outside the report canvas and must be hidden from print output so printed reports contain no operator/admin controls.
- **FR-356-014**: The rendered report must show a readiness-aware hero state at the top. Customer-safe labels may appear only when the stored review/pack readiness supports them; limited, internal, PII, blocked, or not-ready outputs must show an external-sharing warning.
- **FR-356-015**: The report must include a management-readable Executive Summary that explains overall state, reason, impact, recommended next action, and top limitations without making raw state keys the dominant copy.
- **FR-356-016**: Output limitations and evidence-basis copy must be human-readable, early in the report, and honest about shareability. Technical state fields may appear only in the supporting appendix.
- **FR-356-017**: Report localization must not leak raw `localization.*` keys in EN or DE, including the non-certification disclosure.
- **FR-356-018**: Empty or zero-heavy sections must collapse to compact empty-state copy instead of large KPI/card grids.
- **FR-356-019**: The appendix must appear after the management/readiness/risk/evidence sections, be clearly marked as supporting/auditor context, and avoid raw JSON dump presentation.
- **FR-356-020**: Accepted-risk content must use customer-safe summaries when available, must not expose internal rationale as customer-safe copy, and must honestly show expired/expiring/incomplete state without legal or approval claims.
- **FR-356-021**: Controlled MSP co-branding is limited to repo-backed existing workspace/environment names plus TenantPilot generated-by copy. No branding settings, upload UI, theme engine, or new persistence may be added.
- **FR-356-022**: Report/download labels must be readiness-aware and must not use forbidden terms such as customer-ready, certified, approved compliance report, or share-with-customer for limited/internal/blocked output.
- **FR-356-023**: The existing ZIP review-pack download/export contract must continue to work unchanged alongside the rendered HTML/print report.
## Scope Boundaries
### In Scope
- one rendered HTML report over the current review-derived `ReviewPack` contract
- truthful preview/download affordances on the current review/customer-workspace/report seams
- honest PDF handling from the same contract when current repo support exists
- localized copy required to explain rendered output, appendix relationship, and PDF availability truthfully
- focused authorization, audit, disclosure, and browser smoke follow-through for the rendered report
- bounded productization of report presentation, readiness copy, print chrome, localization, compact empty sections, supporting appendix, accepted-risk display, evidence-basis explanation, and repo-backed MSP co-branding slots
### Out Of Scope
- a customer portal
- a standalone `AuditorPack` or `RenderedReport` persistence family
- a new PDF dependency or rendering package
- email delivery, scheduled delivery, public sharing, or branding/white-label systems
- multi-review or multi-tenant bundles
- a second review composition engine
- AI-generated report narratives
- a branding admin UI, logo upload, accent/theme engine, customer-specific templates, public links, customer accounts, or customer portal
## Success Criteria
- Entitled users can open one rendered HTML report from current released-review/current-pack context without unzipping JSON first.
- The rendered output stays customer-safe, limitation-aware, and derived from current stored truth.
- PDF is either served from the same contract without new infrastructure or explicitly and honestly unavailable.
- No second artifact family, new package, new queue family, or live provider render path is introduced.
## Risks
- **Risk 1 - PDF support gap**: current repo dependencies do not provide an approved native report PDF stack for this slice. Mitigation: HTML and browser print are mandatory; native PDF remains a documented follow-up behind the hard no-new-package boundary.
- **Risk 2 - Scope creep into portal/reporting engine work**: presentable output can tempt broader document-delivery ambitions. Mitigation: stay on the current review-pack family and existing routes/surfaces only.
- **Risk 3 - Duplicate truth drift**: rendered copy could diverge from current review detail or executive-summary truth. Mitigation: keep one render contract sourced from current stored review-pack/review data.
- **Risk 4 - Customer-safe overclaim**: a rendered report can look more final than the underlying evidence state deserves. Mitigation: render limitation state and non-certification disclosure explicitly.
## Follow-Up Candidates
- PDF-only delivery hardening if HTML lands and the repo later gains an approved PDF stack
- richer customer-facing localization adoption over rendered delivery surfaces
- customer portal boundary work only after current rendered delivery is proven
## Assumptions
- Spec 355's browser-verified readiness gate is sufficient proof that the current review/output flow is coherent enough for a rendered delivery follow-up.
- Current `ReviewPack` and `EnvironmentReview` summary truth is rich enough to drive an HTML report without live provider calls.
- Implementation and productization changes are included in this branch; close readiness is based on completed validation, screenshots, and reviewer acceptance rather than additional preparation-only work.
## Open Questions
No blocking implementation questions remain.
Runtime constraint result:
- the current repo does not provide an approved native report PDF stack from the same render contract without a new dependency; the slice remains HTML-first with browser print and records native PDF as a bounded follow-up instead of widening scope

View File

@ -0,0 +1,159 @@
---
description: "Task list for Review Pack PDF/HTML Renderer v1"
---
# Tasks: Review Pack PDF/HTML Renderer v1
**Input**: Design documents from `specs/356-review-pack-pdf-html-renderer-v1/`
**Prerequisites**: `spec.md`, `plan.md`, and `checklists/requirements.md`
**Tests**: REQUIRED (Pest). Keep proof bounded to existing `Feature` families around `EnvironmentReview`, `ReviewPack` including `ReviewPackResourceTest.php`, and `Reviews`, plus one bounded browser smoke over the current customer-review workspace handoff path.
**Operations**: Reuse the existing `ReviewPackGenerate` `OperationRun` path only. Preview/download of rendered output remains read-only. No new run type, queue family, or renderer-specific lifecycle is allowed.
**RBAC**: Workspace/environment non-members remain `404`; current in-scope review/review-pack view denials remain `403` where the existing contract already does so. No new capability family may be introduced.
**Shared Pattern Reuse**: Reuse `ReviewPackService`, `GenerateReviewPackJob`, `ReviewPackDownloadController`, `CustomerReviewWorkspace`, `ViewEnvironmentReview`, `ReviewPackResource`, current artifact-truth/report disclosure, and current localization files. Do not create a second artifact family or a second report engine.
**Filament / Panel Guardrails**: Filament remains v5 on Livewire v4. Provider registration remains unchanged in `apps/platform/bootstrap/providers.php`. No new panel, no new global-search surface, and no new asset strategy are allowed.
**Organization**: Tasks are grouped by user story so the rendered-report contract, the printable-delivery boundary, and the authorization/audit boundaries stay independently implementable and testable.
## Test Governance Checklist
- [x] Lane assignment stays `confidence` plus one explicit `browser` smoke and remains the narrowest sufficient proof.
- [x] New or changed tests stay in the smallest honest family, and any browser addition beyond one bounded smoke is explicit.
- [x] Shared helpers, factories, seeds, and context defaults stay cheap by default.
- [x] Planned validation commands cover the slice without pulling unrelated lane cost.
- [x] The affected surfaces remain the current review/review-pack owner surfaces plus one bounded rendered report.
- [x] Any material PDF-support gap resolves as `document-in-feature` or `follow-up-spec`, not as hidden dependency growth.
## Productization Patch Addendum (2026-06-05)
**Status**: Complete. Validation and screenshots are complete, and no P0/P1/P2 report-productization findings remain open.
- [x] P356-001 Move report actions into an external toolbar and hide toolbar/app controls from print CSS.
- [x] P356-002 Add readiness-aware hero states for customer-safe ready, limitations, internal/PII, and not-ready output.
- [x] P356-003 Add a management-readable Executive Summary with overall state, reason, impact, next action, and top limitations.
- [x] P356-004 Replace dominant raw limitation/evidence state copy with human report language and move technical details to the supporting appendix.
- [x] P356-005 Add EN/DE localization keys and fallback handling so rendered reports do not expose `localization.*` keys.
- [x] P356-006 Compact empty/zero-heavy findings, accepted-risk, decision, and next-action sections.
- [x] P356-007 Move the appendix to the end and label it as supporting/auditor context without raw JSON dump presentation.
- [x] P356-008 Improve accepted-risk display with status, expiry/review state, customer-safe summary, safe owner display, and internal-rationale guardrails.
- [x] P356-009 Improve evidence-basis copy so missing/partial/stale/complete states explain shareability and operator next action.
- [x] P356-010 Add controlled repo-backed MSP co-branding slots from workspace/environment names and TenantPilot generated-by copy only.
- [x] P356-011 Make rendered report and download labels readiness-aware without forbidden customer-ready/certified/approved/share labels.
- [x] P356-012 Prove the rendered report uses stored DB-local truth and no Graph/provider calls during render.
- [x] P356-013 Preserve existing ZIP review-pack download/export behavior alongside the rendered HTML/print report.
- [x] P356-014 Complete full requested validation, browser screenshots, and final productization analysis before any close recommendation.
## Phase 1: Setup (Shared Context)
**Purpose**: Confirm the current review-pack contract, current delivery seams, and current PDF-support reality before implementation changes begin.
- [x] T001 Review `specs/356-review-pack-pdf-html-renderer-v1/spec.md`, `plan.md`, `tasks.md`, and `checklists/requirements.md` together with `specs/263-auditor-pack-executive-export/spec.md`, `specs/347-review-pack-output-contract-readiness-semantics/spec.md`, `specs/349-customer-review-workspace-output-resolution-guidance/spec.md`, `specs/351-review-output-resolve-actions-v1/spec.md`, and `specs/355-platform-sellable-smoke-matrix/spec.md`.
- [x] T002 [P] Confirm the current review-derived delivery contract in `apps/platform/app/Services/ReviewPackService.php` and `apps/platform/app/Jobs/GenerateReviewPackJob.php`.
- [x] T003 [P] Confirm the current read-only customer-safe delivery seams in `apps/platform/app/Filament/Pages/Reviews/CustomerReviewWorkspace.php`, `apps/platform/app/Filament/Resources/EnvironmentReviewResource/Pages/ViewEnvironmentReview.php`, `apps/platform/app/Filament/Resources/ReviewPackResource.php`, `apps/platform/app/Http/Controllers/ReviewPackDownloadController.php`, and `apps/platform/routes/web.php`.
- [x] T004 [P] Confirm current PDF/render support reality in `apps/platform/composer.json`, `apps/platform/composer.lock`, `apps/platform/package.json`, and any existing render-related runtime code. Record whether PDF can be supported without a new package.
---
## Phase 2: Foundational (Blocking Prerequisites)
**Purpose**: Lock the bounded renderer contract before owner-surface changes begin.
**Critical**: No user-story work should begin until this phase is complete.
- [x] T005 [P] Extend `apps/platform/tests/Feature/EnvironmentReview/EnvironmentReviewExecutivePackTest.php` and `apps/platform/tests/Feature/ReviewPack/EnvironmentReviewDerivedReviewPackTest.php` to require a rendered HTML report contract over the current review-derived pack truth.
- [x] T006 [P] Extend `apps/platform/tests/Feature/EnvironmentReview/EnvironmentReviewExplanationSurfaceTest.php`, `apps/platform/tests/Feature/EnvironmentReview/EnvironmentReviewUiContractTest.php`, `apps/platform/tests/Feature/Reviews/CustomerReviewWorkspacePackAccessTest.php`, and `apps/platform/tests/Feature/ReviewPack/ReviewPackResourceTest.php` to lock rendered-output disclosure, one dominant action, and truthful readiness wording across owner surfaces.
- [x] T007 [P] Extend `apps/platform/tests/Feature/ReviewPack/ReviewPackDownloadTest.php`, `apps/platform/tests/Feature/ReviewPack/ReviewPackResourceTest.php`, and any new focused render-route test only if needed to prove rendered preview/download authorization and audit continuity.
- [x] T008 [P] Extend `apps/platform/tests/Browser/Reviews/CustomerReviewWorkspaceSmokeTest.php` or add one bounded equivalent browser smoke proving the current workspace -> released review -> rendered report handoff.
- [x] T009 Lock the render seam as one new read-only controller/view route under the current `/admin/review-packs/{reviewPack}/...` family while the existing signed route stays ZIP-download-only.
- [x] T010 Lock the HTML-first / PDF-conditional boundary in code comments, tests, and task notes: if current repo support cannot produce PDF without a new package, the implementation must stay HTML-first and keep PDF unavailable honestly.
**Checkpoint**: The current `ReviewPack` family, current run path, and current customer-safe owner surfaces are all locked to the bounded renderer contract before surface-level implementation begins.
---
## Phase 3: User Story 1 - Open A Calm Rendered Review Report (Priority: P1)
**Goal**: An entitled user can open one calm rendered HTML report from the current released review/current pack context without unzipping JSON first.
**Independent Test**: From the current customer-review workspace/released-review flow, open the rendered output and verify it presents executive story, evidence basis, limitations, key findings, accepted risks, and non-certification disclosure without raw diagnostics by default.
### Tests for User Story 1
- [x] T011 [P] [US1] Extend `apps/platform/tests/Feature/EnvironmentReview/EnvironmentReviewExecutivePackTest.php` to prove rendered-report content matches current review/review-pack truth.
- [x] T012 [P] [US1] Extend `apps/platform/tests/Feature/EnvironmentReview/EnvironmentReviewExplanationSurfaceTest.php`, `apps/platform/tests/Feature/Reviews/CustomerReviewWorkspacePackAccessTest.php`, and `apps/platform/tests/Feature/ReviewPack/ReviewPackResourceTest.php` to prove customer-safe rendered-report launch wording and appendix disclosure.
### Implementation for User Story 1
- [x] T013 [US1] Update `apps/platform/app/Services/ReviewPackService.php` and/or `apps/platform/app/Jobs/GenerateReviewPackJob.php` to expose one deterministic HTML rendered-report contract over the current review-derived `EnvironmentReview`/`EnvironmentReviewSection`/`ReviewPack` truth without adding a second artifact family or requiring archive re-parsing as the primary source.
- [x] T014 [US1] Add a bounded rendered-report view under `apps/platform/resources/views/review-packs/` or an equivalent current view seam so the executive story, evidence basis, limitations, key findings, accepted risks, governance decisions, next actions, and non-certification disclosure are human-readable.
- [x] T015 [US1] Update `apps/platform/app/Filament/Pages/Reviews/CustomerReviewWorkspace.php`, `apps/platform/app/Filament/Resources/EnvironmentReviewResource/Pages/ViewEnvironmentReview.php`, `apps/platform/app/Filament/Resources/ReviewPackResource.php`, and any related Blade/infolist entries only where needed so one dominant rendered-output affordance is visible and current readiness wording stays truthful.
**Checkpoint**: The current review/review-pack surfaces can open one calm rendered HTML report without introducing a second delivery domain.
---
## Phase 4: User Story 2 - Keep Printable Delivery Honest And Bounded (Priority: P1)
**Goal**: The same rendered contract supports a printable handoff path only when the repo can do so honestly; otherwise the product remains HTML-first without false PDF claims.
**Independent Test**: Verify that HTML is always available through the current owner surfaces and that PDF is either served from the same contract or explicitly unavailable without dependency growth.
### Tests for User Story 2
- [x] T016 [P] [US2] Extend `apps/platform/tests/Feature/ReviewPack/ReviewPackDownloadTest.php`, `apps/platform/tests/Feature/ReviewPack/ReviewPackResourceTest.php`, and any focused render-route test to prove HTML preview/download continuity and honest PDF availability semantics.
- [x] T017 [P] [US2] Extend `apps/platform/tests/Feature/EnvironmentReview/EnvironmentReviewUiContractTest.php` and `apps/platform/tests/Feature/ReviewPack/ReviewPackResourceTest.php` to prove current owner surfaces do not expose a false PDF affordance.
### Implementation for User Story 2
- [x] T018 [US2] Update `apps/platform/routes/web.php` and the narrowest read-only controller seam to serve rendered HTML and, only when current repo support allows it, PDF from the same contract.
- [x] T019 [US2] Update `apps/platform/app/Services/ReviewPackService.php`, `apps/platform/app/Jobs/GenerateReviewPackJob.php`, and localization copy only where needed so delivery metadata and copy say whether HTML only or HTML plus PDF are available.
- [x] T020 [US2] If current repo support cannot produce PDF without a new package, keep HTML as the shipped v1 floor and record the bounded PDF follow-up instead of widening scope.
**Checkpoint**: Printable delivery remains honest and bounded to current repo truth.
---
## Phase 5: User Story 3 - Keep Delivery Tenant-Safe, Auditable, And Derived (Priority: P2)
**Goal**: The rendered report stays on the current entitlement, audit, and derived-truth seams.
**Independent Test**: Non-members remain `404`, in-scope viewers stay on current read-only permission paths, and rendered preview/download does not create a new run or audit family.
### Tests for User Story 3
- [x] T021 [P] [US3] Extend `apps/platform/tests/Feature/ReviewPack/ReviewPackDownloadTest.php`, `apps/platform/tests/Feature/ReviewPack/ReviewPackResourceTest.php`, and any focused render-route authorization test to confirm non-members and wrong-environment targets remain `404`.
- [x] T022 [P] [US3] Extend `apps/platform/tests/Feature/EnvironmentReview/EnvironmentReviewUiContractTest.php`, `apps/platform/tests/Feature/Reviews/CustomerReviewWorkspacePackAccessTest.php`, and `apps/platform/tests/Feature/ReviewPack/ReviewPackResourceTest.php` to prove owner surfaces stay on the current export/view authority model.
- [x] T023 [P] [US3] Extend current audit-focused review-pack/review tests only if needed to confirm rendered preview/download stays on the existing audit family.
### Implementation for User Story 3
- [x] T024 [US3] Reuse or minimally extend current audit metadata in the current audit/review-pack seams only if rendered preview/download needs additional source-surface context; do not add a new audit family.
- [x] T025 [US3] Reuse current review-pack/review entitlement checks for rendered preview/download and confirm no renderer-specific `OperationRun`, capability family, or persistence family appears.
- [x] T026 [US3] Confirm the implementation does not add a new panel, new global-search surface, new asset strategy, new package, or second artifact family. If any of those become necessary, stop and split the scope.
**Checkpoint**: Rendered delivery remains attributable, tenant-safe, and derived from current truth only.
---
## Phase 6: Polish & Cross-Cutting Validation
**Purpose**: Validate the bounded slice, complete required UI audit follow-through, and stop without widening scope.
- [x] T027 [P] Update `docs/ui-ux-enterprise-audit/route-inventory.md`, `docs/ui-ux-enterprise-audit/design-coverage-matrix.md`, `docs/ui-ux-enterprise-audit/strategic-surfaces.md`, `docs/ui-ux-enterprise-audit/unresolved-pages.md`, and the relevant `docs/ui-ux-enterprise-audit/page-reports/...` entries so the changed Review Pack detail surface and the new rendered-report route are coverage-consistent and `UI-042` no longer remains falsely unresolved.
- [x] T028 [P] Run `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/EnvironmentReview/EnvironmentReviewExecutivePackTest.php tests/Feature/EnvironmentReview/EnvironmentReviewExplanationSurfaceTest.php tests/Feature/EnvironmentReview/EnvironmentReviewUiContractTest.php tests/Feature/Reviews/CustomerReviewWorkspacePackAccessTest.php tests/Feature/ReviewPack/EnvironmentReviewDerivedReviewPackTest.php tests/Feature/ReviewPack/ReviewPackDownloadTest.php tests/Feature/ReviewPack/ReviewPackResourceTest.php`.
- [x] T029 [P] Run one bounded browser smoke for the current customer-review workspace -> released review -> rendered report handoff.
- [x] T030 [P] Run `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Localization/CustomerReviewSurfaceLocalizationTest.php` if rendered-output copy or localized labels change.
- [x] T031 [P] Run `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail bin pint --dirty --format agent`.
- [x] T032 [P] Run `git diff --check`.
- [x] T033 [P] Review touched code to confirm Filament stays on Livewire v4, provider registration remains unchanged in `apps/platform/bootstrap/providers.php`, no global-search contract changes appear, and no new asset strategy is introduced.
- [x] T034 [P] Record explicitly whether PDF landed from current repo support or whether the slice shipped HTML-first with a documented follow-up.
---
## Non-Goals Checklist
- [x] NT001 Do not add a customer portal, public share links, or email delivery.
- [x] NT002 Do not add a new `AuditorPack`, `RenderedReport`, or other second artifact family.
- [x] NT003 Do not add a PDF dependency or a second rendering engine.
- [x] NT004 Do not add a new queue family, `OperationRun`, capability family, or audit family.
- [x] NT005 Do not recompose review truth from live provider calls or raw provider APIs during render.
- [x] NT006 Do not widen the slice into localization-wide cleanup, governance inbox work, or workspace-shell redesign.