Some checks failed
Main Confidence / confidence (push) Failing after 43s
## Summary - add the Spec 220 governance run diagnostic summary seam and wire it through the canonical operation run detail presenter - render summary-first decision guidance for covered governance run families while keeping technical diagnostics secondary - add focused Pest coverage, spec artifacts, and complete the integrated-browser smoke validation for canonical run detail ## Testing - cd apps/platform && ./vendor/bin/sail bin pint --dirty --format agent - cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Monitoring/GovernanceOperationRunSummariesTest.php tests/Feature/Filament/OperationRunBaselineTruthSurfaceTest.php tests/Feature/Monitoring/ArtifactTruthRunDetailTest.php tests/Feature/Authorization/OperatorExplanationSurfaceAuthorizationTest.php tests/Feature/RunAuthorizationTenantIsolationTest.php tests/Unit/Support/OpsUx/GovernanceRunDiagnosticSummaryBuilderTest.php tests/Unit/Support/OperatorExplanation/OperatorExplanationBuilderTest.php - integrated browser smoke pass on localhost:8081 covering summary-first hierarchy, zero-output runs, multi-cause runs, cross-family parity, workspace-wide visibility, and deny-as-not-found tenant safety Co-authored-by: Ahmed Darrazi <ahmed.darrazi@live.de> Reviewed-on: #257
914 lines
38 KiB
PHP
914 lines
38 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\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),
|
|
'tenant.review.compose' => $this->reviewComposeHeadline($artifactTruth, $dominantCause, $operatorExplanation),
|
|
'tenant.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 {
|
|
$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'));
|
|
|
|
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', 'tenant.review.compose', 'tenant.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']),
|
|
'tenant.review.compose' => $this->reviewScaleCue($artifactTruth, $operatorExplanation?->countDescriptors ?? []),
|
|
'tenant.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),
|
|
'tenant.review.compose' => $this->reviewComposeCandidates($candidates, $artifactTruth),
|
|
'tenant.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
|
|
{
|
|
$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');
|
|
|
|
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, 'coverage_unproven', '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);
|
|
}
|
|
}
|