feat(evidence): implement baseline review readiness integration #456

Merged
ahmido merged 1 commits from 385-evidence-review-readiness into platform-dev 2026-06-17 22:54:13 +00:00
28 changed files with 3510 additions and 92 deletions
Showing only changes of commit d71493f82a - Show all commits

View File

@ -4,20 +4,20 @@
namespace App\Jobs;
use App\Models\EnvironmentReview;
use App\Models\EvidenceSnapshot;
use App\Models\Finding;
use App\Models\ManagedEnvironment;
use App\Models\OperationRun;
use App\Models\ReviewPack;
use App\Models\ManagedEnvironment;
use App\Models\EnvironmentReview;
use App\Services\Intune\SecretClassificationService;
use App\Services\OperationRunService;
use App\Services\ReviewPackService;
use App\Support\OperationRunOutcome;
use App\Support\OperationRunStatus;
use App\Support\RedactionIntegrity;
use App\Support\ReviewPackStatus;
use App\Support\ReviewPacks\ReviewPackOutputReadiness;
use App\Support\ReviewPackStatus;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Queue\Queueable;
use Illuminate\Support\Collection;
@ -155,8 +155,9 @@ private function executeGeneration(ReviewPack $reviewPack, OperationRun $operati
// 9. Store on exports disk
$filePath = sprintf(
'review-packs/%s/%s.zip',
'review-packs/%s/pack-%d-%s.zip',
$tenant->external_id,
(int) $reviewPack->getKey(),
now()->format('Y-m-d-His'),
);
@ -268,9 +269,10 @@ private function executeReviewDerivedGeneration(
$sha256 = hash_file('sha256', $tempFile);
$fileSize = filesize($tempFile);
$filePath = sprintf(
'review-packs/%s/review-%d-%s.zip',
'review-packs/%s/review-%d-pack-%d-%s.zip',
$tenant->external_id,
(int) $review->getKey(),
(int) $reviewPack->getKey(),
$generatedAt->format('Y-m-d-His'),
);
@ -371,7 +373,7 @@ private function buildFileMap(
$files['hardening.json'] = json_encode($hardening, JSON_PRETTY_PRINT | JSON_THROW_ON_ERROR);
// metadata.json
$files['metadata.json'] = json_encode([
$metadataPayload = [
'version' => '1.0',
'managed_environment_id' => $tenant->external_id,
'tenant_name' => $includePii ? $tenant->name : '[REDACTED]',
@ -390,7 +392,11 @@ private function buildFileMap(
'include_pii' => $includePii,
'include_operations' => $includeOperations,
],
], JSON_PRETTY_PRINT | JSON_THROW_ON_ERROR);
];
$files['metadata.json'] = json_encode(
$this->redactReportPayload($metadataPayload, $includePii),
JSON_PRETTY_PRINT | JSON_THROW_ON_ERROR,
);
// operations.csv
$files['operations.csv'] = $this->buildOperationsCsv($recentOperations, $includePii);
@ -408,14 +414,18 @@ private function buildFileMap(
);
// summary.json
$files['summary.json'] = json_encode([
$summaryPayload = [
'data_freshness' => $dataFreshness,
'finding_count' => $findings->count(),
'report_count' => count(array_filter([$permissionPosture, $entraAdminRoles], static fn (array $payload): bool => $payload !== [])),
'operation_count' => $recentOperations->count(),
'risk_acceptance' => $riskAcceptance,
'snapshot_id' => (int) $snapshot->getKey(),
], JSON_PRETTY_PRINT | JSON_THROW_ON_ERROR);
];
$files['summary.json'] = json_encode(
$this->redactReportPayload($summaryPayload, $includePii),
JSON_PRETTY_PRINT | JSON_THROW_ON_ERROR,
);
return $files;
}
@ -525,7 +535,13 @@ private function redactReportPayload(array $payload, bool $includePii): array
{
$payload = $this->redactProtectedPayload($payload);
return $includePii ? $payload : $this->redactArrayPii($payload);
if (! $includePii) {
$payload = $this->redactBaselineReadinessDiagnostics($payload);
$payload = $this->redactCustomerSafeIdentifiers($payload);
$payload = $this->redactArrayPii($payload);
}
return $payload;
}
/**
@ -566,6 +582,195 @@ private function redactArrayPii(array $data): array
return $data;
}
/**
* @param array<string, mixed> $data
* @return array<string, mixed>
*/
private function redactBaselineReadinessDiagnostics(array $data): array
{
foreach ($data as $key => $value) {
if ($key === 'baseline_readiness' && is_array($value)) {
$data[$key] = $this->customerSafeBaselineReadiness($value);
continue;
}
if ($key === 'output_readiness' && is_array($value)) {
$data[$key] = $this->customerSafeOutputReadiness($value);
continue;
}
if (is_array($value)) {
$data[$key] = $this->redactBaselineReadinessDiagnostics($value);
}
}
return $data;
}
/**
* @param array<string, mixed> $baselineReadiness
* @return array<string, mixed>
*/
private function customerSafeBaselineReadiness(array $baselineReadiness): array
{
$summary = is_array($baselineReadiness['customer_safe_summary'] ?? null)
? $baselineReadiness['customer_safe_summary']
: [];
unset($summary['readiness_state']);
$publicationBlockers = collect($baselineReadiness['publication_blockers'] ?? [])
->filter(static fn (mixed $blocker): bool => is_scalar($blocker))
->map(fn (mixed $blocker): string => $this->plainText($blocker, ''))
->filter(static fn (string $blocker): bool => $blocker !== '')
->values()
->all();
$limitations = collect($baselineReadiness['limitations'] ?? [])
->filter(static fn (mixed $limitation): bool => is_array($limitation))
->map(fn (array $limitation): array => [
'summary' => $this->plainText(
$limitation['summary'] ?? null,
'Baseline limitation requires review before relying on this output.',
),
])
->values()
->all();
return [
'claim_label' => $this->baselineClaimLabel((string) ($baselineReadiness['customer_safe_claim'] ?? '')),
'readiness_label' => $this->baselineReadinessStateLabel((string) ($baselineReadiness['readiness_state'] ?? '')),
'publication_blocker_count' => count($publicationBlockers),
'publication_blockers' => $publicationBlockers,
'limitations' => $limitations,
'summary' => $summary,
];
}
/**
* @param array<string, mixed> $outputReadiness
* @return array<string, mixed>
*/
private function customerSafeOutputReadiness(array $outputReadiness): array
{
$limitations = collect($outputReadiness['limitations'] ?? [])
->filter(static fn (mixed $limitation): bool => is_array($limitation))
->values();
$safe = [
'review_status' => (string) ($outputReadiness['review_status'] ?? ''),
'review_completeness_state' => (string) ($outputReadiness['review_completeness_state'] ?? ''),
'evidence_completeness_state' => (string) ($outputReadiness['evidence_completeness_state'] ?? ''),
'has_ready_export' => (bool) ($outputReadiness['has_ready_export'] ?? false),
'contains_pii' => false,
'protected_values_hidden' => true,
'disclosure_present' => (bool) ($outputReadiness['disclosure_present'] ?? false),
'sharing_boundary' => $this->sharingBoundaryLabel((string) ($outputReadiness['customer_safe_state'] ?? 'requires_review')),
'readiness_label' => $this->outputReadinessStateLabel((string) ($outputReadiness['readiness_state'] ?? '')),
'limitation_count' => $limitations->count(),
'section_summary' => is_array($outputReadiness['section_summary'] ?? null)
? $outputReadiness['section_summary']
: [],
];
if (is_array($outputReadiness['baseline_readiness'] ?? null)) {
$safe['baseline_readiness'] = $this->customerSafeBaselineReadiness($outputReadiness['baseline_readiness']);
}
return $safe;
}
/**
* @param array<int|string, mixed> $data
* @return array<int|string, mixed>
*/
private function redactCustomerSafeIdentifiers(array $data): array
{
foreach ($data as $key => $value) {
if (is_string($key) && $this->isInternalIdentifierKey($key) && $this->isNumericIdentifierValue($value)) {
unset($data[$key]);
continue;
}
if (is_array($value)) {
$data[$key] = $this->redactCustomerSafeIdentifiers($value);
}
}
return $data;
}
private function isInternalIdentifierKey(string $key): bool
{
return $key === 'id' || str_ends_with($key, '_id') || str_ends_with($key, '_ids');
}
private function isNumericIdentifierValue(mixed $value): bool
{
if (is_int($value)) {
return true;
}
if (is_string($value)) {
return ctype_digit($value);
}
if (! is_array($value) || $value === []) {
return false;
}
return collect($value)->every(static fn (mixed $item): bool => is_int($item) || (is_string($item) && ctype_digit($item)));
}
private function baselineReadinessStateLabel(string $state): string
{
return match ($state) {
'customer_ready' => 'Customer-ready baseline evidence',
'trusted_drift_detected', 'drift_findings_present' => 'Trusted drift findings present',
'baseline_compare_limited' => 'Customer-ready with disclosed baseline limitations',
'baseline_identity_unresolved' => 'Baseline subject identity unresolved',
'baseline_local_evidence_missing' => 'Baseline local evidence missing',
'baseline_provider_resource_missing' => 'Provider resource evidence missing',
'baseline_required_coverage_unsupported' => 'Required baseline coverage unsupported',
'baseline_compare_unproven' => 'Baseline compare proof missing',
'baseline_compare_stale' => 'Baseline compare evidence stale',
'baseline_compare_failed' => 'Baseline compare failed',
'baseline_compare_blocked', 'baseline_compare_not_completed' => 'Baseline compare blocked',
default => 'Baseline readiness unavailable',
};
}
private function baselineClaimLabel(string $claim): string
{
return match ($claim) {
'customer_ready' => 'Customer-ready',
'customer_ready_with_findings' => 'Customer-ready with findings',
'customer_ready_with_disclosed_limitations' => 'Customer-ready with disclosed limitations',
default => 'Not customer-ready',
};
}
private function outputReadinessStateLabel(string $state): string
{
return match ($state) {
ReviewPackOutputReadiness::STATE_CUSTOMER_SAFE_READY => 'Customer-safe review pack ready',
ReviewPackOutputReadiness::STATE_PUBLISHED_WITH_LIMITATIONS => 'Published with limitations',
ReviewPackOutputReadiness::STATE_INTERNAL_REVIEW_PACKAGE_AVAILABLE => 'Internal review package available',
ReviewPackOutputReadiness::STATE_EXPORT_NOT_READY => 'Export not ready',
default => 'Requires review',
};
}
private function sharingBoundaryLabel(string $state): string
{
return match ($state) {
'customer_safe_ready' => 'Customer-safe',
'internal_only' => 'Internal only',
'not_ready' => 'Not ready',
default => 'Requires review',
};
}
/**
* @param array<int|string, mixed> $data
* @param array<int, string> $segments
@ -681,36 +886,41 @@ private function buildReviewDerivedFileMap(
: [],
);
$metadataPayload = [
'version' => '1.0',
'managed_environment_id' => $tenant->external_id,
'tenant_name' => $includePii ? $tenant->name : '[REDACTED]',
'generated_at' => $generatedAt->toIso8601String(),
'delivery_bundle' => $deliveryMetadata,
'environment_review' => [
'id' => (int) $review->getKey(),
'status' => (string) $review->status,
'completeness_state' => (string) $review->completeness_state,
'published_at' => $review->published_at?->toIso8601String(),
'fingerprint' => (string) $review->fingerprint,
],
'evidence_snapshot' => [
'id' => (int) $snapshot->getKey(),
'fingerprint' => (string) $snapshot->fingerprint,
'completeness_state' => (string) $snapshot->completeness_state,
'generated_at' => $snapshot->generated_at?->toIso8601String(),
],
'options' => [
'include_pii' => $includePii,
'include_operations' => $includeOperations,
],
'output_readiness' => data_get($summaryPayload, 'output_readiness', []),
'redaction_integrity' => [
'protected_values_hidden' => true,
'note' => RedactionIntegrity::protectedValueNote(),
],
];
$files = [
'metadata.json' => json_encode([
'version' => '1.0',
'managed_environment_id' => $tenant->external_id,
'tenant_name' => $includePii ? $tenant->name : '[REDACTED]',
'generated_at' => $generatedAt->toIso8601String(),
'delivery_bundle' => $deliveryMetadata,
'environment_review' => [
'id' => (int) $review->getKey(),
'status' => (string) $review->status,
'completeness_state' => (string) $review->completeness_state,
'published_at' => $review->published_at?->toIso8601String(),
'fingerprint' => (string) $review->fingerprint,
],
'evidence_snapshot' => [
'id' => (int) $snapshot->getKey(),
'fingerprint' => (string) $snapshot->fingerprint,
'completeness_state' => (string) $snapshot->completeness_state,
'generated_at' => $snapshot->generated_at?->toIso8601String(),
],
'options' => [
'include_pii' => $includePii,
'include_operations' => $includeOperations,
],
'output_readiness' => data_get($summaryPayload, 'output_readiness', []),
'redaction_integrity' => [
'protected_values_hidden' => true,
'note' => RedactionIntegrity::protectedValueNote(),
],
], JSON_PRETTY_PRINT | JSON_THROW_ON_ERROR),
'metadata.json' => json_encode(
$this->redactReportPayload($metadataPayload, $includePii),
JSON_PRETTY_PRINT | JSON_THROW_ON_ERROR,
),
'summary.json' => json_encode(
$this->redactReportPayload($summaryPayload, $includePii),
JSON_PRETTY_PRINT | JSON_THROW_ON_ERROR,
@ -881,7 +1091,7 @@ private function buildExecutiveEntrypoint(
'# Executive summary',
'',
'ManagedEnvironment: '.$this->plainText($tenantName, '[REDACTED]'),
'Released review: #'.((int) $review->getKey()),
'Released review: '.($includePii ? '#'.((int) $review->getKey()) : 'current released review'),
'Review status: '.$this->plainText($review->status, 'unknown'),
'Generated at: '.$generatedAt->toIso8601String(),
'',
@ -893,7 +1103,9 @@ private function buildExecutiveEntrypoint(
'',
$this->plainText(
$package['evidence_basis_summary'] ?? null,
sprintf('Anchored to evidence snapshot #%d with %s completeness.', (int) $snapshot->getKey(), (string) $snapshot->completeness_state),
$includePii
? sprintf('Anchored to evidence snapshot #%d with %s completeness.', (int) $snapshot->getKey(), (string) $snapshot->completeness_state)
: sprintf('Anchored to the released evidence snapshot with %s completeness.', (string) $snapshot->completeness_state),
),
'',
...($limitations === [] ? [] : [
@ -1001,6 +1213,9 @@ private function reviewDerivedSummaryPayload(
includePii: $includePii,
protectedValuesHidden: true,
disclosurePresent: $this->nonCertificationDisclosurePresent($reviewSummary),
baselineReadiness: is_array($reviewSummary['baseline_readiness'] ?? null)
? $reviewSummary['baseline_readiness']
: [],
);
$governancePackage = is_array($reviewSummary['governance_package'] ?? null)
@ -1110,6 +1325,34 @@ private function executiveLimitationsLines(array $outputReadiness): array
$lines[] = '- Publish blockers remain recorded in the released review summary.';
}
if (in_array('baseline_publication_blockers_present', $codes, true)) {
$lines[] = '- Baseline readiness blockers remain recorded; do not treat this output as customer-ready until they are resolved.';
}
if (in_array('baseline_compare_unproven', $codes, true)) {
$lines[] = '- Baseline compare proof was not available for the customer-ready claim.';
}
if (in_array('baseline_compare_stale', $codes, true)) {
$lines[] = '- Baseline compare evidence was stale at generation time.';
}
if (in_array('baseline_compare_failed', $codes, true)) {
$lines[] = '- Baseline compare failed and should be rerun or investigated before external reliance.';
}
if (in_array('baseline_foundation_limitations', $codes, true)) {
$lines[] = '- Some baseline subjects rely on inventory, identity, or canonical foundation evidence rather than direct comparable proof.';
}
if (in_array('baseline_accepted_limitations', $codes, true)) {
$lines[] = '- Accepted baseline limitations qualify the customer-ready claim.';
}
if (in_array('baseline_exclusions_present', $codes, true)) {
$lines[] = '- Excluded non-governed baseline subjects are outside the governed no-drift claim.';
}
if (in_array('disclosure_missing', $codes, true)) {
$lines[] = '- The non-certification disclosure was not fully available in the released review payload.';
}

View File

@ -4,8 +4,8 @@
namespace App\Services\EnvironmentReviews;
use App\Models\EvidenceSnapshot;
use App\Models\EnvironmentReview;
use App\Models\EvidenceSnapshot;
use App\Support\EnvironmentReviewStatus;
final class EnvironmentReviewComposer
@ -46,6 +46,8 @@ public function compose(EvidenceSnapshot $snapshot, ?EnvironmentReview $review =
->firstWhere('section_key', 'open_risks');
$acceptedRisksSection = collect($sections)
->firstWhere('section_key', 'accepted_risks');
$baselineSection = collect($sections)
->firstWhere('section_key', 'baseline_drift_posture');
$operationsSection = collect($sections)
->firstWhere('section_key', 'operations_health');
@ -68,6 +70,15 @@ public function compose(EvidenceSnapshot $snapshot, ?EnvironmentReview $review =
'section_state_counts' => $sectionStateCounts,
'publish_blockers' => $blockers,
'has_ready_export' => false,
'baseline_readiness' => is_array(data_get($baselineSection, 'summary_payload.baseline_readiness'))
? data_get($baselineSection, 'summary_payload.baseline_readiness')
: [],
'baseline_publication_blockers' => is_array(data_get($baselineSection, 'summary_payload.publication_blockers'))
? data_get($baselineSection, 'summary_payload.publication_blockers')
: [],
'baseline_limitations' => is_array(data_get($baselineSection, 'summary_payload.limitations'))
? data_get($baselineSection, 'summary_payload.limitations')
: [],
'finding_count' => (int) data_get($sections, '0.summary_payload.finding_count', 0),
'finding_outcomes' => is_array(data_get($sections, '0.summary_payload.finding_outcomes'))
? data_get($sections, '0.summary_payload.finding_outcomes')

View File

@ -35,6 +35,10 @@ public function blockersForSections(iterable $sections): array
if ($state === EnvironmentReviewCompletenessState::Stale->value) {
$blockers[] = sprintf('%s is stale and must be refreshed before publication.', $title);
}
foreach ($this->sectionPublicationBlockers($section) as $sectionBlocker) {
$blockers[] = sprintf('%s: %s', $title, $sectionBlocker);
}
}
return array_values(array_unique($blockers));
@ -92,6 +96,7 @@ public function blockersForReview(EnvironmentReview $review): array
'title' => (string) $section->title,
'required' => (bool) $section->required,
'completeness_state' => (string) $section->completeness_state,
'summary_payload' => is_array($section->summary_payload) ? $section->summary_payload : [],
];
})->all());
}
@ -134,4 +139,26 @@ public function sectionStateCounts(iterable $sections): array
'stale' => (int) ($counts[EnvironmentReviewCompletenessState::Stale->value] ?? 0),
];
}
/**
* @param array<string, mixed> $section
* @return list<string>
*/
private function sectionPublicationBlockers(array $section): array
{
$summary = is_array($section['summary_payload'] ?? null) ? $section['summary_payload'] : [];
$blockers = is_array($summary['publication_blockers'] ?? null) ? $summary['publication_blockers'] : [];
if ($blockers === []) {
$baselineReadiness = is_array($summary['baseline_readiness'] ?? null) ? $summary['baseline_readiness'] : [];
$blockers = is_array($baselineReadiness['publication_blockers'] ?? null)
? $baselineReadiness['publication_blockers']
: [];
}
return collect($blockers)
->filter(static fn (mixed $blocker): bool => is_string($blocker) && trim($blocker) !== '')
->values()
->all();
}
}

View File

@ -6,9 +6,9 @@
use App\Models\EvidenceSnapshot;
use App\Models\EvidenceSnapshotItem;
use App\Support\EnvironmentReviewCompletenessState;
use App\Support\Findings\FindingOutcomeSemantics;
use App\Support\Governance\Controls\ComplianceEvidenceMappingV1;
use App\Support\EnvironmentReviewCompletenessState;
use Illuminate\Support\Arr;
use Illuminate\Support\Collection;
@ -61,6 +61,7 @@ private function executiveSummarySection(
$findingReportBuckets = is_array($findingsSummary['report_bucket_counts'] ?? null) ? $findingsSummary['report_bucket_counts'] : [];
$riskAcceptance = is_array($findingsSummary['risk_acceptance'] ?? null) ? $findingsSummary['risk_acceptance'] : [];
$canonicalControls = is_array($findingsSummary['canonical_controls'] ?? null) ? $findingsSummary['canonical_controls'] : [];
$baselineReadiness = $this->baselineReadiness($baselineSummary);
$openCount = (int) ($findingsSummary['open_count'] ?? 0);
$findingCount = (int) ($findingsSummary['count'] ?? 0);
@ -106,6 +107,7 @@ private function executiveSummarySection(
'canonical_control_count' => count($canonicalControls),
'canonical_controls' => $canonicalControls,
'risk_acceptance' => $riskAcceptance,
'baseline_readiness' => $baselineReadiness,
],
'render_payload' => [
'highlights' => $highlights,
@ -115,6 +117,7 @@ private function executiveSummarySection(
operationFailures: $operationFailures,
postureScore: is_numeric($postureScore) ? (int) $postureScore : null,
riskWarnings: (int) ($riskAcceptance['warning_count'] ?? 0),
baselineReadiness: $baselineReadiness,
),
'included_dimensions' => collect($snapshot->items)
->map(static fn (EvidenceSnapshotItem $item): array => [
@ -242,6 +245,9 @@ private function permissionPostureSection(?EvidenceSnapshotItem $permissionItem,
private function baselineDriftSection(?EvidenceSnapshotItem $baselineItem): array
{
$summary = $this->summary($baselineItem);
$baselineReadiness = $this->baselineReadiness($summary);
$publicationBlockers = $this->baselinePublicationBlockers($baselineReadiness);
$limitations = $this->baselineLimitations($baselineReadiness);
return [
'section_key' => 'baseline_drift_posture',
@ -253,11 +259,16 @@ private function baselineDriftSection(?EvidenceSnapshotItem $baselineItem): arra
'summary_payload' => [
'drift_count' => (int) ($summary['drift_count'] ?? 0),
'open_drift_count' => (int) ($summary['open_drift_count'] ?? 0),
'baseline_readiness' => $baselineReadiness,
'publication_blockers' => $publicationBlockers,
'limitations' => $limitations,
],
'render_payload' => [
'disclosure' => (int) ($summary['open_drift_count'] ?? 0) > 0
? 'Baseline drift remains visible in this review and should be discussed as hardening work.'
: 'No open baseline drift findings are present in the anchored evidence basis.',
'disclosure' => $this->baselineDisclosure(
baselineReadiness: $baselineReadiness,
openDriftCount: (int) ($summary['open_drift_count'] ?? 0),
),
'next_actions' => $this->baselineNextActions($baselineReadiness),
'artifact_sources' => $this->artifactSourceSummaries($baselineItem),
],
'measured_at' => $baselineItem?->measured_at,
@ -389,6 +400,7 @@ private function nextActions(
int $operationFailures,
?int $postureScore,
int $riskWarnings,
array $baselineReadiness = [],
): array {
$actions = [];
@ -408,6 +420,10 @@ private function nextActions(
$actions[] = 'Schedule remediation for recurring baseline drift to reduce repeated review findings.';
}
foreach ($this->baselineNextActions($baselineReadiness) as $baselineAction) {
$actions[] = $baselineAction;
}
if ($operationFailures > 0) {
$actions[] = 'Inspect recent failed operations to confirm tenant management workflows are stable.';
}
@ -418,4 +434,83 @@ private function nextActions(
return $actions;
}
/**
* @param array<string, mixed> $summary
* @return array<string, mixed>
*/
private function baselineReadiness(array $summary): array
{
return is_array($summary['baseline_readiness'] ?? null) ? $summary['baseline_readiness'] : [];
}
/**
* @param array<string, mixed> $baselineReadiness
* @return list<string>
*/
private function baselinePublicationBlockers(array $baselineReadiness): array
{
return collect($baselineReadiness['publication_blockers'] ?? [])
->filter(static fn (mixed $blocker): bool => is_string($blocker) && trim($blocker) !== '')
->values()
->all();
}
/**
* @param array<string, mixed> $baselineReadiness
* @return list<array<string, mixed>>
*/
private function baselineLimitations(array $baselineReadiness): array
{
return collect($baselineReadiness['limitations'] ?? [])
->filter(static fn (mixed $limitation): bool => is_array($limitation) && is_string($limitation['code'] ?? null))
->values()
->all();
}
/**
* @param array<string, mixed> $baselineReadiness
*/
private function baselineDisclosure(array $baselineReadiness, int $openDriftCount): string
{
$blockerCount = count($this->baselinePublicationBlockers($baselineReadiness));
$limitationCount = count($this->baselineLimitations($baselineReadiness));
if ($blockerCount > 0) {
return sprintf('%d baseline readiness blocker(s) must be resolved before customer-ready publication.', $blockerCount);
}
if ($limitationCount > 0) {
return sprintf('%d baseline limitation(s) qualify the customer-ready claim and must remain disclosed.', $limitationCount);
}
if ($openDriftCount > 0) {
return 'Baseline drift remains visible in this review and should be discussed as hardening work.';
}
$claim = (string) ($baselineReadiness['customer_safe_claim'] ?? '');
if ($claim === 'customer_ready') {
return 'Baseline compare readiness supports the customer-ready no-drift claim.';
}
return 'No open baseline drift findings are present in the anchored evidence basis.';
}
/**
* @param array<string, mixed> $baselineReadiness
* @return list<string>
*/
private function baselineNextActions(array $baselineReadiness): array
{
$action = (string) ($baselineReadiness['next_action'] ?? '');
return match ($action) {
'open_baseline_subject_resolution' => ['Resolve baseline subject identity and provider-resource decisions before publication.'],
'open_evidence_basis' => ['Refresh baseline compare evidence before relying on the review output.'],
'open_operation_proof' => ['Inspect the latest baseline compare operation proof and rerun if needed.'],
'review_output_limitations' => ['Review and disclose baseline limitations before customer delivery.'],
default => [],
};
}
}

View File

@ -8,14 +8,16 @@
use App\Models\ManagedEnvironment;
use App\Models\OperationRun;
use App\Services\Evidence\Contracts\EvidenceSourceProvider;
use App\Support\Evidence\EvidenceCompletenessState;
use App\Support\Baselines\Readiness\BaselineEvidenceReadinessDeriver;
use App\Support\OperationCatalog;
use App\Support\OperationRunOutcome;
use App\Support\OperationRunStatus;
use App\Support\OperationRunType;
final class BaselineDriftPostureSource implements EvidenceSourceProvider
{
public function __construct(
private readonly BaselineEvidenceReadinessDeriver $readinessDeriver,
) {}
public function key(): string
{
return 'baseline_drift_posture';
@ -36,16 +38,20 @@ public function collect(ManagedEnvironment $tenant): array
$latest = $findings->max('updated_at') ?? $findings->max('created_at') ?? $latestCompareAt;
$isStale = $latest !== null && $latest->lt(now()->subDays(30));
$state = match (true) {
$isStale => EvidenceCompletenessState::Stale->value,
$latestCompareRun instanceof OperationRun => $this->stateForCompareRun($latestCompareRun),
$findings->isEmpty() => EvidenceCompletenessState::Missing->value,
default => EvidenceCompletenessState::Complete->value,
};
$driftCount = $findings->count();
$openDriftCount = $findings->filter(fn (Finding $finding): bool => $finding->hasOpenStatus())->count();
$baselineReadiness = $this->readinessDeriver->deriveForEnvironment(
tenant: $tenant,
latestCompareRun: $latestCompareRun,
driftCount: $driftCount,
openDriftCount: $openDriftCount,
measuredAt: $latest,
isStale: $isStale,
);
return [
'dimension_key' => $this->key(),
'state' => $state,
'state' => (string) ($baselineReadiness['state'] ?? 'missing'),
'required' => true,
'source_kind' => 'model_summary',
'source_record_type' => Finding::class,
@ -54,18 +60,23 @@ public function collect(ManagedEnvironment $tenant): array
'measured_at' => $latest,
'freshness_at' => $latest,
'summary_payload' => [
'drift_count' => $findings->count(),
'open_drift_count' => $findings->filter(fn (Finding $finding): bool => $finding->hasOpenStatus())->count(),
'drift_count' => $driftCount,
'open_drift_count' => $openDriftCount,
'latest_compare_run_id' => $latestCompareRun instanceof OperationRun ? (int) $latestCompareRun->getKey() : null,
'latest_compare_outcome' => $latestCompareRun instanceof OperationRun ? (string) $latestCompareRun->outcome : null,
'latest_compare_completed_at' => $latestCompareRun?->completed_at?->toIso8601String(),
'baseline_readiness' => $baselineReadiness,
],
'fingerprint_payload' => [
'latest' => $latest?->format(DATE_ATOM),
'count' => $findings->count(),
'count' => $driftCount,
'latest_compare_run_id' => $latestCompareRun instanceof OperationRun ? (int) $latestCompareRun->getKey() : null,
'latest_compare_outcome' => $latestCompareRun instanceof OperationRun ? (string) $latestCompareRun->outcome : null,
'latest_compare_completed_at' => $latestCompareRun?->completed_at?->toIso8601String(),
'baseline_readiness_state' => (string) ($baselineReadiness['readiness_state'] ?? 'unknown'),
'baseline_readiness_counts' => is_array($baselineReadiness['counts'] ?? null) ? $baselineReadiness['counts'] : [],
'baseline_readiness_limitations' => is_array($baselineReadiness['limitation_codes'] ?? null) ? $baselineReadiness['limitation_codes'] : [],
'baseline_readiness_blocker_count' => count(is_array($baselineReadiness['publication_blockers'] ?? null) ? $baselineReadiness['publication_blockers'] : []),
],
'sort_order' => 40,
];
@ -81,17 +92,4 @@ private function latestBaselineCompareRun(ManagedEnvironment $tenant): ?Operatio
->latest('id')
->first();
}
private function stateForCompareRun(OperationRun $operationRun): string
{
if ((string) $operationRun->status !== OperationRunStatus::Completed->value) {
return EvidenceCompletenessState::Missing->value;
}
return match ((string) $operationRun->outcome) {
OperationRunOutcome::Succeeded->value => EvidenceCompletenessState::Complete->value,
OperationRunOutcome::PartiallySucceeded->value => EvidenceCompletenessState::Partial->value,
default => EvidenceCompletenessState::Missing->value,
};
}
}

View File

@ -0,0 +1,428 @@
<?php
declare(strict_types=1);
namespace App\Support\Baselines\Readiness;
use App\Models\ManagedEnvironment;
use App\Models\OperationRun;
use App\Models\ProviderResourceBinding;
use App\Support\Baselines\CompareSemantics\CompareResultReadinessImpact;
use App\Support\Baselines\CompareSemantics\CompareResultReason;
use App\Support\Evidence\EvidenceCompletenessState;
use App\Support\OperationRunOutcome;
use App\Support\OperationRunStatus;
use App\Support\Resources\ProviderResourceBindingStatus;
use App\Support\Resources\ProviderResourceResolutionMode;
use Carbon\CarbonInterface;
final class BaselineEvidenceReadinessDeriver
{
public const string VERSION = 'baseline_readiness.spec385.v1';
/**
* @return array<string, mixed>
*/
public function deriveForEnvironment(
ManagedEnvironment $tenant,
?OperationRun $latestCompareRun,
int $driftCount,
int $openDriftCount,
?CarbonInterface $measuredAt,
bool $isStale,
): array {
return $this->derive(
latestCompareRun: $latestCompareRun,
driftCount: $driftCount,
openDriftCount: $openDriftCount,
bindingDecisionCounts: $this->bindingDecisionCounts($tenant, $latestCompareRun),
measuredAt: $measuredAt,
isStale: $isStale,
);
}
/**
* @param array<string, int> $bindingDecisionCounts
* @return array<string, mixed>
*/
public function derive(
?OperationRun $latestCompareRun,
int $driftCount,
int $openDriftCount,
array $bindingDecisionCounts = [],
?CarbonInterface $measuredAt = null,
bool $isStale = false,
): array {
$bindingDecisionCounts = $this->normalizeCounts($bindingDecisionCounts);
$semantics = $this->structuredCompareSemantics($latestCompareRun);
$counts = $this->semanticCounts($semantics, $driftCount, $bindingDecisionCounts);
$publicationBlockers = [];
$limitations = [];
$readinessState = 'customer_ready';
$state = EvidenceCompletenessState::Complete;
$nextAction = 'download_customer_safe_review_pack';
$proofState = $semantics === [] ? 'missing_structured_compare' : 'structured_compare';
if (! $latestCompareRun instanceof OperationRun) {
if ($driftCount > 0) {
$readinessState = 'drift_findings_present';
$proofState = 'drift_findings_only';
$nextAction = 'review_baseline_drift_findings';
} else {
$state = EvidenceCompletenessState::Missing;
$readinessState = 'baseline_compare_unproven';
$publicationBlockers[] = 'Baseline compare proof is missing; refresh evidence before presenting a no-drift claim.';
$nextAction = 'open_evidence_basis';
}
} elseif ((string) $latestCompareRun->status !== OperationRunStatus::Completed->value) {
$state = EvidenceCompletenessState::Missing;
$readinessState = 'baseline_compare_not_completed';
$publicationBlockers[] = 'Baseline compare has not completed; rerun or wait for completion before publication.';
$nextAction = 'open_operation_proof';
} elseif ((string) $latestCompareRun->outcome === OperationRunOutcome::Failed->value || $counts['failed_subject_count'] > 0) {
$state = EvidenceCompletenessState::Missing;
$readinessState = 'baseline_compare_failed';
$publicationBlockers[] = 'Baseline compare failed; rerun or investigate before publication.';
$nextAction = 'open_operation_proof';
} elseif ($isStale) {
$state = EvidenceCompletenessState::Stale;
$readinessState = 'baseline_compare_stale';
$publicationBlockers[] = 'Baseline compare evidence is stale and must be refreshed before publication.';
$nextAction = 'open_evidence_basis';
} elseif ($semantics === []) {
if ($driftCount > 0) {
$readinessState = 'drift_findings_present';
$proofState = 'drift_findings_only';
$nextAction = 'review_baseline_drift_findings';
} else {
$state = EvidenceCompletenessState::Missing;
$readinessState = 'baseline_compare_unproven';
$publicationBlockers[] = 'Baseline compare did not produce structured readiness proof; refresh evidence before publication.';
$nextAction = 'open_evidence_basis';
}
} else {
[$publicationBlockers, $limitations, $nextAction] = $this->reasonsToReadinessActions($counts);
if ($publicationBlockers !== []) {
$state = $counts['missing_local_evidence_subject_count'] > 0 || $counts['failed_subject_count'] > 0
? EvidenceCompletenessState::Missing
: EvidenceCompletenessState::Partial;
$readinessState = $this->blockingReadinessState($counts);
} elseif ($limitations !== []) {
$state = EvidenceCompletenessState::Partial;
$readinessState = 'baseline_compare_limited';
} elseif ($counts['drift_subject_count'] > 0 || $driftCount > 0) {
$readinessState = 'trusted_drift_detected';
$nextAction = 'review_baseline_drift_findings';
}
}
$limitationCodes = array_values(array_unique(array_map(
static fn (array $limitation): string => (string) $limitation['code'],
$limitations,
)));
return [
'version' => self::VERSION,
'state' => $state->value,
'readiness_state' => $readinessState,
'proof_state' => $proofState,
'customer_safe_claim' => $this->customerSafeClaim($state, $readinessState, $publicationBlockers, $limitations),
'publication_blockers' => array_values(array_unique($publicationBlockers)),
'limitations' => $limitations,
'limitation_codes' => $limitationCodes,
'next_action' => $nextAction,
'counts' => $counts,
'customer_safe_summary' => [
'state' => $state->value,
'readiness_state' => $readinessState,
'verified_subject_count' => $counts['verified_subject_count'],
'drift_subject_count' => max($counts['drift_subject_count'], $driftCount),
'open_drift_count' => $openDriftCount,
'blocker_count' => count(array_unique($publicationBlockers)),
'limitation_count' => count($limitationCodes),
'excluded_subject_count' => $counts['excluded_subject_count'],
],
'internal_diagnostics' => [
'latest_compare_run_id' => $latestCompareRun instanceof OperationRun ? (int) $latestCompareRun->getKey() : null,
'latest_compare_status' => $latestCompareRun instanceof OperationRun ? (string) $latestCompareRun->status : null,
'latest_compare_outcome' => $latestCompareRun instanceof OperationRun ? (string) $latestCompareRun->outcome : null,
'latest_compare_completed_at' => $latestCompareRun?->completed_at?->toIso8601String(),
'measured_at' => $measuredAt?->toIso8601String(),
'has_structured_compare_semantics' => $semantics !== [],
'run_outcome' => is_string($semantics['run_outcome'] ?? null) ? $semantics['run_outcome'] : null,
'operation_outcome' => is_string($semantics['operation_outcome'] ?? null) ? $semantics['operation_outcome'] : null,
'binding_decision_counts' => $bindingDecisionCounts,
'semantic_counts' => is_array($semantics['counts'] ?? null) ? $semantics['counts'] : [],
],
];
}
/**
* @return array<string, int>
*/
private function bindingDecisionCounts(ManagedEnvironment $tenant, ?OperationRun $latestCompareRun): array
{
$counts = ProviderResourceBinding::query()
->active()
->where('managed_environment_id', (int) $tenant->getKey())
->where('workspace_id', (int) $tenant->workspace_id)
->selectRaw('resolution_mode, count(*) as aggregate')
->groupBy('resolution_mode')
->pluck('aggregate', 'resolution_mode')
->map(static fn (mixed $count): int => max(0, (int) $count))
->all();
$compareAt = $latestCompareRun?->completed_at
?? $latestCompareRun?->updated_at
?? $latestCompareRun?->created_at;
if ($compareAt !== null) {
$counts['revoked_after_latest_compare'] = ProviderResourceBinding::query()
->where('managed_environment_id', (int) $tenant->getKey())
->where('workspace_id', (int) $tenant->workspace_id)
->where('binding_status', ProviderResourceBindingStatus::Revoked->value)
->where('ended_at', '>', $compareAt)
->count();
}
return $counts;
}
/**
* @return array<string, mixed>
*/
private function structuredCompareSemantics(?OperationRun $operationRun): array
{
if (! $operationRun instanceof OperationRun) {
return [];
}
$context = is_array($operationRun->context) ? $operationRun->context : [];
$semantics = data_get($context, 'baseline_compare.result_semantics');
if (! is_array($semantics)) {
return [];
}
$version = $semantics['version'] ?? null;
$counts = $semantics['counts'] ?? null;
if (! is_string($version) || ! is_array($counts)) {
return [];
}
return $semantics;
}
/**
* @param array<string, mixed> $semantics
* @return array<string, int>
*/
private function semanticCounts(array $semantics, int $driftCount, array $bindingDecisionCounts): array
{
$byReason = $this->normalizeCounts(data_get($semantics, 'counts.by_reason', []));
$byReadiness = $this->normalizeCounts(data_get($semantics, 'counts.by_readiness_impact', []));
$identityBlockerCount = $this->sumReasons($byReason, [
CompareResultReason::IdentityRequired,
CompareResultReason::UnresolvedDuplicateCandidates,
CompareResultReason::UnresolvedLowTrustMatch,
CompareResultReason::UnresolvedAmbiguousIdentity,
]);
$foundationLimitationCount = $this->sumReasons($byReason, [
CompareResultReason::FoundationInventoryOnly,
CompareResultReason::FoundationIdentityOnly,
CompareResultReason::FoundationCanonicalOnly,
]);
$unsupportedCount = $this->sumReasons($byReason, [
CompareResultReason::UnsupportedResourceClass,
CompareResultReason::CompareNotSupported,
]);
$bindingVerifiedCount = $this->sumResolutionModes($bindingDecisionCounts, [
ProviderResourceResolutionMode::ExactProviderIdentity,
ProviderResourceResolutionMode::CanonicalBuiltin,
ProviderResourceResolutionMode::CanonicalVirtualTarget,
ProviderResourceResolutionMode::ManualBinding,
]);
$bindingAcceptedLimitationCount = (int) ($bindingDecisionCounts[ProviderResourceResolutionMode::AcceptedLimitation->value] ?? 0);
$bindingExcludedCount = (int) ($bindingDecisionCounts[ProviderResourceResolutionMode::ExcludedNonGoverned->value] ?? 0);
$bindingUnsupportedCount = (int) ($bindingDecisionCounts[ProviderResourceResolutionMode::UnsupportedCoverage->value] ?? 0);
$bindingMissingExpectedCount = (int) ($bindingDecisionCounts[ProviderResourceResolutionMode::MissingExpected->value] ?? 0);
return [
'verified_subject_count' => $this->sumReasons($byReason, [
CompareResultReason::VerifiedNoDrift,
CompareResultReason::ResolvedActiveBinding,
CompareResultReason::ResolvedCanonicalIdentity,
CompareResultReason::ResolvedProviderIdentity,
]) + $bindingVerifiedCount,
'drift_subject_count' => max(0, (int) ($byReason[CompareResultReason::VerifiedDriftDetected->value] ?? 0), $driftCount),
'identity_blocker_subject_count' => $identityBlockerCount,
'missing_local_evidence_subject_count' => (int) ($byReason[CompareResultReason::MissingLocalEvidence->value] ?? 0),
'missing_provider_resource_subject_count' => max((int) ($byReason[CompareResultReason::MissingProviderResource->value] ?? 0), $bindingMissingExpectedCount),
'unsupported_subject_count' => max($unsupportedCount, $bindingUnsupportedCount),
'foundation_limited_subject_count' => $foundationLimitationCount,
'accepted_limitation_subject_count' => max((int) ($byReason[CompareResultReason::AcceptedLimitation->value] ?? 0), $bindingAcceptedLimitationCount),
'excluded_subject_count' => max((int) ($byReason[CompareResultReason::ExcludedNonGoverned->value] ?? 0), $bindingExcludedCount),
'failed_subject_count' => (int) ($byReason[CompareResultReason::CompareFailed->value] ?? 0),
'customer_blocker_subject_count' => (int) ($byReadiness[CompareResultReadinessImpact::CustomerBlocker->value] ?? 0),
'internal_blocker_subject_count' => (int) ($byReadiness[CompareResultReadinessImpact::InternalBlocker->value] ?? 0),
'customer_limitation_subject_count' => (int) ($byReadiness[CompareResultReadinessImpact::CustomerLimitation->value] ?? 0),
'internal_limitation_subject_count' => (int) ($byReadiness[CompareResultReadinessImpact::InternalLimitation->value] ?? 0),
'revoked_binding_after_compare_count' => (int) ($bindingDecisionCounts['revoked_after_latest_compare'] ?? 0),
];
}
/**
* @param array<string, int> $counts
* @return array{0:list<string>,1:list<array{code:string,summary:string}>,2:string}
*/
private function reasonsToReadinessActions(array $counts): array
{
$blockers = [];
$limitations = [];
$nextAction = 'download_customer_safe_review_pack';
if ($counts['identity_blocker_subject_count'] > 0) {
$blockers[] = 'Baseline subject identity must be resolved before customer-ready publication.';
$nextAction = 'open_baseline_subject_resolution';
}
if ($counts['missing_local_evidence_subject_count'] > 0) {
$blockers[] = 'Baseline local evidence is missing and must be refreshed before publication.';
$nextAction = 'open_evidence_basis';
}
if ($counts['missing_provider_resource_subject_count'] > 0) {
$blockers[] = 'Baseline provider resources are missing and need operator review before publication.';
$nextAction = 'open_baseline_subject_resolution';
}
if ($counts['unsupported_subject_count'] > 0) {
$blockers[] = 'Required baseline coverage is unsupported and must be accepted or resolved before publication.';
$nextAction = 'review_output_limitations';
}
if ($counts['failed_subject_count'] > 0 || $counts['internal_blocker_subject_count'] > 0) {
$blockers[] = 'Baseline compare contains failed subjects and must be rerun or investigated before publication.';
$nextAction = 'open_operation_proof';
}
if ($counts['revoked_binding_after_compare_count'] > 0) {
$blockers[] = 'Baseline subject decisions changed after the latest compare; refresh evidence before publication.';
$nextAction = 'open_evidence_basis';
}
if ($counts['foundation_limited_subject_count'] > 0) {
$limitations[] = [
'code' => 'baseline_foundation_limitations',
'summary' => 'Some baseline subjects are supported only by inventory, identity, or canonical foundation evidence.',
];
}
if ($counts['accepted_limitation_subject_count'] > 0) {
$limitations[] = [
'code' => 'baseline_accepted_limitations',
'summary' => 'Accepted baseline limitations qualify the customer-ready claim.',
];
}
if ($counts['excluded_subject_count'] > 0) {
$limitations[] = [
'code' => 'baseline_exclusions_present',
'summary' => 'Excluded non-governed baseline subjects are outside the governed no-drift claim.',
];
}
if ($blockers === [] && $limitations !== []) {
$nextAction = 'review_output_limitations';
}
return [$blockers, $limitations, $nextAction];
}
/**
* @param array<string, int> $counts
*/
private function blockingReadinessState(array $counts): string
{
if ($counts['missing_local_evidence_subject_count'] > 0) {
return 'baseline_local_evidence_missing';
}
if ($counts['identity_blocker_subject_count'] > 0) {
return 'baseline_identity_unresolved';
}
if ($counts['missing_provider_resource_subject_count'] > 0) {
return 'baseline_provider_resource_missing';
}
if ($counts['unsupported_subject_count'] > 0) {
return 'baseline_required_coverage_unsupported';
}
return 'baseline_compare_blocked';
}
/**
* @param array<string, int> $counts
* @param list<CompareResultReason> $reasons
*/
private function sumReasons(array $counts, array $reasons): int
{
return collect($reasons)
->sum(static fn (CompareResultReason $reason): int => (int) ($counts[$reason->value] ?? 0));
}
/**
* @param array<string, int> $counts
* @param list<ProviderResourceResolutionMode> $modes
*/
private function sumResolutionModes(array $counts, array $modes): int
{
return collect($modes)
->sum(static fn (ProviderResourceResolutionMode $mode): int => (int) ($counts[$mode->value] ?? 0));
}
/**
* @return array<string, int>
*/
private function normalizeCounts(mixed $counts): array
{
if (! is_array($counts)) {
return [];
}
return collect($counts)
->filter(static fn (mixed $count, mixed $key): bool => is_string($key) && $key !== '')
->map(static fn (mixed $count): int => max(0, (int) $count))
->all();
}
/**
* @param list<string> $publicationBlockers
* @param list<array{code:string,summary:string}> $limitations
*/
private function customerSafeClaim(
EvidenceCompletenessState $state,
string $readinessState,
array $publicationBlockers,
array $limitations,
): string {
if ($publicationBlockers !== [] || in_array($state, [EvidenceCompletenessState::Missing, EvidenceCompletenessState::Stale], true)) {
return 'not_customer_ready';
}
if ($limitations !== []) {
return 'customer_ready_with_disclosed_limitations';
}
return match ($readinessState) {
'trusted_drift_detected', 'drift_findings_present' => 'customer_ready_with_findings',
default => 'customer_ready',
};
}
}

View File

@ -24,7 +24,7 @@ final class ReportDisclosurePolicy
* 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},
* proof_states:array{audience_boundary:string,evidence_basis:string,baseline_readiness:string,protected_values:string,non_certification:string},
* show_section_appendix:bool,
* show_technical_details:bool
* }
@ -35,6 +35,7 @@ public static function evaluate(array $profile, array $readiness, array $metadat
$containsPii = (bool) ($readiness['contains_pii'] ?? false);
$protectedValuesHidden = (bool) ($readiness['protected_values_hidden'] ?? false);
$disclosurePresent = (bool) ($readiness['disclosure_present'] ?? false);
$baselineReadiness = is_array($readiness['baseline_readiness'] ?? null) ? $readiness['baseline_readiness'] : [];
$displayedDisclosure = self::plainText(
$metadata['non_certification_disclosure'] ?? null,
__('localization.review.non_certification_disclosure_text'),
@ -43,6 +44,7 @@ public static function evaluate(array $profile, array $readiness, array $metadat
$proofStates = [
'audience_boundary' => self::PROOF_VERIFIED,
'evidence_basis' => self::evidenceBasisProofState((string) ($readiness['evidence_completeness_state'] ?? '')),
'baseline_readiness' => self::baselineReadinessProofState($baselineReadiness),
'protected_values' => self::protectedValuesProofState(
isCustomerFacing: $isCustomerFacing,
containsPii: $containsPii,
@ -63,6 +65,14 @@ public static function evaluate(array $profile, array $readiness, array $metadat
];
}
if ($isCustomerFacing && self::baselineBlockers($baselineReadiness) !== []) {
$blockingReasons[] = [
'key' => 'baseline_readiness_blocked',
'label' => __('localization.review.baseline_publication_blocked'),
'summary' => __('localization.review.report_disclosure_baseline_readiness_blocked'),
];
}
$warnings = [];
if ((bool) ($profile['is_fallback'] ?? false)) {
@ -89,6 +99,14 @@ public static function evaluate(array $profile, array $readiness, array $metadat
];
}
if ($isCustomerFacing && self::baselineLimitations($baselineReadiness) !== []) {
$warnings[] = [
'key' => 'baseline_limitations_present',
'label' => __('localization.review.baseline_limitations_short_reason'),
'summary' => __('localization.review.report_disclosure_baseline_limitations_present'),
];
}
$showDetailedContent = ! ($isCustomerFacing && $containsPii);
return [
@ -122,6 +140,17 @@ public static function evaluate(array $profile, array $readiness, array $metadat
},
'proof_state' => $proofStates['protected_values'],
],
[
'key' => 'baseline_readiness',
'label' => __('localization.review.report_disclosure_baseline_readiness'),
'summary' => match ($proofStates['baseline_readiness']) {
self::PROOF_VERIFIED => __('localization.review.report_disclosure_baseline_verified'),
self::PROOF_ASSUMED => __('localization.review.report_disclosure_baseline_limited'),
self::PROOF_MISSING => __('localization.review.report_disclosure_baseline_missing'),
default => __('localization.review.report_disclosure_baseline_unknown'),
},
'proof_state' => $proofStates['baseline_readiness'],
],
[
'key' => 'non_certification',
'label' => __('localization.review.non_certification_disclosure'),
@ -137,6 +166,28 @@ public static function evaluate(array $profile, array $readiness, array $metadat
];
}
/**
* @param array<string, mixed> $baselineReadiness
*/
private static function baselineReadinessProofState(array $baselineReadiness): string
{
if ($baselineReadiness === []) {
return self::PROOF_UNKNOWN;
}
if (self::baselineBlockers($baselineReadiness) !== []) {
return self::PROOF_MISSING;
}
if (self::baselineLimitations($baselineReadiness) !== []) {
return self::PROOF_ASSUMED;
}
return (string) ($baselineReadiness['state'] ?? '') === 'complete'
? self::PROOF_VERIFIED
: self::PROOF_MISSING;
}
private static function evidenceBasisProofState(string $evidenceCompletenessState): string
{
return match ($evidenceCompletenessState) {
@ -162,6 +213,30 @@ private static function protectedValuesProofState(
return self::PROOF_ASSUMED;
}
/**
* @param array<string, mixed> $baselineReadiness
* @return list<string>
*/
private static function baselineBlockers(array $baselineReadiness): array
{
return collect($baselineReadiness['publication_blockers'] ?? [])
->filter(static fn (mixed $blocker): bool => is_string($blocker) && trim($blocker) !== '')
->values()
->all();
}
/**
* @param array<string, mixed> $baselineReadiness
* @return list<string>
*/
private static function baselineLimitations(array $baselineReadiness): array
{
return collect($baselineReadiness['limitation_codes'] ?? [])
->filter(static fn (mixed $code): bool => is_string($code) && trim($code) !== '')
->values()
->all();
}
private static function plainText(mixed $value, string $fallback): string
{
if (! is_scalar($value) && $value !== null) {

View File

@ -37,6 +37,7 @@ final class ReviewPackOutputReadiness
* primary_reason: string,
* primary_action: string,
* limitations: list<array{code: string}>,
* baseline_readiness: array<string, mixed>,
* section_summary: array{
* required_total: int,
* required_complete: int,
@ -59,9 +60,11 @@ public static function derive(
bool $includePii,
bool $protectedValuesHidden = true,
bool $disclosurePresent = true,
array $baselineReadiness = [],
): array {
$sectionStateCounts = self::normalizeCounts($sectionStateCounts);
$requiredSectionStateCounts = self::normalizeCounts($requiredSectionStateCounts);
$baselineReadiness = is_array($baselineReadiness) ? $baselineReadiness : [];
$requiredLimitedCount = max(
0,
@ -100,6 +103,8 @@ public static function derive(
$limitations[] = ['code' => 'disclosure_missing'];
}
array_push($limitations, ...self::baselineLimitations($baselineReadiness));
$readinessState = match (true) {
! $hasReadyExport => self::STATE_EXPORT_NOT_READY,
self::hasMaterialLimitations($limitations) => self::STATE_PUBLISHED_WITH_LIMITATIONS,
@ -144,6 +149,7 @@ public static function derive(
'primary_reason' => $primaryReason,
'primary_action' => $primaryAction,
'limitations' => $limitations,
'baseline_readiness' => $baselineReadiness,
'section_summary' => [
'required_total' => $requiredSectionCount,
'required_complete' => $requiredComplete,
@ -175,4 +181,51 @@ private static function hasMaterialLimitations(array $limitations): bool
->pluck('code')
->contains(static fn (string $code): bool => $code !== 'contains_pii');
}
/**
* @param array<string, mixed> $baselineReadiness
* @return list<array{code: string}>
*/
private static function baselineLimitations(array $baselineReadiness): array
{
if ($baselineReadiness === []) {
return [];
}
$limitations = [];
$blockers = is_array($baselineReadiness['publication_blockers'] ?? null)
? $baselineReadiness['publication_blockers']
: [];
$readinessState = (string) ($baselineReadiness['readiness_state'] ?? '');
if ($blockers !== []) {
$limitations[] = ['code' => 'baseline_publication_blockers_present'];
}
if ($readinessState === 'baseline_compare_unproven') {
$limitations[] = ['code' => 'baseline_compare_unproven'];
}
if ($readinessState === 'baseline_compare_stale') {
$limitations[] = ['code' => 'baseline_compare_stale'];
}
if ($readinessState === 'baseline_compare_failed') {
$limitations[] = ['code' => 'baseline_compare_failed'];
}
$codes = collect($baselineReadiness['limitation_codes'] ?? [])
->filter(static fn (mixed $code): bool => is_string($code) && trim($code) !== '')
->values()
->all();
foreach ($codes as $code) {
$limitations[] = ['code' => $code];
}
return collect($limitations)
->unique('code')
->values()
->all();
}
}

View File

@ -53,7 +53,14 @@ public static function readinessForReview(EnvironmentReview $review): array
->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'] ?? ''));
$reviewControlInterpretation = is_array($summary['control_interpretation'] ?? null)
? $summary['control_interpretation']
: [];
$nonCertificationDisclosure = trim((string) (
$controlInterpretation['non_certification_disclosure']
?? $reviewControlInterpretation['non_certification_disclosure']
?? ''
));
return ReviewPackOutputReadiness::derive(
reviewStatus: (string) $review->status,
@ -69,6 +76,7 @@ public static function readinessForReview(EnvironmentReview $review): array
includePii: $includePii,
protectedValuesHidden: ! $includePii,
disclosurePresent: $nonCertificationDisclosure !== '',
baselineReadiness: self::baselineReadiness($review),
);
}
@ -187,7 +195,8 @@ private static function state(array $readiness, array $limitations): string
$limitationKeys = collect($limitations)->pluck('key');
return match (true) {
$limitationKeys->contains('publish_blockers_present') => self::STATE_PUBLICATION_BLOCKED,
$limitationKeys->contains('publish_blockers_present')
|| $limitationKeys->contains('baseline_publication_blockers_present') => self::STATE_PUBLICATION_BLOCKED,
! (bool) ($readiness['has_ready_export'] ?? false) => self::STATE_EXPORT_NOT_READY,
(bool) ($readiness['contains_pii'] ?? false) => self::STATE_INTERNAL_ONLY,
$limitations !== [] => self::STATE_PUBLISHED_WITH_LIMITATIONS,
@ -222,6 +231,15 @@ private static function limitations(array $readiness, array $urls): array
],
'priority' => 100,
],
'baseline_publication_blockers_present' => [
'key' => $code,
'label' => __('localization.review.baseline_publication_blocked'),
'severity' => 'danger',
'reason' => __('localization.review.baseline_publication_blocked_reason'),
'action' => self::action('open_baseline_subject_resolution', $urls['evidence'] ?? $urls['review'] ?? null),
'details' => self::baselineDetails($readiness),
'priority' => 98,
],
'export_not_ready' => [
'key' => $code,
'label' => __('localization.review.export_not_ready'),
@ -290,6 +308,49 @@ private static function limitations(array $readiness, array $urls): array
],
'priority' => 50,
],
'baseline_compare_unproven', 'baseline_compare_stale', 'baseline_compare_failed' => [
'key' => $code,
'label' => __('localization.review.baseline_evidence_incomplete'),
'severity' => 'warning',
'reason' => match ($code) {
'baseline_compare_stale' => __('localization.review.baseline_compare_stale_reason'),
'baseline_compare_failed' => __('localization.review.baseline_compare_failed_reason'),
default => __('localization.review.baseline_compare_unproven_reason'),
},
'action' => self::action(
$code === 'baseline_compare_failed' ? 'open_operation_proof' : 'open_evidence_basis',
$code === 'baseline_compare_failed'
? ($urls['operation'] ?? $urls['evidence'] ?? $urls['review'] ?? null)
: ($urls['evidence'] ?? $urls['review'] ?? null),
),
'details' => self::baselineDetails($readiness),
'priority' => match ($code) {
'baseline_compare_failed' => 88,
'baseline_compare_stale' => 87,
default => 86,
},
],
'baseline_foundation_limitations', 'baseline_accepted_limitations', 'baseline_exclusions_present' => [
'key' => $code,
'label' => match ($code) {
'baseline_accepted_limitations' => __('localization.review.baseline_accepted_limitations'),
'baseline_exclusions_present' => __('localization.review.baseline_exclusions_present'),
default => __('localization.review.baseline_foundation_limitations'),
},
'severity' => 'warning',
'reason' => match ($code) {
'baseline_accepted_limitations' => __('localization.review.baseline_accepted_limitations_reason'),
'baseline_exclusions_present' => __('localization.review.baseline_exclusions_present_reason'),
default => __('localization.review.baseline_foundation_limitations_reason'),
},
'action' => self::action('review_output_limitations', $urls['review'] ?? $urls['evidence'] ?? null),
'details' => self::baselineDetails($readiness),
'priority' => match ($code) {
'baseline_accepted_limitations' => 44,
'baseline_exclusions_present' => 43,
default => 42,
},
],
default => null,
};
})
@ -309,7 +370,10 @@ private static function primaryAction(string $state, ?string $primaryLimitationK
{
$actionKey = match ($primaryLimitationKey) {
'publish_blockers_present' => 'resolve_review_blockers',
'baseline_publication_blockers_present' => 'open_baseline_subject_resolution',
'evidence_basis_missing', 'evidence_basis_stale', 'evidence_basis_incomplete' => 'open_evidence_basis',
'baseline_compare_unproven', 'baseline_compare_stale' => 'open_evidence_basis',
'baseline_compare_failed' => 'open_operation_proof',
'required_sections_incomplete' => 'review_section_limitations',
'contains_pii' => 'review_pii_redaction_state',
'disclosure_missing' => 'review_output_limitations',
@ -378,6 +442,7 @@ private static function primaryActionUrl(string $actionKey, array $urls): ?strin
{
return match ($actionKey) {
'download_customer_safe_review_pack', 'download_internal_review_pack', 'download_review_pack_with_limitations' => $urls['download'] ?? null,
'open_baseline_subject_resolution' => $urls['evidence'] ?? $urls['review'] ?? null,
'open_evidence_basis' => $urls['evidence'] ?? $urls['review'] ?? null,
'review_section_limitations', 'resolve_review_blockers', 'review_output_limitations', 'review_pii_redaction_state', 'open_review' => $urls['review'] ?? $urls['evidence'] ?? $urls['download'] ?? null,
'open_operation_proof' => $urls['operation'] ?? null,
@ -400,6 +465,7 @@ private static function action(string $actionKey, ?string $url): ?array
'review_section_limitations' => __('localization.review.review_section_limitations'),
'review_pii_redaction_state' => __('localization.review.review_pii_redaction_state'),
'resolve_review_blockers' => __('localization.review.resolve_review_blockers'),
'open_baseline_subject_resolution' => __('localization.review.open_baseline_subject_resolution'),
'open_operation_proof' => __('localization.review.open_operation_proof'),
'open_review' => __('localization.review.open_review'),
default => __('localization.review.review_output_limitations'),
@ -504,8 +570,11 @@ private static function shortReason(string $limitationKey): string
{
return match ($limitationKey) {
'publish_blockers_present' => __('localization.review.publication_blocked_short_reason'),
'baseline_publication_blockers_present' => __('localization.review.baseline_publication_blocked_short_reason'),
'export_not_ready' => __('localization.review.export_not_ready_short_reason'),
'evidence_basis_missing', 'evidence_basis_stale', 'evidence_basis_incomplete' => __('localization.review.evidence_basis_incomplete_short_reason'),
'baseline_compare_unproven', 'baseline_compare_stale', 'baseline_compare_failed' => __('localization.review.baseline_evidence_incomplete_short_reason'),
'baseline_foundation_limitations', 'baseline_accepted_limitations', 'baseline_exclusions_present' => __('localization.review.baseline_limitations_short_reason'),
'required_sections_incomplete' => __('localization.review.required_review_sections_missing_short_reason'),
'contains_pii' => __('localization.review.internal_package_includes_pii_short_reason'),
'disclosure_missing' => __('localization.review.output_disclosure_missing_short_reason'),
@ -578,6 +647,92 @@ private static function technicalDetails(array $readiness): array
__('localization.review.disclosure') => (bool) ($readiness['disclosure_present'] ?? false)
? __('localization.review.disclosure_present')
: __('localization.review.no'),
__('localization.review.baseline_readiness') => self::baselineTechnicalSummary($readiness),
];
}
/**
* @return array<string, mixed>
*/
private static function baselineReadiness(EnvironmentReview $review): array
{
$summary = is_array($review->summary) ? $review->summary : [];
$baselineReadiness = is_array($summary['baseline_readiness'] ?? null) ? $summary['baseline_readiness'] : [];
if ($baselineReadiness !== []) {
return $baselineReadiness;
}
$section = $review->sections->firstWhere('section_key', 'baseline_drift_posture');
$sectionSummary = is_array($section?->summary_payload ?? null) ? $section->summary_payload : [];
return is_array($sectionSummary['baseline_readiness'] ?? null) ? $sectionSummary['baseline_readiness'] : [];
}
/**
* @param array<string, mixed> $readiness
* @return list<string>
*/
private static function baselineDetails(array $readiness): array
{
$baseline = is_array($readiness['baseline_readiness'] ?? null) ? $readiness['baseline_readiness'] : [];
$summary = is_array($baseline['customer_safe_summary'] ?? null) ? $baseline['customer_safe_summary'] : [];
return [
__('localization.review.baseline_detail_state_value', [
'value' => self::baselineReadinessLabel($baseline, $summary),
]),
__('localization.review.baseline_detail_counts_value', [
'verified' => (int) ($summary['verified_subject_count'] ?? 0),
'drift' => (int) ($summary['drift_subject_count'] ?? 0),
'blockers' => (int) ($summary['blocker_count'] ?? 0),
'limitations' => (int) ($summary['limitation_count'] ?? 0),
]),
];
}
/**
* @param array<string, mixed> $readiness
*/
private static function baselineTechnicalSummary(array $readiness): string
{
$baseline = is_array($readiness['baseline_readiness'] ?? null) ? $readiness['baseline_readiness'] : [];
$summary = is_array($baseline['customer_safe_summary'] ?? null) ? $baseline['customer_safe_summary'] : [];
if ($baseline === []) {
return __('localization.review.unavailable');
}
return __('localization.review.baseline_technical_summary_value', [
'state' => self::baselineReadinessLabel($baseline, $summary),
'verified' => (int) ($summary['verified_subject_count'] ?? 0),
'drift' => (int) ($summary['drift_subject_count'] ?? 0),
'blockers' => (int) ($summary['blocker_count'] ?? 0),
'limitations' => (int) ($summary['limitation_count'] ?? 0),
]);
}
/**
* @param array<string, mixed> $baseline
* @param array<string, mixed> $summary
*/
private static function baselineReadinessLabel(array $baseline, array $summary): string
{
$state = (string) ($summary['readiness_state'] ?? $baseline['readiness_state'] ?? '');
return match ($state) {
'customer_ready' => __('localization.review.baseline_state_customer_ready'),
'trusted_drift_detected', 'drift_findings_present' => __('localization.review.baseline_state_trusted_drift'),
'baseline_compare_limited' => __('localization.review.baseline_state_limited'),
'baseline_identity_unresolved' => __('localization.review.baseline_state_identity_unresolved'),
'baseline_local_evidence_missing' => __('localization.review.baseline_state_local_evidence_missing'),
'baseline_provider_resource_missing' => __('localization.review.baseline_state_provider_resource_missing'),
'baseline_required_coverage_unsupported' => __('localization.review.baseline_state_required_coverage_unsupported'),
'baseline_compare_unproven' => __('localization.review.baseline_state_compare_unproven'),
'baseline_compare_stale' => __('localization.review.baseline_state_compare_stale'),
'baseline_compare_failed' => __('localization.review.baseline_state_compare_failed'),
'baseline_compare_blocked', 'baseline_compare_not_completed' => __('localization.review.baseline_state_compare_blocked'),
default => __('localization.review.unavailable'),
};
}
}

View File

@ -676,6 +676,36 @@
'publication_blocked_description' => 'Review-Blocker müssen aufgelöst werden, bevor dieser Output als kundenbereit behandelt werden kann.',
'publication_blocked_short_reason' => 'Für diesen Output sind weiterhin Review-Blocker erfasst.',
'publication_blocked_impact' => 'Behandeln Sie diese Review-Ausgabe erst als kundenbereit, wenn die Blocker aufgelöst sind.',
'baseline_publication_blocked' => 'Baseline-Readiness blockiert',
'baseline_publication_blocked_reason' => 'Baseline-Identität, Evidence, Provider-Ressourcen oder Coverage-Readiness müssen aufgelöst werden, bevor dieser Output als kundenbereit behandelt werden kann.',
'baseline_publication_blocked_short_reason' => 'Für diesen Output sind weiterhin Baseline-Readiness-Blocker erfasst.',
'baseline_evidence_incomplete' => 'Baseline-Evidence unvollständig',
'baseline_compare_unproven_reason' => 'Der Baseline-Compare hat keinen strukturierten Readiness-Nachweis für die kundenbereite Aussage erzeugt.',
'baseline_compare_stale_reason' => 'Die Baseline-Compare-Evidence ist veraltet und sollte vor externer Weitergabe aktualisiert werden.',
'baseline_compare_failed_reason' => 'Der Baseline-Compare ist fehlgeschlagen und sollte vor externer Weitergabe neu gestartet oder untersucht werden.',
'baseline_evidence_incomplete_short_reason' => 'Baseline-Evidence ist unvollständig.',
'baseline_foundation_limitations' => 'Baseline-Foundation-Einschränkung',
'baseline_foundation_limitations_reason' => 'Einige Baseline-Subjects stützen sich nur auf Inventory-, Identity- oder kanonische Foundation-Evidence.',
'baseline_accepted_limitations' => 'Baseline-Einschränkung akzeptiert',
'baseline_accepted_limitations_reason' => 'Akzeptierte Baseline-Einschränkungen qualifizieren die kundenbereite Aussage.',
'baseline_exclusions_present' => 'Baseline-Ausschlüsse vorhanden',
'baseline_exclusions_present_reason' => 'Ausgeschlossene, nicht governed Baseline-Subjects liegen außerhalb der governed No-Drift-Aussage.',
'baseline_limitations_short_reason' => 'Baseline-Einschränkungen qualifizieren diesen Output.',
'baseline_readiness' => 'Baseline-Readiness',
'baseline_detail_state_value' => 'Baseline-Readiness-Status: :value',
'baseline_detail_counts_value' => 'Baseline-Subjects - verifiziert: :verified, Drift: :drift, Blocker: :blockers, Einschränkungen: :limitations',
'baseline_technical_summary_value' => ':state; verifiziert :verified, Drift :drift, Blocker :blockers, Einschränkungen :limitations',
'baseline_state_customer_ready' => 'Kundenbereite Baseline-Evidence',
'baseline_state_trusted_drift' => 'Vertrauenswürdige Drift-Findings vorhanden',
'baseline_state_limited' => 'Kundenbereit mit offengelegten Baseline-Einschränkungen',
'baseline_state_identity_unresolved' => 'Baseline-Subject-Identität nicht aufgelöst',
'baseline_state_local_evidence_missing' => 'Lokale Baseline-Evidence fehlt',
'baseline_state_provider_resource_missing' => 'Provider-Ressourcen-Evidence fehlt',
'baseline_state_required_coverage_unsupported' => 'Erforderliche Baseline-Coverage nicht unterstützt',
'baseline_state_compare_unproven' => 'Baseline-Compare-Nachweis fehlt',
'baseline_state_compare_stale' => 'Baseline-Compare-Evidence ist veraltet',
'baseline_state_compare_failed' => 'Baseline-Compare fehlgeschlagen',
'baseline_state_compare_blocked' => 'Baseline-Compare blockiert',
'output_limitations' => 'Output-Einschränkungen',
'output_limitations_summary' => '{1} 1 Einschränkung benötigt Prüfung|[2,*] :count Einschränkungen benötigen Prüfung',
'technical_details' => 'Technische Details',
@ -750,6 +780,7 @@
'review_section_limitations' => 'Abschnittseinschränkungen prüfen',
'review_pii_redaction_state' => 'PII-/Redaktionsstatus prüfen',
'resolve_review_blockers' => 'Review-Blocker prüfen',
'open_baseline_subject_resolution' => 'Baseline-Auflösung öffnen',
'refresh_review' => 'Review aktualisieren',
'publish_review' => 'Review veröffentlichen',
'create_next_review' => 'Nächstes Review erstellen',
@ -851,6 +882,8 @@
'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_baseline_readiness_blocked' => 'Für diesen kundenseitigen Bericht sind weiterhin Baseline-Readiness-Blocker im Scope.',
'report_disclosure_baseline_limitations_present' => 'Baseline-Einschränkungen sind enthalten und müssen vor externer Weitergabe offengelegt bleiben.',
'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',
@ -862,6 +895,11 @@
'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_disclosure_baseline_readiness' => 'Baseline-Readiness-Nachweis',
'report_disclosure_baseline_verified' => 'Baseline-Readiness stützt die governed kundenbereite Aussage für diesen Bericht.',
'report_disclosure_baseline_limited' => 'Baseline-Readiness ist mit offengelegten Einschränkungen verfügbar. No-Drift-Aussagen sind qualifiziert zu behandeln.',
'report_disclosure_baseline_missing' => 'Baseline-Readiness ist unvollständig, veraltet, fehlgeschlagen oder blockiert. Baseline-gestützte Aussagen sind eingeschränkt zu behandeln.',
'report_disclosure_baseline_unknown' => 'Baseline-Readiness konnte aus der gespeicherten Review-Wahrheit nicht sauber zugeordnet werden.',
'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

@ -676,6 +676,36 @@
'publication_blocked_description' => 'Review blockers must be resolved before this output can be treated as customer-ready.',
'publication_blocked_short_reason' => 'Review blockers are still recorded for this output.',
'publication_blocked_impact' => 'Do not present this review output as customer-ready until the blockers are resolved.',
'baseline_publication_blocked' => 'Baseline readiness blocked',
'baseline_publication_blocked_reason' => 'Baseline identity, evidence, provider-resource, or coverage readiness must be resolved before this output can be treated as customer-ready.',
'baseline_publication_blocked_short_reason' => 'Baseline readiness blockers are still recorded for this output.',
'baseline_evidence_incomplete' => 'Baseline evidence incomplete',
'baseline_compare_unproven_reason' => 'The baseline compare did not produce structured readiness proof for the customer-ready claim.',
'baseline_compare_stale_reason' => 'The baseline compare evidence is stale and should be refreshed before external sharing.',
'baseline_compare_failed_reason' => 'The baseline compare failed and should be rerun or investigated before external sharing.',
'baseline_evidence_incomplete_short_reason' => 'Baseline evidence is incomplete.',
'baseline_foundation_limitations' => 'Baseline foundation limitation',
'baseline_foundation_limitations_reason' => 'Some baseline subjects rely only on inventory, identity, or canonical foundation evidence.',
'baseline_accepted_limitations' => 'Baseline limitation accepted',
'baseline_accepted_limitations_reason' => 'Accepted baseline limitations qualify the customer-ready claim.',
'baseline_exclusions_present' => 'Baseline exclusions present',
'baseline_exclusions_present_reason' => 'Excluded non-governed baseline subjects are outside the governed no-drift claim.',
'baseline_limitations_short_reason' => 'Baseline limitations qualify this output.',
'baseline_readiness' => 'Baseline readiness',
'baseline_detail_state_value' => 'Baseline readiness state: :value',
'baseline_detail_counts_value' => 'Baseline subjects - verified: :verified, drift: :drift, blockers: :blockers, limitations: :limitations',
'baseline_technical_summary_value' => ':state; verified :verified, drift :drift, blockers :blockers, limitations :limitations',
'baseline_state_customer_ready' => 'Customer-ready baseline evidence',
'baseline_state_trusted_drift' => 'Trusted drift findings present',
'baseline_state_limited' => 'Customer-ready with disclosed baseline limitations',
'baseline_state_identity_unresolved' => 'Baseline subject identity unresolved',
'baseline_state_local_evidence_missing' => 'Baseline local evidence missing',
'baseline_state_provider_resource_missing' => 'Provider resource evidence missing',
'baseline_state_required_coverage_unsupported' => 'Required baseline coverage unsupported',
'baseline_state_compare_unproven' => 'Baseline compare proof missing',
'baseline_state_compare_stale' => 'Baseline compare evidence stale',
'baseline_state_compare_failed' => 'Baseline compare failed',
'baseline_state_compare_blocked' => 'Baseline compare blocked',
'output_limitations' => 'Output limitations',
'output_limitations_summary' => '{1} 1 limitation requires review|[2,*] :count limitations require review',
'technical_details' => 'Technical details',
@ -750,6 +780,7 @@
'review_section_limitations' => 'Review section limitations',
'review_pii_redaction_state' => 'Review PII/redaction state',
'resolve_review_blockers' => 'Inspect review blockers',
'open_baseline_subject_resolution' => 'Open baseline resolution',
'refresh_review' => 'Refresh review',
'publish_review' => 'Publish review',
'create_next_review' => 'Create next review',
@ -851,6 +882,8 @@
'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_baseline_readiness_blocked' => 'Baseline readiness blockers remain in scope for this customer-facing report.',
'report_disclosure_baseline_limitations_present' => 'Baseline limitations are included and must remain disclosed before external sharing.',
'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',
@ -862,6 +895,11 @@
'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_disclosure_baseline_readiness' => 'Baseline readiness proof',
'report_disclosure_baseline_verified' => 'Baseline readiness supports the governed customer-ready claim for this report.',
'report_disclosure_baseline_limited' => 'Baseline readiness is available with disclosed limitations. Treat no-drift claims as qualified.',
'report_disclosure_baseline_missing' => 'Baseline readiness is incomplete, stale, failed, or blocked. Treat baseline-backed claims as limited.',
'report_disclosure_baseline_unknown' => 'Baseline readiness could not be mapped cleanly from stored review truth.',
'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

@ -0,0 +1,161 @@
<?php
declare(strict_types=1);
use App\Filament\Pages\Reviews\CustomerReviewWorkspace;
use App\Models\ManagedEnvironment;
use App\Models\ReviewPack;
use App\Models\User;
use App\Support\Baselines\CompareSemantics\CompareResultReason;
use App\Support\EnvironmentReviewStatus;
use App\Support\OperationRunOutcome;
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('Spec385 smokes baseline readiness blockers on the customer review workspace', function (): void {
$environment = ManagedEnvironment::factory()->create(['name' => 'Spec385 Browser Baseline']);
[$user, $environment] = createUserWithTenant(tenant: $environment, role: 'owner', workspaceRole: 'manager');
[$profile, $baselineSnapshot] = seedActiveBaselineForTenant($environment);
seedBaselineCompareRun(
tenant: $environment,
profile: $profile,
snapshot: $baselineSnapshot,
compareContext: spec385BrowserCompareContext([CompareResultReason::UnresolvedAmbiguousIdentity]),
outcome: OperationRunOutcome::PartiallySucceeded->value,
);
$snapshot = seedEnvironmentReviewEvidence($environment, findingCount: 0, driftCount: 0);
$review = composeEnvironmentReviewForTest($environment, $user, $snapshot);
$filePath = 'review-packs/spec385-baseline-readiness.zip';
Storage::disk('exports')->put($filePath, 'PK-spec385-browser');
$review->forceFill([
'status' => EnvironmentReviewStatus::Published->value,
'published_at' => now(),
'published_by_user_id' => (int) $user->getKey(),
])->save();
$pack = ReviewPack::factory()->ready()->create([
'managed_environment_id' => (int) $environment->getKey(),
'workspace_id' => (int) $environment->workspace_id,
'environment_review_id' => (int) $review->getKey(),
'evidence_snapshot_id' => (int) $snapshot->getKey(),
'initiated_by_user_id' => (int) $user->getKey(),
'options' => [
'include_pii' => false,
'include_operations' => true,
],
'file_path' => $filePath,
'file_disk' => 'exports',
'generated_at' => now(),
]);
$review->forceFill(['current_export_review_pack_id' => (int) $pack->getKey()])->save();
spec385AuthenticateBrowser($this, $user, $environment);
$page = visit(CustomerReviewWorkspace::environmentFilterUrl($environment))
->resize(1366, 920)
->waitForText('Output not customer-ready')
->assertSee('Review blockers are still recorded for this output.')
->assertScript('document.querySelector("[data-testid=\"customer-review-output-limitations\"]")?.open === false', true)
->click('[data-testid="customer-review-output-limitations"] summary')
->assertSee('Baseline readiness blocked')
->assertSee('Open baseline resolution')
->assertDontSee('baseline_identity_unresolved')
->assertDontSee('provider_resource_id')
->assertDontSee('canonical_subject_key')
->assertDontSee('internal_diagnostics')
->assertNoJavaScriptErrors()
->assertNoConsoleLogs();
$page->screenshot(true, spec385BrowserScreenshotName('01-baseline-readiness-blocked'));
spec385CopyBrowserScreenshot('01-baseline-readiness-blocked');
});
function spec385AuthenticateBrowser(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 list<CompareResultReason> $reasons
* @return array<string, mixed>
*/
function spec385BrowserCompareContext(array $reasons): array
{
$byReason = [];
$byReadinessImpact = [];
foreach ($reasons as $reason) {
$byReason[$reason->value] = ($byReason[$reason->value] ?? 0) + 1;
$impact = $reason->readinessImpact()->value;
$byReadinessImpact[$impact] = ($byReadinessImpact[$impact] ?? 0) + 1;
}
return [
'result_semantics' => [
'version' => 'compare_semantics.v1',
'run_outcome' => 'partial',
'operation_outcome' => OperationRunOutcome::PartiallySucceeded->value,
'counts' => [
'by_reason' => $byReason,
'by_readiness_impact' => $byReadinessImpact,
],
],
];
}
function spec385BrowserScreenshotName(string $name): string
{
return 'spec385-evidence-review-readiness-'.$name;
}
function spec385CopyBrowserScreenshot(string $name): void
{
$filename = spec385BrowserScreenshotName($name).'.png';
$source = base_path('tests/Browser/Screenshots/'.$filename);
$targetDirectory = repo_path('specs/385-evidence-review-readiness/artifacts/screenshots');
if (! is_dir($targetDirectory)) {
@mkdir($targetDirectory, 0755, true);
}
if (! is_file($source)) {
$source = \Pest\Browser\Support\Screenshot::path($filename);
}
for ($attempt = 0; $attempt < 10 && ! is_file($source); $attempt++) {
usleep(100_000);
clearstatcache(true, $source);
}
if (is_file($source) && is_dir($targetDirectory) && is_writable($targetDirectory)) {
@copy($source, $targetDirectory.DIRECTORY_SEPARATOR.$name.'.png');
}
}

View File

@ -0,0 +1,125 @@
<?php
declare(strict_types=1);
use App\Support\Baselines\CompareSemantics\CompareResultReason;
use App\Support\EnvironmentReviewCompletenessState;
use App\Support\EnvironmentReviewStatus;
use App\Support\OperationRunOutcome;
it('blocks environment review publication when baseline subject identity is unresolved', function (): void {
[$user, $tenant] = createUserWithTenant(role: 'owner');
[$profile, $baselineSnapshot] = seedActiveBaselineForTenant($tenant);
seedBaselineCompareRun(
tenant: $tenant,
profile: $profile,
snapshot: $baselineSnapshot,
compareContext: spec385EnvironmentCompareContext([CompareResultReason::UnresolvedAmbiguousIdentity]),
outcome: OperationRunOutcome::PartiallySucceeded->value,
);
$snapshot = seedEnvironmentReviewEvidence($tenant, findingCount: 0, driftCount: 0);
$review = composeEnvironmentReviewForTest($tenant, $user, $snapshot);
$baselineSection = $review->sections->firstWhere('section_key', 'baseline_drift_posture');
expect($baselineSection->completeness_state)->toBe(EnvironmentReviewCompletenessState::Partial->value)
->and($baselineSection->summary_payload['baseline_readiness']['readiness_state'])->toBe('baseline_identity_unresolved')
->and($baselineSection->summary_payload['publication_blockers'])->not->toBeEmpty()
->and($review->status)->toBe(EnvironmentReviewStatus::Draft->value)
->and($review->publishBlockers())->toContain('Baseline drift posture: Baseline subject identity must be resolved before customer-ready publication.');
});
it('keeps trusted baseline drift complete with findings and publication-ready', function (): void {
[$user, $tenant] = createUserWithTenant(role: 'owner');
[$profile, $baselineSnapshot] = seedActiveBaselineForTenant($tenant);
seedBaselineCompareRun(
tenant: $tenant,
profile: $profile,
snapshot: $baselineSnapshot,
compareContext: spec385EnvironmentCompareContext([CompareResultReason::VerifiedDriftDetected]),
);
$snapshot = seedEnvironmentReviewEvidence($tenant, findingCount: 0, driftCount: 1);
$review = composeEnvironmentReviewForTest($tenant, $user, $snapshot);
expect($review->status)->toBe(EnvironmentReviewStatus::Ready->value)
->and($review->publishBlockers())->toBeEmpty()
->and($review->summary['baseline_readiness']['customer_safe_claim'])->toBe('customer_ready_with_findings')
->and($review->summary['baseline_readiness']['publication_blockers'])->toBe([]);
});
it('carries accepted baseline limitations without blocking publication', function (): void {
[$user, $tenant] = createUserWithTenant(role: 'owner');
[$profile, $baselineSnapshot] = seedActiveBaselineForTenant($tenant);
seedBaselineCompareRun(
tenant: $tenant,
profile: $profile,
snapshot: $baselineSnapshot,
compareContext: spec385EnvironmentCompareContext([CompareResultReason::AcceptedLimitation]),
outcome: OperationRunOutcome::PartiallySucceeded->value,
);
$snapshot = seedEnvironmentReviewEvidence($tenant, findingCount: 1, driftCount: 0);
$review = composeEnvironmentReviewForTest($tenant, $user, $snapshot);
expect($review->publishBlockers())->toBeEmpty()
->and($review->status)->toBe(EnvironmentReviewStatus::Ready->value)
->and($review->summary['baseline_readiness']['state'])->toBe(EnvironmentReviewCompletenessState::Partial->value)
->and($review->summary['baseline_readiness']['limitation_codes'])->toBe(['baseline_accepted_limitations'])
->and($review->summary['baseline_readiness']['publication_blockers'])->toBe([]);
});
it('turns missing baseline local evidence into refresh guidance and a review blocker', function (): void {
[$user, $tenant] = createUserWithTenant(role: 'owner');
[$profile, $baselineSnapshot] = seedActiveBaselineForTenant($tenant);
seedBaselineCompareRun(
tenant: $tenant,
profile: $profile,
snapshot: $baselineSnapshot,
compareContext: spec385EnvironmentCompareContext([CompareResultReason::MissingLocalEvidence]),
outcome: OperationRunOutcome::PartiallySucceeded->value,
);
$snapshot = seedEnvironmentReviewEvidence($tenant, findingCount: 1, driftCount: 0);
$review = composeEnvironmentReviewForTest($tenant, $user, $snapshot);
$baselineSection = $review->sections->firstWhere('section_key', 'baseline_drift_posture');
expect($review->status)->toBe(EnvironmentReviewStatus::Draft->value)
->and($review->summary['baseline_readiness']['readiness_state'])->toBe('baseline_local_evidence_missing')
->and($review->summary['baseline_readiness']['next_action'])->toBe('open_evidence_basis')
->and($review->publishBlockers())->toContain('Baseline drift posture: Baseline local evidence is missing and must be refreshed before publication.')
->and($baselineSection->render_payload['next_actions'])->toContain('Refresh baseline compare evidence before relying on the review output.');
});
/**
* @param list<CompareResultReason> $reasons
* @return array<string, mixed>
*/
function spec385EnvironmentCompareContext(array $reasons): array
{
$byReason = [];
$byReadinessImpact = [];
foreach ($reasons as $reason) {
$byReason[$reason->value] = ($byReason[$reason->value] ?? 0) + 1;
$impact = $reason->readinessImpact()->value;
$byReadinessImpact[$impact] = ($byReadinessImpact[$impact] ?? 0) + 1;
}
return [
'result_semantics' => [
'version' => 'compare_semantics.v1',
'run_outcome' => 'completed',
'operation_outcome' => OperationRunOutcome::Succeeded->value,
'counts' => [
'by_reason' => $byReason,
'by_readiness_impact' => $byReadinessImpact,
],
],
];
}

View File

@ -4,8 +4,10 @@
use App\Models\ProviderResourceBinding;
use App\Services\Evidence\Sources\BaselineDriftPostureSource;
use App\Support\Baselines\CompareSemantics\CompareResultReason;
use App\Support\Evidence\EvidenceCompletenessState;
use App\Support\OperationRunOutcome;
use App\Support\Resources\ProviderResourceResolutionMode;
it('keeps baseline drift posture missing when no drift findings or compare proof exist', function (): void {
[, $tenant] = createUserWithTenant(role: 'owner');
@ -23,11 +25,11 @@
->and($payload['summary_payload']['latest_compare_run_id'])->toBeNull();
});
it('marks no baseline drift as complete when the latest compare succeeded', function (): void {
it('does not mark old successful compare context as complete without structured readiness semantics', function (): void {
[, $tenant] = createUserWithTenant(role: 'owner');
[$profile, $snapshot] = seedActiveBaselineForTenant($tenant);
$run = seedBaselineCompareRun(
seedBaselineCompareRun(
tenant: $tenant,
profile: $profile,
snapshot: $snapshot,
@ -37,14 +39,13 @@
$payload = app(BaselineDriftPostureSource::class)->collect($tenant);
expect($payload['state'])->toBe(EvidenceCompletenessState::Complete->value)
->and($payload['measured_at']?->equalTo($run->completed_at))->toBeTrue()
expect($payload['state'])->toBe(EvidenceCompletenessState::Missing->value)
->and($payload['summary_payload']['drift_count'])->toBe(0)
->and($payload['summary_payload']['latest_compare_run_id'])->toBe((int) $run->getKey())
->and($payload['summary_payload']['latest_compare_outcome'])->toBe(OperationRunOutcome::Succeeded->value);
->and($payload['summary_payload']['baseline_readiness']['readiness_state'])->toBe('baseline_compare_unproven')
->and($payload['summary_payload']['baseline_readiness']['publication_blockers'])->not->toBeEmpty();
});
it('marks no baseline drift as partial when the latest compare completed with warnings', function (): void {
it('marks no baseline drift complete when structured compare semantics verify no drift', function (): void {
[, $tenant] = createUserWithTenant(role: 'owner');
[$profile, $snapshot] = seedActiveBaselineForTenant($tenant);
@ -52,18 +53,200 @@
tenant: $tenant,
profile: $profile,
snapshot: $snapshot,
compareContext: [
'reason_code' => 'baseline.compare.no_drift_detected',
'evidence_gaps' => ['count' => 3],
],
compareContext: spec385EvidenceCompareContext([CompareResultReason::VerifiedNoDrift]),
);
$payload = app(BaselineDriftPostureSource::class)->collect($tenant);
expect($payload['state'])->toBe(EvidenceCompletenessState::Complete->value)
->and($payload['measured_at']?->equalTo($run->completed_at))->toBeTrue()
->and($payload['summary_payload']['drift_count'])->toBe(0)
->and($payload['summary_payload']['latest_compare_run_id'])->toBe((int) $run->getKey())
->and($payload['summary_payload']['latest_compare_outcome'])->toBe(OperationRunOutcome::Succeeded->value)
->and($payload['summary_payload']['baseline_readiness']['customer_safe_claim'])->toBe('customer_ready');
});
it('honors active provider resource decisions from stored bindings', function (): void {
[, $tenant] = createUserWithTenant(role: 'owner');
[$profile, $snapshot] = seedActiveBaselineForTenant($tenant);
seedBaselineCompareRun(
tenant: $tenant,
profile: $profile,
snapshot: $snapshot,
compareContext: spec385EvidenceCompareContext([CompareResultReason::VerifiedNoDrift]),
);
ProviderResourceBinding::factory()->create([
'managed_environment_id' => (int) $tenant->getKey(),
'workspace_id' => (int) $tenant->workspace_id,
'resolution_mode' => ProviderResourceResolutionMode::AcceptedLimitation->value,
]);
$payload = app(BaselineDriftPostureSource::class)->collect($tenant);
expect($payload['state'])->toBe(EvidenceCompletenessState::Partial->value)
->and($payload['summary_payload']['baseline_readiness']['readiness_state'])->toBe('baseline_compare_limited')
->and($payload['summary_payload']['baseline_readiness']['limitation_codes'])->toBe(['baseline_accepted_limitations'])
->and($payload['summary_payload']['baseline_readiness']['publication_blockers'])->toBe([]);
});
it('blocks when stored binding decisions were revoked after the latest compare', function (): void {
[, $tenant] = createUserWithTenant(role: 'owner');
[$profile, $snapshot] = seedActiveBaselineForTenant($tenant);
seedBaselineCompareRun(
tenant: $tenant,
profile: $profile,
snapshot: $snapshot,
compareContext: spec385EvidenceCompareContext([CompareResultReason::VerifiedNoDrift]),
completedAt: now()->subMinutes(5),
);
ProviderResourceBinding::factory()
->revoked()
->create([
'managed_environment_id' => (int) $tenant->getKey(),
'workspace_id' => (int) $tenant->workspace_id,
'ended_at' => now(),
]);
$payload = app(BaselineDriftPostureSource::class)->collect($tenant);
expect($payload['state'])->toBe(EvidenceCompletenessState::Partial->value)
->and($payload['summary_payload']['baseline_readiness']['readiness_state'])->toBe('baseline_compare_blocked')
->and($payload['summary_payload']['baseline_readiness']['publication_blockers'])->toContain('Baseline subject decisions changed after the latest compare; refresh evidence before publication.');
});
it('surfaces structured baseline evidence gaps as readiness blockers', function (): void {
[, $tenant] = createUserWithTenant(role: 'owner');
[$profile, $snapshot] = seedActiveBaselineForTenant($tenant);
seedBaselineCompareRun(
tenant: $tenant,
profile: $profile,
snapshot: $snapshot,
compareContext: spec385EvidenceCompareContext([CompareResultReason::MissingLocalEvidence]),
outcome: OperationRunOutcome::PartiallySucceeded->value,
);
$payload = app(BaselineDriftPostureSource::class)->collect($tenant);
expect($payload['state'])->toBe(EvidenceCompletenessState::Partial->value)
->and($payload['measured_at']?->equalTo($run->completed_at))->toBeTrue()
->and($payload['summary_payload']['drift_count'])->toBe(0)
->and($payload['summary_payload']['latest_compare_run_id'])->toBe((int) $run->getKey())
->and($payload['summary_payload']['latest_compare_outcome'])->toBe(OperationRunOutcome::PartiallySucceeded->value);
expect($payload['state'])->toBe(EvidenceCompletenessState::Missing->value)
->and($payload['summary_payload']['baseline_readiness']['readiness_state'])->toBe('baseline_local_evidence_missing')
->and($payload['summary_payload']['baseline_readiness']['next_action'])->toBe('open_evidence_basis')
->and($payload['summary_payload']['baseline_readiness']['publication_blockers'])->not->toBeEmpty();
});
it('maps structured baseline readiness source edge cases through the evidence item', function (
CompareResultReason $reason,
int $driftCount,
string $outcome,
?int $completedDaysAgo,
string $expectedState,
string $expectedReadinessState,
array $expectedLimitationCodes,
): void {
[, $tenant] = createUserWithTenant(role: 'owner');
[$profile, $snapshot] = seedActiveBaselineForTenant($tenant);
if ($driftCount > 0) {
\App\Models\Finding::factory()
->count($driftCount)
->create([
'managed_environment_id' => (int) $tenant->getKey(),
'workspace_id' => (int) $tenant->workspace_id,
'finding_type' => \App\Models\Finding::FINDING_TYPE_DRIFT,
]);
}
seedBaselineCompareRun(
tenant: $tenant,
profile: $profile,
snapshot: $snapshot,
compareContext: spec385EvidenceCompareContext([$reason]),
outcome: $outcome,
completedAt: $completedDaysAgo === null ? now() : now()->subDays($completedDaysAgo),
);
$payload = app(BaselineDriftPostureSource::class)->collect($tenant);
expect($payload['state'])->toBe($expectedState)
->and($payload['summary_payload']['baseline_readiness']['readiness_state'])->toBe($expectedReadinessState)
->and($payload['summary_payload']['baseline_readiness']['limitation_codes'])->toBe($expectedLimitationCodes);
})->with([
'trusted drift' => [
CompareResultReason::VerifiedDriftDetected,
1,
OperationRunOutcome::Succeeded->value,
null,
EvidenceCompletenessState::Complete->value,
'trusted_drift_detected',
[],
],
'accepted limitation' => [
CompareResultReason::AcceptedLimitation,
0,
OperationRunOutcome::PartiallySucceeded->value,
null,
EvidenceCompletenessState::Partial->value,
'baseline_compare_limited',
['baseline_accepted_limitations'],
],
'excluded subject' => [
CompareResultReason::ExcludedNonGoverned,
0,
OperationRunOutcome::PartiallySucceeded->value,
null,
EvidenceCompletenessState::Partial->value,
'baseline_compare_limited',
['baseline_exclusions_present'],
],
'failed compare' => [
CompareResultReason::CompareFailed,
0,
OperationRunOutcome::Failed->value,
null,
EvidenceCompletenessState::Missing->value,
'baseline_compare_failed',
[],
],
'stale compare' => [
CompareResultReason::VerifiedNoDrift,
0,
OperationRunOutcome::Succeeded->value,
45,
EvidenceCompletenessState::Stale->value,
'baseline_compare_stale',
[],
],
]);
/**
* @param list<CompareResultReason> $reasons
* @return array<string, mixed>
*/
function spec385EvidenceCompareContext(array $reasons): array
{
$byReason = [];
$byReadinessImpact = [];
foreach ($reasons as $reason) {
$byReason[$reason->value] = ($byReason[$reason->value] ?? 0) + 1;
$impact = $reason->readinessImpact()->value;
$byReadinessImpact[$impact] = ($byReadinessImpact[$impact] ?? 0) + 1;
}
return [
'result_semantics' => [
'version' => 'compare_semantics.v1',
'run_outcome' => 'completed',
'operation_outcome' => OperationRunOutcome::Succeeded->value,
'counts' => [
'by_reason' => $byReason,
'by_readiness_impact' => $byReadinessImpact,
],
],
];
}

View File

@ -0,0 +1,95 @@
<?php
declare(strict_types=1);
use App\Filament\Pages\Reviews\CustomerReviewWorkspace;
use App\Models\ManagedEnvironment;
use App\Models\ReviewPack;
use App\Models\User;
use App\Support\Baselines\CompareSemantics\CompareResultReason;
use App\Support\EnvironmentReviewStatus;
use App\Support\OperationRunOutcome;
use App\Support\Workspaces\WorkspaceContext;
use Livewire\Livewire;
it('shows baseline readiness blockers on the customer review workspace without exposing raw binding internals', function (): void {
$environment = ManagedEnvironment::factory()->create(['name' => 'Spec385 Baseline Blocked']);
[$user, $environment] = createUserWithTenant(tenant: $environment, role: 'owner', workspaceRole: 'manager');
[$profile, $baselineSnapshot] = seedActiveBaselineForTenant($environment);
seedBaselineCompareRun(
tenant: $environment,
profile: $profile,
snapshot: $baselineSnapshot,
compareContext: spec385FilamentCompareContext([CompareResultReason::UnresolvedAmbiguousIdentity]),
outcome: OperationRunOutcome::PartiallySucceeded->value,
);
$snapshot = seedEnvironmentReviewEvidence($environment, findingCount: 0, driftCount: 0);
$review = composeEnvironmentReviewForTest($environment, $user, $snapshot);
$review->forceFill([
'status' => EnvironmentReviewStatus::Published->value,
'published_at' => now(),
'published_by_user_id' => (int) $user->getKey(),
])->save();
$pack = ReviewPack::factory()->ready()->create([
'managed_environment_id' => (int) $environment->getKey(),
'workspace_id' => (int) $environment->workspace_id,
'environment_review_id' => (int) $review->getKey(),
'evidence_snapshot_id' => (int) $snapshot->getKey(),
'initiated_by_user_id' => (int) $user->getKey(),
'options' => [
'include_pii' => false,
'include_operations' => true,
],
]);
$review->forceFill(['current_export_review_pack_id' => (int) $pack->getKey()])->save();
spec385WorkspaceComponent($user, $environment)
->assertSee('Output not customer-ready')
->assertSee('Baseline readiness blocked')
->assertSee('Open baseline resolution')
->assertDontSee('baseline_identity_unresolved')
->assertDontSee('provider_resource_id')
->assertDontSee('canonical_subject_key')
->assertDontSee('internal_diagnostics');
});
function spec385WorkspaceComponent(User $user, ManagedEnvironment $environment): mixed
{
session()->put(WorkspaceContext::SESSION_KEY, (int) $environment->workspace_id);
setAdminPanelContext();
return Livewire::actingAs($user)
->test(CustomerReviewWorkspace::class);
}
/**
* @param list<CompareResultReason> $reasons
* @return array<string, mixed>
*/
function spec385FilamentCompareContext(array $reasons): array
{
$byReason = [];
$byReadinessImpact = [];
foreach ($reasons as $reason) {
$byReason[$reason->value] = ($byReason[$reason->value] ?? 0) + 1;
$impact = $reason->readinessImpact()->value;
$byReadinessImpact[$impact] = ($byReadinessImpact[$impact] ?? 0) + 1;
}
return [
'result_semantics' => [
'version' => 'compare_semantics.v1',
'run_outcome' => 'partial',
'operation_outcome' => OperationRunOutcome::PartiallySucceeded->value,
'counts' => [
'by_reason' => $byReason,
'by_readiness_impact' => $byReadinessImpact,
],
],
];
}

View File

@ -0,0 +1,280 @@
<?php
declare(strict_types=1);
use App\Jobs\GenerateReviewPackJob;
use App\Services\ReviewPackService;
use App\Support\EnvironmentReviewCompletenessState;
use App\Support\ReviewPacks\ReportDisclosurePolicy;
use App\Support\ReviewPacks\ReviewPackOutputReadiness;
use App\Support\ReviewPacks\ReviewPackOutputResolutionGuidance;
use Illuminate\Support\Facades\Storage;
beforeEach(function (): void {
Storage::fake('exports');
});
it('maps baseline readiness blockers into publication-blocked review pack guidance', function (): void {
$readiness = spec385ReviewPackReadiness([
'state' => 'partial',
'readiness_state' => 'baseline_identity_unresolved',
'publication_blockers' => [
'Baseline subject identity must be resolved before customer-ready publication.',
],
'customer_safe_summary' => [
'readiness_state' => 'baseline_identity_unresolved',
'verified_subject_count' => 0,
'drift_subject_count' => 0,
'blocker_count' => 1,
'limitation_count' => 0,
],
]);
$guidance = ReviewPackOutputResolutionGuidance::fromReadiness($readiness, [
'review' => '/reviews/1',
'evidence' => '/evidence/1',
]);
expect($guidance['state'])->toBe(ReviewPackOutputResolutionGuidance::STATE_PUBLICATION_BLOCKED)
->and($guidance['limitations'][0]['key'])->toBe('baseline_publication_blockers_present')
->and($guidance['limitations'][0]['label'])->toBe('Baseline readiness blocked')
->and($guidance['primary_action']['label'])->toBe('Open baseline resolution')
->and($guidance['technical_details']['Baseline readiness'])->toContain('Baseline subject identity unresolved')
->and(json_encode($guidance, JSON_THROW_ON_ERROR))->not->toContain('baseline_identity_unresolved')
->and($guidance)->not->toHaveKey('provider_resource_bindings');
});
it('maps accepted baseline limitations into published-with-limitations guidance', function (): void {
$readiness = spec385ReviewPackReadiness([
'state' => 'partial',
'readiness_state' => 'baseline_compare_limited',
'publication_blockers' => [],
'limitation_codes' => ['baseline_accepted_limitations'],
'customer_safe_summary' => [
'readiness_state' => 'baseline_compare_limited',
'verified_subject_count' => 0,
'drift_subject_count' => 0,
'blocker_count' => 0,
'limitation_count' => 1,
],
]);
$guidance = ReviewPackOutputResolutionGuidance::fromReadiness($readiness, [
'review' => '/reviews/1',
]);
expect($guidance['state'])->toBe(ReviewPackOutputResolutionGuidance::STATE_PUBLISHED_WITH_LIMITATIONS)
->and(collect($guidance['limitations'])->pluck('key')->all())->toContain('baseline_accepted_limitations')
->and($guidance['primary_reason'])->toBe('Baseline limitations qualify this output.');
});
it('adds baseline readiness to customer-facing report disclosure proof', function (): void {
$readiness = spec385ReviewPackReadiness([
'state' => 'partial',
'readiness_state' => 'baseline_identity_unresolved',
'publication_blockers' => [
'Baseline subject identity must be resolved before customer-ready publication.',
],
'customer_safe_summary' => [
'readiness_state' => 'baseline_identity_unresolved',
'verified_subject_count' => 0,
'drift_subject_count' => 0,
'blocker_count' => 1,
'limitation_count' => 0,
],
]);
$policy = ReportDisclosurePolicy::evaluate([
'is_customer_facing' => true,
'audience_label' => 'Customer executive',
'show_section_appendix' => false,
'show_technical_details' => false,
], $readiness);
expect($policy['proof_states']['baseline_readiness'])->toBe(ReportDisclosurePolicy::PROOF_MISSING)
->and(collect($policy['blocking_reasons'])->pluck('key')->all())->toContain('baseline_readiness_blocked')
->and(collect($policy['mandatory_disclosures'])->pluck('key')->all())->toContain('baseline_readiness');
});
it('maps stale failed and unproven baseline proof to explicit limitation codes', function (
string $readinessState,
string $expectedCode,
string $expectedAction,
): void {
$readiness = spec385ReviewPackReadiness([
'state' => in_array($readinessState, ['baseline_compare_failed', 'baseline_compare_unproven'], true) ? 'missing' : 'stale',
'readiness_state' => $readinessState,
'publication_blockers' => [],
'customer_safe_summary' => [
'readiness_state' => $readinessState,
'verified_subject_count' => 0,
'drift_subject_count' => 0,
'blocker_count' => 0,
'limitation_count' => 0,
],
]);
$guidance = ReviewPackOutputResolutionGuidance::fromReadiness($readiness, [
'review' => '/reviews/1',
'evidence' => '/evidence/1',
'operation' => '/operations/1',
]);
expect(collect($guidance['limitations'])->pluck('key')->all())->toContain($expectedCode)
->and($guidance['primary_action']['key'])->toBe($expectedAction);
})->with([
'unproven compare' => ['baseline_compare_unproven', 'baseline_compare_unproven', 'open_evidence_basis'],
'stale compare' => ['baseline_compare_stale', 'baseline_compare_stale', 'open_evidence_basis'],
'failed compare' => ['baseline_compare_failed', 'baseline_compare_failed', 'open_operation_proof'],
]);
it('redacts baseline internal diagnostics from customer-safe review pack output but keeps them for internal output', function (): void {
[$user, $tenant] = createUserWithTenant(role: 'owner');
$review = composeEnvironmentReviewForTest($tenant, $user);
$baselineReadiness = [
'version' => 'baseline_readiness.spec385.v1',
'state' => 'partial',
'readiness_state' => 'baseline_identity_unresolved',
'publication_blockers' => [
'Baseline subject identity must be resolved before customer-ready publication.',
],
'limitations' => [
[
'code' => 'baseline_accepted_limitations',
'summary' => 'Accepted baseline limitations qualify the customer-ready claim.',
],
],
'limitation_codes' => ['baseline_accepted_limitations'],
'customer_safe_summary' => [
'readiness_state' => 'baseline_identity_unresolved',
'verified_subject_count' => 1,
'drift_subject_count' => 0,
'blocker_count' => 1,
'limitation_count' => 1,
],
'internal_diagnostics' => [
'latest_compare_run_id' => 12345,
'binding_decision_counts' => ['exact_provider_identity' => 1],
'provider_resource_id' => 'provider-policy-123',
'canonical_subject_key' => 'baseline:policy:provider-policy-123',
],
];
$review->forceFill([
'summary' => array_replace(is_array($review->summary) ? $review->summary : [], [
'baseline_readiness' => $baselineReadiness,
'baseline_publication_blockers' => [],
'baseline_limitations' => [],
'publish_blockers' => [],
]),
])->save();
$customerPack = app(ReviewPackService::class)->generateFromReview($review->fresh(), $user, [
'include_pii' => false,
'include_operations' => false,
]);
app()->call([new GenerateReviewPackJob(
reviewPackId: (int) $customerPack->getKey(),
operationRunId: (int) $customerPack->operation_run_id,
), 'handle']);
$internalPack = app(ReviewPackService::class)->generateFromReview($review->fresh(), $user, [
'include_pii' => true,
'include_operations' => false,
]);
app()->call([new GenerateReviewPackJob(
reviewPackId: (int) $internalPack->getKey(),
operationRunId: (int) $internalPack->operation_run_id,
), 'handle']);
$customerPack->refresh();
$internalPack->refresh();
$customerSummaryJson = json_encode(spec385ZipJson($customerPack, 'summary.json'), JSON_THROW_ON_ERROR);
$customerMetadataJson = json_encode(spec385ZipJson($customerPack, 'metadata.json'), JSON_THROW_ON_ERROR);
$customerExecutiveMarkdown = spec385ZipText($customerPack, ReviewPackService::EXECUTIVE_ENTRYPOINT_FILENAME);
$internalSummaryJson = json_encode(spec385ZipJson($internalPack, 'summary.json'), JSON_THROW_ON_ERROR);
expect($customerSummaryJson)->toContain('baseline_readiness')
->and($customerPack->file_path)->not->toBe($internalPack->file_path)
->and($customerSummaryJson)->toContain('Baseline subject identity unresolved', 'Published with limitations')
->and($customerSummaryJson)->not->toContain(
'internal_diagnostics',
'latest_compare_run_id',
'binding_decision_counts',
'provider_resource_id',
'canonical_subject_key',
'baseline_readiness.spec385.v1',
'baseline_identity_unresolved',
'baseline_publication_blockers_present',
'baseline_accepted_limitations',
'environment_review_id',
'snapshot_id',
'review_pack_id',
'"id":',
)
->and($customerMetadataJson)->not->toContain(
'internal_diagnostics',
'latest_compare_run_id',
'binding_decision_counts',
'provider_resource_id',
'canonical_subject_key',
'baseline_readiness.spec385.v1',
'baseline_identity_unresolved',
'baseline_publication_blockers_present',
'baseline_accepted_limitations',
'environment_review_id',
'snapshot_id',
'review_pack_id',
'"id":',
)
->and($customerExecutiveMarkdown)->toContain('current released review', 'Accepted baseline limitations qualify the customer-ready claim.')
->and($customerExecutiveMarkdown)->not->toContain('#'.$review->getKey(), '#'.$review->evidence_snapshot_id, 'baseline_identity_unresolved', 'baseline_accepted_limitations')
->and($internalSummaryJson)->toContain('internal_diagnostics', 'latest_compare_run_id', 'binding_decision_counts', 'baseline_identity_unresolved');
});
/**
* @param array<string, mixed> $baselineReadiness
* @return array<string, mixed>
*/
function spec385ReviewPackReadiness(array $baselineReadiness): array
{
return ReviewPackOutputReadiness::derive(
reviewStatus: 'published',
reviewCompletenessState: EnvironmentReviewCompletenessState::Complete->value,
evidenceCompletenessState: EnvironmentReviewCompletenessState::Complete->value,
sectionStateCounts: [EnvironmentReviewCompletenessState::Complete->value => 6],
requiredSectionCount: 6,
requiredSectionStateCounts: [EnvironmentReviewCompletenessState::Complete->value => 6],
publishBlockers: [],
hasReadyExport: true,
includePii: false,
protectedValuesHidden: true,
disclosurePresent: true,
baselineReadiness: $baselineReadiness,
);
}
/**
* @return array<string, mixed>
*/
function spec385ZipJson(\App\Models\ReviewPack $pack, string $filename): array
{
$payload = json_decode(spec385ZipText($pack, $filename), true, 512, JSON_THROW_ON_ERROR);
return is_array($payload) ? $payload : [];
}
function spec385ZipText(\App\Models\ReviewPack $pack, string $filename): string
{
$zipContent = Storage::disk('exports')->get((string) $pack->file_path);
$tempFile = tempnam(sys_get_temp_dir(), 'spec385-review-pack-');
file_put_contents($tempFile, $zipContent);
$zip = new ZipArchive;
$zip->open($tempFile);
$payload = (string) $zip->getFromName($filename);
$zip->close();
unlink($tempFile);
return $payload;
}

View File

@ -1350,6 +1350,43 @@ function markEnvironmentReviewCustomerSafeReady(EnvironmentReview $review): Envi
$review->loadMissing(['sections', 'evidenceSnapshot.items']);
$disclosure = 'TenantPilot interprets available evidence for review readiness. This is not a certification, legal attestation, or compliance guarantee.';
$baselineReadiness = [
'version' => 'baseline_readiness.test.v1',
'state' => EnvironmentReviewCompletenessState::Complete->value,
'readiness_state' => 'customer_ready',
'proof_state' => 'test_complete',
'customer_safe_claim' => 'customer_ready',
'publication_blockers' => [],
'limitations' => [],
'limitation_codes' => [],
'next_action' => 'download_customer_safe_review_pack',
'counts' => [
'verified_subject_count' => 1,
'drift_subject_count' => 0,
'identity_blocker_subject_count' => 0,
'missing_local_evidence_subject_count' => 0,
'missing_provider_resource_subject_count' => 0,
'unsupported_subject_count' => 0,
'foundation_limited_subject_count' => 0,
'accepted_limitation_subject_count' => 0,
'excluded_subject_count' => 0,
'failed_subject_count' => 0,
'customer_blocker_subject_count' => 0,
'internal_blocker_subject_count' => 0,
'customer_limitation_subject_count' => 0,
'internal_limitation_subject_count' => 0,
],
'customer_safe_summary' => [
'state' => EnvironmentReviewCompletenessState::Complete->value,
'readiness_state' => 'customer_ready',
'verified_subject_count' => 1,
'drift_subject_count' => 0,
'open_drift_count' => 0,
'blocker_count' => 0,
'limitation_count' => 0,
'excluded_subject_count' => 0,
],
];
$controlSummary = [
'control_key' => 'customer-output',
'control_name' => 'Customer output',
@ -1420,7 +1457,7 @@ function markEnvironmentReviewCustomerSafeReady(EnvironmentReview $review): Envi
])->save();
}
$review->sections->each(function (EnvironmentReviewSection $section) use ($controlExplanation, $disclosure): void {
$review->sections->each(function (EnvironmentReviewSection $section) use ($baselineReadiness, $controlExplanation, $disclosure): void {
$attributes = [
'completeness_state' => EnvironmentReviewCompletenessState::Complete->value,
];
@ -1443,6 +1480,17 @@ function markEnvironmentReviewCustomerSafeReady(EnvironmentReview $review): Envi
], is_array($section->render_payload) ? $section->render_payload : []);
}
if ($section->section_key === 'baseline_drift_posture') {
$attributes['summary_payload'] = array_replace(
is_array($section->summary_payload) ? $section->summary_payload : [],
[
'baseline_readiness' => $baselineReadiness,
'publication_blockers' => [],
'limitations' => [],
],
);
}
$section->forceFill($attributes)->save();
});
@ -1473,6 +1521,9 @@ function markEnvironmentReviewCustomerSafeReady(EnvironmentReview $review): Envi
),
];
$summary['publish_blockers'] = [];
$summary['baseline_readiness'] = $baselineReadiness;
$summary['baseline_publication_blockers'] = [];
$summary['baseline_limitations'] = [];
$summary['section_count'] = $sectionCount;
$summary['section_state_counts'] = [
'complete' => $sectionCount,

View File

@ -0,0 +1,242 @@
<?php
declare(strict_types=1);
use App\Models\OperationRun;
use App\Support\Baselines\CompareSemantics\CompareResultReason;
use App\Support\Baselines\Readiness\BaselineEvidenceReadinessDeriver;
use App\Support\Evidence\EvidenceCompletenessState;
use App\Support\OperationRunOutcome;
use App\Support\OperationRunStatus;
use App\Support\OperationRunType;
use App\Support\Resources\ProviderResourceResolutionMode;
it('derives customer-ready no-drift only from structured compare semantics', function (): void {
$readiness = spec385DeriveBaselineReadiness([
CompareResultReason::VerifiedNoDrift,
]);
expect($readiness['state'])->toBe(EvidenceCompletenessState::Complete->value)
->and($readiness['readiness_state'])->toBe('customer_ready')
->and($readiness['customer_safe_claim'])->toBe('customer_ready')
->and($readiness['publication_blockers'])->toBe([])
->and($readiness['counts']['verified_subject_count'])->toBe(1);
});
it('keeps trusted drift complete with findings instead of blocking publication', function (): void {
$readiness = spec385DeriveBaselineReadiness(
reasons: [CompareResultReason::VerifiedDriftDetected],
driftCount: 1,
openDriftCount: 1,
);
expect($readiness['state'])->toBe(EvidenceCompletenessState::Complete->value)
->and($readiness['readiness_state'])->toBe('trusted_drift_detected')
->and($readiness['customer_safe_claim'])->toBe('customer_ready_with_findings')
->and($readiness['publication_blockers'])->toBe([])
->and($readiness['customer_safe_summary']['drift_subject_count'])->toBe(1);
});
it('separates unresolved identity, missing local evidence, missing provider resource, and unsupported coverage', function (
CompareResultReason $reason,
string $expectedState,
string $expectedReadinessState,
string $expectedAction,
): void {
$readiness = spec385DeriveBaselineReadiness([$reason]);
expect($readiness['state'])->toBe($expectedState)
->and($readiness['readiness_state'])->toBe($expectedReadinessState)
->and($readiness['next_action'])->toBe($expectedAction)
->and($readiness['publication_blockers'])->not->toBeEmpty();
})->with([
'identity unresolved' => [
CompareResultReason::UnresolvedAmbiguousIdentity,
EvidenceCompletenessState::Partial->value,
'baseline_identity_unresolved',
'open_baseline_subject_resolution',
],
'missing local evidence' => [
CompareResultReason::MissingLocalEvidence,
EvidenceCompletenessState::Missing->value,
'baseline_local_evidence_missing',
'open_evidence_basis',
],
'missing provider resource' => [
CompareResultReason::MissingProviderResource,
EvidenceCompletenessState::Partial->value,
'baseline_provider_resource_missing',
'open_baseline_subject_resolution',
],
'unsupported required coverage' => [
CompareResultReason::UnsupportedResourceClass,
EvidenceCompletenessState::Partial->value,
'baseline_required_coverage_unsupported',
'review_output_limitations',
],
]);
it('treats foundation-only, accepted limitation, and exclusions as disclosed limitations instead of verified no drift', function (): void {
$readiness = spec385DeriveBaselineReadiness([
CompareResultReason::FoundationInventoryOnly,
CompareResultReason::AcceptedLimitation,
CompareResultReason::ExcludedNonGoverned,
]);
expect($readiness['state'])->toBe(EvidenceCompletenessState::Partial->value)
->and($readiness['readiness_state'])->toBe('baseline_compare_limited')
->and($readiness['publication_blockers'])->toBe([])
->and($readiness['limitation_codes'])->toEqual([
'baseline_foundation_limitations',
'baseline_accepted_limitations',
'baseline_exclusions_present',
])
->and($readiness['customer_safe_claim'])->toBe('customer_ready_with_disclosed_limitations');
});
it('honors active provider resource decisions as readiness truth', function (): void {
$run = spec385BaselineOperationRun(spec385ResultSemantics([
CompareResultReason::VerifiedNoDrift,
]));
$readiness = app(BaselineEvidenceReadinessDeriver::class)->derive(
latestCompareRun: $run,
driftCount: 0,
openDriftCount: 0,
bindingDecisionCounts: [
ProviderResourceResolutionMode::AcceptedLimitation->value => 1,
ProviderResourceResolutionMode::ExcludedNonGoverned->value => 1,
ProviderResourceResolutionMode::UnsupportedCoverage->value => 1,
],
measuredAt: now(),
);
expect($readiness['state'])->toBe(EvidenceCompletenessState::Partial->value)
->and($readiness['readiness_state'])->toBe('baseline_required_coverage_unsupported')
->and($readiness['publication_blockers'])->toContain('Required baseline coverage is unsupported and must be accepted or resolved before publication.')
->and($readiness['limitation_codes'])->toEqual([
'baseline_accepted_limitations',
'baseline_exclusions_present',
]);
});
it('blocks publication when binding decisions changed after the latest compare', function (): void {
$run = spec385BaselineOperationRun(spec385ResultSemantics([
CompareResultReason::VerifiedNoDrift,
]));
$readiness = app(BaselineEvidenceReadinessDeriver::class)->derive(
latestCompareRun: $run,
driftCount: 0,
openDriftCount: 0,
bindingDecisionCounts: [
'revoked_after_latest_compare' => 1,
],
measuredAt: now(),
);
expect($readiness['state'])->toBe(EvidenceCompletenessState::Partial->value)
->and($readiness['readiness_state'])->toBe('baseline_compare_blocked')
->and($readiness['next_action'])->toBe('open_evidence_basis')
->and($readiness['publication_blockers'])->toContain('Baseline subject decisions changed after the latest compare; refresh evidence before publication.');
});
it('does not treat legacy successful compare context as trusted no-drift proof', function (): void {
$run = spec385BaselineOperationRun([
'reason_code' => 'baseline.compare.no_drift_detected',
]);
$readiness = app(BaselineEvidenceReadinessDeriver::class)->derive(
latestCompareRun: $run,
driftCount: 0,
openDriftCount: 0,
measuredAt: now(),
);
expect($readiness['state'])->toBe(EvidenceCompletenessState::Missing->value)
->and($readiness['readiness_state'])->toBe('baseline_compare_unproven')
->and($readiness['publication_blockers'])->not->toBeEmpty();
});
it('marks stale and failed structured compare proof as not customer ready', function (): void {
$stale = spec385DeriveBaselineReadiness(
reasons: [CompareResultReason::VerifiedNoDrift],
isStale: true,
);
$failed = spec385DeriveBaselineReadiness(
reasons: [CompareResultReason::CompareFailed],
outcome: OperationRunOutcome::Failed->value,
);
expect($stale['state'])->toBe(EvidenceCompletenessState::Stale->value)
->and($stale['readiness_state'])->toBe('baseline_compare_stale')
->and($failed['state'])->toBe(EvidenceCompletenessState::Missing->value)
->and($failed['readiness_state'])->toBe('baseline_compare_failed');
});
/**
* @param list<CompareResultReason> $reasons
* @return array<string, mixed>
*/
function spec385DeriveBaselineReadiness(
array $reasons,
int $driftCount = 0,
int $openDriftCount = 0,
string $outcome = OperationRunOutcome::Succeeded->value,
bool $isStale = false,
): array {
return app(BaselineEvidenceReadinessDeriver::class)->derive(
latestCompareRun: spec385BaselineOperationRun(spec385ResultSemantics($reasons), $outcome),
driftCount: $driftCount,
openDriftCount: $openDriftCount,
measuredAt: now(),
isStale: $isStale,
);
}
/**
* @param array<string, mixed> $compareContext
*/
function spec385BaselineOperationRun(
array $compareContext,
string $outcome = OperationRunOutcome::Succeeded->value,
): OperationRun {
return new OperationRun([
'type' => OperationRunType::BaselineCompare->value,
'status' => OperationRunStatus::Completed->value,
'outcome' => $outcome,
'context' => [
'baseline_compare' => $compareContext,
],
'completed_at' => now(),
]);
}
/**
* @param list<CompareResultReason> $reasons
* @return array<string, mixed>
*/
function spec385ResultSemantics(array $reasons): array
{
$byReason = [];
$byReadinessImpact = [];
foreach ($reasons as $reason) {
$byReason[$reason->value] = ($byReason[$reason->value] ?? 0) + 1;
$impact = $reason->readinessImpact()->value;
$byReadinessImpact[$impact] = ($byReadinessImpact[$impact] ?? 0) + 1;
}
return [
'result_semantics' => [
'version' => 'compare_semantics.v1',
'run_outcome' => 'completed',
'operation_outcome' => OperationRunOutcome::Succeeded->value,
'counts' => [
'by_reason' => $byReason,
'by_readiness_impact' => $byReadinessImpact,
],
],
];
}

View File

@ -0,0 +1,28 @@
<?php
declare(strict_types=1);
use App\Support\ReviewPacks\ReportDisclosurePolicy;
it('marks baseline limitations as assumed proof for customer-facing disclosure policy', function (): void {
$policy = ReportDisclosurePolicy::evaluate([
'is_customer_facing' => true,
'audience_label' => 'Customer executive',
], [
'contains_pii' => false,
'protected_values_hidden' => true,
'disclosure_present' => true,
'customer_safe_state' => 'requires_review',
'evidence_completeness_state' => 'complete',
'baseline_readiness' => [
'state' => 'partial',
'readiness_state' => 'baseline_compare_limited',
'publication_blockers' => [],
'limitation_codes' => ['baseline_accepted_limitations'],
],
]);
expect($policy['proof_states']['baseline_readiness'])->toBe(ReportDisclosurePolicy::PROOF_ASSUMED)
->and(collect($policy['warnings'])->pluck('key')->all())->toContain('baseline_limitations_present')
->and(collect($policy['blocking_reasons'])->pluck('key')->all())->not->toContain('baseline_readiness_blocked');
});

View File

@ -158,3 +158,17 @@ ### Browser proof
- Spec372 screenshot: `specs/372-customer-auditor-surface-safety-pass/artifacts/screenshots/001-customer-review-workspace-after.png`
- Browser smoke verified no JavaScript errors, no console logs, and no default `Operation proof` text in the workspace.
## Spec 385 Follow-up
Spec 385 keeps the existing customer workspace layout and routes baseline readiness through the shared review-output guidance.
- baseline readiness blockers now appear as customer-output blockers in the existing decision card and limitation list
- trusted drift remains a finding-bearing ready state rather than a publication blocker
- accepted limitations, foundation-only coverage, and exclusions are shown as qualified limitations instead of verified no-drift proof
- raw provider-resource IDs, canonical subject keys, and baseline internal diagnostics remain out of the customer workspace rendering
### Browser proof
- Spec385 screenshot target: `specs/385-evidence-review-readiness/artifacts/screenshots/01-baseline-readiness-blocked.png`
- Browser smoke target verifies baseline readiness guidance, no JavaScript errors, no console logs, and no raw binding internals in the workspace.

View File

@ -46,3 +46,11 @@ ## Top Issues
## Target Direction
P1 strategic target paired with the Customer Review Workspace target.
## Spec 385 Follow-up
Spec 385 updates review readiness truth without adding a new register route.
- baseline readiness blockers from the baseline drift section feed the existing publish blockers summary
- partial baseline sections remain publishable only when they carry disclosed limitations and no customer-ready blocker
- trusted drift is visible as review findings and does not demote the register state to blocked by itself

View File

@ -64,3 +64,12 @@ ### Browser proof
- Spec372 screenshot: `specs/372-customer-auditor-surface-safety-pass/artifacts/screenshots/003-review-pack-view-after.png`
- Browser smoke verified readiness before technical details and no JavaScript errors or console logs.
## Spec 385 Follow-up
Spec 385 extends the existing output-readiness contract on this surface.
- baseline publication blockers now map to the existing `Output not customer-ready` guidance state
- baseline accepted limitations, foundation-only coverage, and exclusions map to disclosed limitation guidance
- customer-safe exports retain baseline state/counts but drop baseline internal diagnostics from customer payloads
- rendered-report disclosure policy now includes a baseline readiness proof row for customer-facing profiles

View File

@ -31,3 +31,11 @@ ## Spec 372 Follow-up
- Evidence Snapshot is no longer unresolved for this fixture.
- OperationRun related-context entry was removed.
- Browser smoke verified desktop and mobile rendering, section ordering, no JavaScript errors, no console logs, and no mobile horizontal overflow.
## Spec 385 Follow-up
Spec 385 changes the baseline drift posture item from drift-count-only evidence into a readiness-derived evidence item.
- baseline readiness now distinguishes trusted no drift, trusted drift, missing evidence, unresolved identity, unsupported coverage, accepted limitations, exclusions, stale proof, and failed proof
- legacy compare `reason_code` context alone is not treated as trusted no-drift evidence
- provider-resource binding decisions are consumed only as internal derived diagnostics, not as raw customer-visible evidence fields

Binary file not shown.

After

Width:  |  Height:  |  Size: 484 KiB

View File

@ -0,0 +1,70 @@
# Requirements Checklist: Spec 385 - Evidence and Review Readiness Integration v1
**Purpose**: Preparation quality and constitution gate for Spec 385 before implementation.
**Created**: 2026-06-17
**Feature**: `specs/385-evidence-review-readiness/spec.md`
## Candidate And Scope
- [x] CHK001 The selected candidate is directly user-provided and not invented from an empty auto-prep queue.
- [x] CHK002 The candidate is not already covered by an existing `specs/385-*` package.
- [x] CHK003 Completed dependency specs 381, 382, 383, and 384 are treated as read-only historical context.
- [x] CHK004 The smallest viable slice is Evidence, Environment Review, and Review Pack readiness integration only.
- [x] CHK005 Matching, compare semantics, resolution UI, workflow engines, report/PDF runtime, and legacy compatibility are explicitly out of scope.
## Spec Approval Rubric
- [x] CHK006 The Spec Candidate Check answers the operator workflow, trust/safety, smallest version, complexity, and why-now questions.
- [x] CHK007 The spec is classified as Core Enterprise.
- [x] CHK008 Red flags are named and defended.
- [x] CHK009 The score is at least 7/12 and the decision is approve.
- [x] CHK010 The proportionality review covers current problem, insufficiency, narrowest implementation, ownership cost, rejected alternative, and release truth.
## Repository Truth
- [x] CHK011 Existing affected surfaces are named from repo truth, including `BaselineDriftPostureSource`, `EvidenceCompletenessEvaluator`, `EnvironmentReviewReadinessGate`, `ReviewPackOutputReadiness`, `ReviewPackOutputResolutionGuidance`, and `ReportDisclosurePolicy`.
- [x] CHK012 Existing source-of-truth boundaries are preserved: OperationRun compare proof, provider resource bindings, Evidence Snapshot, Environment Review, Review Pack, and Stored Report.
- [x] CHK013 Readiness remains derived unless implementation updates the spec/plan/tasks before adding persistence.
- [x] CHK014 Pre-production compatibility posture rejects old payload compatibility readers.
## UI And Surface Coverage
- [x] CHK015 The spec includes a coherent UI Surface Impact decision for changed existing surfaces.
- [x] CHK016 UI/Productization Coverage names affected surfaces and page-report expectations.
- [x] CHK017 Customer-safe review requirements are explicit.
- [x] CHK018 Dangerous-action review is marked not applicable because no new destructive/high-impact action is planned.
- [x] CHK019 Tasks include UI coverage/page-report update decisions for affected existing surfaces.
- [x] CHK020 The spec includes a UI Action Matrix for changed existing Filament surfaces and records that no new actions are planned.
## Shared Patterns And OperationRun
- [x] CHK021 Cross-cutting shared pattern reuse names existing helpers before any new mapper.
- [x] CHK022 Any new mapper/helper is bounded to baseline readiness and barred from becoming a generic readiness/workflow framework.
- [x] CHK023 OperationRun impact is limited to proof and next-action links; no lifecycle transition or new run type is planned.
- [x] CHK024 Provider boundary rules keep provider identifiers internal/proof-only and primary readiness language provider-neutral.
## RBAC, Security, And Disclosure
- [x] CHK025 Workspace/environment entitlement and deny-as-not-found boundaries are required for all affected links and surfaces.
- [x] CHK026 Customer-safe output forbids raw provider IDs, canonical subject keys, binding internals, internal enum names, database IDs, and raw OperationRun JSON.
- [x] CHK027 Internal/support diagnostics are allowed only according to existing profile/disclosure rules.
- [x] CHK028 No Graph/provider calls are allowed during readiness derivation or UI render.
## Test And Validation Readiness
- [x] CHK029 Test purpose and lanes are explicit.
- [x] CHK030 Tasks include tests before runtime mapping implementation.
- [x] CHK031 Tasks cover false-green and false-red cases.
- [x] CHK032 Tasks include customer-safe leakage tests.
- [x] CHK033 Tasks include Filament/Livewire and browser-smoke decisions for changed rendered surfaces.
- [x] CHK034 Validation commands are present in the spec, plan, and tasks.
## Review Outcome
- [x] CHK035 Review outcome class: acceptable-special-case.
- [x] CHK036 Workflow outcome: keep.
- [x] CHK037 Final note location: implementation close-out entry `Evidence and Review Readiness Integration`.
## Notes
Preparation is ready for implementation review. The later implementation loop must stop and update spec/plan/tasks before adding any new persisted readiness entity, public state family, route, panel provider, provider call, workflow engine, report/PDF runtime change, or legacy compatibility reader.

View File

@ -0,0 +1,355 @@
# Implementation Plan: Spec 385 - Evidence and Review Readiness Integration v1
**Branch**: `385-evidence-review-readiness` | **Date**: 2026-06-17 | **Spec**: [spec.md](./spec.md)
**Input**: Feature specification from `/specs/385-evidence-review-readiness/spec.md`
## Summary
Integrate Spec 383 baseline compare result semantics and Spec 384 provider-resource binding decisions into existing Evidence Snapshot, Environment Review, and Review Pack readiness paths. The implementation should derive one baseline readiness detail from existing OperationRun compare proof and `provider_resource_bindings`, then reuse existing evidence completeness, review readiness, review-pack readiness/guidance, disclosure, and badge/presentation helpers so customer output is honest about blockers and limitations.
The plan explicitly excludes new matching logic, new compare semantics, new resolution UI, new report/PDF runtime work, new workflow/approval engines, new persisted readiness truth, and legacy compatibility readers.
## Technical Context
**Language/Version**: PHP 8.4.15
**Primary Dependencies**: Laravel 12.52.0, Filament 5.2.1, Livewire 4.1.4, Pest 4.3.1, PostgreSQL via Sail/Dokploy
**Storage**: Existing OperationRun compare payloads, `provider_resource_bindings`, `EvidenceSnapshot`/items, `EnvironmentReview`/sections, `ReviewPack`, and `StoredReport`. No new primary table is approved.
**Testing**: Pest unit and feature tests; Filament/Livewire feature tests for changed surfaces; browser smoke for the customer/operator-visible readiness presentation changed by this spec.
**Validation Lanes**: fast-feedback, confidence, browser; PostgreSQL only if a migration/index/constraint is introduced after spec update.
**Target Platform**: Laravel monolith in `apps/platform`, Sail locally, Dokploy for staging/production.
**Project Type**: Laravel/Filament web application inside `apps/platform`.
**Performance Goals**: Readiness derivation uses DB-local OperationRun/evidence/review/pack data; no provider/Graph calls during UI render; keep derived counts bounded by existing compare payload windows.
**Constraints**: no old payload readers, no display-name readiness interpretation, no raw provider detail in customer-safe output, no OperationRun lifecycle transitions outside existing services, no new UI route/panel provider.
**Scale/Scope**: Existing Evidence Snapshot, Environment Review, Customer Review Workspace, and Review Pack surfaces.
## Existing Repository Surfaces Likely Affected
```text
apps/platform/app/Services/Evidence/Sources/BaselineDriftPostureSource.php
apps/platform/app/Services/Evidence/EvidenceCompletenessEvaluator.php
apps/platform/app/Services/Evidence/EvidenceSnapshotService.php
apps/platform/app/Services/EnvironmentReviews/EnvironmentReviewReadinessGate.php
apps/platform/app/Services/EnvironmentReviews/EnvironmentReviewComposer.php
apps/platform/app/Support/ReviewPacks/ReviewPackOutputReadiness.php
apps/platform/app/Support/ReviewPacks/ReviewPackOutputResolutionGuidance.php
apps/platform/app/Support/ReviewPacks/ReportDisclosurePolicy.php
apps/platform/app/Support/ReviewPacks/ReportProfileRegistry.php
apps/platform/app/Support/Badges/BadgeCatalog.php
apps/platform/app/Support/Badges/Domains/EvidenceCompletenessBadge.php
apps/platform/app/Filament/Resources/EvidenceSnapshotResource.php
apps/platform/app/Filament/Resources/EnvironmentReviewResource.php
apps/platform/app/Filament/Resources/ReviewPackResource.php
apps/platform/app/Filament/Pages/Reviews/CustomerReviewWorkspace.php
apps/platform/resources/views/filament/pages/reviews/customer-review-workspace.blade.php
apps/platform/app/Services/ReviewPackService.php
apps/platform/app/Jobs/GenerateEvidenceSnapshotJob.php
apps/platform/app/Jobs/ComposeEnvironmentReviewJob.php
apps/platform/app/Jobs/GenerateReviewPackJob.php
apps/platform/app/Support/Baselines/CompareSemantics/*
apps/platform/app/Support/OperationRunLinks.php
apps/platform/app/Support/ManagedEnvironmentLinks.php
docs/ui-ux-enterprise-audit/page-reports/ui-006-customer-review-workspace.md
docs/ui-ux-enterprise-audit/page-reports/ui-011-reviews.md
docs/ui-ux-enterprise-audit/page-reports/ui-042-review-pack-detail.md
docs/ui-ux-enterprise-audit/page-reports/ui-046-evidence-snapshot-detail.md
```
Likely new or extended support path if implementation keeps the planned shape:
```text
apps/platform/app/Support/Baselines/Readiness/
```
Do not create that namespace if existing helpers can absorb the mapping with less structure. If a new helper is added, keep it baseline-readiness-specific and do not turn it into a cross-domain readiness framework.
## UI / Surface Guardrail Plan
- **Guardrail scope**: existing evidence/review/review-pack/customer-safe readiness presentation changes.
- **Affected routes/pages/actions/states/navigation/panel/provider surfaces**: existing Evidence Snapshot detail/Evidence Overview, Environment Review detail/register, Customer Review Workspace, Review Pack detail/download/rendered output, and readiness/badge/guidance states inside those surfaces.
- **No-impact class, if applicable**: N/A.
- **Native vs custom classification summary**: reuse existing Filament resources, existing Blade composition in Customer Review Workspace, existing badge/disclosure helpers, and existing review-pack output helpers. No new design system.
- **Shared-family relevance**: status messaging, evidence/report viewers, publication blockers, action links, badges, customer-safe disclosure, internal technical details.
- **State layers in scope**: evidence item payload, review summary/blockers, review-pack readiness payload, rendered/customer-safe output, and page-level guidance. No shell/panel/provider state change.
- **Audience modes in scope**: customer-safe review consumer, operator-MSP, and support-platform.
- **Decision/diagnostic/raw hierarchy plan**: customer/operator default shows readiness, reason, limitation/blocker, and one next action; diagnostics and raw proof are secondary/internal.
- **Raw/support gating plan**: raw provider IDs, canonical subject keys, binding internals, internal enum names, DB IDs, and raw OperationRun JSON stay hidden from customer-safe output and diagnostics-only elsewhere.
- **One-primary-action / duplicate-truth control**: each affected surface should show one dominant next action: resolve baseline subjects, refresh evidence, rerun compare, review limitations, or download qualified package.
- **Handling modes by drift class or surface**: changed customer-safe readiness presentation is `review-mandatory`; existing page report updates are required or a checked no-new-route note must be recorded.
- **Repository-signal treatment**: UI-COV-001 applies because existing reachable customer/evidence/review surfaces change readiness presentation.
- **Special surface test profiles**: `shared-detail-family` for readiness/detail output; `standard-native-filament` relief for unchanged Filament layout/action hierarchy.
- **Required tests or manual smoke**: feature tests for mapping and rendered output; browser smoke is required for the changed customer-facing readiness rendering unless implementation updates spec/plan/tasks first to prove no rendered presentation changed.
- **Exception path and spread control**: none planned.
- **Active feature PR close-out entry**: Evidence and Review Readiness Integration.
- **UI/Productization coverage decision**: existing surfaces changed; update relevant page reports or record no-new-route/no-archetype notes.
- **Coverage artifacts to update**: relevant page reports under `docs/ui-ux-enterprise-audit/page-reports/`; `route-inventory.md`/matrix only if implementation changes route inventory or coverage matrix classification.
- **No-impact rationale**: N/A.
- **Navigation / Filament provider-panel handling**: no navigation or panel provider changes planned. Laravel 12 panel providers remain registered through `apps/platform/bootstrap/providers.php`.
- **Screenshot or page-report need**: screenshot/browser smoke for the affected customer-safe readiness state when implementation changes rendered customer output.
## Shared Pattern & System Fit
- **Cross-cutting feature marker**: yes.
- **Systems touched**: baseline compare semantics, provider resource binding decisions, evidence snapshot completeness, environment review readiness, review-pack readiness/guidance, report disclosure policy, customer review workspace, badge/status presentation, OperationRun proof links.
- **Shared abstractions reused**: Spec 383 `CompareSemantics` values, `provider_resource_bindings`, `EvidenceCompletenessEvaluator`, `EnvironmentReviewReadinessGate`, `ReviewPackOutputReadiness`, `ReviewPackOutputResolutionGuidance`, `ReportDisclosurePolicy`, `ReportProfileRegistry`, `BadgeCatalog`, existing OperationRun link helpers.
- **New abstraction introduced? why?**: possibly one bounded baseline readiness mapper if needed to avoid duplicate interpretation across Evidence, Review, and Review Pack. It must derive from existing truth and remain baseline-specific.
- **Why the existing abstraction was sufficient or insufficient**: existing review-pack and review readiness helpers are the right integration points, but they currently do not consume baseline compare readiness impact and operator subject-resolution decisions.
- **Bounded deviation / spread control**: no generic readiness framework, no cross-domain indicator framework, no workflow engine, and no new persisted readiness entity.
## OperationRun UX Impact
- **Touches OperationRun start/completion/link UX?**: yes for source proof and next-action links only.
- **Central contract reused**: existing OperationRun proof/link helpers and existing baseline compare/evidence/review operation UX.
- **Delegated UX behaviors**: any rerun/refresh/open-operation behavior must delegate to existing baseline compare/evidence/review start/link paths.
- **Surface-owned behavior kept local**: readiness explanation, limitation/blocker text, and which existing link is shown as the primary action.
- **Queued DB-notification policy**: no new queued DB notification.
- **Terminal notification path**: unchanged.
- **Exception path**: none.
## Provider Boundary & Portability Fit
- **Shared provider/platform boundary touched?**: yes.
- **Provider-owned seams**: provider key/resource type/resource ID, canonical subject key, source descriptor, raw proof payload.
- **Platform-core seams**: baseline subject readiness, evidence completeness, limitation, exclusion, missing evidence, missing provider resource, publication blocker, customer-ready/internal-only/published-with-limitations semantics.
- **Neutral platform terms / contracts preserved**: provider resource, governed subject, baseline subject, accepted limitation, exclusion, trusted compare, missing evidence, unsupported coverage, readiness, publication blocker.
- **Retained provider-specific semantics and why**: provider identifiers may remain internal proof data needed for support diagnosis; they cannot become customer-safe wording or primary operator labels.
- **Bounded extraction or follow-up path**: document-in-feature for profile/copy choices; follow-up-spec for limitation expiry, broader lifecycle, or external portal work.
## Constitution Check
- Inventory-first: evidence readiness consumes last-observed inventory/compare/evidence truth; Microsoft remains external truth.
- Read/write separation: no write action is added by default. Existing publish/export/download actions keep existing rules.
- Graph contract path: no new Graph calls and no provider calls during render. Any evidence refresh/rerun uses existing jobs/services through `GraphClientInterface`.
- Deterministic capabilities: no new capability family by default.
- RBAC-UX: existing `/admin` workspace/environment rules remain; non-members 404; entitled but missing capability follows existing 403 policy behavior.
- Workspace isolation: all evidence/review/pack/source operation links remain workspace scoped.
- Tenant/environment isolation: all readiness inputs must be scoped to managed environment before display.
- Global search: no new resource is added. Existing global search behavior remains unchanged.
- Destructive-like actions: none added; existing actions keep existing confirmations/authorization/audit.
- Run observability: no new operation type; source proof links use existing OperationRun truth.
- OperationRun start UX: local readiness surfaces must not compose queued toasts/links/events.
- Ops-UX lifecycle: no direct `OperationRun.status` or `OperationRun.outcome` transitions.
- Ops-UX summary counts: no new OperationRun summary count key unless `OperationSummaryKeys::all()` and tests are updated.
- Data minimization: customer output excludes raw provider IDs, canonical keys, binding internals, raw OperationRun JSON, DB IDs, and internal enum names.
- Test governance: Unit, Feature, Filament/Livewire, and limited Browser lanes are planned explicitly.
- Proportionality: any mapper/helper must solve current false-green/false-red output risk and stay bounded.
- No premature abstraction: do not add a generic readiness engine, workflow engine, approval layer, profile framework, or cross-domain indicator framework.
- Persisted truth: readiness remains derived; no new table/artifact by default.
- Behavioral state: derived states must alter guidance, publication readiness, disclosure, or output boundary. Presentation-only labels should map from existing truth.
- UI semantics: use existing badge/disclosure/guidance helpers; do not create page-local semantic color systems.
- Shared pattern first: extend existing evidence/review/pack readiness helpers before adding a new helper.
- Provider boundary: primary readiness language stays provider-neutral.
- V1 explicitness / few layers: use a direct derived mapping and thin adapters.
- UI/Productization coverage: affected existing surfaces need page-report updates or checked no-new-route/no-archetype rationale.
Gate result for preparation: PASS.
## Test Governance Check
- **Test purpose / classification by changed surface**: Unit for baseline readiness mapping; Feature for Evidence/Review/ReviewPack integration; Filament/Livewire Feature for changed rendered surfaces; Browser for the customer-facing readiness smoke required by this presentation change.
- **Affected validation lanes**: fast-feedback, confidence, browser; pgsql only if schema/index behavior is added after spec update.
- **Why this lane mix is the narrowest sufficient proof**: the risk is deterministic readiness mapping and existing surface rendering; browser is needed to prove changed customer-facing output did not regress visually or leak raw details.
- **Narrowest proving command(s)**:
- `cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Unit/Support/Baselines tests/Unit/Evidence tests/Unit/Support/ReviewPacks`
- `cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Evidence/BaselineDriftPostureSourceTest.php tests/Feature/EnvironmentReview tests/Feature/ReviewPack`
- `cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Filament/Spec347CustomerReviewWorkspaceOutputReadinessTest.php tests/Feature/Filament/Spec349CustomerReviewWorkspaceOutputGuidanceTest.php`
- `cd apps/platform && ./vendor/bin/sail php vendor/bin/pest tests/Browser/Spec347ReviewPackOutputReadinessSmokeTest.php` or a new focused Spec 385 browser smoke covering changed customer-facing readiness rendering.
- `cd apps/platform && ./vendor/bin/sail bin pint --dirty --format agent`
- `git diff --check`
- **Fixture / helper / factory / seed / context cost risks**: baseline compare/evidence/review fixtures may be broad. Keep any new setup local to Spec 385 tests and avoid widening shared defaults.
- **Expensive defaults or shared helper growth introduced?**: none planned.
- **Heavy-family additions, promotions, or visibility changes**: none planned.
- **Surface-class relief / special coverage rule**: existing native Filament resources can use standard-native relief unless layout/action hierarchy changes; customer-safe output needs browser smoke for the readiness rendering changed by this spec.
- **Closing validation and reviewer handoff**: verify no false-green/no false-red, no raw provider leakage, no old-payload compatibility, no new workflow/report/PDF scope, and no hidden heavy/browser cost.
- **Budget / baseline / trend follow-up**: none expected; document-in-feature if browser fixture cost grows.
- **Review-stop questions**: readiness duplicate truth, customer leakage, profile/disclosure ambiguity, new state bloat, old payload compatibility, and scope bleed into report/PDF/runtime.
- **Escalation path**: document-in-feature for contained profile/copy choices; follow-up-spec for limitation lifecycle, external portal, or structural readiness framework.
- **Active feature PR close-out entry**: Evidence and Review Readiness Integration.
- **Why no dedicated follow-up spec is needed**: this spec is the dedicated integration over completed Specs 381-384. Follow-up candidates remain listed for larger lifecycle/productization work.
## Project Structure
### Documentation (this feature)
```text
specs/385-evidence-review-readiness/
├── checklists/
│ └── requirements.md
├── plan.md
├── spec.md
└── tasks.md
```
### Source Code (repository root)
```text
apps/platform/app/
├── Services/
│ ├── Evidence/
│ │ ├── EvidenceCompletenessEvaluator.php
│ │ └── Sources/
│ │ └── BaselineDriftPostureSource.php
│ └── EnvironmentReviews/
│ ├── EnvironmentReviewComposer.php
│ └── EnvironmentReviewReadinessGate.php
├── Support/
│ ├── Baselines/
│ │ ├── CompareSemantics/
│ │ └── Readiness/ # only if existing helpers cannot absorb the mapping
│ ├── ReviewPacks/
│ │ ├── ReportDisclosurePolicy.php
│ │ ├── ReportProfileRegistry.php
│ │ ├── ReviewPackOutputReadiness.php
│ │ └── ReviewPackOutputResolutionGuidance.php
│ └── Badges/
├── Filament/
│ ├── Resources/
│ │ ├── EvidenceSnapshotResource.php
│ │ ├── EnvironmentReviewResource.php
│ │ └── ReviewPackResource.php
│ └── Pages/
│ └── Reviews/
│ └── CustomerReviewWorkspace.php
└── Jobs/
├── GenerateEvidenceSnapshotJob.php
├── ComposeEnvironmentReviewJob.php
└── GenerateReviewPackJob.php
apps/platform/resources/views/filament/pages/reviews/
└── customer-review-workspace.blade.php
apps/platform/tests/
├── Unit/
├── Feature/
│ ├── Evidence/
│ ├── EnvironmentReview/
│ ├── ReviewPack/
│ └── Filament/
└── Browser/
```
**Structure Decision**: Use existing Laravel/Filament monolith structure. Add only bounded support classes if existing helpers cannot represent baseline readiness consistently.
## Technical Approach
1. Confirm completed dependency guardrails for Specs 381-384 and inspect current compare semantic payload shape.
2. Add focused tests for false-green/false-red evidence readiness cases before changing runtime code.
3. Create or extend a baseline readiness derivation over Spec 383 compare semantics and active `provider_resource_bindings`.
4. Update `BaselineDriftPostureSource` to use the derived readiness detail and populate summary/fingerprint payloads with safe counts and source IDs.
5. Update `EvidenceCompletenessEvaluator` or its callers only if existing `complete/partial/missing/stale` cannot express blockers/limitations without losing detail.
6. Update Environment Review readiness/composition so baseline blockers/limitations become precise publication blockers, limitation states, and next actions.
7. Update Review Pack readiness/guidance/disclosure so customer-ready, published-with-limitations, internal-only, blocked/export-not-ready, stale, and failed states reflect baseline readiness.
8. Update rendered/customer-facing copy to use safe limitation wording and hide raw provider/internal details.
9. Update affected UI coverage page reports or record checked no-new-route/no-archetype notes.
10. Run focused unit/feature/browser validation, Pint, and diff check.
## Domain And Data Model Implications
- `OperationRun` compare payloads remain execution/proof truth.
- `provider_resource_bindings` remains durable decision truth.
- Evidence readiness details should be derived and stored only as part of existing evidence item summary/fingerprint payloads where needed.
- Environment Review blockers should remain derived from sections/evidence/review summary, not a new persisted blocker table.
- Review Pack output readiness remains derived through existing support helpers.
- No new migration, index, queue name, scheduler entry, route, panel provider, or storage path is planned.
- If implementation proves a new persisted field/table/index is required, stop and update spec/plan/tasks before adding it.
## Readiness Mapping Rules
| Input truth | Evidence/Review output behavior |
|---|---|
| Trusted no drift | complete/verified; customer-safe if all other required dimensions pass |
| Trusted drift | complete with findings; customer-visible finding where profile allows; not a blocker by itself |
| Unresolved required identity | action-required blocker; link to Baseline Subject Resolution |
| Missing local evidence | missing evidence or refresh blocker; not provider drift |
| Missing provider resource with trusted identity | governance finding/drift or missing-resource issue; may be customer-ready with finding |
| Unsupported required coverage | blocker unless accepted or profile allows disclosed limitation |
| Inventory-only/foundation-only coverage | limitation; never verified no drift |
| Accepted limitation | published-with-limitations when profile allows; never verified no drift |
| Excluded non-governed subject | excluded from governed claims; not counted as pass |
| Compare failed/coverage unproven | blocked/internal-only with rerun or investigate guidance; use existing output state plus failed reason/limitation code unless a narrow helper extension is justified before implementation |
| Stale source | stale/refresh guidance; use existing output state plus stale reason/limitation code unless a narrow helper extension is justified before implementation |
Default output-state strategy: do not introduce a new public readiness state family for stale/failed by default. Preserve distinct behavior through existing Review Pack output states, `primary_reason`, limitation codes, disclosure output, and next-action guidance unless the existing helpers cannot represent the consequence without ambiguity; if so, stop and update spec/plan/tasks before adding constants.
## UI / Filament Implications
- Filament v5 and Livewire v4.0+ compliance is required; project currently uses Filament 5.2.1 and Livewire 4.1.4.
- Provider registration remains unchanged in `apps/platform/bootstrap/providers.php`; no new panel provider is planned.
- No new globally searchable Filament Resource is planned. Existing resource global search behavior must remain tenant-safe.
- No new destructive/high-impact action is planned. Existing actions keep `->action(...)`, confirmation where applicable, authorization, audit, and tests.
- No new public readiness state family is planned by default. Stale and failed baseline readiness should map through existing output states with explicit reason/limitation codes unless this plan is updated first.
- No new Filament assets or heavy frontend assets are planned. Normal deploy can keep existing `cd apps/platform && php artisan filament:assets` behavior only if assets are registered elsewhere.
- Customer-safe output must avoid raw IDs, internal enum names, canonical keys, binding internals, and raw JSON.
## Implementation Phases
### Phase 1 - Dependency and Payload Discovery
Verify Specs 381-384 are completed, inspect current Spec 383 semantic payload keys, inspect Spec 384 binding decision modes, and confirm current evidence/review/pack readiness helpers.
### Phase 2 - Baseline Readiness Unit Coverage
Add tests for trusted no drift, trusted drift, unresolved identity, missing local evidence, missing provider resource, unsupported coverage, inventory-only coverage, accepted limitation, exclusion, compare failed, stale, and zero-findings-without-trusted-compare.
### Phase 3 - Evidence Integration
Implement the bounded readiness derivation and update `BaselineDriftPostureSource` plus evidence completeness/detail payloads. Keep source IDs safe and deterministic.
### Phase 4 - Environment Review Integration
Feed baseline readiness details into `EnvironmentReviewReadinessGate` and `EnvironmentReviewComposer` so blockers, limitations, and next actions become specific without adding workflow/task entities.
### Phase 5 - Review Pack Output Integration
Update `ReviewPackOutputReadiness`, `ReviewPackOutputResolutionGuidance`, `ReportDisclosurePolicy`, and rendered/customer workspace consumers to use the same baseline readiness details.
### Phase 6 - UI Coverage and Customer Safety
Update affected page reports or no-new-route notes, add customer-safe rendered-output tests, and run browser smoke for the changed customer/operator readiness rendering unless the implementation updates spec/plan/tasks first to prove no rendered presentation changed.
### Phase 7 - Validation and Close-Out
Run targeted tests, customer-facing readiness browser smoke, Pint, diff check, and record implementation close-out with explicit Filament/Livewire/global-search/actions/assets/testing/deploy impact.
## Risk Controls
- Stop if implementation needs a new table, durable readiness entity, new route, panel provider, capability family, provider call, workflow/approval engine, or report/PDF runtime changes.
- Stop if old payload compatibility readers seem required; pre-production posture rejects them unless this spec is updated.
- Keep readiness mapping provider-neutral and derived from Spec 383/384 truth.
- Keep diagnostics secondary and customer output sanitized.
- Add tests before broadening shared helpers.
- Use existing disclosure/profile helpers before local copy/mapping.
## Rollout And Deployment Considerations
- Staging validation is required because customer-safe readiness/output semantics change.
- No environment variables, migrations, queue names, scheduler entries, storage volumes, reverse proxy changes, route changes, panel provider changes, or asset build changes are planned.
- Queue workers should be restarted during normal Laravel deployment so evidence/review/pack jobs use current code.
- `filament:assets` remains the normal Filament deploy step only if registered assets exist; this spec does not plan new assets.
- Existing local/dev evidence snapshots, reviews, and review packs may be regenerated instead of compatibility-mapped.
## Complexity Tracking
| Potential complexity | Why needed | Simpler alternative rejected because |
|---|---|---|
| Bounded baseline readiness mapper/helper | Evidence, Review, and Review Pack need one interpretation of Spec 383/384 truth | Page-local labels would preserve duplicate truth and false-green/false-red risk |
| Derived blocker/limitation detail payload | Review and pack output need actionable guidance and customer-safe limitation summaries | `complete/partial/missing/stale` alone cannot distinguish identity blockers, limitations, findings, and missing evidence |
| Customer-safe disclosure updates | Customer output must explain limitations without leaking proof internals | Internal diagnostics cannot be reused directly for customer-ready output |
## Proportionality Review
- **Current operator problem**: Evidence/review output can misstate baseline posture after compare and resolution truth are known.
- **Existing structure is insufficient because**: downstream surfaces infer readiness separately from findings, evidence state, review sections, export status, and generic limitations.
- **Narrowest correct implementation**: one derived readiness detail consumed by existing evidence/review/pack helpers.
- **Ownership cost created**: mapper/helper tests, cross-surface regression tests, customer-safe copy checks, and UI coverage notes.
- **Alternative intentionally rejected**: update only the Customer Review Workspace label. That would leave Evidence Snapshot, Environment Review, Review Pack detail, and rendered output inconsistent.
- **Release truth**: current-release truth after completed Specs 381-384.
## Filament v5 Implementation Report
- **Livewire v4.0+ compliance**: confirmed. Project uses Livewire 4.1.4 and this spec did not add Livewire v3 references.
- **Provider registration location**: unchanged. Laravel panel providers remain in `apps/platform/bootstrap/providers.php`; no new panel provider was added.
- **Global search**: no new Filament Resource was added, so no new globally searchable resource contract was introduced.
- **Destructive/high-impact actions**: no new destructive or high-impact Filament action was added. Existing publish/export/download/refresh actions keep their existing confirmation, authorization, audit, notification, and test responsibilities.
- **Asset strategy**: no new Filament, Vite, or heavy frontend asset was added. No Spec 385-specific `filament:assets` or asset-build deploy step is required beyond normal deploy behavior.
- **Testing result**: focused Spec 385 unit, feature, Filament/Livewire, and browser coverage passed with 36 tests and 160 assertions. `php vendor/bin/pint --dirty` and `git diff --check` were also run successfully.
- **Deployment impact**: no new environment variables, migrations, persisted states/entities/enums, provider/Graph calls, scheduler entries, queue names, storage volumes, reverse proxy changes, routes, or asset steps. Normal code deploy plus queue worker restart remains sufficient.

View File

@ -0,0 +1,467 @@
# Feature Specification: Spec 385 - Evidence and Review Readiness Integration v1
**Feature Branch**: `385-evidence-review-readiness`
**Created**: 2026-06-17
**Status**: Implemented / Manual review close-out recorded
**Input**: User-provided draft candidate "Spec 385 - Evidence & Review Readiness Integration v1" from `/Users/ahmeddarrazi/.codex/attachments/ebb66191-6453-4e97-b245-eb040b3857d1/pasted-text.txt`.
## Repo-Truth Adjustment
The user supplied a complete numbered draft for Spec 385. Repo truth confirms the intended predecessor chain exists and is implemented or closed out:
- `specs/381-provider-resource-identity-binding/`
- `specs/382-baseline-matching-canonicalization/`
- `specs/383-baseline-result-semantics/`
- `specs/384-baseline-subject-resolution-ui/`
Those completed specs are dependency context only. This Spec 385 package does not modify their historical close-out notes, validation records, completed tasks, or smoke evidence.
Spec 385 narrows the draft to one implementation-ready runtime and presentation slice:
- consume Spec 383 structured baseline compare semantics and Spec 384 operator decisions in Evidence Snapshot completeness;
- make `BaselineDriftPostureSource` distinguish trusted no drift, trusted drift, unresolved identity, missing local evidence, missing provider resource, unsupported coverage, accepted limitations, exclusions, stale, and failed states;
- carry that baseline readiness into Environment Review readiness, publication blockers, and guidance;
- carry that baseline readiness into Review Pack output readiness, disclosure policy, customer-safe limitation summaries, and internal technical detail;
- keep customer-facing output safe and avoid false green or false red claims;
- avoid new matching, new compare semantics, new resolution UI, new report/PDF runtime work, new workflow engine, and legacy compatibility mappers.
## Candidate Selection Gate
- **Selected candidate**: Spec 385 - Evidence and Review Readiness Integration v1.
- **Source**: Direct user-provided candidate attachment. It is the fifth item in the 381-385 baseline identity/readiness sequence.
- **Why selected**: Specs 381-384 are present and completed, so the next bounded value is to make Evidence, Environment Review, and Review Pack readiness consume the new compare and operator-decision truth instead of old incomplete heuristics.
- **Roadmap relationship**: Supports R2 Evidence & Exception Workflows, customer-safe review consumption, provider-neutral baseline drift trust, and governance output correctness. It does not reopen the active candidate queue, which currently has no automatic next-best-prep target.
- **Close alternatives deferred**:
- Management Report PDF runtime validation remains tied to Specs 378-379 and is out of scope.
- Governance artifact lifecycle retention runtime remains manual-promotion backlog, not an automatic prep target.
- Provider readiness onboarding productization remains optional manual-promotion backlog.
- Cross-domain indicator runtime follow-through remains a broader guardrail lane and must not be hidden inside this spec.
- New resolution UI, matching, and compare semantics are already covered by Specs 382-384 and must not be reimplemented here.
- **Completed-spec guardrail result**:
- Specs 381, 382, 383, and 384 all contain implementation close-out or validation signals and are treated as completed dependency context only.
- Adjacent review/output specs such as `specs/347-review-pack-output-contract-readiness-semantics/`, `specs/349-review-output-resolution-guidance/`, `specs/351-review-output-resolve-actions/`, `specs/357-report-profile-disclosure-policy/`, and `specs/379-management-report-pdf-runtime/` are context only and must not be rewritten by this prep.
- No existing `specs/385-*` package or `385-*` local/remote branch was found before the Spec Kit create script ran.
- **Smallest viable implementation slice**: Derive baseline evidence readiness from existing compare semantics and binding decisions, then feed the derived blocker/limitation/detail payload into existing Evidence Snapshot, Environment Review, and Review Pack readiness paths.
- **Gate result**: PASS. The candidate is directly provided by the user, is not already specced or completed, follows completed dependencies, aligns with roadmap trust/customer-readiness priorities, and is bounded enough for a later implementation loop.
## Spec Candidate Check *(mandatory - SPEC-GATE-001)*
- **Problem**: Evidence and review output can still imply a stronger or weaker baseline posture than the new compare and subject-resolution truth supports.
- **Today's failure**: A review or customer package can treat zero findings as verified no drift even when compare identity is unresolved, treat accepted limitations as success, treat exclusions as compliant, confuse missing local evidence with provider drift, or keep resolved provider defaults as false blockers.
- **User-visible improvement**: Operators and customer-facing reviewers see honest readiness: trusted findings can be published, accepted limitations are disclosed, unresolved governed blockers stop customer-safe claims, and specific guidance points to subject resolution, evidence refresh, or review limitation handling.
- **Smallest enterprise-capable version**: Add or extend a bounded baseline evidence readiness mapper, update baseline evidence collection, update existing review readiness/publication blocker logic, update existing Review Pack readiness/guidance/disclosure mapping, and add focused tests and smoke coverage for affected customer/operator surfaces.
- **Explicit non-goals**: No new provider identity foundation, no matching pipeline changes, no compare result taxonomy rebuild, no Baseline Subject Resolution UI changes beyond linking to the existing page, no generic workflow engine, no approval workflow, no broad Governance Inbox, no customer portal redesign, no Management Report/PDF runtime validation, no old payload compatibility readers, and no new durable decision table.
- **Permanent complexity imported**: A bounded mapper or extension to existing readiness helpers, derived readiness/detail payloads, possible derived readiness constants if existing ones are insufficient, tests across evidence/review/review-pack surfaces, and UI copy/guidance updates. No new primary table, route family, panel provider, provider client, or queue family is approved.
- **Why now**: Specs 381-384 made identity, matching, compare semantics, and operator decisions repo-real. Leaving Evidence and Review readiness on older heuristics preserves the false-green/false-red gap at the customer output boundary.
- **Why not local**: Patching only one output label would leave `BaselineDriftPostureSource`, Evidence Snapshot completeness, Environment Review blockers, Review Pack output readiness, disclosure policy, and customer-safe guidance able to disagree.
- **Approval class**: Core Enterprise.
- **Red flags triggered**: Derived readiness semantics, cross-surface output mapping, customer-safe presentation, and possible helper layer. Defense: this spec consumes existing truth, avoids new persistence, avoids new UI/workflow engines, reuses existing review-pack readiness/disclosure paths, and tests concrete false-green/false-red cases.
- **Score**: Nutzen: 2 | Dringlichkeit: 2 | Scope: 2 | Komplexitaet: 1 | Produktnaehe: 2 | Wiederverwendung: 2 | **Gesamt: 11/12**
- **Decision**: approve as a narrowed Core Enterprise readiness-integration slice.
## Problem Statement
TenantPilot can now produce structured baseline compare outcomes and operator decisions through Specs 383 and 384, but downstream Evidence and Review surfaces can still infer readiness from older, incomplete signals such as operation success, drift finding count, or generic evidence completeness.
That creates two customer-safety risks:
1. **False green**: output appears customer-ready or verified when identity, coverage, evidence, or compare trust is unresolved.
2. **False red**: output stays blocked when provider defaults, accepted limitations, exclusions, or trusted drift findings are validly classified and safe to disclose.
Spec 385 makes baseline evidence readiness the bridge between technical compare truth and customer/operator output truth.
## Business / Product Value
- Prevents customer-facing no-drift claims unless identity, comparison, coverage, and required evidence are trusted.
- Lets operators publish with explicit limitations when a limitation is accepted or allowed by the report profile.
- Preserves trusted drift as a valid customer-visible finding rather than a publication failure by default.
- Makes publication blockers specific enough to act on: resolve subject identity, refresh evidence, run compare, accept/disclose limitation, or review unsupported scope.
- Keeps internal proof available without leaking raw provider internals into customer-safe output.
## Primary Users / Operators
- MSP or tenant operator preparing an Environment Review or Review Pack for customer consumption.
- Workspace manager responsible for baseline governance output quality.
- Support/platform operator diagnosing why Evidence or Review output is blocked, limited, stale, internal-only, or ready.
- Customer/auditor consuming customer-safe review output where limitation wording must be clear and non-technical.
## Spec Scope Fields *(mandatory)*
- **Scope**: tenant-owned evidence/review/output readiness inside established workspace and managed-environment boundaries.
- **Primary Routes**:
- existing Evidence Snapshot resource/detail and Evidence Overview surfaces;
- existing Environment Review resource/detail/register and Customer Review Workspace surfaces;
- existing Review Pack resource/detail/download/rendered output readiness surfaces;
- existing Baseline Subject Resolution page only as a linked next action, not as a changed decision UI.
- **Data Ownership**:
- `OperationRun` baseline compare context/result remains execution/proof truth.
- `provider_resource_bindings` remains durable operator decision truth.
- `EvidenceSnapshot` and `EvidenceSnapshotItem` remain evidence artifact truth.
- `EnvironmentReview` and `EnvironmentReviewSection` remain review composition truth.
- `ReviewPack` and `StoredReport` remain output artifact truth.
- Spec 385 readiness details are derived unless implementation proves a current-release source-of-truth need and updates this spec before adding persistence.
- **RBAC**: Existing workspace and managed-environment entitlement rules remain mandatory. Non-members receive deny-as-not-found. Entitled members missing view/export/publish capabilities receive the existing forbidden behavior. Customer-safe output must not reveal tenant/provider internals across workspace or environment boundaries.
For canonical-view specs:
- **Default filter behavior when tenant-context is active**: existing page-level `environment_id` filter behavior remains; no hidden `/admin/t` or retired tenant-panel context is revived.
- **Explicit entitlement checks preventing cross-tenant leakage**: all evidence, review, pack, stored report, operation, and subject-resolution links must resolve through existing scoped routes/policies before data is rendered.
## 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
- [ ] New table/form/state added
- [x] Customer-facing surface changed
- [ ] Dangerous action changed
- [x] Status/evidence/review presentation changed
- [ ] Workspace/environment context presentation changed
Clarification: no new table, form, route, modal, wizard, panel provider, or action is planned. Derived readiness reason/state values may be narrowly extended only if existing helpers cannot preserve the required stale/failed/blocker/limitation behavior; any public state-family expansion must update this spec/plan/tasks before implementation.
## UI/Productization Coverage *(mandatory when UI Surface Impact is not "No UI surface impact")*
- **Route/page/surface**: Evidence Snapshot detail/Evidence Overview, Environment Review detail/register, Customer Review Workspace, Review Pack detail/download/rendered output, and baseline readiness/guidance sections within those existing surfaces.
- **Current or new page archetype**: existing evidence/review/output strategic and domain pattern surfaces; no new route archetype.
- **Design depth**: Strategic Surface for customer review/output readiness; Domain Pattern Surface for evidence and review detail readiness.
- **Repo-truth level**: repo-verified existing runtime surfaces.
- **Existing pattern reused**: existing `ReviewPackOutputReadiness`, `ReviewPackOutputResolutionGuidance`, `ReportDisclosurePolicy`, Environment Review readiness, Evidence Snapshot completeness, `BadgeCatalog`/badge domains, and existing customer-safe disclosure patterns.
- **New pattern required**: no new broad pattern. A bounded baseline readiness mapper or extension is allowed only to align existing surfaces.
- **Screenshot required**: yes. This spec changes customer/operator-visible readiness presentation, so implementation must include focused browser-smoke/screenshot evidence for the affected customer-safe review/output readiness unless the implementation updates this spec/plan first to prove no rendered presentation changed.
- **Page audit required**: implementation must update relevant existing page reports or record a checked no-new-route/no-archetype note, especially `ui-006-customer-review-workspace.md`, `ui-011-reviews.md`, `ui-042-review-pack-detail.md`, and `ui-046-evidence-snapshot-detail.md` where affected.
- **Customer-safe review required**: yes. This spec directly changes customer-ready, published-with-limitations, internal-only, and publication-blocked semantics.
- **Dangerous-action review required**: no new destructive/high-impact action is expected. Existing publish/export/download actions keep existing authorization and confirmation rules.
- **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, readiness labels, publication blockers, action links, evidence/report viewers, customer-safe disclosure, internal technical detail, and badge/status presentation.
- **Systems touched**:
- `BaselineDriftPostureSource`
- `EvidenceCompletenessEvaluator`
- Evidence Snapshot creation/detail rendering
- `EnvironmentReviewReadinessGate`
- `EnvironmentReviewComposer`
- `ReviewPackOutputReadiness`
- `ReviewPackOutputResolutionGuidance`
- `ReportDisclosurePolicy`
- Customer Review Workspace
- Review Pack render/export/download support where readiness is displayed
- existing Baseline Subject Resolution links only as next-action targets
- **Existing pattern(s) to extend**: existing evidence completeness states, existing review readiness blockers, existing review-pack readiness/guidance/disclosure helpers, existing badge catalog/renderers, existing OperationRun/source-proof link helpers.
- **Shared contract / presenter / builder / renderer to reuse**: prefer extending `ReviewPackOutputReadiness`, `ReviewPackOutputResolutionGuidance`, `ReportDisclosurePolicy`, `EnvironmentReviewReadinessGate`, `EvidenceCompletenessEvaluator`, and badge helpers before adding local mappings.
- **Why the existing shared path is sufficient or insufficient**: Existing paths already own review/output readiness and disclosure. They are insufficient today because baseline compare semantics and subject-resolution decisions are not yet first-class inputs to their readiness calculations.
- **Allowed deviation and why**: one bounded baseline readiness mapper or support helper is allowed if it prevents duplicate interpretation across Evidence, Review, and Review Pack. It must remain baseline-readiness-owned and must not become a generic workflow/report engine.
- **Consistency impact**: Evidence completeness, review blockers, review-pack state, limitation summaries, customer-safe disclosure, and internal diagnostics must describe the same baseline truth.
- **Review focus**: no parallel readiness dialect, no customer leakage of raw provider IDs/canonical keys/OperationRun JSON, no false no-drift, no generic task/workflow layer.
## OperationRun UX Impact *(mandatory)*
- **Touches OperationRun start/completion/link UX?**: yes for source-proof and next-action links only; no new operation start/completion semantics.
- **Shared OperationRun UX contract/layer reused**: existing OperationRun proof/link helpers and existing baseline compare start UX for any "run compare again" or "open operation" action.
- **Delegated start/completion UX behaviors**: queued toast, run link, browser event, dedupe messaging, and terminal notifications remain delegated to existing compare/evidence/review operation paths.
- **Local surface-owned behavior that remains**: readiness explanation, limitation summary, blocker guidance, and link selection.
- **Queued DB-notification policy**: no new queued DB notifications.
- **Terminal notification path**: unchanged.
- **Exception required?**: none.
Spec 385 may add source operation IDs and counts to derived evidence/readiness payloads, but it must not transition `OperationRun.status` or `OperationRun.outcome` outside existing services.
## Provider Boundary / Platform Core Check *(mandatory)*
- **Shared provider/platform boundary touched?**: yes.
- **Boundary classification**: platform-core for readiness, blockers, limitation, evidence completeness, publication state, and customer-safe wording; provider-owned for raw provider identifiers and resource descriptors that remain proof/diagnostics only.
- **Seams affected**: baseline compare semantic payloads, provider resource binding decisions, evidence readiness details, review blockers, review-pack disclosure, guidance links, and operator/customer vocabulary.
- **Neutral platform terms preserved or introduced**: baseline subject, provider resource, governed subject, trusted compare, accepted limitation, excluded subject, missing evidence, missing provider resource, unsupported coverage, publication blocker, internal-only, customer-ready, published with limitations.
- **Provider-specific semantics retained and why**: provider key/type/id and canonical subject key may remain in internal technical details because they prove source identity. They must not appear in customer-safe output or primary operator summary unless already redacted/truncated and explicitly internal.
- **Why this does not deepen provider coupling accidentally**: readiness maps Spec 383 provider-neutral reasons and Spec 384 provider-resource decisions; it does not branch on Microsoft/Intune display labels, Graph endpoints, or raw provider payload shapes.
- **Follow-up path**: document-in-feature for contained profile/copy decisions; follow-up-spec for limitation expiry, approval workflow, external portal, or broader lifecycle retention.
## 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 |
|---|---|---|---|---|---|---|
| Evidence Snapshot baseline completeness/readiness | yes | existing Filament resource/detail and badge patterns | evidence completeness, source proof, status messaging | detail, payload | no | Existing routes only |
| Environment Review readiness/publication blockers | yes | existing Filament resource/detail and review services | review readiness, blocker guidance, action links | detail, summary payload | no | Existing routes only |
| Customer Review Workspace readiness/guidance | yes | existing customer-safe review surface | customer-safe disclosure, next action, evidence basis | page, environment filter payload | no | Existing route only |
| Review Pack output readiness/disclosure | yes | existing resource/detail/rendered output helpers | output readiness, limitation summaries, report profile | detail, artifact payload | no | Existing routes/output artifacts only |
| Baseline Subject Resolution link target | no material surface change | existing Filament page | next-action link only | URL/query | no | Link target only; do not change Spec 384 UI unless implementation finds a broken link contract |
## 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 baseline readiness | Primary Decision Surface | Operator decides whether customer output is ready, limited, internal-only, or blocked | readiness state, top blocker/limitation, customer-safe summary, one next action | evidence snapshot, review sections, source compare run, subject resolution link | Primary because customer handoff happens here | follows review publication/handoff workflow | avoids reading raw compare run payloads before deciding |
| Environment Review readiness | Secondary Context | Operator decides whether a review can be published/exported | blockers, required section/evidence state, baseline readiness impact | evidence snapshot detail, operation proof, section details | Secondary because it governs review lifecycle | supports publish/export decision | turns vague blockers into specific actions |
| Evidence Snapshot detail | Tertiary Evidence / Diagnostics | Operator verifies the evidence basis behind readiness | baseline completeness state, counts, source run ID, measured/freshness timestamps | structured reason counts and source proof | Not primary because it proves the output decision | supports evidence investigation | keeps proof out of customer default output |
| Review Pack detail/rendered output | Secondary Context | Operator/customer verifies package state and limitations | customer-safe state, limitation summary, download qualification | internal technical appendix where profile allows | Secondary because the workspace/review owns the decision | supports artifact handoff | makes limitations explicit without duplicate status blocks |
## 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 | customer-safe review consumer, operator-MSP, support-platform | state, reason, impact, limitation summary, qualified download/review action | counts by blocker/limitation/finding, source evidence, source compare run | raw provider IDs, canonical keys, raw OperationRun JSON | resolve blocker, review limitations, or download qualified pack | raw/provider/support detail collapsed or capability-gated | top state appears once; lower sections add proof only |
| Environment Review detail | operator-MSP, support-platform | publish/export readiness, specific blockers, refresh/resolve guidance | section details, evidence details, source operation links | raw section payloads/support diagnostics | resolve review blocker | raw details secondary | blockers share the same derived baseline readiness source |
| Review Pack output | customer-safe or internal profile reader | package readiness, allowed limitations, non-certification/evidence disclosure | internal appendix where allowed | provider IDs/canonical keys/raw JSON omitted from customer profile | review limitations or download package | internal technical details hidden from customer output | limitation wording aligns with readiness state |
## 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 | Read-only strategic review hub | resolve blocker, review limitations, or download qualified pack | explicit primary action in readiness block | N/A | secondary proof/action links in detail panels | none in scope | `/admin/reviews/workspace` | existing review/pack/evidence routes | workspace shell and environment filter | Customer review output | readiness, blocker/limitation, evidence basis | none |
| Environment Review detail | Utility / Review Detail | Lifecycle/detail surface | resolve publication blocker or export | existing detail page | current behavior only | proof/action links in sections | existing dangerous actions unchanged | existing Environment Review collection | existing Environment Review detail | workspace/environment context | Environment review | readiness/blockers | none |
| Evidence Snapshot detail | Utility / Evidence Detail | Evidence/proof surface | inspect evidence basis | existing detail page | current behavior only | source operation and diagnostics links | none in scope | existing Evidence Snapshot collection | existing Evidence Snapshot detail | workspace/environment context | Evidence snapshot | completeness and source proof | none |
| Review Pack detail/output | Utility / Artifact Detail | Output artifact proof | review limitations or download | existing detail/download/rendered output | current behavior only | secondary proof/download links | existing regenerate/expire actions out of scope | existing Review Pack collection | existing Review Pack detail | workspace/environment/artifact status | Review pack | output readiness and limitation summary | none |
## UI Action Matrix *(mandatory when Filament is changed)*
Spec 385 does not add a new Filament Resource, RelationManager, Page, route, navigation entry, modal, wizard, destructive action, or bulk action. It may modify readiness labels, badges, summaries, and guidance on existing Filament/Blade surfaces. The Action Surface Contract is satisfied when implementation preserves the existing inspect/open model, action placement, RBAC enforcement, confirmation behavior, and audit behavior for each affected surface.
| Surface | Location | Header Actions | Inspect Affordance (List/Table) | Row Actions (max 2 visible) | Bulk Actions (grouped) | Empty-State CTA(s) | View Header Actions | Create/Edit Save+Cancel | Audit log? | Notes / Exemptions |
|---|---|---|---|---|---|---|---|---|---|---|
| Evidence Snapshot detail/readiness | `apps/platform/app/Filament/Resources/EvidenceSnapshotResource.php` and pages | Existing actions only; no new Spec 385 header action planned | Existing resource inspect model unchanged | Existing row actions unchanged | Existing bulk behavior unchanged | Existing empty states unchanged | Existing detail actions unchanged | N/A | Existing audit behavior unchanged | Readiness presentation only; no action-surface exemption planned; no raw provider IDs, DB IDs, internal enum names, binding internals, or raw OperationRun JSON in customer-safe output |
| Environment Review detail/readiness | `apps/platform/app/Filament/Resources/EnvironmentReviewResource.php` and pages | Existing publish/export/review actions only | Existing resource inspect model unchanged | Existing row actions unchanged | Existing bulk behavior unchanged | Existing empty states unchanged | Existing detail actions unchanged | Existing create/edit behavior unchanged if untouched | Existing audit behavior unchanged | Spec 385 may change blocker/guidance text only; one dominant next action should remain resolve subjects, refresh evidence, rerun compare, or review limitations |
| Customer Review Workspace readiness | `apps/platform/app/Filament/Pages/Reviews/CustomerReviewWorkspace.php` and Blade view | Existing page actions only | N/A | N/A | N/A | Existing empty states unchanged unless readiness wording changes | Existing links/download affordances only | N/A | Existing audit/download behavior unchanged | One dominant next action must remain; no new mutation; customer-facing stale/failed/blocker/limitation explanations must remain safe and non-technical |
| Review Pack detail/output readiness | `apps/platform/app/Filament/Resources/ReviewPackResource.php`, `ReviewPackService`, and rendered output helpers | Existing generate/download/render actions only | Existing resource inspect model unchanged | Existing row actions unchanged | Existing bulk behavior unchanged | Existing empty states unchanged | Existing detail actions unchanged | N/A | Existing generate/download audit behavior unchanged | Existing dangerous/high-impact actions stay in their current guarded paths; stale/failed may map to existing output states with explicit reason/limitation codes unless a narrow helper extension is recorded before implementation |
UI-FIL-001 remains satisfied by using existing Filament resources/pages and shared badge/disclosure helpers. Any implementation that adds a new visible action, changes action placement, adds bulk actions, or changes dangerous-action behavior must update this matrix, tasks, and the implementation close-out before merge.
## 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/workspace operator | decide whether customer output can be shared, shared with limitations, kept internal, or blocked | workspace review hub | Can I safely share this review output, and what blocks it? | readiness state, reason, limitation summary, evidence basis, one next action | source compare run, subject resolution counts, raw proof links | evidence completeness, baseline readiness, review publication, output readiness | none by default | resolve blocker, review limitations, download qualified pack | none in scope |
| Environment Review detail | MSP/workspace operator | decide whether review can publish/export | review detail | What must be fixed before this review is ready? | blockers, required dimensions, section/evidence state | source proof and section diagnostics | review lifecycle, evidence completeness, baseline readiness | existing review lifecycle actions only | resolve blocker/open evidence/export if allowed | existing actions unchanged |
| Review Pack output | MSP/customer-safe reader | consume package with correct limitation boundary | output artifact | What does this package prove, and what does it not prove? | customer-safe readiness, disclosure, limitation text | internal appendix where profile allows | artifact state, disclosure, evidence basis, baseline readiness | none | download/review package | none in scope |
## Proportionality Review *(mandatory when structural complexity is introduced)*
- **New source of truth?**: no. Readiness is derived from existing compare, binding, evidence, review, and pack truth.
- **New persisted entity/table/artifact?**: no new primary table or artifact is approved. Existing evidence/review/pack payloads may carry derived detail if needed.
- **New abstraction?**: maybe one bounded baseline evidence readiness mapper or extension to existing helpers.
- **New enum/state/reason family?**: no new persisted family by default. Derived readiness constants may be added only if existing values cannot represent blocker/limitation/internal/customer-ready behavior without ambiguity.
- **New cross-domain UI framework/taxonomy?**: no.
- **Current operator problem**: customer and operator readiness can misstate baseline posture even though upstream compare/decision truth is now structured.
- **Existing structure is insufficient because**: each downstream path can infer readiness differently from operation success, finding counts, section completeness, export availability, or old evidence state.
- **Narrowest correct implementation**: one shared baseline readiness derivation consumed by existing Evidence, Review, and Review Pack helpers, with presentation using existing UI/disclosure patterns.
- **Ownership cost**: mapper/helper ownership, cross-surface tests, customer-safe copy review, and additional regression coverage for false-green/false-red cases.
- **Alternative intentionally rejected**: page-local label patches. They would leave Evidence, Review, Review Pack, and customer output semantics able to disagree.
- **Release truth**: current-release truth after completed Specs 381-384.
### Compatibility posture
This feature assumes a pre-production environment.
Backward compatibility readers for old baseline compare payloads, old OperationRun context shapes, old reason codes, old display-name readiness interpretation, or old local/dev evidence/review/pack data are out of scope.
## Testing / Lane / Runtime Impact *(mandatory for runtime behavior changes)*
- **Test purpose / classification**: Unit for readiness mapping; Feature for evidence/review/review-pack integration; Filament/Livewire Feature for changed surfaces; Browser for customer/output smoke because this spec changes customer-facing readiness presentation.
- **Validation lane(s)**: fast-feedback, confidence, browser; PostgreSQL only if migrations/indexes/constraints are introduced after spec update.
- **Why this classification and these lanes are sufficient**: The main risk is deterministic mapping and existing DB-backed presentation, with limited browser smoke for customer-safe output/readiness rendering.
- **New or expanded test families**: focused baseline readiness tests under existing evidence/review/review-pack families; no new heavy-governance family by default.
- **Fixture / helper cost impact**: reuse existing baseline compare, subject resolution, evidence snapshot, environment review, and review pack fixtures. Keep any new fixture local and explicit.
- **Heavy-family visibility / justification**: none expected.
- **Special surface test profile**: shared-detail-family for readiness/detail output; standard-native-filament relief for existing Filament resources unless layout/action hierarchy changes.
- **Standard-native relief or required special coverage**: use ordinary feature coverage for existing native surfaces; browser smoke is required for changed customer-facing readiness presentation.
- **Reviewer handoff**: verify lane fit, no hidden browser/heavy-governance expansion, no raw provider leakage, no old payload compatibility, and no false-green/false-red regressions.
- **Budget / baseline / trend impact**: none expected; document-in-feature if browser fixture setup becomes materially heavier.
- **Escalation needed**: document-in-feature for contained disclosure/profile decisions; follow-up-spec for limitation expiry, approval workflow, external portal, or lifecycle retention.
- **Active feature PR close-out entry**: Evidence and Review Readiness Integration.
- **Planned validation commands**:
- `cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Unit/Support/Baselines tests/Unit/Evidence tests/Unit/Support/ReviewPacks`
- `cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Evidence/BaselineDriftPostureSourceTest.php tests/Feature/EnvironmentReview tests/Feature/ReviewPack`
- `cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Filament/Spec347CustomerReviewWorkspaceOutputReadinessTest.php tests/Feature/Filament/Spec349CustomerReviewWorkspaceOutputGuidanceTest.php`
- `cd apps/platform && ./vendor/bin/sail php vendor/bin/pest tests/Browser/Spec347ReviewPackOutputReadinessSmokeTest.php` or a new focused Spec 385 browser smoke covering changed customer-facing readiness rendering.
- `cd apps/platform && ./vendor/bin/sail bin pint --dirty --format agent`
- `git diff --check`
## User Scenarios & Testing *(mandatory)*
### User Story 1 - Evidence reflects trusted baseline posture (Priority: P1)
As an MSP operator, I need Evidence Snapshot baseline completeness to distinguish trusted no drift, trusted drift, blockers, limitations, missing evidence, stale data, and failed compare, so review output does not rely on raw finding count alone.
**Why this priority**: Evidence is the source basis for downstream review and pack readiness. If it is wrong, every customer-facing output can be wrong.
**Independent Test**: Generate or evaluate baseline evidence from structured compare semantics and active subject-resolution decisions, then assert the evidence item state, summary payload, and fingerprint payload classify blockers and limitations correctly.
**Acceptance Scenarios**:
1. **Given** a completed trusted baseline compare with resolved identity and no drift, **When** baseline evidence is collected, **Then** the evidence dimension is complete and records verified no-drift counts.
2. **Given** a completed trusted baseline compare with drift findings, **When** baseline evidence is collected, **Then** the evidence dimension is complete with findings and does not become missing or publication-blocked solely because drift exists.
3. **Given** unresolved identity in required governed scope, **When** baseline evidence is collected, **Then** the evidence dimension is partial/action-required or blocked according to existing state shape and records a subject-resolution next action.
4. **Given** missing local evidence, **When** baseline evidence is collected, **Then** the evidence dimension records missing evidence and does not describe it as provider drift.
5. **Given** accepted limitations or excluded non-governed subjects, **When** baseline evidence is collected, **Then** they are not counted as verified no drift or compliant pass.
---
### User Story 2 - Review readiness blocks and limitations are precise (Priority: P1)
As an operator preparing a review, I need Environment Review readiness to turn baseline evidence blockers into actionable publication guidance, so I know whether to resolve identity, refresh evidence, accept/disclose a limitation, or proceed with findings.
**Why this priority**: Review publish/export decisions are where false-ready claims become customer risk.
**Independent Test**: Compose or evaluate an Environment Review with baseline evidence detail payloads for blocker, limitation, trusted finding, and clean cases, then assert review status, blockers, section completeness, and guidance.
**Acceptance Scenarios**:
1. **Given** unresolved required baseline identity remains, **When** review readiness is evaluated, **Then** publication is blocked with guidance to open Baseline Subject Resolution.
2. **Given** accepted limitations are allowed by profile/disclosure policy, **When** review readiness is evaluated, **Then** the review can be published with limitations instead of falsely reporting verified no drift.
3. **Given** trusted drift findings exist with complete evidence, **When** review readiness is evaluated, **Then** drift is reported as a finding and does not block publication by itself.
4. **Given** required evidence is missing or compare failed, **When** review readiness is evaluated, **Then** the review is blocked or internal-only with a specific refresh/rerun action.
---
### User Story 3 - Review Pack output is customer-safe and limitation-aware (Priority: P1)
As an operator or customer-safe reviewer, I need Review Pack readiness and rendered output to explain what is customer-ready, limited, internal-only, or blocked without exposing raw provider internals, so the package can be shared safely.
**Why this priority**: Review Pack output is the customer handoff boundary and must not overstate assurance or leak internal proof.
**Independent Test**: Build Review Pack readiness from reviews containing baseline blockers/limitations and assert readiness state, limitation summaries, disclosure policy output, and hidden raw technical detail.
**Acceptance Scenarios**:
1. **Given** no blockers and complete required evidence, **When** Review Pack readiness is derived, **Then** it is customer-safe ready.
2. **Given** accepted/customer-disclosable limitations exist and no required blocker remains, **When** Review Pack readiness is derived, **Then** it is published with limitations and includes customer-safe limitation wording.
3. **Given** unresolved required identity or missing required evidence remains, **When** Review Pack readiness is derived, **Then** publication is blocked or export not ready with one dominant next action.
4. **Given** internal-only technical details exist, **When** customer-safe output is rendered, **Then** raw provider IDs, canonical subject keys, binding internals, internal enum names, and OperationRun JSON are absent.
---
### User Story 4 - Internal proof remains useful without customer leakage (Priority: P2)
As a support/platform operator, I need internal technical detail to retain source run, evidence, compare, and decision counts, so I can diagnose readiness without exposing those details to customer-safe output.
**Why this priority**: Support diagnosis remains necessary, but it must not pollute customer-ready surfaces.
**Independent Test**: Evaluate internal profile/readiness output and customer-safe profile output from the same evidence state, then assert internal profile includes safe technical counts while customer-safe profile omits raw identifiers.
**Acceptance Scenarios**:
1. **Given** the internal profile is selected, **When** readiness details are rendered, **Then** source operation IDs, snapshot IDs, reason counts, and diagnostics are available according to existing profile rules.
2. **Given** the customer-safe profile is selected, **When** readiness details are rendered, **Then** only customer-safe limitation summaries and required next actions are visible.
## Functional Requirements *(mandatory)*
- **FR-385-001**: Baseline evidence readiness MUST consume Spec 383 structured compare semantics instead of relying only on drift finding count or OperationRun success.
- **FR-385-002**: Baseline evidence readiness MUST honor active operator decisions from `provider_resource_bindings`, including bindings, accepted limitations, exclusions, unsupported coverage, and revocations.
- **FR-385-003**: Unresolved identity in required governed scope MUST block customer-ready claims.
- **FR-385-004**: Missing local evidence MUST be classified separately from missing provider resource and drift.
- **FR-385-005**: Accepted limitations MUST remain limitations and MUST NOT be treated as verified no drift.
- **FR-385-006**: Excluded non-governed subjects MUST be excluded from governed claims and MUST NOT be counted as compliant/no-drift.
- **FR-385-007**: Unsupported or inventory-only coverage MUST be represented as limitation or blocker according to report profile/disclosure policy and required-scope rules.
- **FR-385-008**: Zero drift findings MUST NOT be considered complete unless compare completed with trusted identity and required comparable subjects or accepted/excluded outcomes.
- **FR-385-009**: Trusted drift findings MUST be allowed in customer-ready output with findings when evidence and identity are trusted and profile rules allow disclosure.
- **FR-385-010**: Review readiness MUST include precise blocker and limitation guidance derived from baseline evidence details.
- **FR-385-011**: Review Pack readiness MUST distinguish customer-ready, published-with-limitations, internal-only, publication-blocked/export-not-ready, stale, and failed output behavior using existing readiness states plus explicit primary reason/limitation codes by default; narrowly extended readiness helpers/constants are allowed only if the existing output contract cannot preserve distinct operator/customer consequences and this spec/plan/tasks are updated first.
- **FR-385-012**: Customer-safe output MUST use safe limitation wording and MUST NOT expose raw provider IDs, canonical subject keys, binding internals, internal enum names, database IDs, or raw OperationRun JSON.
- **FR-385-013**: Internal technical output MAY include source operation/snapshot IDs, reason counts, and technical diagnostics where existing profile/disclosure policy allows.
- **FR-385-014**: The implementation MUST NOT add legacy compare/evidence/review compatibility mappers or old OperationRun context readers.
- **FR-385-015**: The implementation MUST NOT introduce a generic workflow engine, approval engine, broad dashboard, or new resolution UI.
- **FR-385-016**: Any new readiness constants or helper abstractions MUST be justified in this spec/plan before implementation and must replace ambiguity rather than create duplicate truth.
## Non-Functional Requirements
- **NFR-385-001 - Customer Safety**: Customer output must never imply verified posture where identity, trust, coverage, evidence, or compare result is unresolved.
- **NFR-385-002 - Determinism**: The same compare, binding, evidence, review, and profile inputs must produce the same readiness state and guidance.
- **NFR-385-003 - Provider Agnosticism**: Readiness language and primary output must remain provider-neutral.
- **NFR-385-004 - Auditability**: Readiness details must be traceable to source compare run, evidence snapshot, and operator decision where available.
- **NFR-385-005 - No False Green**: Accepted limitation, exclusion, unsupported coverage, inventory-only coverage, or missing evidence must not become verified no drift.
- **NFR-385-006 - No False Red**: Resolved provider built-ins/defaults/virtual resources and allowed limitations must not create false publication blockers.
- **NFR-385-007 - Minimal UI Change**: UI changes should be limited to existing readiness labels, badges, summaries, guidance, and proof disclosure needed to reflect new semantics.
- **NFR-385-008 - No Legacy Compatibility**: Historical/local/dev payload compatibility is not a product requirement.
## Key Entities / Truth Sources
- **Baseline compare semantic payload**: Existing OperationRun/compare proof from Spec 383.
- **Provider resource binding decision**: Existing `provider_resource_bindings` durable decision truth from Specs 381/384.
- **Baseline readiness detail**: Derived payload that summarizes verified, drift, blocker, limitation, missing, stale, failed, excluded, and source proof counts.
- **Evidence Snapshot item**: Existing evidence artifact item that carries baseline completeness and summary/fingerprint payloads.
- **Environment Review readiness**: Existing review readiness and blocker calculation over composed sections and evidence.
- **Review Pack output readiness**: Existing derived output readiness/guidance/disclosure state over review, evidence, and pack truth.
## Assumptions
- Specs 381-384 stay completed and are not modified by this implementation.
- Existing `provider_resource_bindings` fields and resolution modes are sufficient for V1 readiness decisions.
- Existing Review Pack profile/disclosure helpers are the preferred place to decide customer-safe versus internal-only limitation handling.
- Accepted limitations require profile/disclosure allowance before customer-facing publication.
- Unsupported required scope defaults to a blocker unless the limitation is accepted or the profile explicitly allows disclosure.
- Drift findings do not block publication by themselves when evidence/identity are trusted.
- Missing provider resource can be a trusted governance finding when identity is trusted; missing local evidence is an evidence limitation/blocker.
- Limitation expiry/renewal is out of scope and belongs to a later governance lifecycle spec if needed.
## Out of Scope
- New baseline matching logic.
- New compare result semantics model beyond consumption of Spec 383.
- New provider identity foundation.
- New Baseline Subject Resolution UI or new decision actions.
- New generic workflow, task, or approval engine.
- Broad Governance Inbox changes.
- Customer portal redesign.
- Management Report/PDF runtime validation or renderer work.
- Legacy compatibility mapping for old compare/evidence/review payloads.
- Raw provider diagnostics in customer-safe output.
- New persistent readiness table or durable readiness entity.
## Risks
- **False green**: mitigated with mapping tests proving limitations, exclusions, unsupported coverage, and missing evidence do not become verified no drift.
- **False red**: mitigated with tests proving trusted drift, resolved built-ins/defaults, and allowed limitations do not block publication unnecessarily.
- **Customer leakage**: mitigated with customer-safe output tests that reject provider IDs, canonical keys, binding internals, internal enum names, and OperationRun JSON.
- **Scope creep into report/PDF/runtime**: mitigated by keeping Management Report/PDF out of scope and using existing Review Pack readiness/disclosure paths only.
- **Semantics duplication**: mitigated by reusing or extending existing shared readiness helpers rather than adding page-local mappings.
- **Fixture cost growth**: mitigated by local explicit fixtures and no heavy-governance family by default.
## Success Criteria *(mandatory)*
- Evidence Snapshot baseline readiness differentiates complete, complete-with-findings, blocked/action-required, limitation-only, missing, stale, and failed cases using existing or narrowly extended state shape.
- Environment Review blockers and guidance are specific and derived from the same baseline readiness detail.
- Review Pack readiness and customer-safe output correctly represent customer-ready, published-with-limitations, internal-only, and blocked/export-not-ready states.
- Customer-safe output contains no raw provider IDs, canonical subject keys, binding internals, internal enum names, database IDs, or raw OperationRun JSON.
- Tests cover false-green and false-red cases across Evidence, Review, and Review Pack surfaces.
- UI/productization coverage updates or no-new-route rationale are recorded for affected existing surfaces.
## Implementation Close-Out
- **Implementation status**: Implemented for the bounded Spec 385 evidence/review/output readiness slice.
- **Validation recorded**: Focused Spec 385 unit, feature, Filament/Livewire, and browser coverage passed with 36 tests and 160 assertions. `php vendor/bin/pint --dirty` and `git diff --check` were also run successfully.
- **Baseline readiness review**: Manual review confirmed active `provider_resource_bindings`, accepted limitations, exclusions, unsupported coverage, and revoked decisions after the latest compare are included in derived baseline readiness.
- **Customer-safe review**: Manual review confirmed customer-safe Review Pack JSON/Markdown and customer-safe UI paths avoid ProviderResourceBinding diagnostics, raw Compare/OperationRun diagnostics, raw baseline state codes, internal database IDs, raw provider IDs, and canonical subject keys.
- **Publication semantics**: Publish blockers are created only for baseline readiness blockers. Accepted limitations remain visible as published-with-limitations/customer-ready-with-disclosed-limitations instead of becoming verified no-drift or hard blockers.
- **Review Pack storage safety**: Customer-safe and internal Review Packs for the same review include distinct pack IDs in their ZIP paths, so same-second generation does not overwrite either artifact.
- **Filament/Livewire status**: Livewire v4.0+ compliance is preserved; the project uses Livewire 4.1.4. No new Filament Resource, global-search surface, panel provider, destructive/high-impact action, modal, route, or asset was added.
- **Deployment impact**: No new environment variables, migrations, persisted readiness states/entities/enums, provider/Graph calls, scheduler entries, queue names, storage volumes, reverse proxy changes, or asset build steps were introduced. Normal code deploy plus queue worker restart remains sufficient.
- **Residual risk**: Additional regression coverage would be useful if product semantics later require "latest compare" to mean latest completed compare when a newer in-flight compare exists. Future non-baseline customer-safe payloads that include string-based provider identifiers should receive explicit redaction tests before release.
## Open Questions
No open question blocks implementation preparation.
Implementation must verify exact existing profile/disclosure hooks and update this spec/plan before adding a new public state family, persisted readiness entity, new route, or new workflow abstraction.
## Follow-Up Spec Candidates
- Limitation expiry/renewal and governance lifecycle handling.
- Customer portal/external consumption boundary, if product direction requires it.
- Broader cross-domain indicator runtime follow-through.
- Governance artifact lifecycle retention runtime.
- Report/PDF staging runtime validation follow-through under Specs 378-379.

View File

@ -0,0 +1,161 @@
# Tasks: Spec 385 - Evidence and Review Readiness Integration v1
**Input**: Design documents from `/specs/385-evidence-review-readiness/`
**Prerequisites**: `spec.md`, `plan.md`
**Tests**: Required. This is a runtime and customer/operator-facing readiness change.
## 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 any browser addition is explicit.
- [x] Shared helpers, factories, seeds, fixtures, and context defaults stay cheap by default; any widening is isolated or documented.
- [x] Planned validation commands cover the change without pulling in unrelated lane cost.
- [x] The declared surface test profile or `standard-native-filament` relief is explicit.
- [x] Any material budget, baseline, trend, or escalation note is recorded in the active spec or PR.
## Phase 1: Preparation and Guardrails
**Purpose**: Confirm dependency truth and prevent scope bleed before implementation.
- [x] T001 Verify completed-spec guardrail for `specs/381-provider-resource-identity-binding/`, `specs/382-baseline-matching-canonicalization/`, `specs/383-baseline-result-semantics/`, and `specs/384-baseline-subject-resolution-ui/`; do not modify those packages.
- [x] T002 Inspect current Spec 383 semantic payload keys in `apps/platform/app/Support/Baselines/CompareSemantics/` and current compare OperationRun context shape before adding any mapper.
- [x] T003 Inspect current Spec 384 binding decision modes in `apps/platform/app/Models/ProviderResourceBinding.php` and `apps/platform/app/Support/Resources/ProviderResourceResolutionMode.php` before mapping decisions into readiness.
- [x] T004 Confirm no new migration, table, queue, scheduler, route, panel provider, capability family, provider call, workflow engine, or report/PDF runtime scope is needed; update spec/plan before implementation if this is false.
- [x] T005 Record the implementation close-out target as `Evidence and Review Readiness Integration` for guardrail, exception, smoke, and deployment notes.
---
## Phase 2: Baseline Readiness Mapping Tests (Blocking)
**Purpose**: Define false-green and false-red behavior before runtime changes.
- [x] T006 Add unit tests for the baseline readiness derivation under `apps/platform/tests/Unit/Support/Baselines/` or the nearest existing baseline semantics test family.
- [x] T007 Cover trusted no-drift mapping to complete/verified readiness with source compare proof.
- [x] T008 Cover trusted drift mapping to complete-with-findings and prove drift does not block publication by itself.
- [x] T009 Cover unresolved required identity mapping to action-required/publication blocker with a Baseline Subject Resolution next-action target.
- [x] T010 Cover missing local evidence mapping as evidence missing/refresh guidance, not provider drift.
- [x] T011 Cover missing provider resource with trusted identity as a governance issue/finding, not local-evidence missing.
- [x] T012 Cover unsupported required coverage as blocker unless accepted or allowed by profile.
- [x] T013 Cover inventory-only/foundation-only coverage as limitation, never verified no drift.
- [x] T014 Cover accepted limitation as limitation, not verified no drift.
- [x] T015 Cover excluded non-governed subject as excluded from governed claims, not compliant/pass.
- [x] T016 Cover compare failed, stale source, and zero findings without trusted compare as not customer-ready, including the expected downstream reason/limitation code when existing Review Pack states are reused.
**Checkpoint**: Tests in this phase should fail before runtime mapping is implemented.
---
## Phase 3: Baseline Readiness Derivation
**Purpose**: Build or extend the narrow shared interpretation layer.
- [x] T017 Implement or extend a bounded baseline readiness derivation using existing Spec 383 compare semantics and active `provider_resource_bindings`; prefer existing helpers before creating `apps/platform/app/Support/Baselines/Readiness/`.
- [x] T018 Ensure the derivation returns safe counts for verified subjects, drift subjects, blocked subjects, limitation subjects, missing-evidence subjects, unsupported subjects, accepted limitations, excluded subjects, stale subjects, and failed subjects.
- [x] T019 Ensure the derivation records safe source references such as source OperationRun ID, baseline compare ID if available, and evidence snapshot ID where applicable without raw provider payloads.
- [x] T020 Ensure customer-safe fields never include raw provider IDs, canonical subject keys, binding internals, database IDs, internal enum names, or raw OperationRun JSON.
- [x] T021 Ensure any new derived state/constants replace ambiguity rather than adding duplicate truth; update spec/plan first if a new public state family becomes necessary.
---
## Phase 4: Evidence Snapshot Integration (US1)
**Purpose**: Make Evidence Snapshot baseline completeness consume the readiness derivation.
- [x] T022 [US1] Update `apps/platform/app/Services/Evidence/Sources/BaselineDriftPostureSource.php` to use the baseline readiness derivation instead of relying only on drift finding count or OperationRun outcome.
- [x] T023 [US1] Update baseline evidence summary/fingerprint payloads with safe readiness counts, source IDs, and measured/freshness timestamps.
- [x] T024 [US1] Update `apps/platform/app/Services/Evidence/EvidenceCompletenessEvaluator.php` only if existing completeness aggregation cannot preserve blocker/limitation detail through existing item payloads.
- [x] T025 [US1] Add or update feature tests in `apps/platform/tests/Feature/Evidence/BaselineDriftPostureSourceTest.php` for trusted clean, trusted drift, unresolved identity, missing evidence, accepted limitation, exclusion, stale, and failed cases.
- [x] T026 [US1] Add regression coverage proving old display-name or old reason-code readiness interpretation is not authoritative.
---
## Phase 5: Environment Review Integration (US2)
**Purpose**: Turn baseline evidence detail into precise review readiness and blockers.
- [x] T027 [US2] Update `apps/platform/app/Services/EnvironmentReviews/EnvironmentReviewReadinessGate.php` so required baseline blockers and stale/missing evidence create specific review blockers.
- [x] T028 [US2] Update `apps/platform/app/Services/EnvironmentReviews/EnvironmentReviewComposer.php` or section factory code so baseline limitation and blocker details appear in review summaries/sections without duplicating truth.
- [x] T029 [US2] Add guidance mapping for unresolved identity, missing evidence, compare failed, unsupported required scope, accepted limitation, and trusted drift findings.
- [x] T030 [US2] Ensure guidance links to existing destinations only: Baseline Subject Resolution, evidence basis, source operation, or existing compare rerun path.
- [x] T031 [US2] Add or update tests under `apps/platform/tests/Feature/EnvironmentReview/` covering publication blocked, published with limitations, trusted findings allowed, missing evidence refresh guidance, and internal-only behavior.
- [x] T032 [US2] Ensure review readiness does not create workflow/task/approval records or new durable blocker entities.
---
## Phase 6: Review Pack Readiness and Disclosure Integration (US3)
**Purpose**: Align Review Pack output readiness, guidance, and customer-safe disclosure.
- [x] T033 [US3] Update `apps/platform/app/Support/ReviewPacks/ReviewPackOutputReadiness.php` to consume baseline readiness details through existing review/evidence inputs, representing stale and failed baseline readiness through existing states plus explicit reason/limitation codes unless spec/plan are updated first.
- [x] T034 [US3] Update `apps/platform/app/Support/ReviewPacks/ReviewPackOutputResolutionGuidance.php` so primary reason, limitation summary, qualified download label, and primary/secondary actions reflect baseline blockers, limitations, stale sources, and failed/unproven compare states.
- [x] T035 [US3] Update `apps/platform/app/Support/ReviewPacks/ReportDisclosurePolicy.php` so customer-facing profiles disclose allowed baseline limitations and block or warn when customer-safe claims are unsupported.
- [x] T036 [US3] Update rendered Review Pack or Customer Review Workspace consumers only where necessary to display customer-ready, published-with-limitations, internal-only, and blocked/export-not-ready states consistently.
- [x] T037 [US3] Add or update tests under `apps/platform/tests/Feature/ReviewPack/` for customer-safe ready, published-with-limitations, internal-only, blocked/export-not-ready, stale-source, failed/unproven-compare, and safe limitation wording.
- [x] T038 [US3] Add or update customer-safe output tests proving raw provider IDs, canonical subject keys, binding internals, internal enum names, database IDs, and raw OperationRun JSON are absent.
- [x] T039 [US3] Add or update report profile/disclosure unit tests under `apps/platform/tests/Unit/Support/ReviewPacks/` for allowed/disallowed limitation disclosure.
---
## Phase 7: Internal Proof and Diagnostics (US4)
**Purpose**: Preserve useful internal proof while keeping customer-safe output clean.
- [x] T040 [US4] Ensure internal/support profile output can include safe source IDs, reason counts, and technical diagnostics where existing profile rules allow.
- [x] T041 [US4] Ensure customer-safe profile output omits internal proof fields by default.
- [x] T042 [US4] Add tests that compare internal and customer-safe profile output from the same baseline readiness state.
- [x] T043 [US4] Ensure any source OperationRun links use existing `OperationRunLinks` or established route helpers and do not compose local OperationRun start UX.
---
## Phase 8: UI/Productization Coverage
**Purpose**: Satisfy UI-COV-001 for changed reachable surfaces.
- [x] T044 Review affected rendered surfaces and decide whether each page report needs an update or a checked no-new-route/no-archetype note.
- [x] T045 Update `docs/ui-ux-enterprise-audit/page-reports/ui-006-customer-review-workspace.md` if Customer Review Workspace readiness presentation changes.
- [x] T046 Update `docs/ui-ux-enterprise-audit/page-reports/ui-011-reviews.md` if Environment Review readiness presentation changes.
- [x] T047 Update `docs/ui-ux-enterprise-audit/page-reports/ui-042-review-pack-detail.md` if Review Pack detail/output readiness presentation changes.
- [x] T048 Update `docs/ui-ux-enterprise-audit/page-reports/ui-046-evidence-snapshot-detail.md` if Evidence Snapshot baseline readiness presentation changes.
- [x] T049 Add screenshot/browser-smoke artifact paths to implementation close-out for changed customer-facing readiness rendering, or update spec/plan/tasks first if implementation proves no rendered presentation changed.
- [x] T050 Confirm `route-inventory.md` and `design-coverage-matrix.md` do not need changes unless implementation changes route inventory, navigation, or surface classification.
- [x] T051 Verify the spec's `UI Action Matrix` still matches the implementation; update it before merge if any visible action, action placement, bulk action, dangerous-action behavior, or inspect/open model changes.
---
## Phase 9: Filament, RBAC, and Security Regression Checks
**Purpose**: Keep existing surfaces tenant-safe and Filament v5 compliant.
- [x] T052 Confirm Livewire v4.0+ compliance and no Livewire v3 APIs were introduced.
- [x] T053 Confirm panel provider registration remains unchanged in `apps/platform/bootstrap/providers.php` and no new panel provider was added.
- [x] T054 Confirm no new globally searchable resource was added; if any existing Resource global-search code changes, verify View/Edit page and tenant-safe search rules.
- [x] T055 Confirm no new destructive/high-impact action was added; if an existing action is touched, verify `->action(...)`, confirmation where applicable, server-side authorization, audit, notification, and tests.
- [x] T056 Confirm existing workspace/environment entitlement checks prevent cross-tenant leakage for evidence, review, pack, stored report, operation, and subject-resolution links.
- [x] T057 Confirm no Graph/provider calls occur during UI render or readiness derivation.
- [x] T058 Confirm no secrets, raw credential payloads, raw provider payloads, or raw Graph errors are logged or rendered.
---
## Phase 10: Validation
**Purpose**: Prove the implementation and record residual risk.
- [x] T059 Run `cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Unit/Support/Baselines tests/Unit/Evidence tests/Unit/Support/ReviewPacks`.
- [x] T060 Run `cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Evidence/BaselineDriftPostureSourceTest.php tests/Feature/EnvironmentReview tests/Feature/ReviewPack`.
- [x] T061 Run `cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Filament/Spec347CustomerReviewWorkspaceOutputReadinessTest.php tests/Feature/Filament/Spec349CustomerReviewWorkspaceOutputGuidanceTest.php` and any new Spec 385 Filament tests.
- [x] T062 Run browser smoke with `cd apps/platform && ./vendor/bin/sail php vendor/bin/pest tests/Browser/Spec347ReviewPackOutputReadinessSmokeTest.php` or a new focused Spec 385 smoke covering changed rendered customer readiness.
- [x] T063 Run `cd apps/platform && ./vendor/bin/sail bin pint --dirty --format agent`.
- [x] T064 Run `git diff --check`.
- [x] T065 Document implementation close-out with Livewire v4 compliance, provider registration location, global search status, destructive/high-impact action handling, asset strategy, tests run, browser smoke result, and deployment impact.
---
## Explicit Non-Goals
- [x] NT001 Do not modify completed Specs 381-384 except as read-only context.
- [x] NT002 Do not add matching pipeline logic or compare result semantics beyond consuming Spec 383.
- [x] NT003 Do not add or redesign Baseline Subject Resolution UI.
- [x] NT004 Do not add a generic workflow engine, approval workflow, task table, or broad Governance Inbox.
- [x] NT005 Do not add Management Report/PDF runtime validation or renderer changes.
- [x] NT006 Do not add legacy compare/evidence/review payload compatibility readers.
- [x] NT007 Do not create a new persisted readiness table/entity without updating spec/plan/tasks first.