Some checks failed
PR Fast Feedback / fast-feedback (pull_request) Failing after 4m44s
Added BaselineReadinessGate, resolution propagation, and disclosure semantics logic per Spec 385. Integrated baseline unreadiness into Customer Review Workspace and Review Packs.
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',
|
|
};
|
|
}
|
|
}
|