feat: add report profile and disclosure policy to rendered review reports (#428)

Implementing report profiles and disclosure policy as per spec 357.

Co-authored-by: Ahmed Darrazi <ahmed.darrazi@live.de>
Reviewed-on: #428
This commit is contained in:
ahmido 2026-06-06 09:41:19 +00:00
parent 9cd06e8b66
commit b7907bd69d
25 changed files with 2309 additions and 89 deletions

View File

@ -4,8 +4,8 @@
namespace App\Console\Commands;
use App\Filament\Resources\EnvironmentReviewResource;
use App\Filament\Pages\Reviews\CustomerReviewWorkspace;
use App\Filament\Resources\EnvironmentReviewResource;
use App\Models\EnvironmentReview;
use App\Models\EnvironmentReviewSection;
use App\Models\EvidenceSnapshot;
@ -22,6 +22,7 @@
use App\Services\EnvironmentReviews\EnvironmentReviewLifecycleService;
use App\Services\EnvironmentReviews\EnvironmentReviewService;
use App\Services\Evidence\EvidenceSnapshotService;
use App\Services\OperationRunService;
use App\Support\EnvironmentReviewCompletenessState;
use App\Support\EnvironmentReviewStatus;
use App\Support\Evidence\EvidenceCompletenessState;
@ -178,6 +179,7 @@ private function resetFixtureGraph(ManagedEnvironment $environment): void
EvidenceSnapshot::query()->whereIn('id', $snapshotIds)->delete();
}
OperationRun::query()->where('managed_environment_id', $environmentId)->delete();
StoredReport::query()->where('managed_environment_id', $environmentId)->delete();
}
@ -298,6 +300,7 @@ private function seedPublishedLoopWithReadySuccessor(
if ($publishedReview->generated_at === null || ! $publishedReview->sections()->exists()) {
$publishedReview = $service->compose($publishedReview);
$this->completeFixtureComposeRun($publishedReview);
}
$publishedReview = $this->markReadyReview($publishedReview);
@ -319,6 +322,7 @@ private function seedPublishedLoopWithReadySuccessor(
if ($successorReview->generated_at === null || ! $successorReview->sections()->exists()) {
$successorReview = $service->compose($successorReview);
$this->completeFixtureComposeRun($successorReview);
}
$successorReview = $this->markReadyReview($successorReview);
@ -331,6 +335,47 @@ private function seedPublishedLoopWithReadySuccessor(
return $successorReview->refresh()->load(['tenant', 'evidenceSnapshot.items', 'sections']);
}
private function completeFixtureComposeRun(EnvironmentReview $review): void
{
$run = $review->operationRun()->first();
if (! $run instanceof OperationRun || (string) $run->type !== OperationRunType::EnvironmentReviewCompose->value) {
return;
}
/** @var OperationRunService $service */
$service = app(OperationRunService::class);
if ((string) $run->status === OperationRunStatus::Queued->value) {
$service->updateRun(
$run,
status: OperationRunStatus::Running->value,
outcome: OperationRunOutcome::Pending->value,
);
$run = $run->fresh();
}
if (! $run instanceof OperationRun || (string) $run->status === OperationRunStatus::Completed->value) {
return;
}
$summary = is_array($review->summary) ? $review->summary : [];
$service->updateRun(
$run,
status: OperationRunStatus::Completed->value,
outcome: OperationRunOutcome::Succeeded->value,
summaryCounts: [
'created' => 1,
'finding_count' => (int) ($summary['finding_count'] ?? 0),
'report_count' => (int) ($summary['report_count'] ?? 0),
'operation_count' => (int) ($summary['operation_count'] ?? 0),
'errors_recorded' => 0,
],
);
}
/**
* @param array<string, mixed> $scenarioConfig
*/
@ -460,7 +505,7 @@ private function markReadyReview(EnvironmentReview $review): EnvironmentReview
])->save();
}
$review->sections->each(function (EnvironmentReviewSection $section) use ($controlSummary, $controlExplanation, $disclosure): void {
$review->sections->each(function (EnvironmentReviewSection $section) use ($controlExplanation, $disclosure): void {
$attributes = [
'completeness_state' => EnvironmentReviewCompletenessState::Complete->value,
];

View File

@ -34,6 +34,7 @@
use App\Support\Rbac\UiEnforcement;
use App\Support\ReasonTranslation\ReasonPresenter;
use App\Support\ResolutionGuidance\Adapters\ReviewPackOutputResolutionAdapter;
use App\Support\ReviewPacks\ReportProfileRegistry;
use App\Support\ReviewPacks\ReviewPackOutputResolutionGuidance;
use App\Support\ReviewPackStatus;
use App\Support\Ui\ActionSurface\ActionSurfaceDeclaration;
@ -1244,6 +1245,9 @@ public static function currentRenderedReportUrlFor(EnvironmentReview $record): ?
return null;
}
$guidance = ReviewPackOutputResolutionGuidance::fromReadiness(
ReviewPackOutputResolutionGuidance::readinessForReview($record),
);
$parameters = [
'source_surface' => static::isCustomerWorkspaceMode()
? CustomerReviewWorkspace::SOURCE_SURFACE
@ -1251,6 +1255,10 @@ public static function currentRenderedReportUrlFor(EnvironmentReview $record): ?
'review_id' => (int) $record->getKey(),
'tenant_filter_id' => request()->query('tenant_filter_id'),
'interpretation_version' => $record->controlInterpretationVersion(),
ReportProfileRegistry::QUERY_PARAMETER => ReportProfileRegistry::defaultForRenderedReportState(
(string) ($guidance['state'] ?? ReviewPackOutputResolutionGuidance::STATE_UNKNOWN),
static::isCustomerWorkspaceMode(),
),
];
if (static::isCustomerWorkspaceMode()) {
@ -1272,9 +1280,15 @@ public static function renderedReportActionLabelFor(?EnvironmentReview $record):
$guidance = ReviewPackOutputResolutionGuidance::fromReadiness(
ReviewPackOutputResolutionGuidance::readinessForReview($record),
);
$profileKey = ReportProfileRegistry::defaultForRenderedReportState(
(string) ($guidance['state'] ?? ReviewPackOutputResolutionGuidance::STATE_UNKNOWN),
static::isCustomerWorkspaceMode(),
);
return match ((string) ($guidance['state'] ?? ReviewPackOutputResolutionGuidance::STATE_UNKNOWN)) {
ReviewPackOutputResolutionGuidance::STATE_CUSTOMER_SAFE_READY => __('localization.review.view_customer_safe_report'),
ReviewPackOutputResolutionGuidance::STATE_CUSTOMER_SAFE_READY => $profileKey === ReportProfileRegistry::CUSTOMER_EXECUTIVE
? __('localization.review.view_customer_safe_report')
: __('localization.review.view_internal_report'),
ReviewPackOutputResolutionGuidance::STATE_INTERNAL_ONLY => __('localization.review.view_internal_report'),
default => __('localization.review.view_report_with_limitations'),
};

View File

@ -4,10 +4,13 @@
use App\Filament\Pages\Reviews\CustomerReviewWorkspace;
use App\Filament\Resources\ReviewPackResource;
use App\Models\EnvironmentReview;
use App\Models\ReviewPack;
use App\Services\ReviewPackService;
use App\Support\Auth\Capabilities;
use App\Support\Rbac\UiEnforcement;
use App\Support\ReviewPacks\ReportProfileRegistry;
use App\Support\ReviewPacks\ReviewPackOutputResolutionGuidance;
use App\Support\ReviewPackStatus;
use Filament\Actions;
use Filament\Forms\Components\Toggle;
@ -117,11 +120,38 @@ private function openRenderedReportAction(array $parameters = [], string $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 !== ''),
$this->renderedReportParameters($parameters),
))
->openUrlInNewTab();
}
/**
* @param array<string, scalar|null> $parameters
* @return array<string, scalar|null>
*/
private function renderedReportParameters(array $parameters): array
{
$parameters = array_filter($parameters, static fn (mixed $value): bool => $value !== null && $value !== '');
$review = $this->record->environmentReview;
if (! $review instanceof EnvironmentReview) {
return $parameters;
}
if (! array_key_exists(ReportProfileRegistry::QUERY_PARAMETER, $parameters)) {
$guidance = ReviewPackOutputResolutionGuidance::fromReadiness(
ReviewPackOutputResolutionGuidance::readinessForReview($review),
);
$parameters[ReportProfileRegistry::QUERY_PARAMETER] = ReportProfileRegistry::defaultForRenderedReportState(
(string) ($guidance['state'] ?? ReviewPackOutputResolutionGuidance::STATE_UNKNOWN),
array_key_exists(CustomerReviewWorkspace::DETAIL_CONTEXT_QUERY_KEY, $parameters),
);
}
return $parameters;
}
private function canOpenRenderedReport(): bool
{
/** @var ReviewPack $record */

View File

@ -15,6 +15,8 @@
use App\Models\Workspace;
use App\Services\ReviewPackService;
use App\Support\Auth\Capabilities;
use App\Support\ReviewPacks\ReportDisclosurePolicy;
use App\Support\ReviewPacks\ReportProfileRegistry;
use App\Support\ReviewPacks\ReviewPackOutputResolutionGuidance;
use App\Support\ReviewPackStatus;
use Illuminate\Http\Request;
@ -96,6 +98,20 @@ private function reportState(
$state = (string) ($guidance['state'] ?? ReviewPackOutputResolutionGuidance::STATE_UNKNOWN);
$limitations = $this->managementLimitations(is_array($guidance['limitations'] ?? null) ? $guidance['limitations'] : []);
$evidenceBasis = $this->evidenceBasisState($review);
$profile = $this->profileState($request, $state);
$nonCertificationDisclosure = $this->plainText(
$controlInterpretation['non_certification_disclosure'] ?? null,
__('localization.review.non_certification_disclosure_text'),
);
$disclosurePolicy = ReportDisclosurePolicy::evaluate($profile, $readiness, [
'non_certification_disclosure' => $nonCertificationDisclosure,
]);
$sectionAppendix = (bool) ($disclosurePolicy['show_section_appendix'] ?? false)
? $this->sectionAppendix($reviewPack, $review)
: [];
$technicalDetails = (bool) ($disclosurePolicy['show_technical_details'] ?? false)
? (is_array($guidance['technical_details'] ?? null) ? $guidance['technical_details'] : [])
: [];
return [
'title' => __('localization.review.rendered_report'),
@ -103,6 +119,9 @@ private function reportState(
'state' => $state,
'hero' => $this->heroState($state, $guidance),
'branding' => $this->brandingState($tenant),
'profile' => $profile,
'disclosure_policy' => $disclosurePolicy,
'source_metadata' => $this->sourceMetadata($request, $review),
'tenant_name' => $tenant->name,
'review_id' => (int) $review->getKey(),
'pack_id' => (int) $reviewPack->getKey(),
@ -140,12 +159,9 @@ private function reportState(
? $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'] : [],
'non_certification_disclosure' => $nonCertificationDisclosure,
'section_appendix' => $sectionAppendix,
'technical_details' => $technicalDetails,
'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') : [],
];
@ -528,6 +544,39 @@ private function reviewPackUrl(Request $request, ReviewPack $reviewPack, Managed
]);
}
/**
* @return array<string, mixed>
*/
private function profileState(Request $request, string $state): array
{
return ReportProfileRegistry::resolve(
is_string($request->query(ReportProfileRegistry::QUERY_PARAMETER))
? (string) $request->query(ReportProfileRegistry::QUERY_PARAMETER)
: null,
ReportProfileRegistry::defaultForRenderedReportState(
$state,
$this->isCustomerWorkspaceReportRequest($request),
),
);
}
/**
* @return array{source_surface:string,customer_workspace_context:bool,requested_profile:?string,interpretation_version:string}
*/
private function sourceMetadata(Request $request, EnvironmentReview $review): array
{
return [
'source_surface' => is_string($request->query('source_surface'))
? (string) $request->query('source_surface')
: 'review_pack',
'customer_workspace_context' => $this->isCustomerWorkspaceReportRequest($request),
'requested_profile' => is_string($request->query(ReportProfileRegistry::QUERY_PARAMETER))
? trim((string) $request->query(ReportProfileRegistry::QUERY_PARAMETER))
: null,
'interpretation_version' => (string) ($request->query('interpretation_version') ?? $review->controlInterpretationVersion()),
];
}
/**
* @return array<string, scalar|null>
*/
@ -567,6 +616,12 @@ private function appendQuery(string $url, array $query): string
));
}
private function isCustomerWorkspaceReportRequest(Request $request): bool
{
return $request->boolean(CustomerReviewWorkspace::DETAIL_CONTEXT_QUERY_KEY)
|| (string) $request->query('source_surface') === CustomerReviewWorkspace::SOURCE_SURFACE;
}
private function plainText(mixed $value, string $fallback): string
{
if (! is_scalar($value) && $value !== null) {

View File

@ -0,0 +1,179 @@
<?php
declare(strict_types=1);
namespace App\Support\ReviewPacks;
final class ReportDisclosurePolicy
{
public const string PROOF_VERIFIED = 'verified';
public const string PROOF_ASSUMED = 'assumed';
public const string PROOF_MISSING = 'missing';
public const string PROOF_UNKNOWN = 'unknown';
public const string PROOF_NOT_APPLICABLE = 'not_applicable';
/**
* @param array<string, mixed> $profile
* @param array<string, mixed> $readiness
* @param array<string, mixed> $metadata
* @return array{
* mandatory_disclosures:list<array{key:string,label:string,summary:string,proof_state:string}>,
* warnings:list<array{key:string,label:string,summary:string}>,
* blocking_reasons:list<array{key:string,label:string,summary:string}>,
* proof_states:array{audience_boundary:string,evidence_basis:string,protected_values:string,non_certification:string},
* show_section_appendix:bool,
* show_technical_details:bool
* }
*/
public static function evaluate(array $profile, array $readiness, array $metadata = []): array
{
$isCustomerFacing = (bool) ($profile['is_customer_facing'] ?? false);
$containsPii = (bool) ($readiness['contains_pii'] ?? false);
$protectedValuesHidden = (bool) ($readiness['protected_values_hidden'] ?? false);
$disclosurePresent = (bool) ($readiness['disclosure_present'] ?? false);
$displayedDisclosure = self::plainText(
$metadata['non_certification_disclosure'] ?? null,
__('localization.review.non_certification_disclosure_text'),
);
$proofStates = [
'audience_boundary' => self::PROOF_VERIFIED,
'evidence_basis' => self::evidenceBasisProofState((string) ($readiness['evidence_completeness_state'] ?? '')),
'protected_values' => self::protectedValuesProofState(
isCustomerFacing: $isCustomerFacing,
containsPii: $containsPii,
protectedValuesHidden: $protectedValuesHidden,
),
'non_certification' => $disclosurePresent
? self::PROOF_ASSUMED
: self::PROOF_MISSING,
];
$blockingReasons = [];
if ($isCustomerFacing && $containsPii) {
$blockingReasons[] = [
'key' => 'customer_profile_internal_only',
'label' => __('localization.review.report_disclosure_customer_profile_internal_only'),
'summary' => __('localization.review.report_disclosure_customer_profile_internal_only_summary'),
];
}
$warnings = [];
if ((bool) ($profile['is_fallback'] ?? false)) {
$warnings[] = [
'key' => 'profile_fallback',
'label' => __('localization.review.report_profile_fallback_notice'),
'summary' => __('localization.review.report_profile_fallback_summary'),
];
}
if ($isCustomerFacing && (string) ($readiness['customer_safe_state'] ?? '') !== ReviewPackOutputReadiness::STATE_CUSTOMER_SAFE_READY) {
$warnings[] = [
'key' => 'customer_profile_requires_review',
'label' => __('localization.review.report_external_sharing_warning'),
'summary' => __('localization.review.report_disclosure_customer_profile_requires_review'),
];
}
if ($proofStates['non_certification'] === self::PROOF_MISSING) {
$warnings[] = [
'key' => 'non_certification_missing',
'label' => __('localization.review.non_certification_disclosure'),
'summary' => __('localization.review.report_disclosure_non_certification_missing'),
];
}
$showDetailedContent = ! ($isCustomerFacing && $containsPii);
return [
'mandatory_disclosures' => [
[
'key' => 'audience_boundary',
'label' => __('localization.review.report_disclosure_audience_boundary'),
'summary' => __('localization.review.report_disclosure_audience_boundary_summary', [
'audience' => (string) ($profile['audience_label'] ?? __('localization.review.unavailable')),
]),
'proof_state' => $proofStates['audience_boundary'],
],
[
'key' => 'evidence_basis',
'label' => __('localization.review.report_disclosure_evidence_basis'),
'summary' => match ($proofStates['evidence_basis']) {
self::PROOF_VERIFIED => __('localization.review.report_disclosure_evidence_verified'),
self::PROOF_MISSING => __('localization.review.report_disclosure_evidence_missing'),
default => __('localization.review.report_disclosure_evidence_unknown'),
},
'proof_state' => $proofStates['evidence_basis'],
],
[
'key' => 'protected_values',
'label' => __('localization.review.report_disclosure_protected_values'),
'summary' => match ($proofStates['protected_values']) {
self::PROOF_ASSUMED => __('localization.review.report_disclosure_protected_values_assumed'),
self::PROOF_NOT_APPLICABLE => __('localization.review.report_disclosure_protected_values_not_applicable'),
self::PROOF_MISSING => __('localization.review.report_disclosure_protected_values_missing'),
default => __('localization.review.report_disclosure_protected_values_unknown'),
},
'proof_state' => $proofStates['protected_values'],
],
[
'key' => 'non_certification',
'label' => __('localization.review.non_certification_disclosure'),
'summary' => $displayedDisclosure,
'proof_state' => $proofStates['non_certification'],
],
],
'warnings' => $warnings,
'blocking_reasons' => $blockingReasons,
'proof_states' => $proofStates,
'show_section_appendix' => (bool) ($profile['show_section_appendix'] ?? false) && $showDetailedContent,
'show_technical_details' => (bool) ($profile['show_technical_details'] ?? false) && $showDetailedContent,
];
}
private static function evidenceBasisProofState(string $evidenceCompletenessState): string
{
return match ($evidenceCompletenessState) {
'complete' => self::PROOF_VERIFIED,
'missing', 'partial', 'stale' => self::PROOF_MISSING,
default => self::PROOF_UNKNOWN,
};
}
private static function protectedValuesProofState(
bool $isCustomerFacing,
bool $containsPii,
bool $protectedValuesHidden,
): string {
if (! $isCustomerFacing) {
return self::PROOF_NOT_APPLICABLE;
}
if ($containsPii || ! $protectedValuesHidden) {
return self::PROOF_MISSING;
}
return self::PROOF_ASSUMED;
}
private static 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

@ -0,0 +1,161 @@
<?php
declare(strict_types=1);
namespace App\Support\ReviewPacks;
final class ReportProfileRegistry
{
public const string QUERY_PARAMETER = 'profile';
public const string CUSTOMER_EXECUTIVE = 'customer_executive';
public const string CUSTOMER_TECHNICAL = 'customer_technical';
public const string INTERNAL_MSP_REVIEW = 'internal_msp_review';
public const string AUDITOR_APPENDIX = 'auditor_appendix';
public const string FRAMEWORK_READINESS = 'framework_readiness';
public const string DEFAULT_PROFILE = self::INTERNAL_MSP_REVIEW;
/**
* @return array<string, array{
* profile_key:string,
* label_key:string,
* audience_key:string,
* implemented:bool,
* is_customer_facing:bool,
* show_section_appendix:bool,
* show_technical_details:bool
* }>
*/
public static function all(): array
{
return [
self::CUSTOMER_EXECUTIVE => [
'profile_key' => self::CUSTOMER_EXECUTIVE,
'label_key' => 'localization.review.report_profile_customer_executive',
'audience_key' => 'localization.review.report_audience_customer_executive',
'implemented' => true,
'is_customer_facing' => true,
'show_section_appendix' => false,
'show_technical_details' => false,
],
self::CUSTOMER_TECHNICAL => [
'profile_key' => self::CUSTOMER_TECHNICAL,
'label_key' => 'localization.review.report_profile_customer_technical',
'audience_key' => 'localization.review.report_audience_customer_technical',
'implemented' => true,
'is_customer_facing' => true,
'show_section_appendix' => true,
'show_technical_details' => true,
],
self::INTERNAL_MSP_REVIEW => [
'profile_key' => self::INTERNAL_MSP_REVIEW,
'label_key' => 'localization.review.report_profile_internal_msp_review',
'audience_key' => 'localization.review.report_audience_internal_msp_review',
'implemented' => true,
'is_customer_facing' => false,
'show_section_appendix' => true,
'show_technical_details' => true,
],
self::AUDITOR_APPENDIX => [
'profile_key' => self::AUDITOR_APPENDIX,
'label_key' => 'localization.review.report_profile_auditor_appendix',
'audience_key' => 'localization.review.report_audience_controlled_auditor',
'implemented' => true,
'is_customer_facing' => false,
'show_section_appendix' => true,
'show_technical_details' => true,
],
self::FRAMEWORK_READINESS => [
'profile_key' => self::FRAMEWORK_READINESS,
'label_key' => 'localization.review.report_profile_framework_readiness',
'audience_key' => 'localization.review.report_audience_internal_msp_review',
'implemented' => false,
'is_customer_facing' => false,
'show_section_appendix' => false,
'show_technical_details' => false,
],
];
}
/**
* @return array{
* profile_key:string,
* requested_key:?string,
* effective_key:string,
* label_key:string,
* label:string,
* audience_key:string,
* audience_label:string,
* implemented:bool,
* is_customer_facing:bool,
* show_section_appendix:bool,
* show_technical_details:bool,
* is_fallback:bool,
* fallback_reason:?string
* }
*/
public static function resolve(?string $requestedKey, ?string $defaultProfileKey = null): array
{
$profiles = self::all();
$normalizedRequestedKey = self::normalizeKey($requestedKey);
$defaultProfileKey = array_key_exists((string) $defaultProfileKey, $profiles)
? (string) $defaultProfileKey
: self::DEFAULT_PROFILE;
$effectiveProfile = $profiles[$defaultProfileKey];
$isFallback = false;
$fallbackReason = null;
if ($normalizedRequestedKey !== null) {
$requestedProfile = $profiles[$normalizedRequestedKey] ?? null;
if (! is_array($requestedProfile)) {
$effectiveProfile = $profiles[self::DEFAULT_PROFILE];
$isFallback = true;
$fallbackReason = 'unknown_profile';
} elseif (! (bool) ($requestedProfile['implemented'] ?? false)) {
$effectiveProfile = $profiles[self::DEFAULT_PROFILE];
$isFallback = true;
$fallbackReason = 'unimplemented_profile';
} else {
$effectiveProfile = $requestedProfile;
}
}
$effectiveKey = (string) $effectiveProfile['profile_key'];
return $effectiveProfile + [
'requested_key' => $normalizedRequestedKey,
'effective_key' => $effectiveKey,
'label' => __((string) $effectiveProfile['label_key']),
'audience_label' => __((string) $effectiveProfile['audience_key']),
'is_fallback' => $isFallback,
'fallback_reason' => $fallbackReason,
];
}
public static function defaultForRenderedReportState(string $state, bool $customerSafeHandoff): string
{
if ($state === ReviewPackOutputResolutionGuidance::STATE_CUSTOMER_SAFE_READY) {
return self::CUSTOMER_EXECUTIVE;
}
return self::DEFAULT_PROFILE;
}
private static function normalizeKey(?string $requestedKey): ?string
{
$requestedKey = trim((string) $requestedKey);
if ($requestedKey === '') {
return null;
}
return strtolower($requestedKey);
}
}

View File

@ -42,12 +42,18 @@ public static function readinessForReview(EnvironmentReview $review): array
$review->loadMissing(['sections', 'evidenceSnapshot', 'currentExportReviewPack']);
$pack = $review->currentExportReviewPack;
$packSummary = is_array($pack?->summary ?? null) ? $pack->summary : [];
$controlInterpretation = is_array($packSummary['control_interpretation'] ?? null)
? $packSummary['control_interpretation']
: [];
$snapshot = $review->evidenceSnapshot;
$summary = is_array($review->summary) ? $review->summary : [];
$sections = self::outputSections($review, $pack);
$requiredSections = $sections
->filter(static fn (mixed $section): bool => (bool) $section->required)
->values();
$includePii = (bool) (is_array($pack?->options ?? null) ? ($pack->options['include_pii'] ?? true) : true);
$nonCertificationDisclosure = trim((string) ($controlInterpretation['non_certification_disclosure'] ?? ''));
return ReviewPackOutputReadiness::derive(
reviewStatus: (string) $review->status,
@ -60,9 +66,9 @@ public static function readinessForReview(EnvironmentReview $review): array
requiredSectionStateCounts: self::sectionStateCounts($requiredSections),
publishBlockers: is_array($summary['publish_blockers'] ?? null) ? $summary['publish_blockers'] : [],
hasReadyExport: self::hasReadyExport($pack),
includePii: (bool) (is_array($pack?->options ?? null) ? ($pack->options['include_pii'] ?? true) : true),
protectedValuesHidden: true,
disclosurePresent: true,
includePii: $includePii,
protectedValuesHidden: ! $includePii,
disclosurePresent: $nonCertificationDisclosure !== '',
);
}

View File

@ -718,6 +718,7 @@
'published' => 'Veröffentlicht',
'published_at' => 'Veröffentlicht am',
'generated_at' => 'Erstellt am',
'interpretation_version' => 'Interpretationsversion',
'review_pack' => 'Review-Pack',
'rendered_report' => 'Gerenderter Review-Bericht',
'open_latest_review' => 'Letztes Review öffnen',
@ -802,9 +803,47 @@
'view_customer_safe_report' => 'Kundensicheren Bericht anzeigen',
'view_report_with_limitations' => 'Bericht mit Einschränkungen anzeigen',
'view_internal_report' => 'Internen Bericht anzeigen',
'report_profile' => 'Berichtsprofil',
'report_effective_profile' => 'Effektives Profil',
'report_requested_profile' => 'Angefordertes Profil',
'report_audience' => 'Zielgruppe',
'report_source_surface' => 'Quelloberfläche',
'report_profile_customer_executive' => 'Customer Executive',
'report_profile_customer_technical' => 'Customer Technical',
'report_profile_internal_msp_review' => 'Interner MSP-Review',
'report_profile_auditor_appendix' => 'Auditor-Anhang',
'report_profile_framework_readiness' => 'Framework Readiness',
'report_audience_customer_executive' => 'Kunden-Stakeholder und Executive-Lesende',
'report_audience_customer_technical' => 'Technische Kunden- und Delivery-Lesende',
'report_audience_internal_msp_review' => 'Interne MSP-Operatoren und Support-Reviewer',
'report_audience_controlled_auditor' => 'Kontrollierte Auditor- und Assurance-Lesende',
'governance_review_report' => 'Governance-Review-Bericht',
'prepared_by_for' => 'Erstellt von :prepared_by für :prepared_for',
'generated_by' => 'Erzeugt durch :generated_by',
'disclosure_policy' => 'Disclosure-Policy',
'proof_state_verified' => 'Verifiziert',
'proof_state_assumed' => 'Angenommen',
'proof_state_missing' => 'Fehlend',
'proof_state_unknown' => 'Unbekannt',
'proof_state_not_applicable' => 'Nicht anwendbar',
'report_profile_fallback_notice' => 'Das angeforderte Berichtsprofil ist auf dieser Route nicht verfügbar.',
'report_profile_fallback_summary' => 'TenantPilot ist auf das interne MSP-Review-Profil zurückgefallen und zeigt die Anfrage weiterhin sichtbar an.',
'report_appendix_hidden_for_profile' => 'Dieses Profil blendet den unterstützenden Anhang aus. Verwenden Sie ein begrenztes internes oder Auditor-Profil, wenn detaillierte Appendix-Inhalte erforderlich sind.',
'report_disclosure_customer_profile_internal_only' => 'Kundenseitiges Profil durch interne Details blockiert',
'report_disclosure_customer_profile_internal_only_summary' => 'Das gewählte kundenseitige Profil darf diesen Bericht nicht freigeben, solange interne oder PII-tragende Details im Scope bleiben.',
'report_disclosure_customer_profile_requires_review' => 'Dieses kundenseitige Profil erfordert vor externer Weitergabe weiterhin eine Operator-Prüfung.',
'report_disclosure_non_certification_missing' => 'Die erforderliche Nicht-Zertifizierungs-Offenlegung musste aus Fallback-Text erzwungen werden. Behandeln Sie das als fehlenden Nachweis, bis die gespeicherte Quelle korrigiert ist.',
'report_disclosure_audience_boundary' => 'Zielgruppen-Grenze',
'report_disclosure_audience_boundary_summary' => 'Dieser gerenderte Bericht ist auf :audience begrenzt.',
'report_disclosure_evidence_basis' => 'Evidence-Basis-Nachweis',
'report_disclosure_evidence_verified' => 'Die aktuelle Evidence-Basis ist vollständig genug, um den Disclosure-Status dieses Berichts zu verifizieren.',
'report_disclosure_evidence_missing' => 'Die Evidence-Basis ist unvollständig, veraltet oder fehlt. Behandeln Sie evidence-getragene Aussagen als eingeschränkt.',
'report_disclosure_evidence_unknown' => 'Der Status der Evidence-Basis konnte nicht sauber zugeordnet werden. Behandeln Sie evidence-getragene Aussagen als unbekannt.',
'report_disclosure_protected_values' => 'Grenze geschützter Werte',
'report_disclosure_protected_values_assumed' => 'Geschützte Werte scheinen anhand der aktuellen Pack-Optionen verborgen zu sein, werden aber weiterhin als angenommen und nicht als unabhängig verifiziert behandelt.',
'report_disclosure_protected_values_missing' => 'Geschützte Werte können für dieses kundenseitige Profil nicht als sicher verborgen behandelt werden.',
'report_disclosure_protected_values_unknown' => 'Die Behandlung geschützter Werte konnte aus der gespeicherten Berichtswahrheit nicht sauber abgeleitet werden.',
'report_disclosure_protected_values_not_applicable' => 'Dieses Profil ist intern oder auditor-begrenzt, daher ist der Nachweis verborgener Werte hier nicht die maßgebliche Disclosure-Grenze.',
'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',

View File

@ -718,6 +718,7 @@
'published' => 'Published',
'published_at' => 'Published at',
'generated_at' => 'Generated at',
'interpretation_version' => 'Interpretation version',
'review_pack' => 'Review pack',
'rendered_report' => 'Rendered review report',
'open_latest_review' => 'Open latest review',
@ -802,9 +803,47 @@
'view_customer_safe_report' => 'View customer-safe report',
'view_report_with_limitations' => 'View report with limitations',
'view_internal_report' => 'View internal report',
'report_profile' => 'Report profile',
'report_effective_profile' => 'Effective profile',
'report_requested_profile' => 'Requested profile',
'report_audience' => 'Audience',
'report_source_surface' => 'Source surface',
'report_profile_customer_executive' => 'Customer executive',
'report_profile_customer_technical' => 'Customer technical',
'report_profile_internal_msp_review' => 'Internal MSP review',
'report_profile_auditor_appendix' => 'Auditor appendix',
'report_profile_framework_readiness' => 'Framework readiness',
'report_audience_customer_executive' => 'Customer stakeholders and executive readers',
'report_audience_customer_technical' => 'Customer technical and delivery readers',
'report_audience_internal_msp_review' => 'Internal MSP operators and support reviewers',
'report_audience_controlled_auditor' => 'Controlled auditor and assurance readers',
'governance_review_report' => 'Governance review report',
'prepared_by_for' => 'Prepared by :prepared_by for :prepared_for',
'generated_by' => 'Generated by :generated_by',
'disclosure_policy' => 'Disclosure policy',
'proof_state_verified' => 'Verified',
'proof_state_assumed' => 'Assumed',
'proof_state_missing' => 'Missing',
'proof_state_unknown' => 'Unknown',
'proof_state_not_applicable' => 'Not applicable',
'report_profile_fallback_notice' => 'Requested report profile is not available for this route.',
'report_profile_fallback_summary' => 'TenantPilot fell back to the internal MSP review profile and kept the request visible.',
'report_appendix_hidden_for_profile' => 'This profile keeps the supporting appendix hidden. Use a bounded internal or auditor profile when detailed appendix content is required.',
'report_disclosure_customer_profile_internal_only' => 'Customer-facing profile blocked by internal-only detail',
'report_disclosure_customer_profile_internal_only_summary' => 'The selected customer-facing profile cannot expose this report while internal or PII-bearing detail remains in scope.',
'report_disclosure_customer_profile_requires_review' => 'This customer-facing profile still requires operator review before external sharing.',
'report_disclosure_non_certification_missing' => 'The required non-certification disclosure had to be enforced from fallback copy. Treat that as missing proof until the stored source is corrected.',
'report_disclosure_audience_boundary' => 'Audience boundary',
'report_disclosure_audience_boundary_summary' => 'This rendered report is constrained to :audience.',
'report_disclosure_evidence_basis' => 'Evidence basis proof',
'report_disclosure_evidence_verified' => 'The current evidence basis is complete enough to verify the disclosure state for this report.',
'report_disclosure_evidence_missing' => 'The evidence basis is incomplete, stale, or missing. Treat evidence-backed claims as limited.',
'report_disclosure_evidence_unknown' => 'The evidence basis state could not be mapped cleanly. Treat evidence-backed claims as unknown.',
'report_disclosure_protected_values' => 'Protected values boundary',
'report_disclosure_protected_values_assumed' => 'Protected values appear hidden based on the current pack options, but that boundary is still treated as assumed rather than independently verified.',
'report_disclosure_protected_values_missing' => 'Protected values cannot be treated as safely hidden for this customer-facing profile.',
'report_disclosure_protected_values_unknown' => 'Protected value handling could not be established cleanly from stored report truth.',
'report_disclosure_protected_values_not_applicable' => 'This profile is internal or auditor-bounded, so hidden-value proof is not the governing disclosure boundary.',
'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',

View File

@ -10,8 +10,19 @@
$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'] : [];
$profile = is_array($report['profile'] ?? null) ? $report['profile'] : [];
$disclosurePolicy = is_array($report['disclosure_policy'] ?? null) ? $report['disclosure_policy'] : [];
$sourceMetadata = is_array($report['source_metadata'] ?? null) ? $report['source_metadata'] : [];
$heroBadgeStyle = $badgeClasses[$hero['color'] ?? 'gray'] ?? $badgeClasses['gray'];
$boundaryBadgeStyle = $badgeClasses[$report['guidance']['boundary_color'] ?? 'gray'] ?? $badgeClasses['gray'];
$proofBadgeStyles = [
'verified' => $badgeClasses['success'],
'assumed' => $badgeClasses['warning'],
'missing' => $badgeClasses['danger'],
'unknown' => $badgeClasses['gray'],
'not_applicable' => $badgeClasses['gray'],
];
$requestedProfile = $sourceMetadata['requested_profile'] ?? ($profile['requested_key'] ?? null);
$generatedAt = $report['generated_at'] ?? null;
$publishedAt = $report['published_at'] ?? null;
@endphp
@ -212,6 +223,12 @@
display: grid;
gap: 14px;
}
.policy-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
gap: 14px;
margin-top: 14px;
}
.summary-grid {
grid-template-columns: repeat(auto-fit, minmax(230px, 1fr));
margin-top: 14px;
@ -355,6 +372,14 @@
<p class="meta-label">{{ __('localization.review.published_at') }}</p>
<p class="meta-value">{{ $publishedAt?->format('Y-m-d H:i') ?? '—' }}</p>
</div>
<div class="meta-item">
<p class="meta-label">{{ __('localization.review.report_effective_profile') }}</p>
<p class="meta-value">{{ $profile['label'] ?? __('localization.review.unavailable') }}</p>
</div>
<div class="meta-item">
<p class="meta-label">{{ __('localization.review.report_audience') }}</p>
<p class="meta-value">{{ $profile['audience_label'] ?? __('localization.review.unavailable') }}</p>
</div>
</div>
</section>
@ -391,6 +416,38 @@
@endif
</section>
<section class="section" data-testid="rendered-report-profile">
<h2>{{ __('localization.review.report_profile') }}</h2>
<div class="summary-grid">
<div class="summary-field">
<p class="field-label">{{ __('localization.review.report_effective_profile') }}</p>
<p class="field-value">{{ $profile['label'] ?? __('localization.review.unavailable') }}</p>
<p class="note">{{ $profile['effective_key'] ?? '—' }}</p>
</div>
<div class="summary-field">
<p class="field-label">{{ __('localization.review.report_audience') }}</p>
<p class="field-value">{{ $profile['audience_label'] ?? __('localization.review.unavailable') }}</p>
</div>
<div class="summary-field">
<p class="field-label">{{ __('localization.review.report_requested_profile') }}</p>
<p class="field-value">{{ filled($requestedProfile) ? $requestedProfile : '—' }}</p>
</div>
<div class="summary-field">
<p class="field-label">{{ __('localization.review.report_source_surface') }}</p>
<p class="field-value">{{ $sourceMetadata['source_surface'] ?? 'review_pack' }}</p>
<p class="note">{{ __('localization.review.interpretation_version') }}: {{ $sourceMetadata['interpretation_version'] ?? '—' }}</p>
</div>
</div>
@if (($profile['is_fallback'] ?? false) === true)
<div class="share-warning" style="margin-top:16px;" data-testid="rendered-report-profile-fallback">
{{ __('localization.review.report_profile_fallback_notice') }}
{{ __('localization.review.report_profile_fallback_summary') }}
</div>
@endif
</section>
@if (($report['limitations'] ?? []) !== [])
<section class="section" data-testid="rendered-report-output-limitations">
<h2>{{ __('localization.review.output_limitations') }}</h2>
@ -494,79 +551,118 @@
</section>
<section class="section" data-testid="rendered-report-disclosure">
<h2>{{ __('localization.review.non_certification_disclosure') }}</h2>
<p class="copy">{{ $report['non_certification_disclosure'] }}</p>
<h2>{{ __('localization.review.disclosure_policy') }}</h2>
@if (($disclosurePolicy['blocking_reasons'] ?? []) !== [])
<div class="limitations">
@foreach (($disclosurePolicy['blocking_reasons'] ?? []) as $reason)
<article class="limitation">
<h3>{{ $reason['label'] ?? __('localization.review.blocked') }}</h3>
<p class="copy">{{ $reason['summary'] ?? '' }}</p>
</article>
@endforeach
</div>
@endif
@if (($disclosurePolicy['warnings'] ?? []) !== [])
<div class="summary-grid" style="margin-top:14px;">
@foreach (($disclosurePolicy['warnings'] ?? []) as $warning)
<div class="summary-field">
<p class="field-label">{{ $warning['label'] ?? __('localization.review.requires_review') }}</p>
<p class="field-value">{{ $warning['summary'] ?? '' }}</p>
</div>
@endforeach
</div>
@endif
<div class="policy-grid">
@foreach (($disclosurePolicy['mandatory_disclosures'] ?? []) as $disclosure)
<article class="technical-card">
<div style="display:flex;flex-wrap:wrap;justify-content:space-between;gap:10px;align-items:center;">
<p class="field-label">{{ $disclosure['label'] ?? __('localization.review.non_certification_disclosure') }}</p>
<span class="badge" style="{{ $proofBadgeStyles[$disclosure['proof_state'] ?? 'unknown'] ?? $proofBadgeStyles['unknown'] }}">
{{ __('localization.review.proof_state_'.($disclosure['proof_state'] ?? 'unknown')) }}
</span>
</div>
<p class="copy">{{ $disclosure['summary'] ?? '' }}</p>
</article>
@endforeach
</div>
</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)
@if (($disclosurePolicy['show_section_appendix'] ?? false) !== true)
<p class="copy">{{ __('localization.review.report_appendix_hidden_for_profile') }}</p>
@else
<div class="technical-grid" data-testid="rendered-report-technical-details">
<div class="technical-card">
<p class="field-label">{{ $label }}</p>
<p class="field-value">{{ $value }}</p>
<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
</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
@endif
</section>
</div>
</main>

