Added `BaselineReadinessGate`, resolution propagation, and disclosure semantics logic per Spec 385. Integrates baseline unreadiness into Customer Review Workspace and Review Packs to prevent report generation when identity bindings are unresolved. Co-authored-by: Ahmed Darrazi <ahmed.darrazi@live.de> Reviewed-on: #456
281 lines
12 KiB
PHP
281 lines
12 KiB
PHP
<?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;
|
|
}
|