'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 $baselineReadiness * @return array */ 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 */ 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; }