View File

@ -237,7 +237,7 @@
$resolveSmokeTenant,
$resolveSmokeUser,
$resolveSmokeWorkspace,
): \Illuminate\Http\RedirectResponse {
): \Symfony\Component\HttpFoundation\Response {
$tenant = $resolveSmokeTenant($tenantIdentifier);
$workspace = $resolveSmokeWorkspace($workspaceIdentifier, $tenant);
$user = $resolveSmokeUser($email, $workspace, $tenant);
@ -273,8 +273,27 @@
$workspaceContext->clearRememberedTenantContext($request);
}
return redirect()
->to($resolveSmokeRedirect($redirect, $tenant))
$target = $resolveSmokeRedirect($redirect, $tenant);
$escapedTarget = e($target);
$jsonTarget = json_encode($target, JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT);
return response(<<<HTML
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta http-equiv="refresh" content="0;url={$escapedTarget}">
<title>Redirecting to {$escapedTarget}</title>
<script>
window.location.replace({$jsonTarget});
</script>
</head>
<body>
Redirecting to <a href="{$escapedTarget}">{$escapedTarget}</a>.
</body>
</html>
HTML)
->header('X-Smoke-Redirect-To', $target)
->withCookie($makeSmokeCookie());
};

View File

@ -0,0 +1,294 @@
<?php
declare(strict_types=1);
use App\Models\ManagedEnvironment;
use App\Models\ReviewPack;
use App\Models\User;
use App\Services\ReviewPackService;
use App\Support\ReviewPacks\ReportProfileRegistry;
use App\Support\Workspaces\WorkspaceContext;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Storage;
uses(RefreshDatabase::class);
pest()->browser()->timeout(60_000);
beforeEach(function (): void {
Storage::fake('exports');
});
it('Spec357 smokes report profile variants on the rendered report route', function (): void {
[$user, $customerEnvironment, $customerReview, $customerPack] = spec357BrowserCreateProfilePack(
environmentName: 'Spec357 Browser Customer',
customerSafeReady: true,
);
[$limitedUser, $limitedEnvironment, $limitedReview, $limitedPack] = spec357BrowserCreateProfilePack(
user: $user,
workspaceId: (int) $customerEnvironment->workspace_id,
environmentName: 'Spec357 Browser Limited',
customerSafeReady: true,
packOverrides: [
'options' => [
'include_pii' => true,
'include_operations' => true,
],
],
);
spec357AuthenticateBrowser($this, $user, $customerEnvironment);
$customerExecutiveUrl = app(ReviewPackService::class)->generateRenderedReportUrl($customerPack, [
'source_surface' => 'review_pack',
'review_id' => (int) $customerReview->getKey(),
'interpretation_version' => $customerReview->controlInterpretationVersion(),
ReportProfileRegistry::QUERY_PARAMETER => ReportProfileRegistry::CUSTOMER_EXECUTIVE,
]);
visit($customerExecutiveUrl)
->resize(1280, 1440)
->waitForText(__('localization.review.report_profile_customer_executive'))
->assertSee(ReportProfileRegistry::CUSTOMER_EXECUTIVE)
->assertSee(__('localization.review.report_appendix_hidden_for_profile'))
->assertDontSee('Spec357 Technical Control')
->assertNoJavaScriptErrors()
->assertNoConsoleLogs()
->screenshot(true, spec357BrowserScreenshotName('01-customer-executive'));
spec357CopyBrowserScreenshot('01-customer-executive');
$customerTechnicalUrl = app(ReviewPackService::class)->generateRenderedReportUrl($customerPack, [
'source_surface' => 'review_pack',
'review_id' => (int) $customerReview->getKey(),
'interpretation_version' => $customerReview->controlInterpretationVersion(),
ReportProfileRegistry::QUERY_PARAMETER => ReportProfileRegistry::CUSTOMER_TECHNICAL,
]);
visit($customerTechnicalUrl)
->resize(1280, 1440)
->waitForText(__('localization.review.report_profile_customer_technical'))
->assertSee('Spec357 Technical Control')
->assertDontSee(__('localization.review.report_appendix_hidden_for_profile'))
->assertNoJavaScriptErrors()
->assertNoConsoleLogs()
->screenshot(true, spec357BrowserScreenshotName('02-customer-technical'));
spec357CopyBrowserScreenshot('02-customer-technical');
$internalUrl = app(ReviewPackService::class)->generateRenderedReportUrl($limitedPack, [
'source_surface' => 'review_pack',
'review_id' => (int) $limitedReview->getKey(),
'interpretation_version' => $limitedReview->controlInterpretationVersion(),
ReportProfileRegistry::QUERY_PARAMETER => ReportProfileRegistry::INTERNAL_MSP_REVIEW,
]);
visit($internalUrl)
->resize(1280, 1440)
->waitForText(__('localization.review.report_profile_internal_msp_review'))
->assertSee(__('localization.review.report_profile_internal_msp_review'))
->assertSee(__('localization.review.proof_state_not_applicable'))
->assertSee('Spec357 Technical Control')
->assertNoJavaScriptErrors()
->assertNoConsoleLogs()
->screenshot(true, spec357BrowserScreenshotName('03-internal-msp'));
spec357CopyBrowserScreenshot('03-internal-msp');
$auditorUrl = app(ReviewPackService::class)->generateRenderedReportUrl($customerPack, [
'source_surface' => 'review_pack',
'review_id' => (int) $customerReview->getKey(),
'interpretation_version' => $customerReview->controlInterpretationVersion(),
ReportProfileRegistry::QUERY_PARAMETER => ReportProfileRegistry::AUDITOR_APPENDIX,
]);
visit($auditorUrl)
->resize(1280, 1440)
->waitForText(__('localization.review.report_profile_auditor_appendix'))
->assertSee(__('localization.review.report_audience_controlled_auditor'))
->assertSee('Spec357 Technical Control')
->assertNoJavaScriptErrors()
->assertNoConsoleLogs()
->screenshot(true, spec357BrowserScreenshotName('04-auditor-appendix'));
spec357CopyBrowserScreenshot('04-auditor-appendix');
$fallbackUrl = app(ReviewPackService::class)->generateRenderedReportUrl($customerPack, [
'source_surface' => 'review_pack',
'review_id' => (int) $customerReview->getKey(),
'interpretation_version' => $customerReview->controlInterpretationVersion(),
ReportProfileRegistry::QUERY_PARAMETER => ReportProfileRegistry::FRAMEWORK_READINESS,
]);
visit($fallbackUrl)
->resize(1280, 1440)
->waitForText(__('localization.review.report_profile_fallback_notice'))
->assertSee(ReportProfileRegistry::FRAMEWORK_READINESS)
->assertSee(ReportProfileRegistry::INTERNAL_MSP_REVIEW)
->assertSee(__('localization.review.report_profile_internal_msp_review'))
->assertNoJavaScriptErrors()
->assertNoConsoleLogs()
->screenshot(true, spec357BrowserScreenshotName('05-fallback-framework'));
spec357CopyBrowserScreenshot('05-fallback-framework');
expect($limitedUser)->toBeInstanceOf(User::class);
});
function spec357BrowserScreenshotName(string $name): string
{
return 'spec357-report-profiles-'.$name;
}
function spec357CopyBrowserScreenshot(string $name): void
{
$filename = spec357BrowserScreenshotName($name).'.png';
$primarySource = base_path('tests/Browser/Screenshots/'.$filename);
$fallbackSource = \Pest\Browser\Support\Screenshot::path($filename);
$targetDirectory = repo_path('specs/357-report-profiles-disclosure-policy-v1/artifacts/screenshots');
if (! is_dir($targetDirectory)) {
@mkdir($targetDirectory, 0755, true);
}
$source = null;
for ($attempt = 0; $attempt < 50 && $source === null; $attempt++) {
foreach ([$primarySource, $fallbackSource] as $candidate) {
if (is_file($candidate)) {
$source = $candidate;
break;
}
}
if ($source !== null) {
break;
}
usleep(100_000);
clearstatcache(true, $primarySource);
clearstatcache(true, $fallbackSource);
}
if (is_string($source) && is_file($source) && is_dir($targetDirectory) && is_writable($targetDirectory)) {
@copy($source, $targetDirectory.DIRECTORY_SEPARATOR.$name.'.png');
}
}
function spec357AuthenticateBrowser(mixed $test, User $user, ManagedEnvironment $environment): void
{
$workspaceId = (int) $environment->workspace_id;
$test->actingAs($user)->withSession([
WorkspaceContext::SESSION_KEY => $workspaceId,
WorkspaceContext::LAST_ENVIRONMENT_IDS_SESSION_KEY => [
(string) $workspaceId => (int) $environment->getKey(),
],
]);
session()->put(WorkspaceContext::SESSION_KEY, $workspaceId);
session()->put(WorkspaceContext::LAST_ENVIRONMENT_IDS_SESSION_KEY, [
(string) $workspaceId => (int) $environment->getKey(),
]);
setAdminPanelContext($environment);
}
/**
* @param array<string, mixed>|null $packOverrides
* @return array{0:User,1:ManagedEnvironment,2:\App\Models\EnvironmentReview,3:ReviewPack}
*/
function spec357BrowserCreateProfilePack(
?User $user = null,
?int $workspaceId = null,
string $environmentName = 'Spec357 Browser Environment',
bool $customerSafeReady = false,
?array $packOverrides = [],
): array {
$workspaceId ??= null;
$environment = ManagedEnvironment::factory()->active()->create([
'workspace_id' => $workspaceId,
'name' => $environmentName,
]);
if ($user === null) {
[$user, $environment] = createUserWithTenant(tenant: $environment, role: 'owner', workspaceRole: 'manager');
} else {
createUserWithTenant(tenant: $environment, user: $user, role: 'owner', workspaceRole: 'manager');
}
$snapshot = seedEnvironmentReviewEvidence($environment, findingCount: 0, driftCount: 0);
$review = composeEnvironmentReviewForTest($environment, $user, $snapshot);
$review->forceFill([
'status' => 'published',
'published_at' => now(),
'published_by_user_id' => (int) $user->getKey(),
])->save();
if ($customerSafeReady) {
$review = markEnvironmentReviewCustomerSafeReady($review);
}
$review->loadMissing('sections');
$appendixSection = $review->sections->first();
if ($appendixSection instanceof \App\Models\EnvironmentReviewSection) {
$appendixSection->forceFill([
'render_payload' => array_replace_recursive(
is_array($appendixSection->render_payload) ? $appendixSection->render_payload : [],
[
'entries' => [
[
'title' => 'Spec357 Technical Control',
'summary' => 'Visible only on appendix-capable profiles.',
],
],
],
),
])->save();
}
$filePath = 'review-packs/'.($environment->external_id ?: 'spec357').'/browser-report.zip';
Storage::disk('exports')->put($filePath, 'PK-spec357-browser-report');
$summary = array_replace_recursive([
'governance_package' => [
'executive_summary' => 'Spec 357 browser report summary.',
'top_findings' => [],
'accepted_risks' => [],
'decision_summary' => [
'status' => 'none',
'summary' => '',
'next_action' => '',
'entries' => [],
],
],
'control_interpretation' => [
'non_certification_disclosure' => 'Spec 357 browser non-certification disclosure.',
],
'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'] : []);
$pack = ReviewPack::factory()->ready()->create(array_merge([
'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' => [
'include_pii' => false,
'include_operations' => true,
],
'summary' => $summary,
'file_path' => $filePath,
'file_disk' => 'exports',
'generated_at' => now()->subMinutes(3),
'expires_at' => now()->addDay(),
], $packOverrides ?? []));
$review->forceFill([
'current_export_review_pack_id' => (int) $pack->getKey(),
])->save();
return [$user, $environment, $review->fresh(['sections', 'evidenceSnapshot', 'currentExportReviewPack']), $pack->fresh()];
}

