TenantAtlas/apps/platform/app/Support/Baselines/Readiness/BaselineEvidenceReadinessDeriver.php
ahmido 3a9402998a feat(evidence): implement baseline review readiness integration (#456)
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
2026-06-17 22:54:11 +00:00

429 lines
19 KiB
PHP

<?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',
};
}
}