Some checks failed
PR Fast Feedback / fast-feedback (pull_request) Failing after 4m7s
Implemented deterministic Baseline Result Semantics (Spec 383), introducing CompareSubjectResult and CompareEvidenceResult. Replaced generic arrays with strict Data Transfer Objects for Baseline engine output.
984 lines
42 KiB
PHP
984 lines
42 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace App\Support\OpsUx;
|
|
|
|
use App\Models\OperationRun;
|
|
use App\Support\Badges\BadgeCatalog;
|
|
use App\Support\Badges\BadgeDomain;
|
|
use App\Support\Baselines\BaselineCompareReasonCode;
|
|
use App\Support\Baselines\BaselineReasonCodes;
|
|
use App\Support\OperationCatalog;
|
|
use App\Support\ReasonTranslation\ReasonPresenter;
|
|
use App\Support\ReasonTranslation\ReasonResolutionEnvelope;
|
|
use App\Support\Ui\GovernanceArtifactTruth\ArtifactTruthEnvelope;
|
|
use App\Support\Ui\GovernanceArtifactTruth\ArtifactTruthPresenter;
|
|
use App\Support\Ui\OperatorExplanation\CountDescriptor;
|
|
use App\Support\Ui\OperatorExplanation\OperatorExplanationPattern;
|
|
|
|
final class GovernanceRunDiagnosticSummaryBuilder
|
|
{
|
|
public function __construct(
|
|
private readonly ArtifactTruthPresenter $artifactTruthPresenter,
|
|
private readonly ReasonPresenter $reasonPresenter,
|
|
) {}
|
|
|
|
public function build(
|
|
OperationRun $run,
|
|
?ArtifactTruthEnvelope $artifactTruth = null,
|
|
?OperatorExplanationPattern $operatorExplanation = null,
|
|
?ReasonResolutionEnvelope $reasonEnvelope = null,
|
|
): ?GovernanceRunDiagnosticSummary {
|
|
if (! $run->supportsOperatorExplanation()) {
|
|
return null;
|
|
}
|
|
|
|
$artifactTruth ??= $this->artifactTruthPresenter->forOperationRun($run);
|
|
$operatorExplanation ??= $artifactTruth?->operatorExplanation;
|
|
$reasonEnvelope ??= $this->reasonPresenter->forOperationRun($run, 'run_detail');
|
|
|
|
if (! $artifactTruth instanceof ArtifactTruthEnvelope && ! $operatorExplanation instanceof OperatorExplanationPattern) {
|
|
return null;
|
|
}
|
|
|
|
$canonicalType = OperationCatalog::canonicalCode((string) $run->type);
|
|
$context = is_array($run->context) ? $run->context : [];
|
|
$counts = SummaryCountsNormalizer::normalize(is_array($run->summary_counts) ? $run->summary_counts : []);
|
|
$causeCandidates = $this->rankCauseCandidates($canonicalType, $run, $artifactTruth, $operatorExplanation, $reasonEnvelope, $context);
|
|
$dominantCause = $causeCandidates[0] ?? $this->fallbackCause($artifactTruth, $operatorExplanation, $reasonEnvelope);
|
|
$secondaryCauses = array_values(array_slice($causeCandidates, 1));
|
|
$artifactImpactLabel = $this->artifactImpactLabel($artifactTruth, $operatorExplanation);
|
|
$headline = $this->headline($canonicalType, $run, $artifactTruth, $operatorExplanation, $dominantCause, $context, $counts);
|
|
$primaryReason = $this->primaryReason($dominantCause, $artifactTruth, $operatorExplanation, $reasonEnvelope);
|
|
$nextActionCategory = $this->nextActionCategory($canonicalType, $run, $reasonEnvelope, $operatorExplanation, $context);
|
|
$nextActionText = $this->nextActionText($artifactTruth, $operatorExplanation, $reasonEnvelope);
|
|
$affectedScaleCue = $this->affectedScaleCue($canonicalType, $run, $artifactTruth, $operatorExplanation, $context, $counts);
|
|
$secondaryFacts = $this->secondaryFacts($artifactTruth, $operatorExplanation, $secondaryCauses, $nextActionCategory, $nextActionText);
|
|
|
|
return new GovernanceRunDiagnosticSummary(
|
|
headline: $headline,
|
|
executionOutcomeLabel: $this->executionOutcomeLabel($run),
|
|
artifactImpactLabel: $artifactImpactLabel,
|
|
primaryReason: $primaryReason,
|
|
affectedScaleCue: $affectedScaleCue,
|
|
nextActionCategory: $nextActionCategory,
|
|
nextActionText: $nextActionText,
|
|
dominantCause: [
|
|
'code' => $dominantCause['code'] ?? null,
|
|
'label' => $dominantCause['label'],
|
|
'explanation' => $dominantCause['explanation'],
|
|
],
|
|
secondaryCauses: array_map(
|
|
static fn (array $cause): array => [
|
|
'code' => $cause['code'] ?? null,
|
|
'label' => $cause['label'],
|
|
'explanation' => $cause['explanation'],
|
|
],
|
|
$secondaryCauses,
|
|
),
|
|
secondaryFacts: $secondaryFacts,
|
|
diagnosticsAvailable: (bool) ($operatorExplanation?->diagnosticsAvailable ?? false),
|
|
);
|
|
}
|
|
|
|
private function executionOutcomeLabel(OperationRun $run): string
|
|
{
|
|
$spec = BadgeCatalog::spec(BadgeDomain::OperationRunOutcome, (string) $run->outcome);
|
|
|
|
return $spec->label !== 'Unknown'
|
|
? $spec->label
|
|
: ucfirst(str_replace('_', ' ', trim((string) $run->outcome)));
|
|
}
|
|
|
|
private function artifactImpactLabel(
|
|
?ArtifactTruthEnvelope $artifactTruth,
|
|
?OperatorExplanationPattern $operatorExplanation,
|
|
): string {
|
|
if ($artifactTruth instanceof ArtifactTruthEnvelope && trim($artifactTruth->primaryLabel) !== '') {
|
|
return $artifactTruth->primaryLabel;
|
|
}
|
|
|
|
if ($operatorExplanation instanceof OperatorExplanationPattern) {
|
|
return $operatorExplanation->trustworthinessLabel();
|
|
}
|
|
|
|
return 'Result needs review';
|
|
}
|
|
|
|
/**
|
|
* @param array{code: ?string, label: string, explanation: string} $dominantCause
|
|
* @param array<string, mixed> $context
|
|
* @param array<string, int> $counts
|
|
*/
|
|
private function headline(
|
|
string $canonicalType,
|
|
OperationRun $run,
|
|
?ArtifactTruthEnvelope $artifactTruth,
|
|
?OperatorExplanationPattern $operatorExplanation,
|
|
array $dominantCause,
|
|
array $context,
|
|
array $counts,
|
|
): string {
|
|
return match ($canonicalType) {
|
|
'baseline.capture' => $this->baselineCaptureHeadline($artifactTruth, $context, $counts, $operatorExplanation),
|
|
'baseline.compare' => $this->baselineCompareHeadline($artifactTruth, $context, $counts, $operatorExplanation),
|
|
'tenant.evidence.snapshot.generate' => $this->evidenceSnapshotHeadline($artifactTruth, $operatorExplanation),
|
|
'environment.review.compose' => $this->reviewComposeHeadline($artifactTruth, $dominantCause, $operatorExplanation),
|
|
'environment.review_pack.generate' => $this->reviewPackHeadline($artifactTruth, $dominantCause, $operatorExplanation),
|
|
default => $operatorExplanation?->headline
|
|
?? $artifactTruth?->primaryExplanation
|
|
?? 'This governance run needs review before it can be relied on.',
|
|
};
|
|
}
|
|
|
|
/**
|
|
* @param array<string, mixed> $context
|
|
* @param array<string, int> $counts
|
|
*/
|
|
private function baselineCaptureHeadline(
|
|
?ArtifactTruthEnvelope $artifactTruth,
|
|
array $context,
|
|
array $counts,
|
|
?OperatorExplanationPattern $operatorExplanation,
|
|
): string {
|
|
$reasonCode = (string) data_get($context, 'baseline_capture.reason_code', data_get($context, 'reason_code', ''));
|
|
$subjectsTotal = $this->intValue(data_get($context, 'baseline_capture.subjects_total'));
|
|
$resumeToken = data_get($context, 'baseline_capture.resume_token');
|
|
$gapCount = $this->intValue(data_get($context, 'baseline_capture.gaps.count'));
|
|
$changedAfterEnqueue = data_get($context, 'baseline_capture.eligibility.changed_after_enqueue') === true;
|
|
|
|
if ($reasonCode === BaselineReasonCodes::CAPTURE_INVENTORY_MISSING) {
|
|
return 'The baseline capture could not continue because no current inventory basis was available.';
|
|
}
|
|
|
|
if ($reasonCode === BaselineReasonCodes::CAPTURE_INVENTORY_BLOCKED) {
|
|
return $changedAfterEnqueue
|
|
? 'The baseline capture stopped because the latest inventory sync changed after the run was queued.'
|
|
: 'The baseline capture was blocked because the latest inventory sync was blocked.';
|
|
}
|
|
|
|
if ($reasonCode === BaselineReasonCodes::CAPTURE_INVENTORY_FAILED) {
|
|
return $changedAfterEnqueue
|
|
? 'The baseline capture stopped because the latest inventory sync failed after the run was queued.'
|
|
: 'The baseline capture was blocked because the latest inventory sync failed.';
|
|
}
|
|
|
|
if ($reasonCode === BaselineReasonCodes::CAPTURE_UNUSABLE_COVERAGE) {
|
|
return $changedAfterEnqueue
|
|
? 'The baseline capture stopped because the latest inventory coverage became unusable after the run was queued.'
|
|
: 'The baseline capture could not produce a usable baseline because the latest inventory coverage was not credible.';
|
|
}
|
|
|
|
if ($reasonCode === BaselineReasonCodes::CAPTURE_ZERO_SUBJECTS) {
|
|
return 'The baseline capture finished without a usable baseline because no governed subjects were in scope.';
|
|
}
|
|
|
|
if ($subjectsTotal === 0) {
|
|
return 'No baseline was captured because no governed subjects were ready.';
|
|
}
|
|
|
|
if (is_string($resumeToken) && trim($resumeToken) !== '') {
|
|
return 'The baseline capture started, but more evidence still needs to be collected.';
|
|
}
|
|
|
|
if ($gapCount > 0) {
|
|
return 'The baseline capture finished, but evidence gaps still limit the snapshot.';
|
|
}
|
|
|
|
if (($artifactTruth?->artifactExistence ?? null) === 'created_but_not_usable') {
|
|
return 'The baseline capture finished without a usable snapshot.';
|
|
}
|
|
|
|
if (($counts['total'] ?? 0) === 0 && ($counts['processed'] ?? 0) === 0) {
|
|
return 'The baseline capture finished without producing a decision-grade snapshot.';
|
|
}
|
|
|
|
return $operatorExplanation?->headline
|
|
?? $artifactTruth?->primaryExplanation
|
|
?? 'The baseline capture needs review before it can be used.';
|
|
}
|
|
|
|
/**
|
|
* @param array<string, mixed> $context
|
|
* @param array<string, int> $counts
|
|
*/
|
|
private function baselineCompareHeadline(
|
|
?ArtifactTruthEnvelope $artifactTruth,
|
|
array $context,
|
|
array $counts,
|
|
?OperatorExplanationPattern $operatorExplanation,
|
|
): string {
|
|
$reasonCode = (string) data_get($context, 'baseline_compare.reason_code', '');
|
|
$proof = data_get($context, 'baseline_compare.coverage.proof');
|
|
$resumeToken = data_get($context, 'baseline_compare.resume_token');
|
|
|
|
if ($reasonCode === BaselineCompareReasonCode::AmbiguousSubjects->value) {
|
|
return 'The compare finished, but ambiguous subject matching limited the result.';
|
|
}
|
|
|
|
if ($reasonCode === BaselineCompareReasonCode::StrategyFailed->value) {
|
|
return 'The compare finished, but a compare strategy failure kept the result incomplete.';
|
|
}
|
|
|
|
if (is_string($resumeToken) && trim($resumeToken) !== '') {
|
|
return 'The compare finished, but evidence capture still needs to resume before the result is complete.';
|
|
}
|
|
|
|
if (($counts['total'] ?? 0) === 0 && ($counts['processed'] ?? 0) === 0) {
|
|
return 'The compare finished, but no decision-grade result is available yet.';
|
|
}
|
|
|
|
if ($proof === false) {
|
|
return 'The compare finished, but missing coverage proof suppressed the normal result.';
|
|
}
|
|
|
|
return $operatorExplanation?->headline
|
|
?? $artifactTruth?->primaryExplanation
|
|
?? 'The compare needs follow-up before it can be treated as complete.';
|
|
}
|
|
|
|
private function evidenceSnapshotHeadline(
|
|
?ArtifactTruthEnvelope $artifactTruth,
|
|
?OperatorExplanationPattern $operatorExplanation,
|
|
): string {
|
|
return match (true) {
|
|
$artifactTruth?->freshnessState === 'stale' => 'The snapshot finished processing, but its evidence basis is already stale.',
|
|
$artifactTruth?->contentState === 'partial' => 'The snapshot finished processing, but its evidence basis is incomplete.',
|
|
$artifactTruth?->contentState === 'missing_input' => 'The snapshot finished processing without a complete evidence basis.',
|
|
default => $operatorExplanation?->headline
|
|
?? $artifactTruth?->primaryExplanation
|
|
?? 'The evidence snapshot needs review before it is relied on.',
|
|
};
|
|
}
|
|
|
|
/**
|
|
* @param array{code: ?string, label: string, explanation: string} $dominantCause
|
|
*/
|
|
private function reviewComposeHeadline(
|
|
?ArtifactTruthEnvelope $artifactTruth,
|
|
array $dominantCause,
|
|
?OperatorExplanationPattern $operatorExplanation,
|
|
): string {
|
|
return match (true) {
|
|
$artifactTruth?->contentState === 'partial' && $artifactTruth?->freshnessState === 'stale' => 'The review was generated, but missing sections and stale evidence keep it from being decision-grade.',
|
|
$artifactTruth?->contentState === 'partial' => 'The review was generated, but required sections are still incomplete.',
|
|
$artifactTruth?->freshnessState === 'stale' => 'The review was generated, but it relies on stale evidence.',
|
|
default => $operatorExplanation?->headline
|
|
?? $dominantCause['explanation']
|
|
?? 'The review needs follow-up before it should guide action.',
|
|
};
|
|
}
|
|
|
|
/**
|
|
* @param array{code: ?string, label: string, explanation: string} $dominantCause
|
|
*/
|
|
private function reviewPackHeadline(
|
|
?ArtifactTruthEnvelope $artifactTruth,
|
|
array $dominantCause,
|
|
?OperatorExplanationPattern $operatorExplanation,
|
|
): string {
|
|
return match (true) {
|
|
$artifactTruth?->publicationReadiness === 'blocked' => 'The pack did not produce a shareable artifact yet.',
|
|
$artifactTruth?->publicationReadiness === 'internal_only' => 'The pack finished, but it should stay internal until the source review is refreshed.',
|
|
default => $operatorExplanation?->headline
|
|
?? $dominantCause['explanation']
|
|
?? 'The review pack needs follow-up before it is shared.',
|
|
};
|
|
}
|
|
|
|
/**
|
|
* @param array{code: ?string, label: string, explanation: string} $dominantCause
|
|
*/
|
|
private function primaryReason(
|
|
array $dominantCause,
|
|
?ArtifactTruthEnvelope $artifactTruth,
|
|
?OperatorExplanationPattern $operatorExplanation,
|
|
?ReasonResolutionEnvelope $reasonEnvelope,
|
|
): string {
|
|
return $dominantCause['explanation']
|
|
?? $operatorExplanation?->dominantCauseExplanation
|
|
?? $reasonEnvelope?->shortExplanation
|
|
?? $artifactTruth?->primaryExplanation
|
|
?? $operatorExplanation?->reliabilityStatement
|
|
?? 'TenantPilot recorded diagnostic detail for this run.';
|
|
}
|
|
|
|
/**
|
|
* @param array<string, mixed> $context
|
|
*/
|
|
private function nextActionCategory(
|
|
string $canonicalType,
|
|
OperationRun $run,
|
|
?ReasonResolutionEnvelope $reasonEnvelope,
|
|
?OperatorExplanationPattern $operatorExplanation,
|
|
array $context,
|
|
): string {
|
|
if ($reasonEnvelope?->actionability === 'retryable_transient' || $operatorExplanation?->nextActionCategory === 'retry_later') {
|
|
return 'retry_later';
|
|
}
|
|
|
|
if (in_array($canonicalType, ['baseline.capture', 'baseline.compare'], true)) {
|
|
$resumeToken = $canonicalType === 'baseline.capture'
|
|
? data_get($context, 'baseline_capture.resume_token')
|
|
: data_get($context, 'baseline_compare.resume_token');
|
|
|
|
if (is_string($resumeToken) && trim($resumeToken) !== '') {
|
|
return 'resume_capture_or_generation';
|
|
}
|
|
}
|
|
|
|
$reasonCode = (string) (data_get($context, 'baseline_compare.reason_code') ?? $reasonEnvelope?->internalCode ?? '');
|
|
|
|
if (in_array($reasonCode, [
|
|
BaselineCompareReasonCode::AmbiguousSubjects->value,
|
|
BaselineCompareReasonCode::UnsupportedSubjects->value,
|
|
], true)) {
|
|
return 'review_scope_or_ambiguous_matches';
|
|
}
|
|
|
|
if ($canonicalType === 'baseline.capture' && $this->intValue(data_get($context, 'baseline_capture.subjects_total')) === 0) {
|
|
return 'refresh_prerequisite_data';
|
|
}
|
|
|
|
if ($operatorExplanation?->nextActionCategory === 'none' || trim((string) $operatorExplanation?->nextActionText) === 'No action needed') {
|
|
return 'no_further_action';
|
|
}
|
|
|
|
if (
|
|
$reasonEnvelope?->actionability === 'prerequisite_missing'
|
|
|| in_array($canonicalType, ['tenant.evidence.snapshot.generate', 'environment.review.compose', 'environment.review_pack.generate'], true)
|
|
) {
|
|
return 'refresh_prerequisite_data';
|
|
}
|
|
|
|
return 'manually_validate';
|
|
}
|
|
|
|
private function nextActionText(
|
|
?ArtifactTruthEnvelope $artifactTruth,
|
|
?OperatorExplanationPattern $operatorExplanation,
|
|
?ReasonResolutionEnvelope $reasonEnvelope,
|
|
): string {
|
|
$text = $operatorExplanation?->nextActionText
|
|
?? $artifactTruth?->nextStepText()
|
|
?? $reasonEnvelope?->firstNextStep()?->label
|
|
?? 'No action needed';
|
|
|
|
return trim(rtrim($text, '.')).'.';
|
|
}
|
|
|
|
/**
|
|
* @param array<string, mixed> $context
|
|
* @param array<string, int> $counts
|
|
* @return array{label: string, value: string, source: string, confidence?: string}|null
|
|
*/
|
|
private function affectedScaleCue(
|
|
string $canonicalType,
|
|
OperationRun $run,
|
|
?ArtifactTruthEnvelope $artifactTruth,
|
|
?OperatorExplanationPattern $operatorExplanation,
|
|
array $context,
|
|
array $counts,
|
|
): ?array {
|
|
return match ($canonicalType) {
|
|
'baseline.capture' => $this->baselineCaptureScaleCue($context, $counts),
|
|
'baseline.compare' => $this->baselineCompareScaleCue($context, $counts),
|
|
'tenant.evidence.snapshot.generate' => $this->countDescriptorScaleCue($operatorExplanation?->countDescriptors ?? [], ['Missing dimensions', 'Stale dimensions', 'Evidence dimensions']),
|
|
'environment.review.compose' => $this->reviewScaleCue($artifactTruth, $operatorExplanation?->countDescriptors ?? []),
|
|
'environment.review_pack.generate' => $this->reviewPackScaleCue($artifactTruth, $operatorExplanation?->countDescriptors ?? []),
|
|
default => $this->summaryCountsScaleCue($counts),
|
|
};
|
|
}
|
|
|
|
/**
|
|
* @param array<string, mixed> $context
|
|
* @param array<string, int> $counts
|
|
* @return array{label: string, value: string, source: string, confidence?: string}|null
|
|
*/
|
|
private function baselineCaptureScaleCue(array $context, array $counts): ?array
|
|
{
|
|
$subjectsTotal = $this->intValue(data_get($context, 'baseline_capture.subjects_total'));
|
|
$gapCount = $this->intValue(data_get($context, 'baseline_capture.gaps.count'));
|
|
|
|
if ($gapCount > 0) {
|
|
return [
|
|
'label' => 'Affected subjects',
|
|
'value' => "{$gapCount} governed subjects still need evidence follow-up.",
|
|
'source' => 'context',
|
|
'confidence' => 'exact',
|
|
];
|
|
}
|
|
|
|
if ($subjectsTotal >= 0) {
|
|
return [
|
|
'label' => 'Capture scope',
|
|
'value' => "{$subjectsTotal} governed subjects were in the recorded capture scope.",
|
|
'source' => 'context',
|
|
'confidence' => 'exact',
|
|
];
|
|
}
|
|
|
|
return $this->summaryCountsScaleCue($counts);
|
|
}
|
|
|
|
/**
|
|
* @param array<string, mixed> $context
|
|
* @param array<string, int> $counts
|
|
* @return array{label: string, value: string, source: string, confidence?: string}|null
|
|
*/
|
|
private function baselineCompareScaleCue(array $context, array $counts): ?array
|
|
{
|
|
$gapCount = $this->intValue(data_get($context, 'baseline_compare.evidence_gaps.count'));
|
|
$subjectsTotal = $this->intValue(data_get($context, 'baseline_compare.subjects_total'));
|
|
$uncoveredTypes = array_values(array_filter((array) data_get($context, 'baseline_compare.coverage.uncovered_types', []), 'is_string'));
|
|
|
|
if ($gapCount > 0) {
|
|
return [
|
|
'label' => 'Affected subjects',
|
|
'value' => "{$gapCount} governed subjects still have evidence gaps.",
|
|
'source' => 'context',
|
|
'confidence' => 'exact',
|
|
];
|
|
}
|
|
|
|
if ($uncoveredTypes !== []) {
|
|
$count = count($uncoveredTypes);
|
|
|
|
return [
|
|
'label' => 'Coverage scope',
|
|
'value' => "{$count} policy types were left without proven compare coverage.",
|
|
'source' => 'context',
|
|
'confidence' => 'bounded',
|
|
];
|
|
}
|
|
|
|
if ($subjectsTotal > 0) {
|
|
return [
|
|
'label' => 'Compare scope',
|
|
'value' => "{$subjectsTotal} governed subjects were in scope for this compare run.",
|
|
'source' => 'context',
|
|
'confidence' => 'exact',
|
|
];
|
|
}
|
|
|
|
return $this->summaryCountsScaleCue($counts);
|
|
}
|
|
|
|
/**
|
|
* @param array<int, CountDescriptor> $countDescriptors
|
|
* @return array{label: string, value: string, source: string, confidence?: string}|null
|
|
*/
|
|
private function reviewScaleCue(?ArtifactTruthEnvelope $artifactTruth, array $countDescriptors): ?array
|
|
{
|
|
if ($artifactTruth?->contentState === 'partial') {
|
|
$sections = $this->findCountDescriptor($countDescriptors, 'Sections');
|
|
|
|
if ($sections instanceof CountDescriptor) {
|
|
return [
|
|
'label' => 'Review sections',
|
|
'value' => "{$sections->value} sections were recorded and still need review for completeness.",
|
|
'source' => 'related_artifact_truth',
|
|
'confidence' => 'best_available',
|
|
];
|
|
}
|
|
|
|
return [
|
|
'label' => 'Review sections',
|
|
'value' => 'Required review sections are still incomplete.',
|
|
'source' => 'related_artifact_truth',
|
|
'confidence' => 'best_available',
|
|
];
|
|
}
|
|
|
|
if ($artifactTruth?->freshnessState === 'stale') {
|
|
return [
|
|
'label' => 'Evidence freshness',
|
|
'value' => 'The source evidence is stale for at least part of this review.',
|
|
'source' => 'related_artifact_truth',
|
|
'confidence' => 'best_available',
|
|
];
|
|
}
|
|
|
|
return $this->countDescriptorScaleCue($countDescriptors, ['Sections', 'Findings']);
|
|
}
|
|
|
|
/**
|
|
* @param array<int, CountDescriptor> $countDescriptors
|
|
* @return array{label: string, value: string, source: string, confidence?: string}|null
|
|
*/
|
|
private function reviewPackScaleCue(?ArtifactTruthEnvelope $artifactTruth, array $countDescriptors): ?array
|
|
{
|
|
if ($artifactTruth?->publicationReadiness === 'internal_only') {
|
|
return [
|
|
'label' => 'Sharing scope',
|
|
'value' => 'The pack is suitable for internal follow-up only in its current state.',
|
|
'source' => 'related_artifact_truth',
|
|
'confidence' => 'best_available',
|
|
];
|
|
}
|
|
|
|
return $this->countDescriptorScaleCue($countDescriptors, ['Reports', 'Findings']);
|
|
}
|
|
|
|
/**
|
|
* @param array<int, CountDescriptor> $countDescriptors
|
|
* @param list<string> $preferredLabels
|
|
* @return array{label: string, value: string, source: string, confidence?: string}|null
|
|
*/
|
|
private function countDescriptorScaleCue(array $countDescriptors, array $preferredLabels): ?array
|
|
{
|
|
foreach ($preferredLabels as $label) {
|
|
$descriptor = $this->findCountDescriptor($countDescriptors, $label);
|
|
|
|
if (! $descriptor instanceof CountDescriptor || $descriptor->value <= 0) {
|
|
continue;
|
|
}
|
|
|
|
return [
|
|
'label' => $descriptor->label,
|
|
'value' => "{$descriptor->value} {$this->pluralizeDescriptor($descriptor)}.",
|
|
'source' => 'related_artifact_truth',
|
|
'confidence' => 'exact',
|
|
];
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* @param array<string, int> $counts
|
|
* @return array{label: string, value: string, source: string, confidence?: string}|null
|
|
*/
|
|
private function summaryCountsScaleCue(array $counts): ?array
|
|
{
|
|
foreach (['total', 'processed', 'failed', 'items', 'finding_count'] as $key) {
|
|
$value = (int) ($counts[$key] ?? 0);
|
|
|
|
if ($value <= 0) {
|
|
continue;
|
|
}
|
|
|
|
return [
|
|
'label' => SummaryCountsNormalizer::label($key),
|
|
'value' => "{$value} recorded in the canonical run counters.",
|
|
'source' => 'summary_counts',
|
|
'confidence' => 'exact',
|
|
];
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* @param array<string, mixed> $context
|
|
* @return list<array{rank: int, code: ?string, label: string, explanation: string}>
|
|
*/
|
|
private function rankCauseCandidates(
|
|
string $canonicalType,
|
|
OperationRun $run,
|
|
?ArtifactTruthEnvelope $artifactTruth,
|
|
?OperatorExplanationPattern $operatorExplanation,
|
|
?ReasonResolutionEnvelope $reasonEnvelope,
|
|
array $context,
|
|
): array {
|
|
$candidates = [];
|
|
|
|
$this->pushCandidate(
|
|
$candidates,
|
|
code: $reasonEnvelope?->internalCode ?? $operatorExplanation?->dominantCauseCode,
|
|
label: $reasonEnvelope?->operatorLabel ?? $operatorExplanation?->dominantCauseLabel ?? $artifactTruth?->primaryLabel,
|
|
explanation: $reasonEnvelope?->shortExplanation ?? $operatorExplanation?->dominantCauseExplanation ?? $artifactTruth?->primaryExplanation,
|
|
rank: $this->reasonRank($reasonEnvelope, $operatorExplanation),
|
|
);
|
|
|
|
match ($canonicalType) {
|
|
'baseline.capture' => $this->baselineCaptureCandidates($candidates, $context),
|
|
'baseline.compare' => $this->baselineCompareCandidates($candidates, $context),
|
|
'tenant.evidence.snapshot.generate' => $this->evidenceSnapshotCandidates($candidates, $artifactTruth, $operatorExplanation),
|
|
'environment.review.compose' => $this->reviewComposeCandidates($candidates, $artifactTruth),
|
|
'environment.review_pack.generate' => $this->reviewPackCandidates($candidates, $artifactTruth),
|
|
default => null,
|
|
};
|
|
|
|
usort($candidates, static function (array $left, array $right): int {
|
|
$rank = ($right['rank'] <=> $left['rank']);
|
|
|
|
if ($rank !== 0) {
|
|
return $rank;
|
|
}
|
|
|
|
return strcmp($left['label'], $right['label']);
|
|
});
|
|
|
|
return array_values(array_map(
|
|
static fn (array $candidate): array => [
|
|
'code' => $candidate['code'],
|
|
'label' => $candidate['label'],
|
|
'explanation' => $candidate['explanation'],
|
|
],
|
|
$candidates,
|
|
));
|
|
}
|
|
|
|
/**
|
|
* @param list<array{code: ?string, label: string, explanation: string, rank: int}> $candidates
|
|
*/
|
|
private function pushCandidate(array &$candidates, ?string $code, ?string $label, ?string $explanation, int $rank): void
|
|
{
|
|
$label = is_string($label) ? trim($label) : '';
|
|
$explanation = is_string($explanation) ? trim($explanation) : '';
|
|
|
|
if ($label === '' || $explanation === '') {
|
|
return;
|
|
}
|
|
|
|
foreach ($candidates as $candidate) {
|
|
if (($candidate['label'] ?? null) === $label && ($candidate['explanation'] ?? null) === $explanation) {
|
|
return;
|
|
}
|
|
}
|
|
|
|
$candidates[] = [
|
|
'code' => $code,
|
|
'label' => $label,
|
|
'explanation' => $explanation,
|
|
'rank' => $rank,
|
|
];
|
|
}
|
|
|
|
/**
|
|
* @param list<array{code: ?string, label: string, explanation: string, rank: int}> $candidates
|
|
* @param array<string, mixed> $context
|
|
*/
|
|
private function baselineCaptureCandidates(array &$candidates, array $context): void
|
|
{
|
|
$reasonCode = (string) data_get($context, 'baseline_capture.reason_code', data_get($context, 'reason_code', ''));
|
|
$subjectsTotal = $this->intValue(data_get($context, 'baseline_capture.subjects_total'));
|
|
$gapCount = $this->intValue(data_get($context, 'baseline_capture.gaps.count'));
|
|
$resumeToken = data_get($context, 'baseline_capture.resume_token');
|
|
$changedAfterEnqueue = data_get($context, 'baseline_capture.eligibility.changed_after_enqueue') === true;
|
|
|
|
if ($reasonCode === BaselineReasonCodes::CAPTURE_INVENTORY_MISSING) {
|
|
$this->pushCandidate($candidates, $reasonCode, 'Run tenant sync first', 'No current inventory basis was available for this baseline capture.', 95);
|
|
}
|
|
|
|
if ($reasonCode === BaselineReasonCodes::CAPTURE_INVENTORY_BLOCKED) {
|
|
$this->pushCandidate(
|
|
$candidates,
|
|
$reasonCode,
|
|
'Latest inventory sync was blocked',
|
|
$changedAfterEnqueue
|
|
? 'The latest inventory sync changed after the run was queued and blocked the capture.'
|
|
: 'The latest inventory sync was blocked before this capture could produce a trustworthy baseline.',
|
|
95,
|
|
);
|
|
}
|
|
|
|
if ($reasonCode === BaselineReasonCodes::CAPTURE_INVENTORY_FAILED) {
|
|
$this->pushCandidate(
|
|
$candidates,
|
|
$reasonCode,
|
|
'Latest inventory sync failed',
|
|
$changedAfterEnqueue
|
|
? 'The latest inventory sync failed after the run was queued, so the capture stopped without refreshing baseline truth.'
|
|
: 'The latest inventory sync failed before this capture could produce a trustworthy baseline.',
|
|
95,
|
|
);
|
|
}
|
|
|
|
if ($reasonCode === BaselineReasonCodes::CAPTURE_UNUSABLE_COVERAGE) {
|
|
$this->pushCandidate(
|
|
$candidates,
|
|
$reasonCode,
|
|
'Latest inventory coverage unusable',
|
|
$changedAfterEnqueue
|
|
? 'The latest inventory coverage became unusable after the run was queued, so the capture stopped without refreshing baseline truth.'
|
|
: 'The latest inventory sync did not produce usable governed-subject coverage for this baseline capture.',
|
|
95,
|
|
);
|
|
}
|
|
|
|
if ($reasonCode === BaselineReasonCodes::CAPTURE_ZERO_SUBJECTS) {
|
|
$this->pushCandidate($candidates, $reasonCode, 'No subjects were in scope', 'No governed subjects were available for this baseline capture.', 95);
|
|
}
|
|
|
|
if ($subjectsTotal === 0) {
|
|
$this->pushCandidate($candidates, 'no_subjects_in_scope', 'No governed subjects captured', 'No governed subjects were available for this baseline capture.', 95);
|
|
}
|
|
|
|
if ($gapCount > 0) {
|
|
$this->pushCandidate($candidates, 'baseline_capture_gaps', 'Evidence gaps remain', "{$gapCount} governed subjects still need evidence capture before the snapshot is complete.", 82);
|
|
}
|
|
|
|
if (is_string($resumeToken) && trim($resumeToken) !== '') {
|
|
$this->pushCandidate($candidates, 'baseline_capture_resume', 'Capture can resume', 'TenantPilot recorded a resume point because this capture could not finish in one pass.', 84);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* @param list<array{code: ?string, label: string, explanation: string, rank: int}> $candidates
|
|
* @param array<string, mixed> $context
|
|
*/
|
|
private function baselineCompareCandidates(array &$candidates, array $context): void
|
|
{
|
|
$reasonCode = (string) data_get($context, 'baseline_compare.reason_code', '');
|
|
$gapCount = $this->intValue(data_get($context, 'baseline_compare.evidence_gaps.count'));
|
|
$uncoveredTypes = array_values(array_filter((array) data_get($context, 'baseline_compare.coverage.uncovered_types', []), 'is_string'));
|
|
$proof = data_get($context, 'baseline_compare.coverage.proof');
|
|
$resumeToken = data_get($context, 'baseline_compare.resume_token');
|
|
|
|
if ($reasonCode === BaselineCompareReasonCode::AmbiguousSubjects->value) {
|
|
$this->pushCandidate($candidates, $reasonCode, 'Ambiguous matches', 'One or more governed subjects stayed ambiguous, so the compare result needs scope review.', 92);
|
|
}
|
|
|
|
if ($reasonCode === BaselineCompareReasonCode::StrategyFailed->value) {
|
|
$this->pushCandidate($candidates, $reasonCode, 'Compare strategy failed', 'A compare strategy failed while processing in-scope governed subjects.', 94);
|
|
}
|
|
|
|
if ($gapCount > 0) {
|
|
$this->pushCandidate($candidates, 'baseline_compare_gaps', 'Evidence gaps', "{$gapCount} governed subjects still have evidence gaps, so the compare output is incomplete.", 83);
|
|
}
|
|
|
|
if ($proof === false || $uncoveredTypes !== []) {
|
|
$count = count($uncoveredTypes);
|
|
$explanation = $count > 0
|
|
? "{$count} policy types were left without proven compare coverage."
|
|
: 'Coverage proof was missing, so TenantPilot suppressed part of the normal compare output.';
|
|
|
|
$this->pushCandidate($candidates, BaselineCompareReasonCode::CoverageUnproven->value, 'Coverage proof missing', $explanation, 81);
|
|
}
|
|
|
|
if (is_string($resumeToken) && trim($resumeToken) !== '') {
|
|
$this->pushCandidate($candidates, 'baseline_compare_resume', 'Evidence capture needs to resume', 'The compare recorded a resume point because evidence capture did not finish in one pass.', 80);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* @param list<array{code: ?string, label: string, explanation: string, rank: int}> $candidates
|
|
* @param array<int, CountDescriptor> $countDescriptors
|
|
*/
|
|
private function evidenceSnapshotCandidates(array &$candidates, ?ArtifactTruthEnvelope $artifactTruth, ?OperatorExplanationPattern $operatorExplanation): void
|
|
{
|
|
$countDescriptors = $operatorExplanation?->countDescriptors ?? [];
|
|
$missing = $this->findCountDescriptor($countDescriptors, 'Missing dimensions');
|
|
$stale = $this->findCountDescriptor($countDescriptors, 'Stale dimensions');
|
|
|
|
if ($missing instanceof CountDescriptor && $missing->value > 0) {
|
|
$this->pushCandidate($candidates, 'missing_dimensions', 'Missing dimensions', "{$missing->value} evidence dimensions are still missing from this snapshot.", 88);
|
|
}
|
|
|
|
if ($artifactTruth?->freshnessState === 'stale' || ($stale instanceof CountDescriptor && $stale->value > 0)) {
|
|
$value = $stale instanceof CountDescriptor && $stale->value > 0
|
|
? "{$stale->value} evidence dimensions are stale and should be refreshed."
|
|
: 'Part of the evidence basis is stale and should be refreshed before use.';
|
|
|
|
$this->pushCandidate($candidates, 'stale_evidence', 'Stale evidence basis', $value, 82);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* @param list<array{code: ?string, label: string, explanation: string, rank: int}> $candidates
|
|
*/
|
|
private function reviewComposeCandidates(array &$candidates, ?ArtifactTruthEnvelope $artifactTruth): void
|
|
{
|
|
if ($artifactTruth?->contentState === 'partial') {
|
|
$this->pushCandidate($candidates, 'review_missing_sections', 'Missing sections', 'Required review sections are still incomplete for this generated review.', 90);
|
|
}
|
|
|
|
if ($artifactTruth?->freshnessState === 'stale') {
|
|
$this->pushCandidate($candidates, 'review_stale_evidence', 'Stale evidence basis', 'The review relies on stale evidence and needs a refreshed evidence basis.', 86);
|
|
}
|
|
|
|
if ($artifactTruth?->publicationReadiness === 'blocked') {
|
|
$this->pushCandidate($candidates, 'review_blocked', 'Publication blocked', 'The review cannot move forward until its blocking prerequisites are cleared.', 95);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* @param list<array{code: ?string, label: string, explanation: string, rank: int}> $candidates
|
|
*/
|
|
private function reviewPackCandidates(array &$candidates, ?ArtifactTruthEnvelope $artifactTruth): void
|
|
{
|
|
if ($artifactTruth?->publicationReadiness === 'blocked') {
|
|
$this->pushCandidate($candidates, 'review_pack_blocked', 'Shareable pack not available', 'The pack did not produce a shareable artifact yet.', 94);
|
|
}
|
|
|
|
if ($artifactTruth?->publicationReadiness === 'internal_only') {
|
|
$this->pushCandidate($candidates, 'review_pack_internal_only', 'Internal-only outcome', 'The pack can support internal follow-up, but it should not be shared externally yet.', 80);
|
|
}
|
|
|
|
if ($artifactTruth?->freshnessState === 'stale') {
|
|
$this->pushCandidate($candidates, 'review_pack_stale_source', 'Source review is stale', 'The pack inherits stale review evidence and needs a refreshed source review.', 84);
|
|
}
|
|
|
|
if ($artifactTruth?->contentState === 'partial') {
|
|
$this->pushCandidate($candidates, 'review_pack_partial_source', 'Source review is incomplete', 'The pack inherits incomplete source review content and needs follow-up before sharing.', 86);
|
|
}
|
|
}
|
|
|
|
private function reasonRank(
|
|
?ReasonResolutionEnvelope $reasonEnvelope,
|
|
?OperatorExplanationPattern $operatorExplanation,
|
|
): int {
|
|
if ($reasonEnvelope?->actionability === 'retryable_transient') {
|
|
return 76;
|
|
}
|
|
|
|
return match ($operatorExplanation?->nextActionCategory) {
|
|
'fix_prerequisite' => 92,
|
|
'retry_later' => 76,
|
|
'none' => 40,
|
|
default => 85,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* @param list<array{code: ?string, label: string, explanation: string}> $secondaryCauses
|
|
* @return list<array{
|
|
* label: string,
|
|
* value: string,
|
|
* hint?: ?string,
|
|
* emphasis?: string
|
|
* }>
|
|
*/
|
|
private function secondaryFacts(
|
|
?ArtifactTruthEnvelope $artifactTruth,
|
|
?OperatorExplanationPattern $operatorExplanation,
|
|
array $secondaryCauses,
|
|
string $nextActionCategory,
|
|
string $nextActionText,
|
|
): array {
|
|
$facts = [];
|
|
|
|
if ($operatorExplanation instanceof OperatorExplanationPattern) {
|
|
$facts[] = [
|
|
'label' => 'Result trust',
|
|
'value' => $operatorExplanation->trustworthinessLabel(),
|
|
'hint' => $this->deduplicateSecondaryFactHint(
|
|
$operatorExplanation->reliabilityStatement,
|
|
$operatorExplanation->dominantCauseExplanation,
|
|
$artifactTruth?->primaryExplanation,
|
|
),
|
|
'emphasis' => $this->emphasisFromTrust($operatorExplanation->trustworthinessLevel->value),
|
|
];
|
|
|
|
if ($operatorExplanation->evaluationResultLabel() !== '') {
|
|
$facts[] = [
|
|
'label' => 'Result meaning',
|
|
'value' => $operatorExplanation->evaluationResultLabel(),
|
|
'hint' => $operatorExplanation->coverageStatement,
|
|
'emphasis' => 'neutral',
|
|
];
|
|
}
|
|
}
|
|
|
|
if ($secondaryCauses !== []) {
|
|
$facts[] = [
|
|
'label' => 'Secondary causes',
|
|
'value' => implode(' · ', array_map(static fn (array $cause): string => $cause['label'], $secondaryCauses)),
|
|
'hint' => 'Additional contributing causes stay visible without replacing the dominant cause.',
|
|
'emphasis' => 'caution',
|
|
];
|
|
}
|
|
|
|
if ($artifactTruth?->relatedArtifactUrl === null && $nextActionCategory !== 'no_further_action') {
|
|
$facts[] = [
|
|
'label' => 'Related artifact access',
|
|
'value' => 'No related artifact link is available from this run.',
|
|
'emphasis' => 'neutral',
|
|
];
|
|
}
|
|
|
|
return $facts;
|
|
}
|
|
|
|
private function emphasisFromTrust(string $trust): string
|
|
{
|
|
return match ($trust) {
|
|
'unusable' => 'blocked',
|
|
'diagnostic_only', 'limited_confidence' => 'caution',
|
|
default => 'neutral',
|
|
};
|
|
}
|
|
|
|
private function deduplicateSecondaryFactHint(?string $hint, ?string ...$duplicates): ?string
|
|
{
|
|
$normalizedHint = $this->normalizeFactText($hint);
|
|
|
|
if ($normalizedHint === null) {
|
|
return null;
|
|
}
|
|
|
|
foreach ($duplicates as $duplicate) {
|
|
if ($normalizedHint === $this->normalizeFactText($duplicate)) {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
return trim($hint ?? '');
|
|
}
|
|
|
|
private function fallbackCause(
|
|
?ArtifactTruthEnvelope $artifactTruth,
|
|
?OperatorExplanationPattern $operatorExplanation,
|
|
?ReasonResolutionEnvelope $reasonEnvelope,
|
|
): array {
|
|
return [
|
|
'code' => $reasonEnvelope?->internalCode ?? $operatorExplanation?->dominantCauseCode,
|
|
'label' => $reasonEnvelope?->operatorLabel
|
|
?? $operatorExplanation?->dominantCauseLabel
|
|
?? $artifactTruth?->primaryLabel
|
|
?? 'Follow-up required',
|
|
'explanation' => $reasonEnvelope?->shortExplanation
|
|
?? $operatorExplanation?->dominantCauseExplanation
|
|
?? $artifactTruth?->primaryExplanation
|
|
?? 'TenantPilot recorded enough detail to keep this run out of an all-clear state.',
|
|
];
|
|
}
|
|
|
|
private function findCountDescriptor(array $countDescriptors, string $label): ?CountDescriptor
|
|
{
|
|
foreach ($countDescriptors as $descriptor) {
|
|
if ($descriptor instanceof CountDescriptor && $descriptor->label === $label) {
|
|
return $descriptor;
|
|
}
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
private function intValue(mixed $value): ?int
|
|
{
|
|
return is_numeric($value) ? (int) $value : null;
|
|
}
|
|
|
|
private function pluralizeDescriptor(CountDescriptor $descriptor): string
|
|
{
|
|
return match ($descriptor->label) {
|
|
'Missing dimensions' => 'evidence dimensions are missing',
|
|
'Stale dimensions' => 'evidence dimensions are stale',
|
|
'Evidence dimensions' => 'evidence dimensions were recorded',
|
|
'Sections' => 'sections were recorded',
|
|
'Reports' => 'reports were recorded',
|
|
'Findings' => 'findings were recorded',
|
|
default => strtolower($descriptor->label).' were recorded',
|
|
};
|
|
}
|
|
|
|
private function normalizeFactText(?string $value): ?string
|
|
{
|
|
if (! is_string($value)) {
|
|
return null;
|
|
}
|
|
|
|
$normalized = trim((string) preg_replace('/\s+/', ' ', $value));
|
|
|
|
if ($normalized === '') {
|
|
return null;
|
|
}
|
|
|
|
return mb_strtolower($normalized);
|
|
}
|
|
}
|