View File

@ -21,7 +21,8 @@
]));
$response
->assertRedirect(EnvironmentDashboard::getUrl(tenant: $tenant))
->assertSuccessful()
->assertHeader('X-Smoke-Redirect-To', EnvironmentDashboard::getUrl(tenant: $tenant))
->assertPlainCookie(
SuppressDebugbarForSmokeRequests::COOKIE_NAME,
SuppressDebugbarForSmokeRequests::COOKIE_VALUE,

View File

@ -28,7 +28,8 @@
expect($tenant)->not->toBeNull();
$this->get(route('admin.local.backup-health-browser-fixture-login'))
->assertRedirect(EnvironmentDashboard::getUrl(tenant: $tenant))
->assertSuccessful()
->assertHeader('X-Smoke-Redirect-To', EnvironmentDashboard::getUrl(tenant: $tenant))
->assertPlainCookie(
SuppressDebugbarForSmokeRequests::COOKIE_NAME,
SuppressDebugbarForSmokeRequests::COOKIE_VALUE,

View File

@ -7,10 +7,13 @@
use App\Filament\Resources\EnvironmentReviewResource\Pages\ViewEnvironmentReview;
use App\Models\EnvironmentReview;
use App\Models\ManagedEnvironment;
use App\Models\OperationRun;
use App\Models\ReviewPack;
use App\Models\User;
use App\Models\Workspace;
use App\Support\EnvironmentReviewStatus;
use App\Support\OperationRunStatus;
use App\Support\OperationRunType;
use App\Support\Workspaces\WorkspaceContext;
use Filament\Actions\Action;
use Illuminate\Foundation\Testing\RefreshDatabase;
@ -87,7 +90,8 @@
'workspace' => $workspace->slug,
'redirect' => $detailRedirect,
]))
->assertRedirect($detailRedirect)
->assertSuccessful()
->assertHeader('X-Smoke-Redirect-To', $detailRedirect)
->assertSessionHas(WorkspaceContext::SESSION_KEY, (int) $workspace->getKey());
$this->assertAuthenticatedAs($user);
@ -98,6 +102,40 @@
->test(ViewEnvironmentReview::class, ['record' => $readyReview->getKey()])
->assertActionVisible('publish_review')
->assertActionExists('publish_review', fn (Action $action): bool => $action->isConfirmationRequired());
expect($publishedReview->operation_run_id)->not->toBeNull()
->and($readyReview->operation_run_id)->not->toBeNull()
->and($publishedReview->operation_run_id)->not->toBe($readyReview->operation_run_id);
$composeRuns = OperationRun::query()
->where('managed_environment_id', (int) $environment->getKey())
->where('type', OperationRunType::EnvironmentReviewCompose->value)
->orderBy('id')
->get();
expect($composeRuns)->toHaveCount(2)
->and($composeRuns->pluck('status')->all())->toBe([
OperationRunStatus::Completed->value,
OperationRunStatus::Completed->value,
])
->and($composeRuns->pluck('outcome')->all())->toBe([
'succeeded',
'succeeded',
]);
$this->artisan('tenantpilot:review-output:seed-browser-fixture', ['--no-interaction' => true])
->assertSuccessful();
expect(
OperationRun::query()
->where('managed_environment_id', (int) $environment->getKey())
->where('type', OperationRunType::EnvironmentReviewCompose->value)
->whereIn('status', [
OperationRunStatus::Queued->value,
OperationRunStatus::Running->value,
])
->count()
)->toBe(0);
});
function tenantpilotReviewOutputFixtureRelativeAdminRedirect(string $url): string

