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
429 lines
19 KiB
PHP
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',
|
|
};
|
|
}
|
|
}
|