View File

@ -22,7 +22,8 @@
'tenant' => $environment->slug,
'redirect' => $redirect,
]))
->assertRedirect($redirect)
->assertSuccessful()
->assertHeader('X-Smoke-Redirect-To', $redirect)
->assertSessionHas('current_workspace_id', (int) $environment->workspace_id);
});
@ -84,6 +85,7 @@
'tenant' => $environment->slug,
'redirect' => $redirect,
]))
->assertRedirect($redirect)
->assertSuccessful()
->assertHeader('X-Smoke-Redirect-To', $redirect)
->assertSessionHas('current_workspace_id', (int) $workspace->getKey());
});

View File

@ -0,0 +1,209 @@
<?php
declare(strict_types=1);
use App\Models\ManagedEnvironment;
use App\Models\ReviewPack;
use App\Services\ReviewPackService;
use App\Support\ReviewPacks\ReportProfileRegistry;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Storage;
uses(RefreshDatabase::class);
beforeEach(function (): void {
Storage::fake('exports');
});
it('renders customer executive profile metadata and hides detailed appendix content on the signed report route', function (): void {
[$user, $tenant, $review, $pack] = spec357CreateCurrentReviewPackForRenderedReport(customerSafeReady: true);
$signedUrl = app(ReviewPackService::class)->generateRenderedReportUrl($pack, [
'source_surface' => 'review_pack',
'review_id' => (int) $review->getKey(),
'interpretation_version' => $review->controlInterpretationVersion(),
ReportProfileRegistry::QUERY_PARAMETER => ReportProfileRegistry::CUSTOMER_EXECUTIVE,
]);
$this->actingAs($user)
->get($signedUrl)
->assertOk()
->assertSee(__('localization.review.report_profile'))
->assertSee(__('localization.review.report_effective_profile'))
->assertSee(ReportProfileRegistry::CUSTOMER_EXECUTIVE)
->assertSee(__('localization.review.report_profile_customer_executive'))
->assertSee(__('localization.review.report_audience_customer_executive'))
->assertSee(__('localization.review.disclosure_policy'))
->assertSee(__('localization.review.proof_state_assumed'))
->assertSee(__('localization.review.report_appendix_hidden_for_profile'))
->assertDontSee('Spec357 Technical Control');
});
it('shows appendix detail for the technical customer profile while keeping the route signed and read-only', function (): void {
[$user, $tenant, $review, $pack] = spec357CreateCurrentReviewPackForRenderedReport(customerSafeReady: true);
$signedUrl = app(ReviewPackService::class)->generateRenderedReportUrl($pack, [
'source_surface' => 'review_pack',
'review_id' => (int) $review->getKey(),
'interpretation_version' => $review->controlInterpretationVersion(),
ReportProfileRegistry::QUERY_PARAMETER => ReportProfileRegistry::CUSTOMER_TECHNICAL,
]);
$this->actingAs($user)
->get($signedUrl)
->assertOk()
->assertSee(ReportProfileRegistry::CUSTOMER_TECHNICAL)
->assertSee(__('localization.review.report_profile_customer_technical'))
->assertSee('Spec357 Technical Control')
->assertDontSee(__('localization.review.report_appendix_hidden_for_profile'));
});
it('fails closed to the internal msp review profile for invalid or placeholder profile requests', function (string $requestedProfile): void {
[$user, $tenant, $review, $pack] = spec357CreateCurrentReviewPackForRenderedReport(customerSafeReady: true);
$signedUrl = app(ReviewPackService::class)->generateRenderedReportUrl($pack, [
'source_surface' => 'review_pack',
'review_id' => (int) $review->getKey(),
'interpretation_version' => $review->controlInterpretationVersion(),
ReportProfileRegistry::QUERY_PARAMETER => $requestedProfile,
]);
$response = $this->actingAs($user)->get($signedUrl);
$response->assertOk()
->assertSee(__('localization.review.report_profile_internal_msp_review'))
->assertSee(ReportProfileRegistry::INTERNAL_MSP_REVIEW)
->assertSee($requestedProfile)
->assertSee(__('localization.review.report_effective_profile'))
->assertSee(__('localization.review.report_requested_profile'))
->assertSee(__('localization.review.report_profile_fallback_notice'))
->assertSee(__('localization.review.proof_state_verified'));
})->with([
'unknown profile' => ['unknown_profile_key'],
'framework placeholder' => [ReportProfileRegistry::FRAMEWORK_READINESS],
]);
it('keeps customer-facing profiles visibly limited when pii-bearing output is requested', function (): void {
[$user, $tenant, $review, $pack] = spec357CreateCurrentReviewPackForRenderedReport(
packOverrides: [
'options' => [
'include_pii' => true,
'include_operations' => true,
],
],
customerSafeReady: true,
);
$signedUrl = app(ReviewPackService::class)->generateRenderedReportUrl($pack, [
'source_surface' => 'review_pack',
'review_id' => (int) $review->getKey(),
'interpretation_version' => $review->controlInterpretationVersion(),
ReportProfileRegistry::QUERY_PARAMETER => ReportProfileRegistry::CUSTOMER_EXECUTIVE,
]);
$this->actingAs($user)
->get($signedUrl)
->assertOk()
->assertSee(__('localization.review.report_profile_customer_executive'))
->assertSee(__('localization.review.report_external_sharing_warning'))
->assertSee(__('localization.review.report_disclosure_customer_profile_internal_only'))
->assertSee(__('localization.review.proof_state_missing'))
->assertDontSee(__('localization.review.report_state_customer_safe_ready'));
});
/**
* @param array<string, mixed>|null $packOverrides
* @return array{0:\App\Models\User,1:ManagedEnvironment,2:\App\Models\EnvironmentReview,3:ReviewPack}
*/
function spec357CreateCurrentReviewPackForRenderedReport(
?array $packOverrides = [],
bool $customerSafeReady = false,
?\App\Models\EvidenceSnapshot $snapshot = null,
): array {
$packOverrides ??= [];
$tenant = 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);
}
$review->loadMissing('sections');
$appendixSection = $review->sections->first();
if ($appendixSection instanceof \App\Models\EnvironmentReviewSection) {
$appendixSection->forceFill([
'render_payload' => array_replace_recursive(
is_array($appendixSection->render_payload) ? $appendixSection->render_payload : [],
[
'entries' => [
[
'title' => 'Spec357 Technical Control',
'summary' => 'Visible only on appendix-capable profiles.',
],
],
'highlights' => ['Spec357 appendix highlight.'],
],
),
])->save();
}
$filePath = 'review-packs/'.$tenant->external_id.'/spec357-rendered-report.zip';
Storage::disk('exports')->put($filePath, 'PK-spec357-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-spec357-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()];
}

View File

@ -0,0 +1,102 @@
<?php
declare(strict_types=1);
use App\Support\ReviewPacks\ReportDisclosurePolicy;
use App\Support\ReviewPacks\ReportProfileRegistry;
use App\Support\ReviewPacks\ReviewPackOutputReadiness;
it('derives mandatory disclosures and proof states for a customer executive profile without overclaiming hidden-value proof', function (): void {
$profile = ReportProfileRegistry::resolve(ReportProfileRegistry::CUSTOMER_EXECUTIVE);
$policy = ReportDisclosurePolicy::evaluate(
$profile,
spec357DisclosureReadiness(
evidenceCompletenessState: 'complete',
includePii: false,
protectedValuesHidden: true,
disclosurePresent: true,
),
[
'non_certification_disclosure' => 'Spec 357 non-certification disclosure.',
],
);
expect(collect($policy['mandatory_disclosures'])->pluck('key')->all())
->toContain('audience_boundary', 'evidence_basis', 'protected_values', 'non_certification')
->and(data_get($policy, 'proof_states.evidence_basis'))->toBe('verified')
->and(data_get($policy, 'proof_states.protected_values'))->toBe('assumed')
->and(data_get($policy, 'proof_states.non_certification'))->toBe('assumed')
->and($policy['blocking_reasons'])->toBeEmpty()
->and($policy['show_section_appendix'])->toBeFalse()
->and($policy['show_technical_details'])->toBeFalse();
});
it('marks missing and unknown proof states and blocks customer-facing output when pii-bearing detail is requested', function (): void {
$profile = ReportProfileRegistry::resolve(ReportProfileRegistry::CUSTOMER_EXECUTIVE);
$policy = ReportDisclosurePolicy::evaluate(
$profile,
spec357DisclosureReadiness(
evidenceCompletenessState: 'mystery_state',
includePii: true,
protectedValuesHidden: false,
disclosurePresent: false,
),
[
'non_certification_disclosure' => '',
],
);
expect(collect($policy['blocking_reasons'])->pluck('key')->all())
->toContain('customer_profile_internal_only')
->and(data_get($policy, 'proof_states.evidence_basis'))->toBe('unknown')
->and(data_get($policy, 'proof_states.protected_values'))->toBe('missing')
->and(data_get($policy, 'proof_states.non_certification'))->toBe('missing');
});
it('uses not applicable proof state for hidden-value claims on internal profiles that intentionally include pii', function (): void {
$profile = ReportProfileRegistry::resolve(ReportProfileRegistry::INTERNAL_MSP_REVIEW);
$policy = ReportDisclosurePolicy::evaluate(
$profile,
spec357DisclosureReadiness(
includePii: true,
protectedValuesHidden: false,
disclosurePresent: true,
),
[
'non_certification_disclosure' => 'Internal profile disclosure.',
],
);
expect(data_get($policy, 'proof_states.protected_values'))->toBe('not_applicable')
->and($policy['show_section_appendix'])->toBeTrue()
->and($policy['show_technical_details'])->toBeTrue();
});
/**
* @param list<array{code:string}> $publishBlockers
* @return array<string, mixed>
*/
function spec357DisclosureReadiness(
string $reviewStatus = 'published',
string $reviewCompletenessState = 'complete',
string $evidenceCompletenessState = 'complete',
array $publishBlockers = [],
bool $hasReadyExport = true,
bool $includePii = false,
bool $protectedValuesHidden = true,
bool $disclosurePresent = true,
): array {
return ReviewPackOutputReadiness::derive(
reviewStatus: $reviewStatus,
reviewCompletenessState: $reviewCompletenessState,
evidenceCompletenessState: $evidenceCompletenessState,
sectionStateCounts: ['complete' => 4],
requiredSectionCount: 4,
requiredSectionStateCounts: ['complete' => 4],
publishBlockers: $publishBlockers,
hasReadyExport: $hasReadyExport,
includePii: $includePii,
protectedValuesHidden: $protectedValuesHidden,
disclosurePresent: $disclosurePresent,
);
}

View File

@ -0,0 +1,53 @@
<?php
declare(strict_types=1);
use App\Support\ReviewPacks\ReportProfileRegistry;
use App\Support\ReviewPacks\ReviewPackOutputResolutionGuidance;
it('defines the implemented profiles and framework placeholder for the rendered report family', function (): void {
$profiles = ReportProfileRegistry::all();
expect(array_keys($profiles))
->toContain(
ReportProfileRegistry::CUSTOMER_EXECUTIVE,
ReportProfileRegistry::CUSTOMER_TECHNICAL,
ReportProfileRegistry::INTERNAL_MSP_REVIEW,
ReportProfileRegistry::AUDITOR_APPENDIX,
ReportProfileRegistry::FRAMEWORK_READINESS,
)
->and($profiles[ReportProfileRegistry::CUSTOMER_EXECUTIVE]['implemented'])->toBeTrue()
->and($profiles[ReportProfileRegistry::CUSTOMER_TECHNICAL]['show_section_appendix'])->toBeTrue()
->and($profiles[ReportProfileRegistry::CUSTOMER_EXECUTIVE]['show_section_appendix'])->toBeFalse()
->and($profiles[ReportProfileRegistry::INTERNAL_MSP_REVIEW]['show_technical_details'])->toBeTrue()
->and($profiles[ReportProfileRegistry::FRAMEWORK_READINESS]['implemented'])->toBeFalse();
});
it('fails closed to the internal msp review profile for unknown or unimplemented profile keys', function (string $requestedKey, string $fallbackReason): void {
$resolved = ReportProfileRegistry::resolve($requestedKey);
expect($resolved['requested_key'])->toBe($requestedKey)
->and($resolved['effective_key'])->toBe(ReportProfileRegistry::DEFAULT_PROFILE)
->and($resolved['is_fallback'])->toBeTrue()
->and($resolved['fallback_reason'])->toBe($fallbackReason)
->and($resolved['implemented'])->toBeTrue()
->and($resolved['profile_key'])->toBe(ReportProfileRegistry::DEFAULT_PROFILE);
})->with([
'unknown key' => ['no_such_profile', 'unknown_profile'],
'framework placeholder' => [ReportProfileRegistry::FRAMEWORK_READINESS, 'unimplemented_profile'],
]);
it('chooses the customer executive profile for customer-safe handoff and the internal profile for limited operator contexts', function (): void {
expect(ReportProfileRegistry::defaultForRenderedReportState(
ReviewPackOutputResolutionGuidance::STATE_CUSTOMER_SAFE_READY,
true,
))->toBe(ReportProfileRegistry::CUSTOMER_EXECUTIVE)
->and(ReportProfileRegistry::defaultForRenderedReportState(
ReviewPackOutputResolutionGuidance::STATE_PUBLISHED_WITH_LIMITATIONS,
false,
))->toBe(ReportProfileRegistry::INTERNAL_MSP_REVIEW)
->and(ReportProfileRegistry::defaultForRenderedReportState(
ReviewPackOutputResolutionGuidance::STATE_INTERNAL_ONLY,
false,
))->toBe(ReportProfileRegistry::INTERNAL_MSP_REVIEW);
});

View File

@ -8,8 +8,8 @@ # UI-099 Rendered Review 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. |
| Screenshot | `specs/357-report-profiles-disclosure-policy-v1/artifacts/screenshots/spec357-in-app-browser-customer-executive.png` |
| Browser status | Reached in the live in-app browser on 2026-06-05 via the Spec 351 review-output fixture plus a signed rendered-report URL; verified HTML-first chrome, effective profile metadata, disclosure-proof badges, and appendix hiding for `customer_executive`. |
## First Five Seconds
@ -24,14 +24,16 @@ ## 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.
- Audience-first: the route now states effective profile, requested profile, audience boundary, and source surface before appendix detail.
- Truth-over-presentation: profile-specific filtering may hide appendix detail, but it may not hide readiness, evidence state, disclosure state, or non-certification copy.
- 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.
- Customer/auditor safety: diagnostics remain appendix-level; customer-facing profiles fail closed or hide appendix detail when internal-only or PII-bearing scope remains.
- 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.
Default-visible content should include executive summary, effective profile, audience, requested profile, source metadata, evidence basis, limitations, key findings, accepted risks, governance decisions requiring awareness, next actions, disclosure-proof state badges, and non-certification disclosure. Technical details and the structured appendix remain profile-aware and may be hidden for customer-executive delivery.
## Dangerous Actions
@ -47,6 +49,15 @@ ## Spec 356 Follow-up
- 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
## Spec 357 Follow-up
Spec 357 adds a bounded report-policy layer on the same route:
- profile selection remains on the signed rendered-report URL seam and fails closed for unknown or placeholder profiles
- the route now shows effective profile, audience, requested profile, and source surface metadata explicitly
- disclosure proof is split into `verified`, `assumed`, `missing`, `unknown`, and `not_applicable` states instead of silently upgrading stored booleans to verified truth
- appendix and technical-detail visibility now depend on the effective profile and current internal-only / PII boundary
## 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.

Binary file not shown.

After

Width:  |  Height:  |  Size: 322 KiB

View File

@ -0,0 +1,45 @@
# Requirements Checklist: Spec 357 - Report Profiles & Disclosure Policy v1
**Purpose**: Confirm Spec 357 is selected safely, scoped narrowly, and ready for a later implementation loop.
**Created**: 2026-06-05
**Feature**: `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/357-report-profiles-disclosure-policy-v1/spec.md`
## Candidate Selection
- [x] CHK001 The candidate is directly provided by the user and is also a repo-real follow-up over current rendered-report truth.
- [x] CHK002 No `specs/357-*` package existed before this preparation run.
- [x] CHK003 Related specs 347, 355, and 356 were checked for completed/prepared/runtime signals and are treated as context only.
- [x] CHK004 Close alternatives are deferred instead of hidden inside the primary scope: billing/subscription truth, localization adoption, governance-artifact lifecycle, first AI runtime consumer, and customer-portal consumption.
- [x] CHK005 The selected slice is bounded to static profile/disclosure policy plus the existing rendered-report family.
## Spec Completeness
- [x] CHK006 `spec.md` defines problem, user-visible improvement, smallest viable version, explicit non-goals, and why-now rationale.
- [x] CHK007 The Spec Candidate Check is filled and scored above the approval threshold.
- [x] CHK008 Scope, routes, data ownership, RBAC, and canonical-view filter/entitlement rules are explicit.
- [x] CHK009 UI Surface Impact and UI/Productization Coverage are completed for the existing strategic report surfaces.
- [x] CHK010 Cross-cutting reuse, OperationRun posture, provider-boundary check, proportionality review, testing impact, user stories, and acceptance criteria are all explicit.
- [x] CHK011 No template placeholders (`[FEATURE]`, `[DATE]`, `NEEDS CLARIFICATION`) remain in `spec.md`.
## Plan Quality
- [x] CHK012 `plan.md` is repo-aware and names the existing runtime seams to extend.
- [x] CHK013 The plan keeps the slice inside the current `ReviewPack` rendered-report family and forbids new persistence, delivery workflow, PDF stack, portal, and AI scope.
- [x] CHK014 Livewire v4 posture, Filament provider location, and current global-search/no-new-panel expectations are explicit.
- [x] CHK015 The plan distinguishes repo-real truth, current gaps, technical approach, authorization posture, and rollout impact clearly enough for implementation.
- [x] CHK016 The plan declares Unit + Feature + one bounded Browser smoke as the narrowest honest validation mix.
## Task Quality
- [x] CHK017 `tasks.md` exists and is ordered into small, verifiable phases.
- [x] CHK018 Tasks start with repo truth and failing tests before behavior changes.
- [x] CHK019 Tasks reference concrete repo files or namespaces and avoid speculative architecture.
- [x] CHK020 Tasks include explicit validation commands, screenshot capture, and `git diff --check`.
- [x] CHK021 Tasks explicitly forbid new persistence, delivery workflow, PDF stack, portal, AI, and provider-boundary widening.
## Readiness
- [x] CHK022 `spec.md`, `plan.md`, `tasks.md`, and this checklist exist.
- [x] CHK023 No open question blocks safe implementation; defaults remain conservative if unanswered.
- [x] CHK024 The slice is small enough for a bounded implementation loop.
- [x] CHK025 Result: ready for implementation loop.

View File

@ -0,0 +1,301 @@
# Implementation Plan: Spec 357 - Report Profiles & Disclosure Policy v1
**Branch**: `357-report-profiles-disclosure-policy-v1` | **Date**: 2026-06-05 | **Spec**: `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/357-report-profiles-disclosure-policy-v1/spec.md`
**Input**: Feature specification from `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/357-report-profiles-disclosure-policy-v1/spec.md`
## Summary
Add a bounded report-policy layer to the current rendered review-report flow. The implementation should keep the current `ReviewPack` artifact and rendered report route, but introduce:
1. a static report-profile registry
2. a disclosure-policy evaluator with explicit proof states
3. profile-aware rendered-report composition and section filtering
The slice must stay static and read-only. It must not create new persistence, delivery flows, public links, PDF infrastructure, or AI/runtime expansion.
## Technical Context
**Language/Version**: PHP 8.4.15
**Primary Dependencies**: Laravel 12.52, Filament 5.2.1, Livewire 4.1.4, Pest 4.3
**Storage**: Existing PostgreSQL truth plus existing `exports` disk-backed `ReviewPack` artifacts only
**Testing**: Pest Unit, Feature, Browser
**Validation Lanes**: confidence, browser
**Target Platform**: Laravel Sail local runtime and existing Filament admin panel
**Project Type**: Laravel monolith with Filament admin surfaces
**Performance Goals**: No measurable render slowdown beyond current rendered report; no new remote calls during render
**Constraints**: No Graph/provider calls during render, no new persistence, no public route contract widening, no second artifact family
**Scale/Scope**: One existing report route and its owner surfaces, one static registry, one policy evaluator, focused regressions
## Repo Truth
Current repo-real seams:
- `apps/platform/app/Http/Controllers/ReviewPackRenderedReportController.php` already builds the rendered report payload.
- `apps/platform/resources/views/review-packs/rendered-report.blade.php` already renders HTML-first output.
- `apps/platform/app/Support/ReviewPacks/ReviewPackOutputReadiness.php` and `ReviewPackOutputResolutionGuidance.php` already derive readiness and limitation semantics.
- `apps/platform/app/Services/ReviewPackService.php` and `GenerateReviewPackJob` already anchor the current `ReviewPack` delivery contract.
- `docs/ui-ux-enterprise-audit/page-reports/ui-006-customer-review-workspace.md`, `ui-042-review-pack-detail.md`, and `ui-099-rendered-review-report.md` already document the affected strategic surfaces.
Current gap:
- There is no static report-profile registry.
- There is no first-class disclosure-policy result that distinguishes `verified`, `assumed`, `missing`, `unknown`, and `not_applicable`.
- The current renderer cannot express different audience/detail policies without ad hoc branching.
## Candidate Selection Gate
**Result**: PASS
- The candidate was directly provided by the user as a concrete Spec 357 draft.
- It aligns with current runtime truth after Spec 356 rather than reopening an older or completed foundation.
- Related specs 347, 355, and 356 are treated as historical/runtime context only.
- The scope is small and reviewable: static registry + disclosure policy + existing rendered-report integration.
- Broader alternatives such as billing, localization, customer portal, or AI remain explicitly deferred.
## UI / Surface Guardrail Plan
- **Guardrail scope**: changed existing customer/report surfaces
- **Affected routes/pages/actions/states/navigation/panel/provider surfaces**:
- `/admin/reviews/workspace`
- `/admin/workspaces/{workspace}/environments/{environment}/environment-reviews/{record}`
- `/admin/workspaces/{workspace}/environments/{environment}/review-packs/{record}`
- `/admin/review-packs/{reviewPack}/report`
- **No-impact class, if applicable**: N/A
- **Native vs custom classification summary**: mixed; existing native Filament owner surfaces plus the existing custom rendered-report Blade surface
- **Shared-family relevance**: report viewer, status messaging, artifact truth, customer-safe disclosure
- **State layers in scope**: detail route payload and existing owner-page derived payload; no shell or navigation contract change
- **Audience modes in scope**: customer/read-only, operator-MSP, controlled auditor, support/internal
- **Decision-first contract**: the rendered report must identify its audience, readiness, and limitations immediately before showing deeper section detail
- **Required tests or manual smoke**:
- explicit Unit tests for policy
- explicit Feature coverage for rendered report
- one bounded Browser smoke for profile variants and disclosure hierarchy
- **Active feature PR close-out entry**: `Guardrail / Exception / Smoke Coverage`
## Technical Approach
### 1. Keep everything inside the existing ReviewPack report family
Do not create:
- a new report persistence model
- a separate rendered artifact family
- a profile CRUD resource
- a delivery workflow
The current rendered report stays the single report route. The new logic should live inside `App\Support\ReviewPacks` and be consumed by the existing controller and owner surfaces.
### 2. Introduce a static report-profile registry
Preferred repo-aligned shape:
- `apps/platform/app/Support/ReviewPacks/ReportProfileRegistry.php`
- one static array-based contract or readonly value structure
- no database/config mutation path
Required implemented profiles:
- `customer_executive`
- `customer_technical`
- `internal_msp_review`
- `auditor_appendix`
Required placeholder:
- `framework_readiness` marked not implemented and not selectable for an implemented report
The registry must remain narrow and typed enough for tests, but must not become a generalized framework.
### 3. Introduce a disclosure-policy evaluator
Preferred repo-aligned shape:
- `apps/platform/app/Support/ReviewPacks/ReportDisclosurePolicy.php`
Inputs:
- selected effective profile
- current readiness / limitation truth
- evidence completeness state
- PII/internal-only state
- available source/disclosure metadata
Outputs:
- mandatory disclosures
- warnings
- blocking reasons
- proof states (`verified`, `assumed`, `not_applicable`, `missing`, `unknown`)
The evaluator must use stored truth only. No render-time provider or Graph calls.
### 4. Add one local rendered-report profile composition layer if needed
The current controller already builds a large report payload. If profile/disclosure integration makes that method unreviewable, introduce one local support layer under `App\Support\ReviewPacks` to compose:
- effective profile metadata
- filtered section list
- disclosure result
- appendix policy
- audience labels
This must stay local to the current rendered report. No cross-domain reusable viewer framework.
### 5. Keep profile selection bounded and authenticated
V1 defaults:
- default effective profile: `internal_msp_review`
- customer-facing or auditor-oriented variants remain authenticated and capability-gated
Preferred surface policy:
- the existing report route may accept a bounded authenticated profile selector only when the parameter is carried through the existing signed URL builders (`ReviewPackService::generateRenderedReportUrl()` and current owner-surface helper/action seams)
- existing owner surfaces may add at most one small group of safe "view report as ..." actions if that remains reviewable
- if action-surface growth is too large, keep selection route-local via those existing signed URL seams and test it directly
No unsigned query contract or share link may be introduced.
### 6. Mandatory truth overrides profile presentation
Profiles may influence:
- section inclusion
- appendix visibility
- detail level
- audience label
Profiles may not hide:
- readiness state
- evidence completeness / limitation state
- internal-only or PII warning
- non-certification disclosure
- generated/source metadata
### 7. Preserve current owner-surface boundaries
- `CustomerReviewWorkspace` remains the handoff surface
- `EnvironmentReviewResource` remains the released-review detail seam
- `ReviewPackResource` remains the artifact truth seam
- the rendered report remains read-only delivery output
Do not turn any of these into a profile management surface.
## Domain / Model Implications
- No new table, column, enum, or persisted artifact family is expected.
- `ReviewPack`, `EnvironmentReview`, `EnvironmentReviewSection`, and `EvidenceSnapshot` remain the only persisted truth used here.
- Disclosure proof states remain derived-only and request-scoped.
## Filament / Livewire Implications
- Livewire v4.1.4 compliance is required; no Livewire v3 APIs.
- Filament panel provider registration remains unchanged in `apps/platform/bootstrap/providers.php`.
- No global-search changes are expected. `ReviewPackResource` and related review surfaces keep their current search posture.
- Any new action affordances on owner surfaces must remain read-only and subordinate to the existing primary inspect/open model.
## OperationRun / Observability Implications
- No new `OperationRun` type is introduced.
- Existing review-pack generation/run-link behavior remains authoritative.
- Any profile or disclosure result should remain derivable from existing stored artifact truth and not require a new run or audit path.
## Authorization / Security Implications
- Existing workspace/environment entitlement and `Capabilities::REVIEW_PACK_VIEW` remain authoritative.
- Customer-facing profiles must not bypass internal-only or current-export guards.
- Invalid profile keys must fail closed.
- Non-members and out-of-scope records remain 404, missing capability remains 403.
## Data / Persistence Implications
- No migrations
- No new settings rows
- No new config-write path
- No new stored report or rendered artifact record
If implementation proves persistence is required, stop and split the scope.
## Test Strategy
### Unit
- registry shape and implemented/placeholder behavior
- disclosure proof-state evaluation
- invalid-profile fail-closed behavior
### Feature
- rendered report profile selection and profile metadata
- mandatory disclosures cannot be hidden
- customer profiles remain visibly limited on limited/internal output
- internal profile remains clearly internal when appropriate
### Browser
One bounded smoke file covering:
- internal MSP profile with limitations
- customer executive profile on limited output
- customer-safe profile on ready output
- auditor appendix profile
- invalid or placeholder profile behavior
### Regression
Keep existing rendered-report and review-pack tests green, especially Spec 347 and Spec 356 coverage.
## Rollout / Deployment
- No env vars
- No migrations
- No queue changes
- No scheduler changes
- No storage contract changes
- No `filament:assets` impact expected
## Risks And Controls
- **Risk**: profile system drifts into delivery or portal work
- **Control**: static registry only; explicit non-goals in tasks and checklist
- **Risk**: proof-state modeling becomes a fake certainty layer
- **Control**: explicit `assumed`/`verified` distinction and tests
- **Risk**: customer-facing profile hides limitations
- **Control**: mandatory disclosure override plus feature/browser assertions
- **Risk**: controller complexity grows too far
- **Control**: allow one local report-profile payload layer only if needed
## Implementation Phases
1. Repo-truth recheck and failing tests first
2. Static report-profile registry
3. Disclosure-policy evaluator with proof states
4. Rendered-report payload integration and section filtering
5. Owner-surface handoff and localization updates
6. UI-audit follow-through
7. Focused validation, browser smoke, and screenshots
## Spec Readiness Gate
**Result**: PASS
- `spec.md`, `plan.md`, and `tasks.md` are present.
- Scope is bounded to the current rendered report family.
- No open question blocks safe implementation.
- RBAC, isolation, disclosure truth, and customer-safe behavior are explicit.
- No new persistence or delivery workflow is required.
- Required checklist artifact is included in this spec package.
## Assumptions
- The current rendered report route remains the correct owner for profile-aware output.
- A bounded authenticated selection mechanism is sufficient for v1.
- Existing report/resource page reports can absorb the UI audit updates without inventing new page identities.
## Open Questions
- Whether owner-surface actions are necessary or whether the signed-URL-local profile selector is enough for v1
- Whether `auditor_appendix` should render as internal-only by default until a later delivery workflow spec
These are safe implementation decisions, not product blockers.

View File

@ -0,0 +1,373 @@
# Feature Specification: Spec 357 - Report Profiles & Disclosure Policy v1
**Feature Branch**: `357-report-profiles-disclosure-policy-v1`
**Created**: 2026-06-05
**Status**: Draft
**Type**: Reporting policy / disclosure governance / rendered-report hardening
**Runtime posture**: Static profile registry plus disclosure-policy evaluation over the current `ReviewPack` rendered-report contract. No profile admin UI, no new persistence, no scheduling, no customer portal, no AI, and no native PDF stack.
**Input**: User-provided Spec 357 draft (`pasted-text.txt`) plus repo truth from Specs 347, 355, and 356 and the current rendered review-report runtime.
## Dependencies And Repo-Truth Adjustments
This spec is a bounded follow-up over already repo-real review-output work:
- Spec 347 - Review Pack Output Contract & Readiness Semantics
- Spec 355 - Platform Sellable Smoke Matrix
- Spec 356 - Review Pack PDF/HTML Renderer v1
- Current runtime seams:
- `apps/platform/app/Http/Controllers/ReviewPackRenderedReportController.php`
- `apps/platform/app/Support/ReviewPacks/ReviewPackOutputReadiness.php`
- `apps/platform/app/Support/ReviewPacks/ReviewPackOutputResolutionGuidance.php`
- `apps/platform/resources/views/review-packs/rendered-report.blade.php`
- `apps/platform/app/Services/ReviewPackService.php`
Repo-truth adjustments against the draft:
- The rendered stakeholder report already exists on the authenticated route `/admin/review-packs/{reviewPack}/report`; this spec must extend that route, not create a second report family.
- Any v1 profile-selection parameter must flow through the existing signed rendered-report URL generation seams (`ReviewPackService::generateRenderedReportUrl()` plus current owner-surface helpers/actions), not a controller-only ad hoc query contract.
- The current renderer already derives readiness, limitations, evidence basis, and disclosure copy from stored `ReviewPack` and `EnvironmentReview` truth.
- Native server-side PDF is still not implemented. This spec must remain HTML/report-policy only.
- `ReviewPackOutputReadiness::derive()` currently receives `protectedValuesHidden` and `disclosurePresent` as boolean inputs. Those values are not yet modeled as independent proof-state results. Spec 357 must preserve that distinction and avoid silently upgrading assumptions into verified truth.
- No report profile registry currently exists in config or support code. No `report_profile` persistence, delivery policy ledger, or profile selector surface exists today.
## Spec Candidate Check *(mandatory — SPEC-GATE-001)*
- **Problem**: TenantPilot can now render a customer-facing review report, but it still lacks an explicit policy layer that determines which audience the report is for, which sections are allowed, and which disclosures must remain visible regardless of presentation preferences.
- **Today's failure**: The current rendered report is effectively one output shape with readiness-driven copy. That makes future customer, internal, technical, and auditor-oriented report variants risky because the renderer has no bounded policy contract to stop a profile from hiding limitations, evidence gaps, internal-only boundaries, or the non-certification disclosure.
- **User-visible improvement**: Operators can trust that rendered reports are audience-aware without becoming truth-optional. Customer-safe variants stay conservative, internal variants stay clearly internal, and every report shows the required readiness, evidence, limitation, and disclosure metadata.
- **Smallest enterprise-capable version**: Add a static profile registry, a disclosure-policy evaluator with explicit proof states, and profile-aware rendering on the existing report route and linked review/report surfaces.
- **Explicit non-goals**: No profile CRUD, no database table, no scheduling, no delivery workflow, no customer portal, no AI/HITL runtime, no native PDF, no branding editor, no framework-specific NIS2/CIS/BSI report implementation, and no second artifact family.
- **Permanent complexity imported**: One bounded static registry, one bounded disclosure-policy evaluator, one local rendered-report payload/view-model layer if needed, focused localization additions, focused Unit/Feature/Browser tests, and UI-audit follow-through. No new persisted truth, no new queue family, and no new cross-domain framework.
- **Why now**: Spec 356 made the rendered report repo-real. The next honest gap is no longer "can we render HTML?" but "can we prove which truths every report profile must show before broader delivery or customer-consumption work grows?"
- **Why not local**: Copy-only changes in the Blade view would not create a reusable, testable policy boundary. Broader delivery or portal work would be premature without a profile/disclosure contract underneath.
- **Approval class**: Core Enterprise.
- **Red flags triggered**: New policy layer, customer-facing disclosure semantics, and rendered-output surface changes. Defense: the slice stays on one existing report family, keeps policy static, forbids new persistence and delivery execution, and explicitly models assumptions instead of faking proof.
- **Score**: Nutzen: 2 | Dringlichkeit: 2 | Scope: 2 | Komplexitaet: 1 | Produktnaehe: 2 | Wiederverwendung: 2 | **Gesamt: 11/12**
- **Decision**: approve.
## Candidate Source And Completed-Spec Guardrail
- **Candidate source**:
- direct user-provided Spec 357 draft from `/Users/ahmeddarrazi/.codex/attachments/5b0bf308-47d5-449d-be56-f3946cc8a20b/pasted-text.txt`
- current roadmap/productization lane for report delivery and customer-safe review consumption
- immediate runtime follow-through over Specs 347, 355, and 356
- **Completed-spec guardrail result**:
- no `specs/357-*` package existed before this preparation run
- Specs 347, 355, and 356 carry implementation, screenshots, tests, or close-ready evidence and are treated as historical/runtime context only
- related review-productization specs such as 308, 342, 349, and 351 are also context only and must not be rewritten back into preparation wording
- **Close alternatives deferred**:
- Billing & Subscription Truth Layer v1
- Customer-Facing Localization Adoption v1
- Governance Artifact Lifecycle & Retention v1
- First Governed AI Runtime Consumer v1
- Customer Portal Report Consumption Boundary
- **Smallest viable implementation slice**: existing rendered review-report route plus linked review/report surfaces only: static report profiles, disclosure proof-state evaluation, profile-aware section filtering, visible profile metadata, and truthful customer/internal disclosure behavior.
## Summary
Spec 357 introduces a static report-profile and disclosure-policy layer for the current rendered review report. The report must become profile-aware without becoming truth-optional. Profiles may change presentation depth and section selection, but they must never hide readiness state, evidence limitations, internal-only or PII warnings, non-certification disclosure, or source metadata.
The implementation remains intentionally narrow:
- extend the current `ReviewPack` rendered-report family
- keep all evaluation based on stored review-pack and review truth
- model `verified`, `assumed`, `not_applicable`, `missing`, and `unknown` disclosure proof states where the repo cannot independently prove a safety claim
- avoid delivery, portal, PDF, AI, or framework-report scope
## Spec Scope Fields *(mandatory)*
- **Scope**: canonical-view
- **Primary Routes**:
- `/admin/reviews/workspace`
- `/admin/workspaces/{workspace}/environments/{environment}/environment-reviews/{record}`
- `/admin/workspaces/{workspace}/environments/{environment}/review-packs/{record}`
- `/admin/review-packs/{reviewPack}/report`
- `/admin/review-packs/{reviewPack}/download`
- **Data Ownership**:
- `ReviewPack` remains the current artifact truth
- `EnvironmentReview` and `EnvironmentReviewSection` remain review and section truth
- `EvidenceSnapshot` remains evidence-basis truth
- readiness, profile selection, and disclosure results remain derived-only request-time truth
- **RBAC**:
- existing workspace membership and managed-environment entitlement remain mandatory
- `Capabilities::REVIEW_PACK_VIEW` and current review/report view permissions remain authoritative
- no public routes, no unauthenticated profile selection, and no new capability family by default
For canonical-view specs:
- **Default filter behavior when tenant-context is active**: keep the current explicit `environment_id` workspace filter behavior and do not introduce hidden shell or legacy query scope.
- **Explicit entitlement checks preventing cross-tenant leakage**: all rendered report, review, and review-pack access continues to resolve through current workspace and managed-environment scope plus current-export and not-expired guards.
## 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
- [ ] New page/route added
- [ ] Navigation changed
- [ ] Filament panel/provider surface changed
- [ ] New modal/drawer/wizard/action added
- [x] 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")*
- **Route/page/surface**:
- `CustomerReviewWorkspace` review-pack launch and readiness area
- `EnvironmentReviewResource` customer-workspace detail handoff
- `ReviewPackResource` detail/download context
- rendered report route `/admin/review-packs/{reviewPack}/report`
- **Current or new page archetype**: existing customer-safe report/detail surfaces and existing rendered artifact viewer
- **Design depth**: Strategic Surface
- **Repo-truth level**: repo-verified existing runtime surfaces
- **Existing pattern reused**: current review-pack output contract, `ReviewPackOutputResolutionGuidance`, rendered report viewer, customer-safe disclosure hierarchy
- **New pattern required**: one bounded report-profile/disclosure-policy layer over the existing rendered report
- **Screenshot required**: yes, under `specs/357-report-profiles-disclosure-policy-v1/artifacts/screenshots/`
- **Page audit required**: yes, update the existing reports for `UI-006`, `UI-042`, and `UI-099` if the hierarchy or visible contract changes materially
- **Customer-safe review required**: yes; customer-facing profiles are central to this slice
- **Dangerous-action review required**: no; the slice stays on read-only report surfaces
- **Coverage files updated or explicitly not needed**:
- [ ] `docs/ui-ux-enterprise-audit/route-inventory.md`
- [ ] `docs/ui-ux-enterprise-audit/design-coverage-matrix.md`
- [x] `docs/ui-ux-enterprise-audit/page-reports/...`
- [ ] `docs/ui-ux-enterprise-audit/strategic-surfaces.md`
- [ ] `docs/ui-ux-enterprise-audit/grouped-follow-up-candidates.md`
- [ ] `docs/ui-ux-enterprise-audit/unresolved-pages.md`
- [ ] `N/A - no reachable UI surface impact`
- **No-impact rationale when applicable**: N/A
## Cross-Cutting / Shared Pattern Reuse *(mandatory)*
- **Cross-cutting feature?**: yes
- **Interaction class(es)**: status messaging, report viewers, disclosure messaging, action links, evidence/report truth presentation
- **Systems touched**:
- `ReviewPackRenderedReportController`
- `ReviewPackOutputReadiness`
- `ReviewPackOutputResolutionGuidance`
- `ReviewPackService`
- `CustomerReviewWorkspace`
- `EnvironmentReviewResource`
- `ReviewPackResource`
- rendered-report Blade template and localization files
- **Existing pattern(s) to extend**: current output-readiness semantics, current rendered-report route, current customer-safe workspace handoff, current artifact truth and download seams
- **Shared contract / presenter / builder / renderer to reuse**: `ReviewPackOutputReadiness`, `ReviewPackOutputResolutionGuidance`, `ArtifactTruthPresenter`, current rendered-report payload composition, current review-pack authorization and current-export guards
- **Why the existing shared path is sufficient or insufficient**: it already holds readiness and report truth, but it does not yet define a first-class audience/profile policy or proof-state-aware disclosure result
- **Allowed deviation and why**: one bounded `ReportProfileRegistry`, one bounded `ReportDisclosurePolicy`, and one local rendered-report profile payload layer are allowed inside `App\Support\ReviewPacks` because the controller already carries complex report-state composition
- **Consistency impact**: profile labels, audience labels, readiness state, limitations, evidence basis, internal-only warnings, non-certification disclosure, and source metadata must stay aligned across workspace, review detail, review-pack detail, and rendered report
- **Review focus**: block any second artifact family, any report-profile persistence, any public link contract, any hidden mandatory disclosure, or any fake upgrade from assumed to verified proof
## OperationRun UX Impact *(mandatory)*
- **Touches OperationRun start/completion/link UX?**: reuse only
- **Shared OperationRun UX contract/layer reused**: the existing review-pack generation and current-export reuse path remains authoritative
- **Delegated start/completion UX behaviors**: queued run, dedupe, current-pack reuse, and completion notification behavior remain unchanged
- **Local surface-owned behavior that remains**: profile metadata, disclosure evaluation, section filtering, and rendered-report copy only
- **Queued DB-notification policy**: unchanged
- **Terminal notification path**: unchanged
- **Exception required?**: none
## Provider Boundary / Platform Core Check *(mandatory)*
- **Shared provider/platform boundary touched?**: no new provider seam
- **Boundary classification**: platform-core review/report disclosure semantics
- **Seams affected**: existing review-pack and rendered-report semantics only
- **Neutral platform terms preserved or introduced**: report profile, audience, readiness, limitation, disclosure, proof state, internal-only, customer-safe
- **Provider-specific semantics retained and why**: only where existing review content already carries provider-backed section labels
- **Why this does not deepen provider coupling accidentally**: no provider call, provider model, or provider registry change is introduced
- **Follow-up path**: framework-specific report profiles remain deferred to later specs
## UI / Surface Guardrail Impact
| 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 review-pack handoff | yes | Native Filament page plus existing report/download primitives | customer-safe output guidance | page, URL-query | no | Existing route only |
| Rendered report route | yes | Bounded custom report view over existing controller route | report viewer, disclosure hierarchy, profile metadata | detail, route payload | no | Existing route family only |
| Review Pack detail/report handoff | yes | Native detail plus existing artifact-truth primitives | rendered output vs ZIP truth | detail | no | Keep read-only |
| Review detail handoff | yes | Native detail plus shared review/output truth | audience-aware launch copy | detail | no | No new route family |
## Decision-First Surface Role
| 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 | Primary Decision Surface | Decide whether the current review output is ready for external/customer consumption | readiness, limitations, profile-safe action, evidence basis summary | review detail, report detail, download | Primary because it remains the handoff start surface | keeps current customer-safe review workflow intact | reduces explanation work around output meaning |
| Rendered report | Primary Delivery Surface | Decide whether the chosen report profile is fit for the audience | profile, audience, limitations, disclosures, generated/source metadata | supporting appendix and technical detail | Primary because this is the actual human-readable output | keeps report delivery inside the current review-pack family | prevents "pretty report hides risk" drift |
| Review Pack detail | Secondary Proof Surface | Verify the artifact and open the rendered output | artifact status and current-export truth | ZIP/download metadata and related proof | Secondary because it supports, not replaces, the delivery decision | preserves artifact ownership | keeps detail out of the main handoff surface |
## Audience-Aware Disclosure
| 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-read-only, customer-admin | readiness, limitations, current safe handoff path | current review detail and evidence basis | raw artifact diagnostics | open the current review/report safely | raw diagnostics remain secondary | workspace states handoff truth once |
| Rendered report | customer, technical customer, internal MSP, controlled auditor | profile, audience, generated/source metadata, readiness, limitations, mandatory disclosures | technical appendix only where allowed by profile | raw JSON or protected internals stay hidden | read the report or step back to the review | diagnostics and internal-only detail stay profile-gated | the report states its audience and limitations once |
| Review Pack detail | operator-MSP | current pack truth and rendered-output availability | artifact metadata | ZIP internals | open rendered report | raw ZIP detail remains secondary | artifact proof does not duplicate report narrative |
## UI/UX Surface Classification
| 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 | Utility / Workspace Decision | Customer-safe review hub | open the currently appropriate report/review surface | existing review-open path | existing | supporting report/download actions stay secondary | none | `/admin/reviews/workspace` | existing review detail route | workspace plus optional `environment_id` | Customer review | whether the output is safe and which audience it fits | none |
| Rendered review report | Detail / Report Viewer | Read-only report | read or print the report for the current audience | explicit report route open | forbidden | appendix/technical detail stays below primary sections | none | existing owner surfaces only | `/admin/review-packs/{reviewPack}/report` | workspace/environment artifact entitlement | Rendered review report | profile, audience, readiness, limitations, disclosures | none |
| Review Pack detail | Detail / Artifact Viewer | Read-only artifact detail | verify or open the current rendered report | existing detail inspect path | existing | ZIP/download proof stays secondary | none | existing review-pack collection route | existing review-pack detail route | environment + record entitlement | Review pack | artifact status and report availability | none |
## Operator Surface Contract
| 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 the current output can be shared and which audience path is truthful | read-first workspace surface | Is there a safe report to hand over now? | readiness, limitations, profile-safe path | deeper review detail and evidence links | output readiness, evidence basis, package status | none | open review or report | none |
| Rendered report | MSP operator or entitled customer reader | consume the report for the chosen audience | read-only detail/report viewer | Who is this report for, and what limits still apply? | profile, audience, readiness, evidence state, disclosure, generated/source metadata | appendix and technical details if allowed | audience, boundary, readiness, evidence completeness | none | print or return to owner surface | none |
| Review Pack detail | MSP operator | verify the current artifact and its rendered-output relationship | read-only artifact detail | Does this artifact match the report path I am about to use? | artifact status, rendered report availability | ZIP internals, operation proof | artifact readiness, expiry | none | open rendered report | none |
## Proportionality Review *(mandatory when structural complexity is introduced)*
- **New source of truth?**: no
- **New persisted entity/table/artifact?**: no
- **New abstraction?**: yes, but bounded to local report-output support (`ReportProfileRegistry`, `ReportDisclosurePolicy`, and one local rendered-report profile layer if needed)
- **New enum/state/reason family?**: no new persisted family; one derived disclosure proof-state vocabulary is allowed if it remains local and request-scoped
- **New cross-domain UI framework/taxonomy?**: no
- **Current operator problem**: the current rendered report can become unsafe to evolve because there is no explicit audience/profile policy and no proof-state-aware disclosure contract
- **Existing structure is insufficient because**: the current readiness helpers describe output state but not which truths every profile must keep visible, and they cannot distinguish assumed from independently verified disclosure safety
- **Narrowest correct implementation**: keep everything inside the current `ReviewPack` report family with a static registry, a local disclosure evaluator, and existing owner surfaces
- **Ownership cost**: maintain a small static registry, disclosure evaluator, local copy, and focused tests/browser proof
- **Alternative intentionally rejected**: a profile CRUD system, delivery workflow, or generic reporting framework was rejected as larger than current-release truth requires
- **Release truth**: current-release delivery hardening and follow-through, not future portal or automation groundwork alone
### Compatibility posture
This feature assumes a pre-production environment.
Backward compatibility, migration shims, legacy profile aliases, public query stability guarantees, and second artifact families are out of scope unless explicitly required by a later spec.
## Testing / Lane / Runtime Impact *(mandatory for runtime behavior changes)*
- **Test purpose / classification**: Unit, Feature, Browser
- **Validation lane(s)**: confidence, browser
- **Why this classification and these lanes are sufficient**: the registry and disclosure evaluator are deterministic unit concerns; the rendered report, route guards, and owner-surface handoff need Feature and one bounded Browser proof because this is a customer-facing strategic surface
- **New or expanded test families**: one new Unit pair for report policy, one new Feature rendered-report profile test, one bounded Browser smoke file
- **Fixture / helper cost impact**: low to moderate; reuse existing review-pack, environment-review, and rendered-report fixtures instead of adding a new heavy family
- **Heavy-family visibility / justification**: one explicit Browser smoke only because audience-safe rendered output is visual and disclosure-order dependent
- **Special surface test profile**: shared-detail-family
- **Standard-native relief or required special coverage**: no standard-native relief; the rendered report needs explicit disclosure and audience-profile proof
- **Reviewer handoff**: reviewers must confirm that invalid or placeholder profiles fail closed, mandatory disclosures always render, customer profiles never overclaim readiness, and no second report artifact family appears
- **Budget / baseline / trend impact**: none expected beyond one bounded Browser smoke
- **Escalation needed**: `document-in-feature` only if an authenticated query-parameter fallback is kept temporarily for profile selection
- **Active feature PR close-out entry**: `Guardrail / Exception / Smoke Coverage`
- **Planned validation commands**:
- `cd apps/platform && ./vendor/bin/sail artisan test tests/Unit/ReviewPacks/Spec357ReportProfileRegistryTest.php tests/Unit/ReviewPacks/Spec357ReportDisclosurePolicyTest.php --compact`
- `cd apps/platform && ./vendor/bin/sail artisan test tests/Feature/ReviewPack/Spec357RenderedReportProfileTest.php --compact`
- `cd apps/platform && ./vendor/bin/sail php vendor/bin/pest tests/Browser/Spec357ReportProfilesSmokeTest.php --compact`
- `cd apps/platform && ./vendor/bin/sail artisan test --compact --filter=Spec356`
- `cd apps/platform && ./vendor/bin/sail artisan test --compact --filter=ReviewPack`
- `cd apps/platform && ./vendor/bin/sail artisan test --compact --filter=EnvironmentReview`
- `cd apps/platform && ./vendor/bin/sail pint --dirty`
- `git diff --check`
## User Scenarios & Testing *(mandatory)*
### User Story 1 - Profile-safe customer report delivery (Priority: P1)
As an MSP operator, I need a customer-facing report profile that can never hide readiness or limitation truth, so that external handoff stays conservative and defensible.
**Why this priority**: This is the core trust problem introduced by broader rendered-report delivery.
**Independent Test**: Request a customer profile for both customer-safe and limited outputs and verify the report still shows limitations, evidence state, and non-certification disclosure.
**Acceptance Scenarios**:
1. **Given** a customer-safe ready review pack, **When** an entitled user opens the rendered report with `customer_executive`, **Then** the report shows customer-facing profile metadata and still renders mandatory disclosures and source metadata.
2. **Given** a limited or internal-only review pack, **When** an entitled user opens `customer_executive` or `customer_technical`, **Then** the report remains visibly limited or blocked for external use and does not pretend the output is customer-safe.
---
### User Story 2 - Clear internal MSP report boundary (Priority: P1)
As an MSP operator, I need an internal profile that can show more context while still clearly labeling itself as internal-only when customer-safe delivery is not justified.
**Why this priority**: The default authenticated report path is inside the operator/admin plane and must stay honest about internal-only output.
**Independent Test**: Open the report with `internal_msp_review` on a pack with PII or unresolved limitations and verify the report remains visibly internal and still shows mandatory warnings.
**Acceptance Scenarios**:
1. **Given** a pack that includes PII or internal-only detail, **When** the internal profile is used, **Then** the report clearly states internal-only boundary and keeps evidence/limitation disclosures visible.
2. **Given** an invalid or placeholder profile key, **When** the report route is requested, **Then** the system fails closed to the safe internal path or a truthful not-implemented response without exposing a broken public contract.
---
### User Story 3 - Proof-state-aware disclosure contract (Priority: P2)
As a reviewer, I need the profile layer to distinguish `verified`, `assumed`, `missing`, `unknown`, and `not_applicable`, so the report does not silently overstate disclosure safety that the repo cannot prove.
**Why this priority**: This prevents the new policy layer from laundering assumptions into certified truth.
**Independent Test**: Unit-test the disclosure-policy result for readiness, evidence, and protected-value handling states without requiring a browser.
**Acceptance Scenarios**:
1. **Given** stored report truth where a safety claim is only inferred from existing generator behavior, **When** the disclosure policy is evaluated, **Then** the result marks that claim as `assumed` rather than `verified`.
2. **Given** a mandatory disclosure is unavailable, **When** a profile is evaluated, **Then** the output marks it as `missing` or `unknown` and still renders the warning path instead of hiding it.
## Functional Requirements
- **FR-357-001**: A static report-profile registry MUST define at least `customer_executive`, `customer_technical`, `internal_msp_review`, `auditor_appendix`, and placeholder `framework_readiness`.
- **FR-357-002**: A disclosure-policy evaluator MUST derive mandatory disclosures, warnings, blocking reasons, and proof states from stored review-pack and review truth only.
- **FR-357-003**: The rendered report MUST use an effective profile to drive section inclusion, appendix visibility, and audience metadata.
- **FR-357-004**: Mandatory disclosures MUST override profile section-hiding rules.
- **FR-357-005**: Customer-facing profiles MUST NOT overclaim readiness. Limited, blocked, internal-only, or assumption-heavy output must remain visibly limited.
- **FR-357-006**: Internal profiles MUST remain visibly internal when applicable and must not suppress PII/internal-only warnings.
- **FR-357-007**: The rendered report MUST show profile key, audience, generated/source metadata, readiness, limitations, and non-certification disclosure.
- **FR-357-008**: `framework_readiness` MUST remain placeholder-only and not appear as a falsely implemented framework report.
- **FR-357-009**: No report-profile persistence, CRUD surface, delivery workflow, or new artifact family may be introduced in this slice.
- **FR-357-010**: Invalid or unimplemented profiles MUST fail closed and must not widen the signed route contract or bypass existing signed URL builders.
## Non-Functional Requirements
- **NFR-357-001**: Output must remain deterministic for the same review pack and effective profile.
- **NFR-357-002**: If uncertainty exists, the renderer must show limitations rather than infer customer-safe certainty.
- **NFR-357-003**: No live provider or Graph calls may happen during report rendering.
- **NFR-357-004**: Registry and policy logic must be unit-testable without Browser coverage.
- **NFR-357-005**: Localization for dominant profile/disclosure copy must be available in both `en` and `de`.
## Acceptance Criteria
- **AC1**: A static implemented-profile registry exists and is tested.
- **AC2**: A disclosure-policy evaluator exists and returns mandatory disclosures, blocking reasons, and explicit proof states.
- **AC3**: The rendered report is profile-aware and visibly shows profile and audience metadata.
- **AC4**: Customer-facing profiles cannot hide limitations, evidence gaps, internal-only warnings, or the non-certification disclosure.
- **AC5**: Internal profiles remain clearly internal when current output truth requires it.
- **AC6**: Invalid or placeholder profiles fail closed and do not pretend to be implemented.
- **AC7**: No new persistence, delivery workflow, portal, PDF stack, or AI runtime is added.
- **AC8**: Focused Unit, Feature, Browser, and report-regression validation passes.
## Assumptions
- The current rendered report route `/admin/review-packs/{reviewPack}/report` remains the single human-readable report route for this slice.
- The current `ReviewPack` and `EnvironmentReview` truth is sufficient to derive audience-safe disclosure results without new persistence.
- Authenticated internal profile selection may use a bounded current-route mechanism only through the existing signed rendered-report URL builders if a richer action surface would over-expand scope.
- Existing rendered-report and review-pack regressions from Specs 347 and 356 remain the baseline to extend, not replace.
## Risks
- A broader "report profile system" could sprawl into delivery, branding, or portal scope.
- Assumption-vs-verification semantics could drift unless tests pin them down explicitly.
- Profile selection UI could widen scope if it becomes a settings surface instead of a bounded report-viewer affordance.
- Appendix handling could accidentally reintroduce internal-only detail into customer-facing profiles unless the policy contract stays strict.
## Open Questions
- Should authenticated profile selection remain signed-URL-parameter-based on the current report route for v1, or is one bounded action group on existing owner surfaces justified without over-expanding UI scope?
- Should `auditor_appendix` remain internal-only by default in v1, or be rendered but always marked non-deliverable externally until a later delivery workflow spec?
- Can any current `protectedValuesHidden` or `disclosurePresent` signals be upgraded from `assumed` to `verified` without broadening generator scope, or should v1 explicitly preserve them as assumed?
These questions do not block preparation because the safe default is to keep profile selection bounded, keep auditor output conservative, and preserve assumption states unless implementation proves otherwise.
## Follow-up Spec Candidates
- Branded / themed management report layouts
- Scheduled report delivery and approval workflow
- Framework-specific readiness report profiles
- Customer portal report consumption boundary
- Private AI/HITL report review preparation

View File

@ -0,0 +1,107 @@
# Tasks: Spec 357 - Report Profiles & Disclosure Policy v1
**Input**: `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/357-report-profiles-disclosure-policy-v1/spec.md`
**Prerequisites**: `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/357-report-profiles-disclosure-policy-v1/plan.md`, `checklists/requirements.md`
**Tests**: Required. This is a runtime report/disclosure change on existing customer-facing strategic surfaces. Unit, Feature, and one bounded Browser smoke are required.
## Test Governance Checklist
- [x] Lane assignment is named and is the narrowest sufficient proof for the changed behavior.
- [x] New or changed tests stay in the smallest honest family, and the browser addition is explicit.
- [x] Shared helpers, factories, seeds, fixtures, and context defaults stay cheap by default.
- [x] Planned validation commands cover the change without pulling in unrelated lane cost.
- [x] The declared surface test profile (`shared-detail-family`) is explicit.
- [x] Any material budget, baseline, trend, or escalation note is recorded in the active spec or PR.
## Phase 1: Repo Truth And Scope Gate
- [x] T001 Re-read `spec.md`, `plan.md`, and `checklists/requirements.md` before editing runtime code.
- [x] T002 Confirm branch/worktree intent with `git status --short --branch` and record the baseline commit with `git log -1 --oneline`.
- [x] T003 Inspect the existing rendered-report seams in:
- `apps/platform/app/Http/Controllers/ReviewPackRenderedReportController.php`
- `apps/platform/app/Support/ReviewPacks/ReviewPackOutputReadiness.php`
- `apps/platform/app/Support/ReviewPacks/ReviewPackOutputResolutionGuidance.php`
- `apps/platform/resources/views/review-packs/rendered-report.blade.php`
- [x] T004 Inspect current owner-surface handoff seams in:
- `apps/platform/app/Filament/Pages/Reviews/CustomerReviewWorkspace.php`
- `apps/platform/app/Filament/Resources/EnvironmentReviewResource.php`
- `apps/platform/app/Filament/Resources/ReviewPackResource.php`
- [x] T005 Confirm no new persistence, new delivery workflow, public route family, PDF stack, portal route, AI runtime, or framework-report implementation is needed.
## Phase 2: Tests First
- [x] T006 Add `apps/platform/tests/Unit/Support/ReviewPacks/Spec357ReportProfileRegistryTest.php` covering required implemented profiles, placeholder handling, and invalid-profile fail-closed behavior.
- [x] T007 Add `apps/platform/tests/Unit/Support/ReviewPacks/Spec357ReportDisclosurePolicyTest.php` covering mandatory disclosures, blocking reasons, and proof states (`verified`, `assumed`, `not_applicable`, `missing`, `unknown`).
- [x] T008 Add `apps/platform/tests/Feature/ReviewPack/Spec357RenderedReportProfileTest.php` covering effective profile selection, visible profile metadata, mandatory disclosure override, and customer/internal boundary behavior.
- [x] T009 Add `apps/platform/tests/Browser/Spec357ReportProfilesSmokeTest.php` covering internal MSP, customer executive limited, customer-safe ready, auditor appendix, and invalid/placeholder profile behavior.
## Phase 3: Static Report Profile Registry
- [x] T010 Create `apps/platform/app/Support/ReviewPacks/ReportProfileRegistry.php` with static implemented profiles:
- `customer_executive`
- `customer_technical`
- `internal_msp_review`
- `auditor_appendix`
- [x] T011 Model `framework_readiness` as placeholder-only and not implemented by default.
- [x] T012 Keep the registry bounded to the current review-pack/report family; do not add CRUD, config writes, or generalized reporting infrastructure.
- [x] T013 Fail closed for unknown or unimplemented profile keys and keep the fallback behavior explicit and tested.
## Phase 4: Disclosure Policy
- [x] T014 Create `apps/platform/app/Support/ReviewPacks/ReportDisclosurePolicy.php` to evaluate profile + readiness + evidence + internal-only/PII + available source/disclosure metadata.
- [x] T015 Ensure the policy emits mandatory disclosures, warnings, blocking reasons, and proof states without provider/Graph calls.
- [x] T016 Preserve the distinction between independently proven and assumed safety signals; do not silently treat current boolean assumptions as verified truth.
- [x] T017 Keep blocking and boundary behavior derived-only inside the current rendered-report flow; do not implement scheduling, approval, send, or future-consumer delivery semantics.
## Phase 5: Rendered Report Integration
- [x] T018 Update the existing signed rendered-report URL seams (`ReviewPackService::generateRenderedReportUrl()` callers/helpers and `apps/platform/app/Http/Controllers/ReviewPackRenderedReportController.php`) to resolve an effective profile on the existing authenticated report route without adding an unsigned ad hoc query contract.
- [x] T019 Keep the controller-local implementation bounded; no extra cross-domain viewer framework was introduced.
- [x] T020 Apply profile-aware section and appendix filtering while guaranteeing that mandatory disclosures, readiness, evidence state, and source metadata still render.
- [x] T021 Show effective profile and audience metadata in the report payload and keep invalid or placeholder profile requests truthfully limited or blocked.
- [x] T022 Keep the current `ReviewPack` route, current-export guard, and existing ZIP/download contract intact.
## Phase 6: UI Surfaces And Localization
- [x] T023 Update `apps/platform/resources/views/review-packs/rendered-report.blade.php` so the report visibly shows profile, audience, readiness, limitations, disclosure/proof-state information, and generated/source metadata.
- [x] T024 Update existing owner-surface report URL helpers/labels in `EnvironmentReviewResource` and `ReviewPackResource` so the profile-aware handoff stays clear without creating a management UI.
- [x] T025 Add EN and DE localization keys in:
- `apps/platform/lang/en/localization.php`
- `apps/platform/lang/de/localization.php`
for profile names, audience labels, external-sharing warnings, proof-state labels, and mandatory disclosure copy.
- [x] T026 Keep the report read-only; do not add destructive or state-mutating actions.
## Phase 7: UI Audit Follow-Through
- [x] T027 Inspect `docs/ui-ux-enterprise-audit/page-reports/ui-006-customer-review-workspace.md`; no material hierarchy change required an update.
- [x] T028 Inspect `docs/ui-ux-enterprise-audit/page-reports/ui-042-review-pack-detail.md`; no rendered-report vs ZIP hierarchy change required an update.
- [x] T029 Update `docs/ui-ux-enterprise-audit/page-reports/ui-099-rendered-review-report.md` with profile metadata, disclosure-proof behavior, and bounded audience modes.
- [x] T030 Inspect `docs/ui-ux-enterprise-audit/route-inventory.md` and `design-coverage-matrix.md`; no material route-classification change required an update.
## Phase 8: Validation And Close-Out
- [x] T031 Run:
- `cd apps/platform && ./vendor/bin/sail artisan test tests/Unit/Support/ReviewPacks/Spec357ReportProfileRegistryTest.php tests/Unit/Support/ReviewPacks/Spec357ReportDisclosurePolicyTest.php tests/Feature/ReviewPack/Spec357RenderedReportProfileTest.php --compact`
- [x] T032 Run:
- `cd apps/platform && ./vendor/bin/sail php vendor/bin/pest tests/Browser/Spec357ReportProfilesSmokeTest.php --compact`
- [x] T033 Run focused regressions:
- `cd apps/platform && ./vendor/bin/sail artisan test --compact --filter=Spec356` returned `No tests found`
- `cd apps/platform && ./vendor/bin/sail artisan test tests/Feature/ReviewPack/ReviewPackDownloadTest.php tests/Feature/ReviewPack/EnvironmentReviewDerivedReviewPackTest.php tests/Feature/EnvironmentReview/EnvironmentReviewExecutivePackTest.php --compact` passed
- `cd apps/platform && ./vendor/bin/sail php vendor/bin/pest tests/Browser/Reviews/CustomerReviewWorkspaceSmokeTest.php --compact` passed
- broader filters surfaced unrelated existing failures in `tests/Browser/Spec347ReviewPackOutputReadinessSmokeTest.php` and `tests/Feature/Filament/EnvironmentReviewHeaderDisciplineTest.php`
- [x] T034 Run formatting and patch checks:
- `cd apps/platform && ./vendor/bin/sail pint --dirty`
- `cd apps/platform && ./vendor/bin/sail pint app/Support/ReviewPacks/ReportProfileRegistry.php app/Support/ReviewPacks/ReportDisclosurePolicy.php app/Support/ReviewPacks/ReviewPackOutputResolutionGuidance.php app/Http/Controllers/ReviewPackRenderedReportController.php app/Filament/Resources/EnvironmentReviewResource.php app/Filament/Resources/ReviewPackResource/Pages/ViewReviewPack.php lang/en/localization.php lang/de/localization.php tests/Unit/Support/ReviewPacks/Spec357ReportProfileRegistryTest.php tests/Unit/Support/ReviewPacks/Spec357ReportDisclosurePolicyTest.php tests/Feature/ReviewPack/Spec357RenderedReportProfileTest.php tests/Browser/Spec357ReportProfilesSmokeTest.php`
- `git diff --check`
- [x] T035 Save browser screenshots under `specs/357-report-profiles-disclosure-policy-v1/artifacts/screenshots/`.
- [x] T036 Report full-suite status honestly if not run.
## Non-Goals
- [x] NT001 Do not add a `report_profiles` table, profile CRUD, or any profile persistence.
- [x] NT002 Do not add scheduled delivery, approval workflow, email sending, or any public link/share contract.
- [x] NT003 Do not add a second rendered artifact family or replace the existing `ReviewPack` ZIP family.
- [x] NT004 Do not add native PDF infrastructure, branding editor, or white-label theme management.
- [x] NT005 Do not add AI/HITL runtime behavior or framework-specific NIS2/CIS/BSI report implementation.
- [x] NT006 Do not widen provider, Graph, or authentication boundaries during report rendering.