Compare commits

...

2 Commits

Author SHA1 Message Date
02e75e1cda feat: harden baseline compare summary trust surfaces (#196)
## Summary
- add a shared baseline compare summary assessment and assessor for compact trust propagation
- harden dashboard, landing, and banner baseline compare surfaces against false all-clear claims
- add focused Pest coverage for dashboard, landing, banner, reason translation, and canonical detail parity

## Validation
- vendor/bin/sail bin pint --dirty --format agent
- vendor/bin/sail artisan test --compact tests/Feature/Baselines/BaselineCompareSummaryAssessmentTest.php tests/Feature/Baselines/BaselineCompareExplanationFallbackTest.php tests/Feature/Filament/BaselineCompareNowWidgetTest.php tests/Feature/Filament/NeedsAttentionWidgetTest.php tests/Feature/Filament/BaselineCompareExplanationSurfaceTest.php tests/Feature/Filament/BaselineCompareLandingWhyNoFindingsTest.php tests/Feature/Filament/BaselineCompareCoverageBannerTest.php tests/Feature/Filament/BaselineCompareSummaryConsistencyTest.php tests/Feature/Filament/OperationRunBaselineTruthSurfaceTest.php tests/Feature/ReasonTranslation/ReasonTranslationExplanationTest.php

## Notes
- Livewire compliance: Filament v5 / Livewire v4 stack unchanged
- Provider registration: unchanged, Laravel 12 providers remain in bootstrap/providers.php
- Global search: no searchable resource behavior changed
- Destructive actions: none introduced by this change
- Assets: no new assets registered; existing deploy process remains unchanged

Co-authored-by: Ahmed Darrazi <ahmed.darrazi@live.de>
Reviewed-on: #196
2026-03-27 00:19:53 +00:00
20b6aa6a32 refactor: reduce operation run detail density (#194)
## Summary
- collapse secondary and diagnostic operation-run sections by default to reduce page density
- visually emphasize the primary next step while keeping counts readable but secondary
- keep failures and other actionable detail available without dominating the default reading path

## Testing
- vendor/bin/sail artisan test --compact tests/Feature/Filament/OperationRunBaselineTruthSurfaceTest.php tests/Feature/Filament/OperationRunEnterpriseDetailPageTest.php tests/Feature/Filament/EnterpriseDetailTemplateRegressionTest.php tests/Feature/Operations/TenantlessOperationRunViewerTest.php
- vendor/bin/sail bin pint --dirty --format agent

Co-authored-by: Ahmed Darrazi <ahmed.darrazi@live.de>
Reviewed-on: #194
2026-03-26 13:23:52 +00:00
72 changed files with 5745 additions and 837 deletions

View File

@ -106,6 +106,10 @@ ## Active Technologies
- PHP 8.4 / Laravel 12, Blade, Alpine via Filament, Tailwind CSS v4 + Filament v5, Livewire v4, Pest v4, Laravel Sail, existing `OperationRun` and baseline compare services (162-baseline-gap-details)
- PostgreSQL with JSONB-backed `operation_runs.context`; no new tables required (162-baseline-gap-details)
- PostgreSQL via existing application tables, especially `operation_runs.context` and baseline snapshot summary JSON (163-baseline-subject-resolution)
- PHP 8.4, Laravel 12, Blade views, Alpine via Filament v5 / Livewire v4 + Filament v5, Livewire v4, Pest v4, Laravel Sail, existing `OperationRunResource`, `TenantlessOperationRunViewer`, `EnterpriseDetailBuilder`, `ArtifactTruthPresenter`, `OperationUxPresenter`, and `SummaryCountsNormalizer` (164-run-detail-hardening)
- PostgreSQL with existing `operation_runs` JSONB-backed `context`, `summary_counts`, and `failure_summary`; no schema change planned (164-run-detail-hardening)
- PHP 8.4, Laravel 12, Blade, Filament v5, Livewire v4 + Filament v5, Livewire v4, Pest v4, Laravel Sail, existing `BaselineCompareStats`, `BaselineCompareExplanationRegistry`, `ReasonPresenter`, `BadgeCatalog` or `BadgeRenderer`, `UiEnforcement`, and `OperationRunLinks` (165-baseline-summary-trust)
- PostgreSQL with existing baseline, findings, and `operation_runs` tables plus JSONB-backed compare context; no schema change planned (165-baseline-summary-trust)
- PHP 8.4.15 (feat/005-bulk-operations)
@ -125,8 +129,8 @@ ## Code Style
PHP 8.4.15: Follow standard conventions
## Recent Changes
- 165-baseline-summary-trust: Added PHP 8.4, Laravel 12, Blade, Filament v5, Livewire v4 + Filament v5, Livewire v4, Pest v4, Laravel Sail, existing `BaselineCompareStats`, `BaselineCompareExplanationRegistry`, `ReasonPresenter`, `BadgeCatalog` or `BadgeRenderer`, `UiEnforcement`, and `OperationRunLinks`
- 164-run-detail-hardening: Added PHP 8.4, Laravel 12, Blade views, Alpine via Filament v5 / Livewire v4 + Filament v5, Livewire v4, Pest v4, Laravel Sail, existing `OperationRunResource`, `TenantlessOperationRunViewer`, `EnterpriseDetailBuilder`, `ArtifactTruthPresenter`, `OperationUxPresenter`, and `SummaryCountsNormalizer`
- 163-baseline-subject-resolution-session-1774398153: Added PHP 8.4 + Laravel 12, Filament v5, Livewire v4
- 163-baseline-subject-resolution: Added PHP 8.4 + Laravel 12, Filament v5, Livewire v4
- 162-baseline-gap-details: Added PHP 8.4 / Laravel 12, Blade, Alpine via Filament, Tailwind CSS v4 + Filament v5, Livewire v4, Pest v4, Laravel Sail, existing `OperationRun` and baseline compare services
<!-- MANUAL ADDITIONS START -->
<!-- MANUAL ADDITIONS END -->

View File

@ -1,19 +1,22 @@
<!--
Sync Impact Report
- Version change: 1.11.0 → 1.12.0
- Version change: 1.12.0 → 1.13.0
- Modified principles:
- None
- Added sections:
- Operator Surface Principles (OPSURF-001)
- Filament Native First / No Ad-hoc Styling (UI-FIL-001)
- Removed sections: None
- Templates requiring updates:
- ✅ .specify/memory/constitution.md
- ✅ .specify/templates/spec-template.md
- ✅ .specify/templates/plan-template.md
- ✅ .specify/templates/tasks-template.md
- ✅ docs/product/principles.md
- ✅ docs/product/standards/README.md
- ✅ docs/HANDOVER.md
- Commands checked:
- N/A `.specify/templates/commands/*.md` directory is not present in this repo
- Follow-up TODOs:
- None.
-->
@ -416,6 +419,39 @@ ### Badge Semantics Are Centralized (BADGE-001)
- Introducing or changing a status-like value MUST include updating the relevant badge mapper and adding/updating tests for the mapping.
- Tag/category chips (e.g., type/platform/environment) are not status-like and are not governed by BADGE-001.
### Filament Native First / No Ad-hoc Styling (UI-FIL-001)
- Admin and operator-facing surfaces MUST use native Filament components, existing shared UI primitives, and centralized design patterns first.
- If Filament already provides the required semantic element, feature code MUST use the Filament-native component instead of a locally assembled replacement.
- Preferred native elements include `x-filament::badge`, `x-filament::button`, `x-filament::icon`, and Filament Forms, Infolists, Tables, Sections, Tabs, Grids, and Actions.
Forbidden local replacements
- Feature code MUST NOT hand-build badges, pills, status chips, alert cards, or action buttons from raw `<span>`, `<div>`, or `<button>` markup plus Tailwind classes when Filament-native or shared project primitives can express the same meaning.
- Feature code MUST NOT introduce page-local visual status languages for status, risk, outcome, drift, trust, importance, or severity.
- Feature code MUST NOT make local color, border, rounding, or emphasis decisions for semantic UI states using ad-hoc classes such as `bg-danger-100`, `text-warning-900`, `border-dashed`, or `rounded-full` when the same state can be expressed through Filament props or shared primitives.
Shared primitive before local override
- If the same UI pattern can recur, it MUST use an existing shared primitive or introduce a new central primitive instead of reassembling the pattern inside a Blade view.
- Central badge and status catalogs remain the canonical source for status semantics; local views MUST consume them rather than re-map them.
Upgrade-safe preference
- Update-safe, framework-native implementations take priority over page-local styling shortcuts.
- Filament props such as `color`, `icon`, and standard component composition are the default mechanism for semantic emphasis.
- Publishing or emulating Filament internals for cosmetic speed is not acceptable when native composition or shared primitives are sufficient.
Exception rule
- Ad-hoc markup or styling is allowed only when all of the following are true:
- native Filament components cannot express the required semantics,
- no suitable shared primitive exists,
- and the deviation is justified briefly in code and in the governing spec or PR.
- Approved exceptions MUST stay layout-neutral, use the minimum local classes necessary, and MUST NOT invent a new page-local status language.
Review and enforcement
- Every UI review MUST answer:
- which native Filament element or shared primitive was used,
- why an existing component was insufficient if an exception was taken,
- and whether any ad-hoc status or emphasis styling was introduced.
- UI work is not Done if it introduces ad-hoc status styling or framework-foreign replacement components where a native Filament or shared UI solution was viable.
### Incremental UI Standards Enforcement (UI-STD-001)
- UI consistency is enforced incrementally, not by recurring cleanup passes.
- New tables, filters, and list surfaces MUST follow established Filament-native standards from the first implementation.
@ -451,4 +487,4 @@ ### Versioning Policy (SemVer)
- **MINOR**: new principle/section or materially expanded guidance.
- **MAJOR**: removing/redefining principles in a backward-incompatible way.
**Version**: 1.12.0 | **Ratified**: 2026-01-03 | **Last Amended**: 2026-03-21
**Version**: 1.13.0 | **Ratified**: 2026-01-03 | **Last Amended**: 2026-03-26

View File

@ -49,6 +49,7 @@ ## Constitution Check
- Automation: queued/scheduled ops use locks + idempotency; handle 429/503 with backoff+jitter
- Data minimization: Inventory stores metadata + whitelisted meta; logs contain no secrets/tokens
- Badge semantics (BADGE-001): status-like badges use `BadgeCatalog` / `BadgeRenderer`; no ad-hoc mappings; new values include tests
- Filament-native UI (UI-FIL-001): admin/operator surfaces use native Filament components or shared primitives first; no ad-hoc status UI, local semantic color/border decisions, or hand-built replacements when native/shared semantics exist; any exception is explicitly justified
- UI naming (UI-NAMING-001): operator-facing labels use `Verb + Object`; scope (`Workspace`, `Tenant`) is never the primary action label; source/domain is secondary unless disambiguation is required; runs/toasts/audit prose use the same domain vocabulary; implementation-first terms do not appear in primary operator UI
- Operator surfaces (OPSURF-001): `/admin` defaults are operator-first; default-visible content avoids raw implementation detail; diagnostics are explicitly revealed secondarily
- Operator surfaces (OPSURF-001): execution outcome, data completeness, governance result, and lifecycle/readiness are modeled as distinct status dimensions when all apply; they are not collapsed into one ambiguous status

View File

@ -127,6 +127,12 @@ ## Requirements *(mandatory)*
**Constitution alignment (BADGE-001):** If this feature changes status-like badges (status/outcome/severity/risk/availability/boolean),
the spec MUST describe how badge semantics stay centralized (no ad-hoc mappings) and which tests cover any new/changed values.
**Constitution alignment (UI-FIL-001):** If this feature adds or changes Filament or Blade UI for admin/operator surfaces, the spec MUST describe:
- which native Filament components or shared UI primitives are used,
- whether any local replacement markup was avoided for badges, alerts, buttons, or status surfaces,
- how semantic emphasis is expressed through Filament props or central primitives rather than page-local color/border classes,
- and any exception where Filament or a shared primitive was insufficient, including why the exception is necessary and how it avoids introducing a new local status language.
**Constitution alignment (UI-NAMING-001):** If this feature adds or changes operator-facing buttons, header actions, run titles,
notifications, audit prose, or related helper copy, the spec MUST describe:
- the target object,
@ -147,6 +153,7 @@ ## Requirements *(mandatory)*
**Constitution alignment (Filament Action Surfaces):** If this feature adds or modifies any Filament Resource / RelationManager / Page,
the spec MUST include a “UI Action Matrix” (see below) and explicitly state whether the Action Surface Contract is satisfied.
If the contract is not satisfied, the spec MUST include an explicit exemption with rationale.
The same section MUST also state whether UI-FIL-001 is satisfied and identify any approved exception.
**Constitution alignment (UX-001 — Layout & Information Architecture):** If this feature adds or modifies any Filament screen,
the spec MUST describe compliance with UX-001: Create/Edit uses Main/Aside layout (3-col grid), all fields inside Sections/Cards
(no naked inputs), View pages use Infolists (not disabled edit forms), status badges use BADGE-001, empty states have a specific

View File

@ -53,6 +53,9 @@ # Tasks: [FEATURE NAME]
- grouping bulk actions via BulkActionGroup,
- adding confirmations for destructive actions (and typed confirmation where required by scale),
- adding `AuditLog` entries for relevant mutations,
- using native Filament components or shared UI primitives before any local Blade/Tailwind assembly for badges, alerts, buttons, and semantic status surfaces,
- avoiding page-local semantic color, border, rounding, or highlight styling when Filament props or shared primitives can express the same state,
- documenting any UI-FIL-001 exception with rationale in the spec/PR,
- adding/updated tests that enforce the contract and block merge on violations, OR documenting an explicit exemption with rationale.
**Filament UI UX-001 (Layout & IA)**: If this feature adds/modifies any Filament screen, tasks MUST include:
- ensuring Create/Edit pages use Main/Aside layout (3-col grid, Main=columnSpan(2), Aside=columnSpan(1)),

View File

@ -104,6 +104,9 @@ class BaselineCompareLanding extends Page
/** @var array<string, mixed>|null */
public ?array $operatorExplanation = null;
/** @var array<string, mixed>|null */
public ?array $summaryAssessment = null;
public static function canAccess(): bool
{
$user = auth()->user();
@ -166,6 +169,7 @@ public function refreshStats(): void
: null;
$this->rbacRoleDefinitionSummary = $stats->rbacRoleDefinitionSummary !== [] ? $stats->rbacRoleDefinitionSummary : null;
$this->operatorExplanation = $stats->operatorExplanation()->toArray();
$this->summaryAssessment = $stats->summaryAssessment()->toArray();
}
/**
@ -248,6 +252,7 @@ protected function getViewData(): array
'whyNoFindingsMessage' => $whyNoFindingsMessage,
'whyNoFindingsFallback' => $whyNoFindingsFallback,
'whyNoFindingsColor' => $whyNoFindingsColor,
'summaryAssessment' => is_array($this->summaryAssessment) ? $this->summaryAssessment : null,
];
}

View File

@ -178,17 +178,15 @@ public function blockedExecutionBanner(): ?array
? array_values(array_filter([
$operatorExplanation->headline,
$operatorExplanation->dominantCauseExplanation,
OperationUxPresenter::surfaceGuidance($this->run),
]))
: ($reasonEnvelope?->toBodyLines() ?? [
: ($reasonEnvelope?->toBodyLines(false) ?? [
OperationUxPresenter::surfaceFailureDetail($this->run) ?? 'The queued run was refused before side effects could begin.',
OperationUxPresenter::surfaceGuidance($this->run) ?? 'Review the blocked prerequisite before retrying.',
]);
return [
'tone' => 'amber',
'title' => 'Blocked by prerequisite',
'body' => implode(' ', $lines),
'body' => implode(' ', array_values(array_unique($lines))),
];
}
@ -208,19 +206,17 @@ public function lifecycleBanner(): ?array
}
$detail = OperationUxPresenter::surfaceFailureDetail($this->run) ?? 'Lifecycle truth needs operator review.';
$guidance = OperationUxPresenter::surfaceGuidance($this->run);
$body = $guidance !== null ? $detail.' '.$guidance : $detail;
return match ($this->run->freshnessState()->value) {
'likely_stale' => [
'tone' => 'amber',
'title' => 'Likely stale run',
'body' => $body,
'body' => $detail,
],
'reconciled_failed' => [
'tone' => 'rose',
'title' => 'Automatically reconciled',
'body' => $body,
'body' => $detail,
],
default => null,
};

View File

@ -33,7 +33,9 @@
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceInspectAffordance;
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceProfile;
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceSlot;
use App\Support\Ui\GovernanceArtifactTruth\ArtifactTruthEnvelope;
use App\Support\Ui\GovernanceArtifactTruth\ArtifactTruthPresenter;
use App\Support\Ui\OperatorExplanation\OperatorExplanationPattern;
use App\Support\Workspaces\WorkspaceContext;
use BackedEnum;
use Filament\Actions;
@ -257,7 +259,7 @@ private static function enterpriseDetailPage(OperationRun $record): \App\Support
$statusSpec = BadgeRenderer::spec(BadgeDomain::OperationRunStatus, static::statusBadgeState($record));
$outcomeSpec = BadgeRenderer::spec(BadgeDomain::OperationRunOutcome, static::outcomeBadgeState($record));
$targetScope = static::targetScopeDisplay($record);
$targetScope = static::targetScopeDisplay($record) ?? 'No target scope details were recorded for this run.';
$summaryLine = \App\Support\OpsUx\SummaryCountsNormalizer::renderSummaryLine(is_array($record->summary_counts) ? $record->summary_counts : []);
$referencedTenantLifecycle = $record->tenant instanceof Tenant
? ReferencedTenantLifecyclePresentation::forOperationRun($record->tenant)
@ -266,14 +268,14 @@ private static function enterpriseDetailPage(OperationRun $record): \App\Support
? app(ArtifactTruthPresenter::class)->forOperationRun($record)
: null;
$operatorExplanation = $artifactTruth?->operatorExplanation;
$artifactTruthBadge = $artifactTruth !== null
? $factory->statusBadge(
$artifactTruth->primaryBadgeSpec()->label,
$artifactTruth->primaryBadgeSpec()->color,
$artifactTruth->primaryBadgeSpec()->icon,
$artifactTruth->primaryBadgeSpec()->iconColor,
)
: null;
$primaryNextStep = static::resolvePrimaryNextStep($record, $artifactTruth, $operatorExplanation);
$supportingGroups = static::supportingGroups(
record: $record,
factory: $factory,
referencedTenantLifecycle: $referencedTenantLifecycle,
operatorExplanation: $operatorExplanation,
primaryNextStep: $primaryNextStep,
);
$builder = \App\Support\Ui\EnterpriseDetail\EnterpriseDetailBuilder::make('operation_run', 'workspace-context')
->header(new \App\Support\Ui\EnterpriseDetail\SummaryHeaderData(
@ -284,173 +286,121 @@ private static function enterpriseDetailPage(OperationRun $record): \App\Support
$factory->statusBadge($outcomeSpec->label, $outcomeSpec->color, $outcomeSpec->icon, $outcomeSpec->iconColor),
],
keyFacts: [
$factory->keyFact('Target', $targetScope ?? 'No target scope details were recorded for this run.'),
$factory->keyFact('Initiator', $record->initiator_name),
$factory->keyFact('Target', $targetScope),
$factory->keyFact('Elapsed', RunDurationInsights::elapsedHuman($record)),
$factory->keyFact('Expected', RunDurationInsights::expectedHuman($record)),
],
descriptionHint: 'Run identity, outcome, scope, and related next steps stay ahead of stored payloads and diagnostic fragments.',
descriptionHint: 'Decision guidance and high-signal context stay ahead of diagnostic payloads and raw JSON.',
))
->addSection(
$factory->factsSection(
id: 'run_summary',
kind: 'core_details',
title: 'Run summary',
items: [
$factory->keyFact('Operation', OperationCatalog::label((string) $record->type)),
$factory->keyFact('Initiator', $record->initiator_name),
$factory->keyFact('Target scope', $targetScope ?? 'No target scope details were recorded for this run.'),
$factory->keyFact('Expected duration', RunDurationInsights::expectedHuman($record)),
],
->decisionZone($factory->decisionZone(
facts: array_values(array_filter([
$factory->keyFact(
'Execution state',
$statusSpec->label,
badge: $factory->statusBadge($statusSpec->label, $statusSpec->color, $statusSpec->icon, $statusSpec->iconColor),
),
$factory->keyFact(
'Outcome',
$outcomeSpec->label,
badge: $factory->statusBadge($outcomeSpec->label, $outcomeSpec->color, $outcomeSpec->icon, $outcomeSpec->iconColor),
),
static::artifactTruthFact($factory, $artifactTruth),
$operatorExplanation instanceof OperatorExplanationPattern
? $factory->keyFact(
'Result meaning',
$operatorExplanation->evaluationResultLabel(),
$operatorExplanation->headline,
)
: null,
$operatorExplanation instanceof OperatorExplanationPattern
? $factory->keyFact(
'Result trust',
$operatorExplanation->trustworthinessLabel(),
static::detailHintUnlessDuplicate(
$operatorExplanation->reliabilityStatement,
$artifactTruth?->primaryExplanation,
),
)
: null,
])),
primaryNextStep: $factory->primaryNextStep(
$primaryNextStep['text'],
$primaryNextStep['source'],
$primaryNextStep['secondaryGuidance'],
),
$factory->viewSection(
id: 'artifact_truth',
kind: 'current_status',
title: 'Artifact truth',
view: 'filament.infolists.entries.governance-artifact-truth',
viewData: ['artifactTruthState' => $artifactTruth?->toArray()],
visible: $artifactTruth !== null,
description: 'Run lifecycle stays separate from whether the intended governance artifact was actually produced and usable.',
),
$factory->viewSection(
id: 'related_context',
kind: 'related_context',
title: 'Related context',
view: 'filament.infolists.entries.related-context',
viewData: ['entries' => app(RelatedNavigationResolver::class)
->detailEntries(CrossResourceNavigationMatrix::SOURCE_OPERATION_RUN, $record)],
emptyState: $factory->emptyState('No related context is available for this record.'),
),
)
->addSupportingCard(
$factory->supportingFactsCard(
kind: 'status',
title: 'Current state',
items: array_values(array_filter([
$factory->keyFact('Status', $statusSpec->label, badge: $factory->statusBadge($statusSpec->label, $statusSpec->color, $statusSpec->icon, $statusSpec->iconColor)),
$factory->keyFact('Outcome', $outcomeSpec->label, badge: $factory->statusBadge($outcomeSpec->label, $outcomeSpec->color, $outcomeSpec->icon, $outcomeSpec->iconColor)),
$artifactTruth !== null
? $factory->keyFact('Artifact truth', $artifactTruth->primaryLabel, badge: $artifactTruthBadge)
: null,
$operatorExplanation !== null
? $factory->keyFact('Result meaning', $operatorExplanation->evaluationResultLabel())
: null,
$operatorExplanation !== null
? $factory->keyFact('Result trust', $operatorExplanation->trustworthinessLabel())
: null,
$referencedTenantLifecycle !== null
? $factory->keyFact(
'Tenant lifecycle',
$referencedTenantLifecycle->presentation->label,
badge: $factory->statusBadge(
$referencedTenantLifecycle->presentation->label,
$referencedTenantLifecycle->presentation->badgeColor,
$referencedTenantLifecycle->presentation->badgeIcon,
$referencedTenantLifecycle->presentation->badgeIconColor,
),
)
: null,
$referencedTenantLifecycle?->selectorAvailabilityMessage() !== null
? $factory->keyFact('Tenant selector context', $referencedTenantLifecycle->selectorAvailabilityMessage())
: null,
$referencedTenantLifecycle?->contextNote !== null
? $factory->keyFact('Viewer context', $referencedTenantLifecycle->contextNote)
: null,
static::freshnessLabel($record) !== null
? $factory->keyFact('Freshness', (string) static::freshnessLabel($record))
: null,
static::reconciliationHeadline($record) !== null
? $factory->keyFact('Lifecycle truth', (string) static::reconciliationHeadline($record))
: null,
static::reconciledAtLabel($record) !== null
? $factory->keyFact('Reconciled at', (string) static::reconciledAtLabel($record))
: null,
static::reconciliationSourceLabel($record) !== null
? $factory->keyFact('Reconciled by', (string) static::reconciliationSourceLabel($record))
: null,
$operatorExplanation !== null
? $factory->keyFact('Artifact next step', $operatorExplanation->nextActionText)
: ($artifactTruth !== null
? $factory->keyFact('Artifact next step', $artifactTruth->nextStepText())
: null),
$operatorExplanation !== null && $operatorExplanation->coverageStatement !== null
? $factory->keyFact('Coverage', $operatorExplanation->coverageStatement)
: null,
OperationUxPresenter::surfaceGuidance($record) !== null
? $factory->keyFact('Next step', OperationUxPresenter::surfaceGuidance($record))
: null,
$summaryLine !== null ? $factory->keyFact('Counts', $summaryLine) : null,
static::blockedExecutionReasonCode($record) !== null
? $factory->keyFact('Blocked reason', static::blockedExecutionReasonCode($record))
: null,
static::blockedExecutionDetail($record) !== null
? $factory->keyFact('Blocked detail', static::blockedExecutionDetail($record))
: null,
static::blockedExecutionSource($record) !== null
? $factory->keyFact('Blocked by', static::blockedExecutionSource($record))
: null,
RunDurationInsights::stuckGuidance($record) !== null ? $factory->keyFact('Guidance', RunDurationInsights::stuckGuidance($record)) : null,
])),
),
$factory->supportingFactsCard(
kind: 'timestamps',
title: 'Timing',
items: [
$factory->keyFact('Created', static::formatDetailTimestamp($record->created_at)),
$factory->keyFact('Started', static::formatDetailTimestamp($record->started_at)),
$factory->keyFact('Completed', static::formatDetailTimestamp($record->completed_at)),
$factory->keyFact('Elapsed', RunDurationInsights::elapsedHuman($record)),
],
),
)
->addTechnicalSection(
$factory->technicalDetail(
title: 'Context',
entries: [
$factory->keyFact('Identity hash', $record->run_identity_hash),
$factory->keyFact('Workspace scope', $record->workspace_id),
$factory->keyFact('Tenant scope', $record->tenant_id),
],
description: 'Stored run context stays available for debugging without dominating the default reading path.',
view: 'filament.infolists.entries.snapshot-json',
viewData: ['payload' => static::contextPayload($record)],
),
);
description: 'Start here to see how the run ended, whether the result is trustworthy enough to use, and the one primary next step.',
compactCounts: $summaryLine !== null
? $factory->countPresentation(summaryLine: $summaryLine)
: null,
attentionNote: static::decisionAttentionNote($record),
));
if ($supportingGroups !== []) {
$builder->addSupportingGroup(...$supportingGroups);
}
$builder->addSection(
$factory->viewSection(
id: 'related_context',
kind: 'related_context',
title: 'Related context',
view: 'filament.infolists.entries.related-context',
viewData: ['entries' => app(RelatedNavigationResolver::class)
->detailEntries(CrossResourceNavigationMatrix::SOURCE_OPERATION_RUN, $record)],
emptyState: $factory->emptyState('No related context is available for this record.'),
),
$factory->viewSection(
id: 'artifact_truth',
kind: 'supporting_detail',
title: 'Artifact truth details',
view: 'filament.infolists.entries.governance-artifact-truth',
viewData: [
'artifactTruthState' => $artifactTruth?->toArray(),
'surface' => 'expanded',
],
visible: $artifactTruth !== null,
description: 'Detailed artifact-truth context explains evidence quality and caveats without repeating the top decision summary.',
collapsible: true,
collapsed: true,
),
);
$counts = static::summaryCountFacts($record, $factory);
if ($counts !== []) {
$builder->addSection(
$factory->factsSection(
id: 'counts',
kind: 'current_status',
title: 'Counts',
items: $counts,
$builder->addTechnicalSection(
$factory->technicalDetail(
title: 'Count diagnostics',
entries: $counts,
description: 'Normalized run counters remain available for deeper inspection without competing with the primary decision.',
collapsible: true,
collapsed: true,
variant: 'diagnostic',
),
);
}
if (! empty($record->failure_summary)) {
$builder->addSection(
$factory->viewSection(
id: 'failures',
kind: 'operational_context',
$builder->addTechnicalSection(
$factory->technicalDetail(
title: (string) $record->outcome === OperationRunOutcome::Blocked->value ? 'Blocked execution details' : 'Failures',
description: 'Detailed failure evidence stays available for investigation after the decision and supporting context.',
view: 'filament.infolists.entries.snapshot-json',
viewData: ['payload' => $record->failure_summary ?? []],
collapsible: true,
collapsed: false,
),
);
}
if (static::reconciliationPayload($record) !== []) {
$builder->addSection(
$factory->viewSection(
id: 'reconciliation',
kind: 'operational_context',
$builder->addTechnicalSection(
$factory->technicalDetail(
title: 'Lifecycle reconciliation',
description: 'Lifecycle reconciliation is diagnostic evidence showing when TenantPilot force-resolved the run.',
view: 'filament.infolists.entries.snapshot-json',
viewData: ['payload' => static::reconciliationPayload($record)],
description: 'Lifecycle reconciliation is diagnostic evidence showing when TenantPilot force-resolved the run.',
collapsible: true,
collapsed: true,
),
);
}
@ -466,9 +416,12 @@ private static function enterpriseDetailPage(OperationRun $record): \App\Support
$builder->addSection(
$factory->factsSection(
id: 'baseline_compare',
kind: 'operational_context',
kind: 'type_specific_detail',
title: 'Baseline compare',
items: $baselineCompareFacts,
description: 'Type-specific comparison detail stays below the canonical decision and supporting layers.',
collapsible: true,
collapsed: true,
),
);
}
@ -477,7 +430,7 @@ private static function enterpriseDetailPage(OperationRun $record): \App\Support
$builder->addSection(
$factory->viewSection(
id: 'baseline_compare_gap_details',
kind: 'operational_context',
kind: 'type_specific_detail',
title: 'Evidence gap details',
description: 'Policies affected by evidence gaps, grouped by reason and searchable by reason, policy type, or subject key.',
view: 'filament.infolists.entries.evidence-gap-subjects',
@ -487,7 +440,7 @@ private static function enterpriseDetailPage(OperationRun $record): \App\Support
'searchId' => 'baseline-compare-gap-search-'.$record->getKey(),
],
collapsible: true,
collapsed: false,
collapsed: true,
),
);
}
@ -496,10 +449,12 @@ private static function enterpriseDetailPage(OperationRun $record): \App\Support
$builder->addSection(
$factory->viewSection(
id: 'baseline_compare_evidence',
kind: 'operational_context',
kind: 'type_specific_detail',
title: 'Baseline compare evidence',
view: 'filament.infolists.entries.snapshot-json',
viewData: ['payload' => $baselineCompareEvidence],
collapsible: true,
collapsed: true,
),
);
}
@ -512,10 +467,12 @@ private static function enterpriseDetailPage(OperationRun $record): \App\Support
$builder->addSection(
$factory->viewSection(
id: 'baseline_capture_evidence',
kind: 'operational_context',
kind: 'type_specific_detail',
title: 'Baseline capture evidence',
view: 'filament.infolists.entries.snapshot-json',
viewData: ['payload' => $baselineCaptureEvidence],
collapsible: true,
collapsed: true,
),
);
}
@ -525,7 +482,7 @@ private static function enterpriseDetailPage(OperationRun $record): \App\Support
$builder->addSection(
$factory->viewSection(
id: 'verification_report',
kind: 'operational_context',
kind: 'type_specific_detail',
title: 'Verification report',
view: 'filament.components.verification-report-viewer',
viewData: static::verificationReportViewData($record),
@ -533,9 +490,321 @@ private static function enterpriseDetailPage(OperationRun $record): \App\Support
);
}
$builder->addTechnicalSection(
$factory->technicalDetail(
title: 'Context',
entries: [
$factory->keyFact('Identity hash', $record->run_identity_hash, mono: true),
$factory->keyFact('Workspace scope', $record->workspace_id),
$factory->keyFact('Tenant scope', $record->tenant_id),
],
description: 'Stored run context stays available for debugging without dominating the default reading path.',
view: 'filament.infolists.entries.snapshot-json',
viewData: ['payload' => static::contextPayload($record)],
),
);
return $builder->build();
}
/**
* @return list<\App\Support\Ui\EnterpriseDetail\SupportingCardData>
*/
private static function supportingGroups(
OperationRun $record,
\App\Support\Ui\EnterpriseDetail\EnterpriseDetailSectionFactory $factory,
?ReferencedTenantLifecyclePresentation $referencedTenantLifecycle,
?OperatorExplanationPattern $operatorExplanation,
array $primaryNextStep,
): array {
$groups = [];
$hasElevatedLifecycleState = OperationUxPresenter::lifecycleAttentionSummary($record) !== null;
$guidanceItems = array_values(array_filter([
$operatorExplanation instanceof OperatorExplanationPattern && $operatorExplanation->coverageStatement !== null
? $factory->keyFact('Coverage', $operatorExplanation->coverageStatement)
: null,
$operatorExplanation instanceof OperatorExplanationPattern && $operatorExplanation->diagnosticsSummary !== null
? $factory->keyFact('Diagnostics summary', $operatorExplanation->diagnosticsSummary)
: null,
...array_map(
static fn (array $guidance): array => $factory->keyFact($guidance['label'], $guidance['text']),
array_values(array_filter(
$primaryNextStep['secondaryGuidance'] ?? [],
static fn (mixed $guidance): bool => is_array($guidance),
)),
),
static::blockedExecutionReasonCode($record) !== null
? $factory->keyFact('Blocked reason', static::blockedExecutionReasonCode($record))
: null,
static::blockedExecutionDetail($record) !== null
? $factory->keyFact('Blocked detail', static::blockedExecutionDetail($record))
: null,
static::blockedExecutionSource($record) !== null
? $factory->keyFact('Blocked by', static::blockedExecutionSource($record))
: null,
RunDurationInsights::stuckGuidance($record) !== null
? $factory->keyFact('Queue guidance', RunDurationInsights::stuckGuidance($record))
: null,
]));
if ($guidanceItems !== []) {
$groups[] = $factory->supportingFactsCard(
kind: 'guidance',
title: 'Guidance',
items: $guidanceItems,
description: 'Secondary guidance explains caveats and context without competing with the primary next step.',
);
}
$lifecycleItems = array_values(array_filter([
$referencedTenantLifecycle !== null
? $factory->keyFact(
'Tenant lifecycle',
$referencedTenantLifecycle->presentation->label,
badge: $factory->statusBadge(
$referencedTenantLifecycle->presentation->label,
$referencedTenantLifecycle->presentation->badgeColor,
$referencedTenantLifecycle->presentation->badgeIcon,
$referencedTenantLifecycle->presentation->badgeIconColor,
),
)
: null,
$referencedTenantLifecycle?->selectorAvailabilityMessage() !== null
? $factory->keyFact('Tenant selector context', $referencedTenantLifecycle->selectorAvailabilityMessage())
: null,
$referencedTenantLifecycle?->contextNote !== null
? $factory->keyFact('Viewer context', $referencedTenantLifecycle->contextNote)
: null,
! $hasElevatedLifecycleState && static::freshnessLabel($record) !== null
? $factory->keyFact('Freshness', (string) static::freshnessLabel($record))
: null,
! $hasElevatedLifecycleState && static::reconciliationHeadline($record) !== null
? $factory->keyFact('Lifecycle truth', (string) static::reconciliationHeadline($record))
: null,
static::reconciledAtLabel($record) !== null
? $factory->keyFact('Reconciled at', (string) static::reconciledAtLabel($record))
: null,
static::reconciliationSourceLabel($record) !== null
? $factory->keyFact('Reconciled by', (string) static::reconciliationSourceLabel($record))
: null,
]));
if ($lifecycleItems !== []) {
$groups[] = $factory->supportingFactsCard(
kind: 'lifecycle',
title: 'Lifecycle',
items: $lifecycleItems,
description: 'Lifecycle context explains freshness, reconciliation, and tenant-scoped caveats.',
);
}
$timingItems = [
$factory->keyFact('Created', static::formatDetailTimestamp($record->created_at)),
$factory->keyFact('Started', static::formatDetailTimestamp($record->started_at)),
$factory->keyFact('Completed', static::formatDetailTimestamp($record->completed_at)),
$factory->keyFact('Elapsed', RunDurationInsights::elapsedHuman($record)),
];
$groups[] = $factory->supportingFactsCard(
kind: 'timing',
title: 'Timing',
items: $timingItems,
);
$metadataItems = array_values(array_filter([
$factory->keyFact('Initiator', $record->initiator_name),
RunDurationInsights::expectedHuman($record) !== null
? $factory->keyFact('Expected duration', RunDurationInsights::expectedHuman($record))
: null,
]));
if ($metadataItems !== []) {
$groups[] = $factory->supportingFactsCard(
kind: 'metadata',
title: 'Metadata',
items: $metadataItems,
description: 'Secondary metadata remains visible without crowding the top decision surface.',
);
}
return $groups;
}
/**
* @return array{
* text: string,
* source: string,
* secondaryGuidance: list<array{label: string, text: string, source: string}>
* }
*/
private static function resolvePrimaryNextStep(
OperationRun $record,
?ArtifactTruthEnvelope $artifactTruth,
?OperatorExplanationPattern $operatorExplanation,
): array {
$candidates = [];
static::pushNextStepCandidate($candidates, $operatorExplanation?->nextActionText, 'operator_explanation');
static::pushNextStepCandidate($candidates, $artifactTruth?->nextStepText(), 'artifact_truth');
$opsUxSource = match (true) {
(string) $record->outcome === OperationRunOutcome::Blocked->value => 'blocked_reason',
OperationUxPresenter::lifecycleAttentionSummary($record) !== null => 'lifecycle_attention',
default => 'ops_ux',
};
static::pushNextStepCandidate($candidates, OperationUxPresenter::surfaceGuidance($record), $opsUxSource);
if ($candidates === []) {
return [
'text' => 'No action needed.',
'source' => 'none_required',
'secondaryGuidance' => [],
];
}
$primary = $candidates[0];
$primarySource = static::normalizeGuidance($primary['text']) === 'no action needed'
? 'none_required'
: $primary['source'];
$secondaryGuidance = array_map(
static fn (array $candidate): array => [
'label' => static::guidanceLabel($candidate['source']),
'text' => $candidate['text'],
'source' => $candidate['source'],
],
array_slice($candidates, 1),
);
return [
'text' => $primary['text'],
'source' => $primarySource,
'secondaryGuidance' => $secondaryGuidance,
];
}
/**
* @param array<int, array{text: string, source: string, normalized: string}> $candidates
*/
private static function pushNextStepCandidate(array &$candidates, ?string $text, string $source): void
{
$formattedText = static::formatGuidanceText($text);
if ($formattedText === null) {
return;
}
$normalized = static::normalizeGuidance($formattedText);
foreach ($candidates as $candidate) {
if (($candidate['normalized'] ?? null) === $normalized) {
return;
}
}
$candidates[] = [
'text' => $formattedText,
'source' => $source,
'normalized' => $normalized,
];
}
private static function formatGuidanceText(?string $text): ?string
{
if (! is_string($text)) {
return null;
}
$text = trim($text);
if ($text === '') {
return null;
}
if (preg_match('/[.!?]$/', $text) === 1) {
return $text;
}
return $text.'.';
}
private static function normalizeGuidance(string $text): string
{
$normalized = mb_strtolower(trim($text));
$normalized = preg_replace('/^next step:\s*/', '', $normalized) ?? $normalized;
return trim($normalized, " \t\n\r\0\x0B.!?");
}
private static function guidanceLabel(string $source): string
{
return match ($source) {
'operator_explanation' => 'Operator guidance',
'artifact_truth' => 'Artifact guidance',
'blocked_reason' => 'Blocked prerequisite',
'lifecycle_attention' => 'Lifecycle guidance',
default => 'General guidance',
};
}
/**
* @return array<string, mixed>|null
*/
private static function artifactTruthFact(
\App\Support\Ui\EnterpriseDetail\EnterpriseDetailSectionFactory $factory,
?ArtifactTruthEnvelope $artifactTruth,
): ?array {
if (! $artifactTruth instanceof ArtifactTruthEnvelope) {
return null;
}
$badge = $artifactTruth->primaryBadgeSpec();
return $factory->keyFact(
'Artifact truth',
$artifactTruth->primaryLabel,
$artifactTruth->primaryExplanation,
$factory->statusBadge($badge->label, $badge->color, $badge->icon, $badge->iconColor),
);
}
private static function decisionAttentionNote(OperationRun $record): ?string
{
return null;
}
private static function detailHintUnlessDuplicate(?string $hint, ?string $duplicateOf): ?string
{
$normalizedHint = static::normalizeDetailText($hint);
if ($normalizedHint === null) {
return null;
}
if ($normalizedHint === static::normalizeDetailText($duplicateOf)) {
return null;
}
return trim($hint ?? '');
}
private static function normalizeDetailText(?string $value): ?string
{
if (! is_string($value)) {
return null;
}
$normalized = trim((string) preg_replace('/\s+/', ' ', $value));
if ($normalized === '') {
return null;
}
return mb_strtolower($normalized);
}
/**
* @return list<array<string, mixed>>
*/
@ -546,12 +815,29 @@ private static function summaryCountFacts(
$counts = \App\Support\OpsUx\SummaryCountsNormalizer::normalize(is_array($record->summary_counts) ? $record->summary_counts : []);
return array_map(
static fn (string $key, int $value): array => $factory->keyFact(SummaryCountsNormalizer::label($key), $value),
static fn (string $key, int $value): array => $factory->keyFact(
SummaryCountsNormalizer::label($key),
$value,
tone: self::countTone($key, $value),
),
array_keys($counts),
array_values($counts),
);
}
private static function countTone(string $key, int $value): ?string
{
if (in_array($key, ['failed', 'errors_recorded', 'findings_reopened'], true)) {
return $value > 0 ? 'danger' : 'success';
}
if ($key === 'succeeded' && $value > 0) {
return 'success';
}
return null;
}
private static function blockedExecutionReasonCode(OperationRun $record): ?string
{
if ((string) $record->outcome !== OperationRunOutcome::Blocked->value) {

View File

@ -4,8 +4,11 @@
namespace App\Filament\Widgets\Dashboard;
use App\Filament\Pages\BaselineCompareLanding;
use App\Filament\Resources\FindingResource;
use App\Models\Tenant;
use App\Support\Baselines\BaselineCompareStats;
use App\Support\OperationRunLinks;
use Filament\Facades\Filament;
use Filament\Widgets\Widget;
@ -22,38 +25,47 @@ protected function getViewData(): array
$empty = [
'hasAssignment' => false,
'state' => 'no_assignment',
'message' => null,
'profileName' => null,
'findingsCount' => 0,
'highCount' => 0,
'mediumCount' => 0,
'lowCount' => 0,
'lastComparedAt' => null,
'landingUrl' => null,
'runUrl' => null,
'findingsUrl' => null,
'nextActionUrl' => null,
'summaryAssessment' => null,
];
if (! $tenant instanceof Tenant) {
return $empty;
}
$stats = BaselineCompareStats::forWidget($tenant);
$stats = BaselineCompareStats::forTenant($tenant);
if (in_array($stats->state, ['no_tenant', 'no_assignment'], true)) {
return $empty;
}
$landingUrl = BaselineCompareLanding::getUrl(tenant: $tenant);
$runUrl = $stats->operationRunId !== null
? OperationRunLinks::view($stats->operationRunId, $tenant)
: null;
$findingsUrl = FindingResource::getUrl('index', tenant: $tenant);
$summaryAssessment = $stats->summaryAssessment();
$nextActionUrl = match ($summaryAssessment->nextActionTarget()) {
'run' => $runUrl,
'findings' => $findingsUrl,
'landing' => $landingUrl,
default => null,
};
return [
'hasAssignment' => true,
'state' => $stats->state,
'message' => $stats->message,
'profileName' => $stats->profileName,
'findingsCount' => $stats->findingsCount ?? 0,
'highCount' => $stats->severityCounts['high'] ?? 0,
'mediumCount' => $stats->severityCounts['medium'] ?? 0,
'lowCount' => $stats->severityCounts['low'] ?? 0,
'lastComparedAt' => $stats->lastComparedHuman,
'landingUrl' => \App\Filament\Pages\BaselineCompareLanding::getUrl(tenant: $tenant),
'landingUrl' => $landingUrl,
'runUrl' => $runUrl,
'findingsUrl' => $findingsUrl,
'nextActionUrl' => $nextActionUrl,
'summaryAssessment' => $summaryAssessment->toArray(),
];
}
}

View File

@ -4,12 +4,9 @@
namespace App\Filament\Widgets\Dashboard;
use App\Filament\Pages\BaselineCompareLanding;
use App\Filament\Resources\FindingResource;
use App\Models\Finding;
use App\Models\OperationRun;
use App\Models\Tenant;
use App\Support\OperationRunLinks;
use App\Support\Baselines\BaselineCompareStats;
use App\Support\OpsUx\ActiveRuns;
use Filament\Facades\Filament;
use Filament\Widgets\Widget;
@ -34,6 +31,8 @@ protected function getViewData(): array
}
$tenantId = (int) $tenant->getKey();
$compareStats = BaselineCompareStats::forTenant($tenant);
$compareAssessment = $compareStats->summaryAssessment();
$items = [];
@ -48,71 +47,30 @@ protected function getViewData(): array
$items[] = [
'title' => 'High severity drift findings',
'body' => "{$highSeverityCount} finding(s) need review.",
'url' => FindingResource::getUrl('index', tenant: $tenant),
'badge' => 'Drift',
'badgeColor' => 'danger',
];
}
$latestBaselineCompareSuccess = OperationRun::query()
->where('tenant_id', $tenantId)
->where('type', 'baseline_compare')
->where('status', 'completed')
->where('outcome', 'succeeded')
->whereNotNull('completed_at')
->latest('completed_at')
->first();
if (! $latestBaselineCompareSuccess) {
if ($compareAssessment->stateFamily !== 'positive') {
$items[] = [
'title' => 'No baseline compare yet',
'body' => 'Run a baseline compare after your tenant has an assigned baseline snapshot.',
'url' => BaselineCompareLanding::getUrl(tenant: $tenant),
'badge' => 'Drift',
'badgeColor' => 'warning',
];
} else {
$isStale = $latestBaselineCompareSuccess->completed_at?->lt(now()->subDays(7)) ?? true;
if ($isStale) {
$items[] = [
'title' => 'Baseline compare stale',
'body' => 'Last baseline compare is older than 7 days.',
'url' => BaselineCompareLanding::getUrl(tenant: $tenant),
'badge' => 'Drift',
'badgeColor' => 'warning',
];
}
}
$latestBaselineCompareFailure = OperationRun::query()
->where('tenant_id', $tenantId)
->where('type', 'baseline_compare')
->where('status', 'completed')
->where('outcome', 'failed')
->latest('id')
->first();
if ($latestBaselineCompareFailure instanceof OperationRun) {
$items[] = [
'title' => 'Baseline compare failed',
'body' => 'Investigate the latest failed run.',
'url' => OperationRunLinks::view($latestBaselineCompareFailure, $tenant),
'badge' => 'Operations',
'badgeColor' => 'danger',
'title' => 'Baseline compare posture',
'body' => $compareAssessment->headline,
'supportingMessage' => $compareAssessment->supportingMessage,
'badge' => 'Baseline',
'badgeColor' => $compareAssessment->tone,
'nextStep' => $compareAssessment->nextActionLabel(),
];
}
$activeRuns = (int) OperationRun::query()
->where('tenant_id', $tenantId)
->active()
->count();
$activeRuns = ActiveRuns::existForTenant($tenant)
? (int) \App\Models\OperationRun::query()->where('tenant_id', $tenantId)->active()->count()
: 0;
if ($activeRuns > 0) {
$items[] = [
'title' => 'Operations in progress',
'body' => "{$activeRuns} run(s) are active.",
'url' => OperationRunLinks::index($tenant),
'badge' => 'Operations',
'badgeColor' => 'warning',
];
@ -125,24 +83,16 @@ protected function getViewData(): array
if ($items === []) {
$healthyChecks = [
[
'title' => 'Drift findings look healthy',
'body' => 'No high severity drift findings are open.',
'url' => FindingResource::getUrl('index', tenant: $tenant),
'linkLabel' => 'View findings',
'title' => 'Baseline compare looks trustworthy',
'body' => $compareAssessment->headline,
],
[
'title' => 'Baseline compares are up to date',
'body' => $latestBaselineCompareSuccess?->completed_at
? 'Last baseline compare: '.$latestBaselineCompareSuccess->completed_at->diffForHumans(['short' => true]).'.'
: 'Baseline compare history is available in Baseline Compare.',
'url' => BaselineCompareLanding::getUrl(tenant: $tenant),
'linkLabel' => 'Open Baseline Compare',
'title' => 'No high severity drift is open',
'body' => 'No high severity drift findings are currently open for this tenant.',
],
[
'title' => 'No active operations',
'body' => 'Nothing is currently running for this tenant.',
'url' => OperationRunLinks::index($tenant),
'linkLabel' => 'View operations',
],
];
}

View File

@ -4,6 +4,7 @@
namespace App\Filament\Widgets\Tenant;
use App\Filament\Pages\BaselineCompareLanding;
use App\Models\Tenant;
use App\Support\Baselines\BaselineCompareStats;
use App\Support\OperationRunLinks;
@ -30,28 +31,29 @@ protected function getViewData(): array
}
$stats = BaselineCompareStats::forTenant($tenant);
$uncoveredTypes = $stats->uncoveredTypes ?? [];
$uncoveredTypes = is_array($uncoveredTypes) ? $uncoveredTypes : [];
$coverageStatus = $stats->coverageStatus;
$hasWarnings = in_array($coverageStatus, ['warning', 'unproven'], true) && $uncoveredTypes !== [];
$summaryAssessment = $stats->summaryAssessment();
$runUrl = null;
if ($stats->operationRunId !== null) {
$runUrl = OperationRunLinks::view($stats->operationRunId, $tenant);
}
$landingUrl = BaselineCompareLanding::getUrl(tenant: $tenant);
$nextActionUrl = match ($summaryAssessment->nextActionTarget()) {
'run' => $runUrl,
'landing' => $landingUrl,
default => null,
};
$shouldShow = in_array($summaryAssessment->stateFamily, ['caution', 'stale', 'unavailable', 'in_progress'], true)
|| ($summaryAssessment->stateFamily === 'action_required' && $summaryAssessment->evaluationResult === 'failed_result');
return [
'shouldShow' => ($hasWarnings && $runUrl !== null) || $stats->state === 'no_snapshot',
'shouldShow' => $shouldShow,
'landingUrl' => $landingUrl,
'runUrl' => $runUrl,
'nextActionUrl' => $nextActionUrl,
'summaryAssessment' => $summaryAssessment->toArray(),
'state' => $stats->state,
'message' => $stats->message,
'coverageStatus' => $coverageStatus,
'fidelity' => $stats->fidelity,
'uncoveredTypesCount' => $stats->uncoveredTypesCount ?? count($uncoveredTypes),
'uncoveredTypes' => $uncoveredTypes,
];
}
}

View File

@ -13,6 +13,7 @@
use Filament\Tables\Table;
use Filament\Tables\TableComponent;
use Illuminate\Contracts\View\View;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Pagination\LengthAwarePaginator;
use Illuminate\Support\Collection;
use Illuminate\Support\Str;
@ -91,7 +92,8 @@ public function table(Table $table): Table
->label(__('baseline-compare.evidence_gap_reason'))
->searchable()
->sortable()
->wrap(),
->wrap()
->toggleable(isToggledHiddenByDefault: true),
TextColumn::make('policy_type')
->label(__('baseline-compare.evidence_gap_policy_type'))
->badge()
@ -122,8 +124,7 @@ public function table(Table $table): Table
->label(__('baseline-compare.evidence_gap_subject_key'))
->searchable()
->sortable()
->wrap()
->extraAttributes(['class' => 'font-mono text-xs']),
->wrap(),
])
->actions([])
->bulkActions([])
@ -204,7 +205,12 @@ private function paginateRows(Collection $rows, int $page, int $recordsPerPage):
$perPage = max(1, $recordsPerPage);
$currentPage = max(1, $page);
$total = $rows->count();
$items = $rows->forPage($currentPage, $perPage)->values();
$items = $rows->forPage($currentPage, $perPage)
->values()
->map(fn (array $row, int $index): Model => $this->toTableRecord(
row: $row,
index: (($currentPage - 1) * $perPage) + $index,
));
return new LengthAwarePaginator(
$items,
@ -213,4 +219,36 @@ private function paginateRows(Collection $rows, int $page, int $recordsPerPage):
$currentPage,
);
}
/**
* @param array<string, mixed> $row
*/
private function toTableRecord(array $row, int $index): Model
{
$record = new class extends Model
{
public $timestamps = false;
public $incrementing = false;
protected $keyType = 'string';
protected $guarded = [];
protected $table = 'baseline_compare_evidence_gap_rows';
};
$record->forceFill([
'id' => implode(':', array_filter([
(string) ($row['reason_code'] ?? 'reason'),
(string) ($row['policy_type'] ?? 'policy'),
(string) ($row['subject_key'] ?? 'subject'),
(string) $index,
])),
...$row,
]);
$record->exists = true;
return $record;
}
}

View File

@ -20,6 +20,7 @@ public function spec(mixed $value): BadgeSpec
'full_result' => OperatorOutcomeTaxonomy::spec(BadgeDomain::OperatorExplanationEvaluationResult, $state, 'heroicon-m-check-badge'),
'incomplete_result' => OperatorOutcomeTaxonomy::spec(BadgeDomain::OperatorExplanationEvaluationResult, $state, 'heroicon-m-exclamation-triangle'),
'suppressed_result' => OperatorOutcomeTaxonomy::spec(BadgeDomain::OperatorExplanationEvaluationResult, $state, 'heroicon-m-eye-slash'),
'failed_result' => OperatorOutcomeTaxonomy::spec(BadgeDomain::OperatorExplanationEvaluationResult, $state, 'heroicon-m-x-circle'),
'no_result' => OperatorOutcomeTaxonomy::spec(BadgeDomain::OperatorExplanationEvaluationResult, $state, 'heroicon-m-check'),
'unavailable' => OperatorOutcomeTaxonomy::spec(BadgeDomain::OperatorExplanationEvaluationResult, $state, 'heroicon-m-no-symbol'),
default => BadgeSpec::unknown(),

View File

@ -248,6 +248,15 @@ final class OperatorOutcomeTaxonomy
'legacy_aliases' => ['Suppressed'],
'notes' => 'Normal output was intentionally suppressed because the system could not safely present it as final.',
],
'failed_result' => [
'axis' => 'execution_outcome',
'label' => 'Failed result',
'color' => 'danger',
'classification' => 'primary',
'next_action_policy' => 'required',
'legacy_aliases' => ['Execution failed'],
'notes' => 'The workflow ended without producing a usable result and needs operator investigation.',
],
'no_result' => [
'axis' => 'execution_outcome',
'label' => 'No issues detected',

View File

@ -23,6 +23,8 @@ public function forStats(BaselineCompareStats $stats): OperatorExplanationPatter
$reason = $stats->reasonCode !== null
? $this->reasonPresenter->forArtifactTruth($stats->reasonCode, 'baseline_compare')
: null;
$isFailed = $stats->state === 'failed';
$isInProgress = $stats->state === 'comparing';
$hasCoverageWarnings = in_array($stats->coverageStatus, ['warning', 'unproven'], true);
$hasEvidenceGaps = (int) ($stats->evidenceGapsCount ?? 0) > 0;
$hasWarnings = $hasCoverageWarnings || $hasEvidenceGaps;
@ -42,8 +44,8 @@ public function forStats(BaselineCompareStats $stats): OperatorExplanationPatter
? ExplanationFamily::tryFrom(str_replace('true_no_result', 'no_issues_detected', $reason->absencePattern)) ?? null
: null;
$family ??= match (true) {
$stats->state === 'comparing' => ExplanationFamily::InProgress,
$stats->state === 'failed' => ExplanationFamily::BlockedPrerequisite,
$isInProgress => ExplanationFamily::InProgress,
$isFailed => ExplanationFamily::BlockedPrerequisite,
$stats->state === 'no_tenant',
$stats->state === 'no_assignment',
$stats->state === 'no_snapshot',
@ -62,59 +64,69 @@ public function forStats(BaselineCompareStats $stats): OperatorExplanationPatter
$family === ExplanationFamily::InProgress => TrustworthinessLevel::DiagnosticOnly,
default => TrustworthinessLevel::Unusable,
};
$evaluationResult = match ($family) {
ExplanationFamily::TrustworthyResult => 'full_result',
ExplanationFamily::NoIssuesDetected => 'no_result',
ExplanationFamily::SuppressedOutput => 'suppressed_result',
ExplanationFamily::MissingInput,
ExplanationFamily::BlockedPrerequisite,
ExplanationFamily::Unavailable,
ExplanationFamily::InProgress => 'unavailable',
ExplanationFamily::CompletedButLimited => $reason?->absencePattern === 'suppressed_output'
? 'suppressed_result'
: 'incomplete_result',
};
$headline = match ($family) {
ExplanationFamily::NoIssuesDetected => 'The comparison found no actionable drift.',
ExplanationFamily::TrustworthyResult => 'The comparison produced a usable drift result.',
ExplanationFamily::CompletedButLimited => $findingsCount > 0
? 'The comparison found drift, but the result needs caution.'
: 'The comparison finished, but the current result is not an all-clear.',
ExplanationFamily::SuppressedOutput => 'The comparison finished, but normal result output was suppressed.',
ExplanationFamily::MissingInput => 'The comparison could not produce a usable result because required inputs were missing.',
ExplanationFamily::BlockedPrerequisite => 'The comparison could not produce a usable result because a prerequisite blocked it.',
ExplanationFamily::InProgress => 'The comparison is still running.',
ExplanationFamily::Unavailable => 'A usable comparison result is not currently available.',
$evaluationResult = $isFailed
? 'failed_result'
: match ($family) {
ExplanationFamily::TrustworthyResult => 'full_result',
ExplanationFamily::NoIssuesDetected => 'no_result',
ExplanationFamily::SuppressedOutput => 'suppressed_result',
ExplanationFamily::MissingInput,
ExplanationFamily::BlockedPrerequisite,
ExplanationFamily::Unavailable,
ExplanationFamily::InProgress => 'unavailable',
ExplanationFamily::CompletedButLimited => $reason?->absencePattern === 'suppressed_output'
? 'suppressed_result'
: 'incomplete_result',
};
$headline = match (true) {
$isFailed => 'The comparison failed before it produced a usable result.',
default => match ($family) {
ExplanationFamily::NoIssuesDetected => 'The comparison found no actionable drift.',
ExplanationFamily::TrustworthyResult => 'The comparison produced a usable drift result.',
ExplanationFamily::CompletedButLimited => $findingsCount > 0
? 'The comparison found drift, but the result needs caution.'
: 'The comparison finished, but the current result is not an all-clear.',
ExplanationFamily::SuppressedOutput => 'The comparison finished, but normal result output was suppressed.',
ExplanationFamily::MissingInput => 'The comparison could not produce a usable result because required inputs were missing.',
ExplanationFamily::BlockedPrerequisite => 'The comparison could not produce a usable result because a prerequisite blocked it.',
ExplanationFamily::InProgress => 'The comparison is still running.',
ExplanationFamily::Unavailable => 'A usable comparison result is not currently available.',
},
};
$coverageStatement = match (true) {
$stats->state === 'idle' => 'No compare run has produced a result yet for this tenant.',
$isFailed => 'The last compare run did not finish cleanly, so current counts should not be treated as decision-grade.',
$stats->coverageStatus === 'unproven' => 'Coverage proof was unavailable, so suppressed output is possible even when the findings count is zero.',
$stats->uncoveredTypes !== [] => 'One or more in-scope policy types were not fully covered in this compare run.',
$hasEvidenceGaps => 'Evidence gaps limited the quality of the visible result for some subjects.',
$stats->state === 'comparing' => 'Counts will become decision-grade after the compare run finishes.',
$isInProgress => 'Counts will become decision-grade after the compare run finishes.',
in_array($family, [ExplanationFamily::MissingInput, ExplanationFamily::BlockedPrerequisite, ExplanationFamily::Unavailable], true) => $reason?->shortExplanation ?? 'The compare inputs were not complete enough to produce a normal result.',
default => 'Coverage matched the in-scope compare input for this run.',
};
$reliabilityStatement = match ($trustworthiness) {
TrustworthinessLevel::Trustworthy => $findingsCount > 0
? 'The compare completed with enough coverage to treat the recorded drift as decision-grade.'
: 'The compare completed with enough coverage to treat the absence of findings as trustworthy.',
TrustworthinessLevel::LimitedConfidence => 'The compare completed, but coverage or evidence limitations mean the visible result should be treated as partial.',
TrustworthinessLevel::DiagnosticOnly => 'The compare is still in progress, so current counts are only diagnostic.',
TrustworthinessLevel::Unusable => 'The compare did not produce a result that should be used for the intended decision yet.',
};
$nextActionText = $reason?->firstNextStep()?->label ?? match ($family) {
ExplanationFamily::NoIssuesDetected => 'No action needed',
ExplanationFamily::TrustworthyResult => 'Review the detected drift findings',
ExplanationFamily::SuppressedOutput => 'Review the missing coverage or evidence before treating this compare as complete',
ExplanationFamily::CompletedButLimited => 'Review coverage limits before acting on this compare',
ExplanationFamily::InProgress => 'Wait for the compare to finish',
ExplanationFamily::MissingInput,
ExplanationFamily::BlockedPrerequisite,
ExplanationFamily::Unavailable => $stats->state === 'idle'
? 'Run the baseline compare to generate a result'
: 'Review the blocking baseline or scope prerequisite',
};
$reliabilityStatement = $isFailed
? 'The last compare failed, so the tenant needs review before you rely on this posture.'
: match ($trustworthiness) {
TrustworthinessLevel::Trustworthy => $findingsCount > 0
? 'The compare completed with enough coverage to treat the recorded drift as decision-grade.'
: 'The compare completed with enough coverage to treat the absence of findings as trustworthy.',
TrustworthinessLevel::LimitedConfidence => 'The compare completed, but coverage or evidence limitations mean the visible result should be treated as partial.',
TrustworthinessLevel::DiagnosticOnly => 'The compare is still in progress, so current counts are only diagnostic.',
TrustworthinessLevel::Unusable => 'The compare did not produce a result that should be used for the intended decision yet.',
};
$nextActionText = $isFailed
? 'Review the failed compare run before relying on this tenant posture'
: ($reason?->firstNextStep()?->label ?? match ($family) {
ExplanationFamily::NoIssuesDetected => 'No action needed',
ExplanationFamily::TrustworthyResult => 'Review the detected drift findings',
ExplanationFamily::SuppressedOutput => 'Review the missing coverage or evidence before treating this compare as complete',
ExplanationFamily::CompletedButLimited => 'Review coverage limits before acting on this compare',
ExplanationFamily::InProgress => 'Wait for the compare to finish',
ExplanationFamily::MissingInput,
ExplanationFamily::BlockedPrerequisite,
ExplanationFamily::Unavailable => $stats->state === 'idle'
? 'Run the baseline compare to generate a result'
: 'Review the blocking baseline or scope prerequisite',
});
return $this->builder->build(
family: $family,
@ -128,15 +140,17 @@ public function forStats(BaselineCompareStats $stats): OperatorExplanationPatter
dominantCauseCode: $reason?->internalCode ?? $stats->reasonCode,
dominantCauseLabel: $reason?->operatorLabel,
dominantCauseExplanation: $reason?->shortExplanation ?? $stats->message ?? $stats->failureReason,
nextActionCategory: $family === ExplanationFamily::NoIssuesDetected
? 'none'
: match ($family) {
ExplanationFamily::TrustworthyResult => 'manual_validate',
ExplanationFamily::MissingInput,
ExplanationFamily::BlockedPrerequisite,
ExplanationFamily::Unavailable => 'fix_prerequisite',
default => 'review_evidence_gaps',
},
nextActionCategory: $isFailed
? 'inspect_run'
: ($family === ExplanationFamily::NoIssuesDetected
? 'none'
: match ($family) {
ExplanationFamily::TrustworthyResult => 'manual_validate',
ExplanationFamily::MissingInput,
ExplanationFamily::BlockedPrerequisite,
ExplanationFamily::Unavailable => 'fix_prerequisite',
default => 'review_evidence_gaps',
}),
nextActionText: $nextActionText,
countDescriptors: $this->countDescriptors($stats, $hasCoverageWarnings, $hasEvidenceGaps),
diagnosticsAvailable: $stats->operationRunId !== null || $reason !== null,

View File

@ -58,4 +58,9 @@ public function absencePattern(): ?string
self::NoSubjectsInScope => 'missing_input',
};
}
public function supportsPositiveClaim(): bool
{
return $this === self::NoDriftDetected;
}
}

View File

@ -713,6 +713,14 @@ public function operatorExplanation(): OperatorExplanationPattern
return $registry->forStats($this);
}
public function summaryAssessment(): BaselineCompareSummaryAssessment
{
/** @var BaselineCompareSummaryAssessor $assessor */
$assessor = app(BaselineCompareSummaryAssessor::class);
return $assessor->assess($this);
}
/**
* @return array<int, array{
* label: string,

View File

@ -0,0 +1,150 @@
<?php
declare(strict_types=1);
namespace App\Support\Baselines;
use InvalidArgumentException;
final readonly class BaselineCompareSummaryAssessment
{
public const string STATE_POSITIVE = 'positive';
public const string STATE_CAUTION = 'caution';
public const string STATE_STALE = 'stale';
public const string STATE_ACTION_REQUIRED = 'action_required';
public const string STATE_UNAVAILABLE = 'unavailable';
public const string STATE_IN_PROGRESS = 'in_progress';
public const string EVIDENCE_NONE = 'none';
public const string EVIDENCE_COVERAGE_WARNING = 'coverage_warning';
public const string EVIDENCE_EVIDENCE_GAP = 'evidence_gap';
public const string EVIDENCE_STALE_RESULT = 'stale_result';
public const string EVIDENCE_SUPPRESSED_OUTPUT = 'suppressed_output';
public const string EVIDENCE_UNAVAILABLE = 'unavailable';
public const string NEXT_TARGET_LANDING = 'landing';
public const string NEXT_TARGET_FINDINGS = 'findings';
public const string NEXT_TARGET_RUN = 'run';
public const string NEXT_TARGET_NONE = 'none';
/**
* @param array{label: string, target: string} $nextAction
*/
public function __construct(
public string $stateFamily,
public string $headline,
public ?string $supportingMessage,
public string $tone,
public bool $positiveClaimAllowed,
public string $trustworthinessLevel,
public string $evaluationResult,
public string $evidenceImpact,
public int $findingsVisibleCount,
public int $highSeverityCount,
public array $nextAction,
public ?string $lastComparedLabel = null,
public ?string $reasonCode = null,
) {
if (! in_array($this->stateFamily, [
self::STATE_POSITIVE,
self::STATE_CAUTION,
self::STATE_STALE,
self::STATE_ACTION_REQUIRED,
self::STATE_UNAVAILABLE,
self::STATE_IN_PROGRESS,
], true)) {
throw new InvalidArgumentException('Unsupported baseline summary state family: '.$this->stateFamily);
}
if (trim($this->headline) === '') {
throw new InvalidArgumentException('Baseline summary assessments require a headline.');
}
if (! in_array($this->evidenceImpact, [
self::EVIDENCE_NONE,
self::EVIDENCE_COVERAGE_WARNING,
self::EVIDENCE_EVIDENCE_GAP,
self::EVIDENCE_STALE_RESULT,
self::EVIDENCE_SUPPRESSED_OUTPUT,
self::EVIDENCE_UNAVAILABLE,
], true)) {
throw new InvalidArgumentException('Unsupported baseline summary evidence impact: '.$this->evidenceImpact);
}
if (! in_array($this->nextAction['target'] ?? null, [
self::NEXT_TARGET_LANDING,
self::NEXT_TARGET_FINDINGS,
self::NEXT_TARGET_RUN,
self::NEXT_TARGET_NONE,
], true)) {
throw new InvalidArgumentException('Unsupported baseline summary next-action target.');
}
if (trim((string) ($this->nextAction['label'] ?? '')) === '') {
throw new InvalidArgumentException('Baseline summary assessments require a next-action label.');
}
if ($this->positiveClaimAllowed && $this->stateFamily !== self::STATE_POSITIVE) {
throw new InvalidArgumentException('Positive claim eligibility must resolve to the positive summary state.');
}
}
public function nextActionLabel(): string
{
return $this->nextAction['label'];
}
public function nextActionTarget(): string
{
return $this->nextAction['target'];
}
/**
* @return array{
* stateFamily: string,
* headline: string,
* supportingMessage: ?string,
* tone: string,
* positiveClaimAllowed: bool,
* trustworthinessLevel: string,
* evaluationResult: string,
* evidenceImpact: string,
* findingsVisibleCount: int,
* highSeverityCount: int,
* nextAction: array{label: string, target: string},
* lastComparedLabel: ?string,
* reasonCode: ?string
* }
*/
public function toArray(): array
{
return [
'stateFamily' => $this->stateFamily,
'headline' => $this->headline,
'supportingMessage' => $this->supportingMessage,
'tone' => $this->tone,
'positiveClaimAllowed' => $this->positiveClaimAllowed,
'trustworthinessLevel' => $this->trustworthinessLevel,
'evaluationResult' => $this->evaluationResult,
'evidenceImpact' => $this->evidenceImpact,
'findingsVisibleCount' => $this->findingsVisibleCount,
'highSeverityCount' => $this->highSeverityCount,
'nextAction' => $this->nextAction,
'lastComparedLabel' => $this->lastComparedLabel,
'reasonCode' => $this->reasonCode,
];
}
}

View File

@ -0,0 +1,285 @@
<?php
declare(strict_types=1);
namespace App\Support\Baselines;
use App\Support\Ui\OperatorExplanation\OperatorExplanationPattern;
use App\Support\Ui\OperatorExplanation\TrustworthinessLevel;
use Carbon\CarbonImmutable;
final class BaselineCompareSummaryAssessor
{
private const int STALE_AFTER_DAYS = 7;
public function assess(BaselineCompareStats $stats): BaselineCompareSummaryAssessment
{
$explanation = $stats->operatorExplanation();
$findingsVisibleCount = (int) ($stats->findingsCount ?? 0);
$highSeverityCount = (int) ($stats->severityCounts['high'] ?? 0);
$reasonCode = is_string($stats->reasonCode) ? BaselineCompareReasonCode::tryFrom($stats->reasonCode) : null;
$evaluationResult = $stats->state === 'failed'
? 'failed_result'
: $explanation->evaluationResult;
$positiveClaimAllowed = $this->positiveClaimAllowed($stats, $explanation, $reasonCode, $evaluationResult);
$isStale = $this->hasStaleResult($stats, $evaluationResult);
$stateFamily = $this->stateFamily($stats, $findingsVisibleCount, $positiveClaimAllowed, $isStale);
return new BaselineCompareSummaryAssessment(
stateFamily: $stateFamily,
headline: $this->headline($stats, $stateFamily, $findingsVisibleCount, $highSeverityCount, $evaluationResult),
supportingMessage: $this->supportingMessage($stats, $stateFamily, $findingsVisibleCount, $evaluationResult),
tone: $this->tone($stats, $stateFamily),
positiveClaimAllowed: $positiveClaimAllowed,
trustworthinessLevel: $explanation->trustworthinessLevel->value,
evaluationResult: $evaluationResult,
evidenceImpact: $this->evidenceImpact($stats, $evaluationResult, $isStale),
findingsVisibleCount: $findingsVisibleCount,
highSeverityCount: $highSeverityCount,
nextAction: $this->nextAction($stats, $stateFamily, $findingsVisibleCount, $evaluationResult),
lastComparedLabel: $stats->lastComparedHuman,
reasonCode: $stats->reasonCode,
);
}
private function positiveClaimAllowed(
BaselineCompareStats $stats,
OperatorExplanationPattern $explanation,
?BaselineCompareReasonCode $reasonCode,
string $evaluationResult,
): bool {
if ($stats->state !== 'ready') {
return false;
}
if ((int) ($stats->findingsCount ?? 0) > 0) {
return false;
}
if ($evaluationResult !== 'no_result') {
return false;
}
if ($explanation->trustworthinessLevel !== TrustworthinessLevel::Trustworthy) {
return false;
}
if (in_array($stats->coverageStatus, ['warning', 'unproven'], true)) {
return false;
}
if ((int) ($stats->evidenceGapsCount ?? 0) > 0) {
return false;
}
if ($this->hasStaleResult($stats, $evaluationResult)) {
return false;
}
if ($stats->reasonCode === null) {
return true;
}
return $reasonCode?->supportsPositiveClaim() ?? false;
}
private function stateFamily(
BaselineCompareStats $stats,
int $findingsVisibleCount,
bool $positiveClaimAllowed,
bool $isStale,
): string {
return match (true) {
$stats->state === 'comparing' => BaselineCompareSummaryAssessment::STATE_IN_PROGRESS,
$stats->state === 'failed',
$findingsVisibleCount > 0 => BaselineCompareSummaryAssessment::STATE_ACTION_REQUIRED,
in_array($stats->state, ['no_tenant', 'no_assignment', 'no_snapshot', 'idle'], true) => BaselineCompareSummaryAssessment::STATE_UNAVAILABLE,
$isStale => BaselineCompareSummaryAssessment::STATE_STALE,
$positiveClaimAllowed => BaselineCompareSummaryAssessment::STATE_POSITIVE,
default => BaselineCompareSummaryAssessment::STATE_CAUTION,
};
}
private function evidenceImpact(BaselineCompareStats $stats, string $evaluationResult, bool $isStale): string
{
if (in_array($stats->state, ['no_tenant', 'no_assignment', 'no_snapshot', 'idle', 'failed'], true)) {
return BaselineCompareSummaryAssessment::EVIDENCE_UNAVAILABLE;
}
if ($isStale) {
return BaselineCompareSummaryAssessment::EVIDENCE_STALE_RESULT;
}
if ($evaluationResult === 'suppressed_result') {
return BaselineCompareSummaryAssessment::EVIDENCE_SUPPRESSED_OUTPUT;
}
if ((int) ($stats->evidenceGapsCount ?? 0) > 0) {
return BaselineCompareSummaryAssessment::EVIDENCE_EVIDENCE_GAP;
}
if (in_array($stats->coverageStatus, ['warning', 'unproven'], true)) {
return BaselineCompareSummaryAssessment::EVIDENCE_COVERAGE_WARNING;
}
return BaselineCompareSummaryAssessment::EVIDENCE_NONE;
}
private function headline(
BaselineCompareStats $stats,
string $stateFamily,
int $findingsVisibleCount,
int $highSeverityCount,
string $evaluationResult,
): string {
return match ($stateFamily) {
BaselineCompareSummaryAssessment::STATE_POSITIVE => 'No confirmed drift in the latest baseline compare.',
BaselineCompareSummaryAssessment::STATE_CAUTION => match (true) {
$evaluationResult === 'suppressed_result' => 'The last compare finished, but normal result output was suppressed.',
(int) ($stats->evidenceGapsCount ?? 0) > 0 => 'No confirmed drift is visible, but evidence gaps still limit this result.',
in_array($stats->coverageStatus, ['warning', 'unproven'], true) => 'No confirmed drift is visible, but coverage limits this compare.',
default => 'The latest compare result needs caution before you treat it as an all-clear.',
},
BaselineCompareSummaryAssessment::STATE_STALE => 'The latest baseline compare result is stale.',
BaselineCompareSummaryAssessment::STATE_ACTION_REQUIRED => match (true) {
$stats->state === 'failed' || $evaluationResult === 'failed_result' => 'The latest baseline compare failed before it produced a usable result.',
$highSeverityCount > 0 => sprintf('%d high-severity drift finding%s need review.', $highSeverityCount, $highSeverityCount === 1 ? '' : 's'),
default => sprintf('%d open drift finding%s need review.', $findingsVisibleCount, $findingsVisibleCount === 1 ? '' : 's'),
},
BaselineCompareSummaryAssessment::STATE_IN_PROGRESS => 'Baseline compare is in progress.',
default => match ($stats->state) {
'no_assignment' => 'This tenant does not have an assigned baseline yet.',
'no_snapshot' => 'The current baseline snapshot is not available for compare.',
'idle' => 'A current baseline compare result is not available yet.',
default => 'A usable baseline compare result is not currently available.',
},
};
}
private function supportingMessage(
BaselineCompareStats $stats,
string $stateFamily,
int $findingsVisibleCount,
string $evaluationResult,
): ?string {
return match ($stateFamily) {
BaselineCompareSummaryAssessment::STATE_POSITIVE => $stats->lastComparedHuman !== null
? 'Last compared '.$stats->lastComparedHuman.'.'
: 'The latest compare result is trustworthy enough to treat zero findings as current.',
BaselineCompareSummaryAssessment::STATE_CAUTION => match (true) {
$evaluationResult === 'suppressed_result' => 'Review the run detail before treating zero visible findings as complete.',
(int) ($stats->evidenceGapsCount ?? 0) > 0 => 'Review the compare detail to see which evidence gaps still limit trust.',
in_array($stats->coverageStatus, ['warning', 'unproven'], true) => 'Coverage warnings mean zero visible findings are not an all-clear on their own.',
default => $stats->reasonMessage ?? $stats->message,
},
BaselineCompareSummaryAssessment::STATE_STALE => $stats->lastComparedHuman !== null
? 'Last compared '.$stats->lastComparedHuman.'. Refresh compare before relying on this posture.'
: 'Refresh compare before relying on this posture.',
BaselineCompareSummaryAssessment::STATE_ACTION_REQUIRED => match (true) {
$stats->state === 'failed' => $stats->failureReason,
$findingsVisibleCount > 0 => 'Open findings remain on this tenant and need review.',
default => $stats->message,
},
BaselineCompareSummaryAssessment::STATE_IN_PROGRESS => 'Current counts are diagnostic only until the compare run finishes.',
default => $stats->message,
};
}
private function tone(BaselineCompareStats $stats, string $stateFamily): string
{
return match ($stateFamily) {
BaselineCompareSummaryAssessment::STATE_POSITIVE => 'success',
BaselineCompareSummaryAssessment::STATE_ACTION_REQUIRED => 'danger',
BaselineCompareSummaryAssessment::STATE_IN_PROGRESS => 'info',
BaselineCompareSummaryAssessment::STATE_UNAVAILABLE => $stats->state === 'no_snapshot' ? 'warning' : 'gray',
default => 'warning',
};
}
/**
* @return array{label: string, target: string}
*/
private function nextAction(
BaselineCompareStats $stats,
string $stateFamily,
int $findingsVisibleCount,
string $evaluationResult,
): array {
if ($findingsVisibleCount > 0) {
return [
'label' => 'Open findings',
'target' => BaselineCompareSummaryAssessment::NEXT_TARGET_FINDINGS,
];
}
return match ($stateFamily) {
BaselineCompareSummaryAssessment::STATE_ACTION_REQUIRED => [
'label' => $evaluationResult === 'failed_result' ? 'Review the failed run' : 'Review compare detail',
'target' => $stats->operationRunId !== null
? BaselineCompareSummaryAssessment::NEXT_TARGET_RUN
: BaselineCompareSummaryAssessment::NEXT_TARGET_LANDING,
],
BaselineCompareSummaryAssessment::STATE_CAUTION => [
'label' => 'Review compare detail',
'target' => $stats->operationRunId !== null
? BaselineCompareSummaryAssessment::NEXT_TARGET_RUN
: BaselineCompareSummaryAssessment::NEXT_TARGET_LANDING,
],
BaselineCompareSummaryAssessment::STATE_STALE => [
'label' => 'Open Baseline Compare',
'target' => BaselineCompareSummaryAssessment::NEXT_TARGET_LANDING,
],
BaselineCompareSummaryAssessment::STATE_IN_PROGRESS => [
'label' => $stats->operationRunId !== null ? 'View run' : 'Open Baseline Compare',
'target' => $stats->operationRunId !== null
? BaselineCompareSummaryAssessment::NEXT_TARGET_RUN
: BaselineCompareSummaryAssessment::NEXT_TARGET_LANDING,
],
BaselineCompareSummaryAssessment::STATE_UNAVAILABLE => match ($stats->state) {
'no_assignment' => [
'label' => 'Assign a baseline first',
'target' => BaselineCompareSummaryAssessment::NEXT_TARGET_NONE,
],
'no_snapshot' => [
'label' => 'Review baseline prerequisites',
'target' => BaselineCompareSummaryAssessment::NEXT_TARGET_LANDING,
],
'idle' => [
'label' => 'Open Baseline Compare',
'target' => BaselineCompareSummaryAssessment::NEXT_TARGET_LANDING,
],
default => [
'label' => 'Review compare availability',
'target' => BaselineCompareSummaryAssessment::NEXT_TARGET_NONE,
],
},
default => [
'label' => 'No action needed',
'target' => BaselineCompareSummaryAssessment::NEXT_TARGET_NONE,
],
};
}
private function hasStaleResult(BaselineCompareStats $stats, string $evaluationResult): bool
{
if ($stats->state !== 'ready') {
return false;
}
if ($stats->lastComparedIso === null) {
return false;
}
if (! in_array($evaluationResult, ['full_result', 'no_result', 'incomplete_result', 'suppressed_result'], true)) {
return false;
}
try {
$lastComparedAt = CarbonImmutable::parse($stats->lastComparedIso);
} catch (\Throwable) {
return false;
}
return $lastComparedAt->lt(now()->subDays(self::STALE_AFTER_DAYS));
}
}

View File

@ -220,9 +220,9 @@ private function translateBaselineCompareReason(string $reasonCode): ReasonResol
[$operatorLabel, $shortExplanation, $actionability, $nextStep] = match ($enum) {
BaselineCompareReasonCode::NoDriftDetected => [
'No drift detected',
'The comparison completed for the in-scope subjects without recording drift findings.',
'The comparison completed with enough coverage to treat the absence of drift findings as trustworthy.',
'non_actionable',
'No action needed unless you expected findings.',
'No action needed unless you expected a newer compare result.',
],
BaselineCompareReasonCode::CoverageUnproven => [
'Coverage proof missing',

View File

@ -10,6 +10,11 @@ final class EnterpriseDetailBuilder
{
private ?SummaryHeaderData $header = null;
/**
* @var array<string, mixed>|null
*/
private ?array $decisionZone = null;
/**
* @var list<DetailSectionData>
*/
@ -18,7 +23,7 @@ final class EnterpriseDetailBuilder
/**
* @var list<SupportingCardData>
*/
private array $supportingCards = [];
private array $supportingGroups = [];
/**
* @var list<TechnicalDetailData>
@ -47,6 +52,16 @@ public function header(SummaryHeaderData $header): self
return $this;
}
/**
* @param array<string, mixed> $decisionZone
*/
public function decisionZone(array $decisionZone): self
{
$this->decisionZone = $decisionZone;
return $this;
}
public function addSection(DetailSectionData ...$sections): self
{
foreach ($sections as $section) {
@ -58,8 +73,13 @@ public function addSection(DetailSectionData ...$sections): self
public function addSupportingCard(SupportingCardData ...$cards): self
{
foreach ($cards as $card) {
$this->supportingCards[] = $card;
return $this->addSupportingGroup(...$cards);
}
public function addSupportingGroup(SupportingCardData ...$groups): self
{
foreach ($groups as $group) {
$this->supportingGroups[] = $group;
}
return $this;
@ -94,13 +114,16 @@ public function build(): EnterpriseDetailPageData
resourceType: $this->resourceType,
scope: $this->scope,
header: $this->header,
decisionZone: is_array($this->decisionZone) && $this->decisionZone !== []
? $this->decisionZone
: null,
mainSections: array_values(array_filter(
$this->mainSections,
static fn (DetailSectionData $section): bool => $section->shouldRender(),
)),
supportingCards: array_values(array_filter(
$this->supportingCards,
static fn (SupportingCardData $card): bool => $card->shouldRender(),
supportingGroups: array_values(array_filter(
$this->supportingGroups,
static fn (SupportingCardData $group): bool => $group->shouldRender(),
)),
technicalSections: array_values(array_filter(
$this->technicalSections,

View File

@ -7,8 +7,25 @@
final readonly class EnterpriseDetailPageData
{
/**
* @param array{
* title?: string,
* description?: ?string,
* facts?: list<array<string, mixed>>,
* primaryNextStep?: array{
* label?: string,
* text: string,
* source: string,
* secondaryGuidance?: list<array{label: string, text: string, source: string}>
* },
* compactCounts?: array{
* summaryLine?: ?string,
* primaryFacts?: list<array<string, mixed>>,
* diagnosticFacts?: list<array<string, mixed>>
* },
* attentionNote?: ?string
* }|null $decisionZone
* @param list<DetailSectionData> $mainSections
* @param list<SupportingCardData> $supportingCards
* @param list<SupportingCardData> $supportingGroups
* @param list<TechnicalDetailData> $technicalSections
* @param list<array{title: string, description?: ?string, icon?: ?string}> $emptyStateNotes
*/
@ -16,8 +33,9 @@ public function __construct(
public string $resourceType,
public string $scope,
public SummaryHeaderData $header,
public ?array $decisionZone = null,
public array $mainSections = [],
public array $supportingCards = [],
public array $supportingGroups = [],
public array $technicalSections = [],
public array $emptyStateNotes = [],
) {}
@ -34,8 +52,9 @@ public function __construct(
* primaryActions: list<array{label: string, placement: string, url: ?string, actionName: ?string, destructive: bool, requiresConfirmation: bool, visible: bool, icon: ?string, openInNewTab: bool}>,
* descriptionHint: ?string
* },
* decisionZone: array<string, mixed>|null,
* mainSections: list<array<string, mixed>>,
* supportingCards: list<array<string, mixed>>,
* supportingGroups: list<array<string, mixed>>,
* technicalSections: list<array<string, mixed>>,
* emptyStateNotes: list<array{title: string, description?: ?string, icon?: ?string}>
* }
@ -46,13 +65,14 @@ public function toArray(): array
'resourceType' => $this->resourceType,
'scope' => $this->scope,
'header' => $this->header->toArray(),
'decisionZone' => $this->decisionZone,
'mainSections' => array_values(array_map(
static fn (DetailSectionData $section): array => $section->toArray(),
$this->mainSections,
)),
'supportingCards' => array_values(array_map(
static fn (SupportingCardData $card): array => $card->toArray(),
$this->supportingCards,
'supportingGroups' => array_values(array_map(
static fn (SupportingCardData $group): array => $group->toArray(),
$this->supportingGroups,
)),
'technicalSections' => array_values(array_map(
static fn (TechnicalDetailData $section): array => $section->toArray(),

View File

@ -8,9 +8,11 @@ final class EnterpriseDetailSectionFactory
{
/**
* @param array{label: string, color?: string, icon?: ?string, iconColor?: ?string}|null $badge
* @return array{label: string, value: string, hint?: ?string, badge?: ?array{label: string, color?: string, icon?: ?string, iconColor?: ?string}}
* @param 'default'|'danger'|'success'|'warning'|null $tone Optional color tone for the card border/value
* @param bool $mono Whether the value should be rendered in monospace font (e.g. hashes, IDs)
* @return array{label: string, value: string, hint?: ?string, badge?: ?array{label: string, color?: string, icon?: ?string, iconColor?: ?string}, tone?: string, mono?: bool}
*/
public function keyFact(string $label, mixed $value, ?string $hint = null, ?array $badge = null): array
public function keyFact(string $label, mixed $value, ?string $hint = null, ?array $badge = null, ?string $tone = null, bool $mono = false): array
{
$displayValue = match (true) {
is_bool($value) => $value ? 'Yes' : 'No',
@ -24,6 +26,8 @@ public function keyFact(string $label, mixed $value, ?string $hint = null, ?arra
'value' => $displayValue,
'hint' => $hint,
'badge' => $badge,
'tone' => $tone,
'mono' => $mono ?: null,
], static fn (mixed $item): bool => $item !== null);
}
@ -52,6 +56,92 @@ public function emptyState(string $title, ?string $description = null, ?string $
], static fn (mixed $item): bool => $item !== null);
}
/**
* @param list<array<string, mixed>> $facts
* @param array{
* label?: string,
* text: string,
* source: string,
* secondaryGuidance?: list<array{label: string, text: string, source: string}>
* } $primaryNextStep
* @param array{
* summaryLine?: ?string,
* primaryFacts?: list<array<string, mixed>>,
* diagnosticFacts?: list<array<string, mixed>>
* }|null $compactCounts
* @return array{
* title: string,
* description?: ?string,
* facts: list<array<string, mixed>>,
* primaryNextStep: array{
* label?: string,
* text: string,
* source: string,
* secondaryGuidance?: list<array{label: string, text: string, source: string}>
* },
* compactCounts?: array{
* summaryLine?: ?string,
* primaryFacts?: list<array<string, mixed>>,
* diagnosticFacts?: list<array<string, mixed>>
* },
* attentionNote?: ?string
* }
*/
public function decisionZone(
array $facts,
array $primaryNextStep,
?string $description = null,
?array $compactCounts = null,
?string $attentionNote = null,
string $title = 'Decision',
): array {
return array_filter([
'title' => $title,
'description' => $description,
'facts' => array_values($facts),
'primaryNextStep' => $primaryNextStep,
'compactCounts' => $compactCounts,
'attentionNote' => $attentionNote,
], static fn (mixed $item): bool => $item !== null);
}
/**
* @param list<array{label: string, text: string, source: string}> $secondaryGuidance
* @return array{
* label: string,
* text: string,
* source: string,
* secondaryGuidance: list<array{label: string, text: string, source: string}>
* }
*/
public function primaryNextStep(string $text, string $source, array $secondaryGuidance = [], string $label = 'Primary next step'): array
{
return [
'label' => $label,
'text' => $text,
'source' => $source,
'secondaryGuidance' => array_values($secondaryGuidance),
];
}
/**
* @param list<array<string, mixed>> $primaryFacts
* @param list<array<string, mixed>> $diagnosticFacts
* @return array{
* summaryLine?: ?string,
* primaryFacts: list<array<string, mixed>>,
* diagnosticFacts: list<array<string, mixed>>
* }
*/
public function countPresentation(?string $summaryLine = null, array $primaryFacts = [], array $diagnosticFacts = []): array
{
return [
'summaryLine' => $summaryLine,
'primaryFacts' => array_values($primaryFacts),
'diagnosticFacts' => array_values($diagnosticFacts),
];
}
/**
* @param list<array<string, mixed>> $items
*/
@ -174,6 +264,7 @@ public function technicalDetail(
bool $visible = true,
bool $collapsible = true,
bool $collapsed = true,
string $variant = 'technical',
): TechnicalDetailData {
return new TechnicalDetailData(
title: $title,
@ -185,6 +276,7 @@ public function technicalDetail(
view: $view,
viewData: $viewData,
emptyState: $emptyState,
variant: $variant,
);
}
}

View File

@ -21,6 +21,7 @@ public function __construct(
public ?string $view = null,
public array $viewData = [],
public ?array $emptyState = null,
public string $variant = 'technical',
) {}
public function shouldRender(): bool
@ -59,6 +60,7 @@ public function toArray(): array
'view' => $this->view,
'viewData' => $this->viewData,
'emptyState' => $this->emptyState,
'variant' => $this->variant,
];
}
}

View File

@ -120,7 +120,7 @@ ### Missing (no code, no spec beyond brainstorming)
## Architecture & Principles (Non-Negotiables)
Source: [.specify/memory/constitution.md](.specify/memory/constitution.md) (v1.12.0)
Source: [.specify/memory/constitution.md](.specify/memory/constitution.md) (v1.13.0)
### Core Principles
@ -131,6 +131,7 @@ ### Core Principles
5. **Workspace Isolation** — Non-member → 404; workspace is primary session context. Enforced via `DenyNonMemberTenantAccess` middleware + `EnsureFilamentTenantSelected`.
6. **Tenant Isolation** — Every read/write must be tenant-scoped; `DerivesWorkspaceIdFromTenant` concern auto-fills `workspace_id` from tenant.
7. **Operator Surface Principles**`/admin` defaults are operator-first, diagnostics are progressively disclosed, status dimensions stay distinct, mutation scope is explicit before execution, and every materially changed operator page carries an explicit page contract.
8. **Filament-native first / no ad-hoc styling** — Admin and operator UI must use Filament-native components or shared primitives before any local Blade/Tailwind assembly; page-local status styling is not an acceptable substitute.
### RBAC-UX Rules
@ -158,6 +159,7 @@ ### Filament Standards
- **Action Surface Contract**: List/View/Create/Edit pages each have defined required surfaces (Spec 082, 090).
- **Layout**: Main/Aside layout, sections required, view pages use Infolists.
- **Badge Semantics**: Centralized via `BadgeCatalog`/`BadgeRenderer` (Specs 059, 060).
- **Filament-native UI**: Native Filament components and shared primitives come before any local styling or replacement markup for semantic UI elements.
- **No naked forms**: Everything in sections/cards with proper enterprise IA.
### Provider Gateway

View File

@ -3,7 +3,7 @@ # Product Principles
> Permanent product principles that govern every spec, every UI decision, and every architectural choice.
> New specs must align with these. If a principle needs to change, update this file first.
**Last reviewed**: 2026-03-21
**Last reviewed**: 2026-03-26
---
@ -93,6 +93,11 @@ ### Action Surface Contract (non-negotiable)
### Badge semantics centralized
All status badges via `BadgeCatalog` / `BadgeRenderer`. No ad-hoc badge mappings.
### Filament-native first, no ad-hoc styling
Admin and operator UI uses native Filament components or shared primitives first.
No hand-built status chips, alert cards, or local semantic color/border styling when Filament or a central primitive already expresses the meaning.
Any exception must be justified explicitly and stay minimal.
### Canonical navigation and terminology
Consistent naming, consistent routing, consistent mental model.
No competing terms for the same concept.

View File

@ -47,6 +47,25 @@ ### Baseline Drift Engine (Cutover)
**Active specs**: 119 (cutover)
### R1.9 Platform Localization v1 (DE/EN)
UI-Sprache umschaltbar (`de`, `en`) mit sauberem Locale-Foundation-Layer.
Goal: Konsistente, durchgängige Lokalisierung aller Governance-Oberflächen — ohne Brüche in Export, Audit oder Maschinenformaten.
- Locale-Priorität: expliziter Override → User Preference → Workspace Default → System Default
- Workspace Default Language für neue Nutzer, User kann persönliche Sprache überschreiben
- Core-Surfaces zuerst: Navigation, Dashboard, Tenant Views, Findings, Baseline Compare, Risk Exceptions, Alerts, Operations, Audit-nahe Grundtexte
- Canonical Glossary für Governance-Begriffe (Finding, Baseline, Drift, Risk Accepted, Evidence Gap, Run) — konsistente Terminologie über alle Views
- Locale-aware Anzeigeformate für Datum, Uhrzeit, Zahlen und relative Zeiten
- Maschinen- und Exportformate bleiben invariant/stabil (keine lokalisierte Semantik in CSV/JSON/Audit-Artefakten)
- Notifications, E-Mails und operatorseitige Systemtexte nutzen die aufgelöste Locale des Empfängers
- Fallback-Regel: fehlende Übersetzungen fallen kontrolliert auf Englisch zurück; keine leeren/rohen Keys im UI
- Translation-Key Governance für Labels, Actions, Statuswerte, Empty States, Table Filters, Notifications und Validation-/Systemtexte
- HTML/UI i18n-Foundation: korrektes `lang`/Locale-Setup, keine hartcodierten kritischen UI-Strings, layouts sprachrobust
- Search/Sort/Filter auf kritischen Listen für locale-sensitives Verhalten prüfen
- QA/Foundation: Missing-Key Detection, Locale Regression Tests, Pseudolocalization Smoke Tests für kritische Flows
**Active specs**: — (not yet specced)
---
## Planned (Next Quarter)

View File

@ -4,7 +4,7 @@ # Product Standards
> Specs reference these standards; they do not redefine them.
> Guard tests enforce critical constraints automatically.
**Last reviewed**: 2026-03-21
**Last reviewed**: 2026-03-26
---
@ -42,7 +42,7 @@ ## Related Docs
| Document | Location | Purpose |
|---|---|---|
| Constitution | `.specify/memory/constitution.md` | Permanent principles (OPSURF-001, UX-001, Action Surface Contract, RBAC-UX) |
| Constitution | `.specify/memory/constitution.md` | Permanent principles (OPSURF-001, UI-FIL-001, UX-001, Action Surface Contract, RBAC-UX) |
| Product Principles | `docs/product/principles.md` | High-level product decisions |
| Table Rollout Audit | `docs/ui/filament-table-standard.md` | Rollout inventory and implementation state from Spec 125 |
| Action Surface Contract | `docs/ui/action-surface-contract.md` | Original action surface reference (now governed by this standard) |

View File

@ -34,7 +34,12 @@
'evidence_gap_search_label' => 'Search gap details',
'evidence_gap_search_placeholder' => 'Search by reason, type, class, outcome, action, or subject key',
'evidence_gap_search_help' => 'Filter matches across reason, policy type, subject class, outcome, next action, and subject key.',
'evidence_gap_bucket_help' => 'Reason summaries stay separate from the detailed row table below.',
'evidence_gap_bucket_help_ambiguous_match' => 'Multiple inventory records matched the same policy subject — inspect the mapping to confirm the correct pairing.',
'evidence_gap_bucket_help_policy_record_missing' => 'The expected policy record was not found in the baseline snapshot — verify the policy still exists in the tenant.',
'evidence_gap_bucket_help_inventory_record_missing' => 'No inventory record could be matched for these subjects — confirm the inventory sync is current.',
'evidence_gap_bucket_help_foundation_not_policy_backed' => 'These subjects exist in the foundation layer but are not backed by a managed policy — review whether a policy should be created.',
'evidence_gap_bucket_help_capture_failed' => 'Evidence capture failed for these subjects — retry the comparison or check Graph connectivity.',
'evidence_gap_bucket_help_default' => 'These subjects were flagged during comparison — review the affected rows below for details.',
'evidence_gap_reason' => 'Reason',
'evidence_gap_reason_affected' => ':count affected',
'evidence_gap_reason_recorded' => ':count recorded',
@ -64,10 +69,10 @@
'comparing_indicator' => 'Comparing…',
// Why-no-findings explanations
'no_findings_all_clear' => 'All clear',
'no_findings_coverage_warnings' => 'Coverage warnings',
'no_findings_evidence_gaps' => 'Evidence gaps',
'no_findings_default' => 'No findings',
'no_findings_all_clear' => 'No confirmed drift in the latest compare',
'no_findings_coverage_warnings' => 'No drift is shown, but coverage limits this compare',
'no_findings_evidence_gaps' => 'No drift is shown, but evidence gaps still need review',
'no_findings_default' => 'No drift findings are currently visible',
// Coverage warning banner
'coverage_warning_title' => 'Comparison completed with warnings',
@ -100,11 +105,11 @@
// No drift
'no_drift_title' => 'No Drift Detected',
'no_drift_body' => 'The tenant configuration matches the baseline profile. Everything looks good.',
'no_drift_body' => 'The latest compare recorded no confirmed drift for the assigned baseline profile.',
// Coverage warnings (no findings)
'coverage_warnings_title' => 'Coverage Warnings',
'coverage_warnings_body' => 'The last comparison completed with warnings and produced no drift findings. Run Inventory Sync again to establish full coverage before interpreting results.',
'coverage_warnings_body' => 'The last comparison completed with warnings and produced no confirmed drift findings. Refresh evidence before treating the result as an all-clear.',
// Idle
'idle_title' => 'Ready to Compare',

View File

@ -0,0 +1,69 @@
@php
$decisionZone = $decisionZone ?? [];
$decisionZone = is_array($decisionZone) ? $decisionZone : [];
$facts = array_values(array_filter($decisionZone['facts'] ?? [], 'is_array'));
$primaryNextStep = is_array($decisionZone['primaryNextStep'] ?? null) ? $decisionZone['primaryNextStep'] : null;
$compactCounts = is_array($decisionZone['compactCounts'] ?? null) ? $decisionZone['compactCounts'] : null;
$countFacts = array_values(array_filter($compactCounts['primaryFacts'] ?? [], 'is_array'));
$attentionNote = is_string($decisionZone['attentionNote'] ?? null) && trim($decisionZone['attentionNote']) !== ''
? trim($decisionZone['attentionNote'])
: null;
@endphp
<x-filament::section
:heading="$decisionZone['title'] ?? 'Decision'"
:description="$decisionZone['description'] ?? 'Start here to see how the run ended, whether the result is usable, and what to do next.'"
>
<div class="grid gap-6 xl:grid-cols-[minmax(0,2fr)_minmax(18rem,1fr)]">
<div class="space-y-4">
@if ($attentionNote !== null)
<div class="rounded-2xl border border-amber-200 bg-amber-50 px-4 py-3 text-sm text-amber-900 dark:border-amber-500/30 dark:bg-amber-500/10 dark:text-amber-100">
{{ $attentionNote }}
</div>
@endif
@if ($facts !== [])
@include('filament.infolists.entries.enterprise-detail.section-items', [
'items' => $facts,
'variant' => 'summary',
])
@endif
</div>
<div class="space-y-4">
@if ($primaryNextStep !== null)
<div class="rounded-xl border-l-4 border-primary-500 bg-primary-50 px-4 py-4 dark:bg-primary-500/10">
<div class="text-xs font-semibold uppercase tracking-[0.16em] text-primary-600 dark:text-primary-400">
{{ $primaryNextStep['label'] ?? 'Primary next step' }}
</div>
<div class="mt-2 text-base font-semibold text-gray-950 dark:text-white">
{{ $primaryNextStep['text'] ?? 'No action needed.' }}
</div>
</div>
@endif
@if (filled($compactCounts['summaryLine'] ?? null) || $countFacts !== [])
<div class="rounded-xl border border-gray-200 bg-gray-50/70 px-4 py-4 dark:border-gray-700 dark:bg-gray-800/50">
<div class="text-xs font-semibold uppercase tracking-[0.16em] text-gray-500 dark:text-gray-400">
Counts
</div>
<div class="mt-2 space-y-4">
@if (filled($compactCounts['summaryLine'] ?? null))
<div class="text-sm font-medium text-gray-900 dark:text-white">
{{ $compactCounts['summaryLine'] }}
</div>
@endif
@if ($countFacts !== [])
@include('filament.infolists.entries.enterprise-detail.section-items', [
'items' => $countFacts,
'variant' => 'supporting',
])
@endif
</div>
</div>
@endif
</div>
</div>
</x-filament::section>

View File

@ -9,98 +9,70 @@
$primaryActions = array_values(array_filter($header['primaryActions'] ?? [], 'is_array'));
@endphp
<div class="rounded-2xl border border-gray-200 bg-white/90 p-5 shadow-sm dark:border-gray-800 dark:bg-gray-900/80">
<div class="flex flex-col gap-5 xl:flex-row xl:items-start xl:justify-between">
<div class="space-y-3">
@if ($statusBadges !== [])
<div class="flex flex-wrap items-center gap-2">
@foreach ($statusBadges as $badge)
<x-filament::badge
:color="$badge['color'] ?? 'gray'"
:icon="$badge['icon'] ?? null"
:icon-color="$badge['iconColor'] ?? null"
>
{{ $badge['label'] ?? 'State' }}
</x-filament::badge>
@endforeach
</div>
@endif
<div class="space-y-1">
<div class="text-2xl font-semibold tracking-tight text-gray-950 dark:text-white">
{{ $header['title'] ?? 'Detail' }}
</div>
@if (filled($header['subtitle'] ?? null))
<div class="text-sm text-gray-600 dark:text-gray-300">
{{ $header['subtitle'] }}
</div>
@endif
@if (filled($header['descriptionHint'] ?? null))
<div class="max-w-3xl text-sm text-gray-600 dark:text-gray-300">
{{ $header['descriptionHint'] }}
</div>
@endif
</div>
</div>
@if ($primaryActions !== [])
<x-filament::section
:heading="$header['title'] ?? 'Detail'"
:description="$header['subtitle'] ?? null"
>
@if ($primaryActions !== [])
<x-slot name="afterHeader">
<div class="flex flex-wrap items-center gap-2">
@foreach ($primaryActions as $action)
@if (filled($action['url'] ?? null))
<a
href="{{ $action['url'] }}"
@if (($action['openInNewTab'] ?? false) === true) target="_blank" rel="noreferrer noopener" @endif
class="inline-flex items-center gap-2 rounded-full border px-3 py-1.5 text-sm font-medium transition {{ ($action['destructive'] ?? false) === true ? 'border-rose-300 bg-rose-50 text-rose-700 hover:bg-rose-100 dark:border-rose-500/40 dark:bg-rose-500/10 dark:text-rose-200' : 'border-gray-300 bg-gray-50 text-gray-700 hover:bg-gray-100 dark:border-gray-700 dark:bg-gray-900 dark:text-gray-200 dark:hover:bg-gray-800' }}"
>
@if (filled($action['icon'] ?? null))
<x-filament::icon :icon="$action['icon']" class="h-4 w-4" />
@endif
{{ $action['label'] }}
</a>
@if (($action['openInNewTab'] ?? false) === true)
<x-filament::button
tag="a"
size="sm"
:color="($action['destructive'] ?? false) === true ? 'danger' : 'gray'"
:href="$action['url']"
:icon="$action['icon'] ?? null"
target="_blank"
rel="noreferrer noopener"
>
{{ $action['label'] }}
</x-filament::button>
@else
<x-filament::button
tag="a"
size="sm"
:color="($action['destructive'] ?? false) === true ? 'danger' : 'gray'"
:href="$action['url']"
:icon="$action['icon'] ?? null"
>
{{ $action['label'] }}
</x-filament::button>
@endif
@endif
@endforeach
</div>
</x-slot>
@endif
<div class="space-y-4">
@if ($statusBadges !== [])
<div class="flex flex-wrap items-center gap-2">
@foreach ($statusBadges as $badge)
<x-filament::badge
:color="$badge['color'] ?? 'gray'"
:icon="$badge['icon'] ?? null"
:icon-color="$badge['iconColor'] ?? null"
>
{{ $badge['label'] ?? 'State' }}
</x-filament::badge>
@endforeach
</div>
@endif
@if (filled($header['descriptionHint'] ?? null))
<div class="max-w-3xl text-sm text-gray-600 dark:text-gray-300">
{{ $header['descriptionHint'] }}
</div>
@endif
@if ($keyFacts !== [])
@include('filament.infolists.entries.enterprise-detail.section-items', [
'items' => $keyFacts,
'variant' => 'header',
])
@endif
</div>
@if ($keyFacts !== [])
<div class="mt-5 grid gap-3 sm:grid-cols-2 xl:grid-cols-4">
@foreach ($keyFacts as $fact)
@php
$displayValue = FactPresentation::value($fact);
$badge = is_array($fact['badge'] ?? null) ? $fact['badge'] : null;
@endphp
<div class="rounded-xl border border-gray-200 bg-gray-50/80 px-4 py-3 dark:border-gray-800 dark:bg-gray-950/40">
<div class="text-xs font-semibold uppercase tracking-[0.16em] text-gray-500 dark:text-gray-400">
{{ $fact['label'] ?? 'Fact' }}
</div>
<div class="mt-2 flex min-w-0 flex-wrap items-center gap-2 text-sm font-medium text-gray-900 dark:text-white">
@if ($displayValue !== null)
<span class="min-w-0 break-all whitespace-normal">{{ $displayValue }}</span>
@endif
@if ($badge !== null)
<x-filament::badge
:color="$badge['color'] ?? 'gray'"
:icon="$badge['icon'] ?? null"
:icon-color="$badge['iconColor'] ?? null"
size="sm"
>
{{ $badge['label'] ?? 'State' }}
</x-filament::badge>
@endif
</div>
@if (filled($fact['hint'] ?? null))
<div class="mt-1 text-xs text-gray-500 dark:text-gray-400">
{{ $fact['hint'] }}
</div>
@endif
</div>
@endforeach
</div>
@endif
</div>
</x-filament::section>

View File

@ -2,8 +2,9 @@
$detail = isset($getState) ? $getState() : ($detail ?? null);
$detail = is_array($detail) ? $detail : [];
$decisionZone = is_array($detail['decisionZone'] ?? null) ? $detail['decisionZone'] : [];
$mainSections = array_values(array_filter($detail['mainSections'] ?? [], 'is_array'));
$supportingCards = array_values(array_filter($detail['supportingCards'] ?? [], 'is_array'));
$supportingGroups = array_values(array_filter($detail['supportingGroups'] ?? [], 'is_array'));
$technicalSections = array_values(array_filter($detail['technicalSections'] ?? [], 'is_array'));
$emptyStateNotes = array_values(array_filter($detail['emptyStateNotes'] ?? [], 'is_array'));
@endphp
@ -13,6 +14,12 @@
'header' => is_array($detail['header'] ?? null) ? $detail['header'] : [],
])
@if ($decisionZone !== [])
@include('filament.infolists.entries.enterprise-detail.decision-zone', [
'decisionZone' => $decisionZone,
])
@endif
@if ($emptyStateNotes !== [])
<div class="space-y-3">
@foreach ($emptyStateNotes as $state)
@ -21,42 +28,40 @@
</div>
@endif
<div class="grid gap-6 xl:grid-cols-3">
<div class="{{ $supportingCards === [] ? 'xl:col-span-3' : 'xl:col-span-2' }} space-y-6">
@foreach ($mainSections as $section)
@php
$view = is_string($section['view'] ?? null) && trim($section['view']) !== '' ? trim($section['view']) : null;
$items = is_array($section['items'] ?? null) ? $section['items'] : [];
$emptyState = is_array($section['emptyState'] ?? null) ? $section['emptyState'] : null;
@endphp
<x-filament::section
:heading="$section['title'] ?? 'Details'"
:description="$section['description'] ?? null"
:collapsible="(bool) ($section['collapsible'] ?? false)"
:collapsed="(bool) ($section['collapsed'] ?? false)"
>
@if ($view !== null)
{!! view($view, is_array($section['viewData'] ?? null) ? $section['viewData'] : [])->render() !!}
@elseif ($items !== [])
@include('filament.infolists.entries.enterprise-detail.section-items', [
'items' => $items,
'action' => is_array($section['action'] ?? null) ? $section['action'] : null,
])
@elseif ($emptyState !== null)
@include('filament.infolists.entries.enterprise-detail.empty-state', ['state' => $emptyState])
@endif
</x-filament::section>
@if ($supportingGroups !== [])
<div class="grid gap-4 xl:grid-cols-2">
@foreach ($supportingGroups as $card)
@include('filament.infolists.entries.enterprise-detail.supporting-card', ['card' => $card])
@endforeach
</div>
@endif
@if ($supportingCards !== [])
<aside class="space-y-4">
@foreach ($supportingCards as $card)
@include('filament.infolists.entries.enterprise-detail.supporting-card', ['card' => $card])
@endforeach
</aside>
@endif
<div class="space-y-6">
@foreach ($mainSections as $section)
@php
$view = is_string($section['view'] ?? null) && trim($section['view']) !== '' ? trim($section['view']) : null;
$items = is_array($section['items'] ?? null) ? $section['items'] : [];
$emptyState = is_array($section['emptyState'] ?? null) ? $section['emptyState'] : null;
@endphp
<x-filament::section
:heading="$section['title'] ?? 'Details'"
:description="$section['description'] ?? null"
:collapsible="(bool) ($section['collapsible'] ?? false)"
:collapsed="(bool) ($section['collapsed'] ?? false)"
>
@if ($view !== null)
{!! view($view, is_array($section['viewData'] ?? null) ? $section['viewData'] : [])->render() !!}
@elseif ($items !== [])
@include('filament.infolists.entries.enterprise-detail.section-items', [
'items' => $items,
'action' => is_array($section['action'] ?? null) ? $section['action'] : null,
])
@elseif ($emptyState !== null)
@include('filament.infolists.entries.enterprise-detail.empty-state', ['state' => $emptyState])
@endif
</x-filament::section>
@endforeach
</div>
@if ($technicalSections !== [])

View File

@ -5,23 +5,44 @@
$items = is_array($items) ? array_values(array_filter($items, 'is_array')) : [];
$action = $action ?? null;
$action = is_array($action) ? $action : null;
$variant = is_string($variant ?? null) && trim($variant) !== '' ? trim($variant) : 'default';
$gridClasses = match ($variant) {
'header' => 'grid gap-3 sm:grid-cols-2 xl:grid-cols-4',
'summary' => 'grid gap-3 lg:grid-cols-2',
'supporting' => 'grid gap-3 sm:grid-cols-2',
'diagnostic' => 'grid gap-3 sm:grid-cols-2 xl:grid-cols-4',
'technical' => 'grid gap-3 sm:grid-cols-2 xl:grid-cols-3',
default => 'grid gap-3 sm:grid-cols-2',
};
$cardClasses = match ($variant) {
'summary' => 'rounded-2xl border border-gray-200 bg-white px-4 py-4 shadow-sm dark:border-gray-800 dark:bg-gray-900',
default => 'rounded-xl border border-gray-200 bg-gray-50/70 px-4 py-3 dark:border-gray-800 dark:bg-gray-950/30',
};
@endphp
<div class="space-y-4">
<div class="grid gap-3 sm:grid-cols-2">
<div class="{{ $gridClasses }}">
@foreach ($items as $item)
@php
$displayValue = FactPresentation::value($item);
$badge = is_array($item['badge'] ?? null) ? $item['badge'] : null;
$tone = is_string($item['tone'] ?? null) ? $item['tone'] : null;
$mono = (bool) ($item['mono'] ?? false);
$toneValueClasses = match ($tone) {
'danger' => 'text-danger-600 dark:text-danger-400',
'success' => 'text-success-600 dark:text-success-400',
'warning' => 'text-warning-600 dark:text-warning-400',
default => 'text-gray-900 dark:text-white',
};
@endphp
<div class="rounded-xl border border-gray-200 bg-gray-50/70 px-4 py-3 dark:border-gray-800 dark:bg-gray-950/30">
<div class="{{ $cardClasses }}">
<div class="text-xs font-semibold uppercase tracking-[0.16em] text-gray-500 dark:text-gray-400">
{{ $item['label'] ?? 'Detail' }}
</div>
<div class="mt-2 flex min-w-0 flex-wrap items-center gap-2 text-sm font-medium text-gray-900 dark:text-white">
<div class="mt-2 flex min-w-0 flex-wrap items-center gap-2 text-sm font-medium {{ $toneValueClasses }}">
@if ($displayValue !== null)
<span class="min-w-0 break-all whitespace-normal">{{ $displayValue }}</span>
<span class="min-w-0 break-all whitespace-normal {{ $mono ? 'font-mono text-xs' : '' }}">{{ $displayValue }}</span>
@endif
@if ($badge !== null)
@ -47,16 +68,25 @@
@if ($action !== null && filled($action['url'] ?? null))
<div>
<a
href="{{ $action['url'] }}"
@if (($action['openInNewTab'] ?? false) === true) target="_blank" rel="noreferrer noopener" @endif
class="inline-flex items-center gap-2 text-sm font-medium {{ ($action['destructive'] ?? false) === true ? 'text-rose-700 hover:text-rose-600 dark:text-rose-200 dark:hover:text-rose-100' : 'text-primary-600 hover:text-primary-500 dark:text-primary-400 dark:hover:text-primary-300' }}"
>
@if (filled($action['icon'] ?? null))
<x-filament::icon :icon="$action['icon']" class="h-4 w-4" />
@endif
{{ $action['label'] }}
</a>
@if (($action['openInNewTab'] ?? false) === true)
<x-filament::link
:href="$action['url']"
:icon="$action['icon'] ?? null"
size="sm"
target="_blank"
rel="noreferrer noopener"
>
{{ $action['label'] }}
</x-filament::link>
@else
<x-filament::link
:href="$action['url']"
:icon="$action['icon'] ?? null"
size="sm"
>
{{ $action['label'] }}
</x-filament::link>
@endif
</div>
@endif
</div>

View File

@ -6,31 +6,59 @@
$items = is_array($card['items'] ?? null) ? $card['items'] : [];
$emptyState = is_array($card['emptyState'] ?? null) ? $card['emptyState'] : null;
$action = is_array($card['action'] ?? null) ? $card['action'] : null;
$eyebrow = match ($card['kind'] ?? null) {
'guidance' => 'Guidance',
'lifecycle' => 'Lifecycle',
'timing' => 'Timing',
'metadata' => 'Metadata',
default => 'Supporting detail',
};
@endphp
<x-filament::section
:heading="$card['title'] ?? 'Supporting detail'"
:description="$card['description'] ?? null"
>
@if ($action !== null && filled($action['url'] ?? null))
<x-slot name="headerEnd">
<a
href="{{ $action['url'] }}"
@if (($action['openInNewTab'] ?? false) === true) target="_blank" rel="noreferrer noopener" @endif
class="text-xs font-medium {{ ($action['destructive'] ?? false) === true ? 'text-rose-700 hover:text-rose-600 dark:text-rose-200 dark:hover:text-rose-100' : 'text-primary-600 hover:text-primary-500 dark:text-primary-400 dark:hover:text-primary-300' }}"
>
{{ $action['label'] }}
</a>
</x-slot>
@endif
<div>
@if ($view !== null)
{!! view($view, is_array($card['viewData'] ?? null) ? $card['viewData'] : [])->render() !!}
@elseif ($items !== [])
@include('filament.infolists.entries.enterprise-detail.section-items', ['items' => $items])
@elseif ($emptyState !== null)
@include('filament.infolists.entries.enterprise-detail.empty-state', ['state' => $emptyState])
@endif
<div class="space-y-2" @if (filled($card['kind'] ?? null)) data-supporting-group-kind="{{ $card['kind'] }}" @endif>
<div class="text-[11px] font-semibold uppercase tracking-[0.18em] text-gray-500 dark:text-gray-400">
{{ $eyebrow }}
</div>
</x-filament::section>
<x-filament::section
:heading="$card['title'] ?? 'Supporting detail'"
:description="$card['description'] ?? null"
>
@if ($action !== null && filled($action['url'] ?? null))
<x-slot name="afterHeader">
@if (($action['openInNewTab'] ?? false) === true)
<x-filament::link
:href="$action['url']"
:icon="$action['icon'] ?? null"
size="sm"
target="_blank"
rel="noreferrer noopener"
>
{{ $action['label'] }}
</x-filament::link>
@else
<x-filament::link
:href="$action['url']"
:icon="$action['icon'] ?? null"
size="sm"
>
{{ $action['label'] }}
</x-filament::link>
@endif
</x-slot>
@endif
<div>
@if ($view !== null)
{!! view($view, is_array($card['viewData'] ?? null) ? $card['viewData'] : [])->render() !!}
@elseif ($items !== [])
@include('filament.infolists.entries.enterprise-detail.section-items', [
'items' => $items,
'variant' => 'supporting',
])
@elseif ($emptyState !== null)
@include('filament.infolists.entries.enterprise-detail.empty-state', ['state' => $emptyState])
@endif
</div>
</x-filament::section>
</div>

View File

@ -14,17 +14,16 @@
:collapsed="(bool) ($section['collapsed'] ?? true)"
>
@if ($entries !== [])
@include('filament.infolists.entries.enterprise-detail.section-items', ['items' => $entries])
@include('filament.infolists.entries.enterprise-detail.section-items', [
'items' => $entries,
'variant' => is_string($section['variant'] ?? null) ? $section['variant'] : 'technical',
])
@endif
@if ($view !== null)
@if ($entries !== [])
<div class="mt-4">
{!! view($view, is_array($section['viewData'] ?? null) ? $section['viewData'] : [])->render() !!}
</div>
@else
<div @class(['mt-4' => $entries !== []])>
{!! view($view, is_array($section['viewData'] ?? null) ? $section['viewData'] : [])->render() !!}
@endif
</div>
@elseif ($emptyState !== null)
<div @class(['mt-4' => $entries !== []])>
@include('filament.infolists.entries.enterprise-detail.empty-state', ['state' => $emptyState])

View File

@ -34,15 +34,15 @@
<div class="space-y-4">
@if ($detailState === 'structured_details_recorded' && ($structuralCount > 0 || $operationalCount > 0 || $transientCount > 0))
<div class="flex flex-wrap gap-2">
<span class="inline-flex items-center rounded-full bg-danger-100 px-2.5 py-1 text-xs font-medium text-danger-900 dark:bg-danger-900/30 dark:text-danger-100">
<x-filament::badge color="danger" size="sm">
{{ __('baseline-compare.evidence_gap_structural', ['count' => $structuralCount]) }}
</span>
<span class="inline-flex items-center rounded-full bg-primary-100 px-2.5 py-1 text-xs font-medium text-primary-900 dark:bg-primary-900/30 dark:text-primary-100">
</x-filament::badge>
<x-filament::badge color="primary" size="sm">
{{ __('baseline-compare.evidence_gap_operational', ['count' => $operationalCount]) }}
</span>
<span class="inline-flex items-center rounded-full bg-warning-100 px-2.5 py-1 text-xs font-medium text-warning-900 dark:bg-warning-900/30 dark:text-warning-100">
</x-filament::badge>
<x-filament::badge color="warning" size="sm">
{{ __('baseline-compare.evidence_gap_transient', ['count' => $transientCount]) }}
</span>
</x-filament::badge>
</div>
@endif
@ -63,9 +63,14 @@
@foreach ($buckets as $bucket)
@php
$reasonLabel = is_string($bucket['reason_label'] ?? null) ? $bucket['reason_label'] : 'Evidence gap';
$reasonCode = is_string($bucket['reason_code'] ?? null) ? $bucket['reason_code'] : 'default';
$count = is_numeric($bucket['count'] ?? null) ? (int) $bucket['count'] : 0;
$recordedCount = is_numeric($bucket['recorded_count'] ?? null) ? (int) $bucket['recorded_count'] : 0;
$missingDetailCount = is_numeric($bucket['missing_detail_count'] ?? null) ? (int) $bucket['missing_detail_count'] : 0;
$bucketHelpKey = 'baseline-compare.evidence_gap_bucket_help_'.$reasonCode;
$bucketHelp = __($bucketHelpKey) !== $bucketHelpKey
? __($bucketHelpKey)
: __('baseline-compare.evidence_gap_bucket_help_default');
@endphp
<section class="rounded-xl border border-gray-200 bg-white p-4 shadow-sm dark:border-gray-800 dark:bg-gray-900/70">
@ -75,36 +80,36 @@
{{ $reasonLabel }}
</h4>
<p class="text-xs text-gray-500 dark:text-gray-400">
{{ __('baseline-compare.evidence_gap_bucket_help') }}
{{ $bucketHelp }}
</p>
</div>
<div class="flex flex-wrap gap-2">
<span class="inline-flex items-center rounded-full bg-warning-100 px-2.5 py-1 text-xs font-medium text-warning-900 dark:bg-warning-900/40 dark:text-warning-100">
<x-filament::badge color="warning" size="sm">
{{ __('baseline-compare.evidence_gap_reason_affected', ['count' => $count]) }}
</span>
<span class="inline-flex items-center rounded-full bg-primary-100 px-2.5 py-1 text-xs font-medium text-primary-900 dark:bg-primary-900/40 dark:text-primary-100">
</x-filament::badge>
<x-filament::badge color="primary" size="sm">
{{ __('baseline-compare.evidence_gap_reason_recorded', ['count' => $recordedCount]) }}
</span>
</x-filament::badge>
@if ($missingDetailCount > 0)
<span class="inline-flex items-center rounded-full bg-gray-100 px-2.5 py-1 text-xs font-medium text-gray-700 dark:bg-gray-800 dark:text-gray-200">
<x-filament::badge color="gray" size="sm">
{{ __('baseline-compare.evidence_gap_reason_missing_detail', ['count' => $missingDetailCount]) }}
</span>
</x-filament::badge>
@endif
@if ((int) ($bucket['structural_count'] ?? 0) > 0)
<span class="inline-flex items-center rounded-full bg-danger-100 px-2.5 py-1 text-xs font-medium text-danger-900 dark:bg-danger-900/30 dark:text-danger-100">
<x-filament::badge color="danger" size="sm">
{{ __('baseline-compare.evidence_gap_bucket_structural', ['count' => (int) $bucket['structural_count']]) }}
</span>
</x-filament::badge>
@endif
@if ((int) ($bucket['operational_count'] ?? 0) > 0)
<span class="inline-flex items-center rounded-full bg-primary-100 px-2.5 py-1 text-xs font-medium text-primary-900 dark:bg-primary-900/30 dark:text-primary-100">
<x-filament::badge color="primary" size="sm">
{{ __('baseline-compare.evidence_gap_bucket_operational', ['count' => (int) $bucket['operational_count']]) }}
</span>
</x-filament::badge>
@endif
@if ((int) ($bucket['transient_count'] ?? 0) > 0)
<span class="inline-flex items-center rounded-full bg-warning-100 px-2.5 py-1 text-xs font-medium text-warning-900 dark:bg-warning-900/30 dark:text-warning-100">
<x-filament::badge color="warning" size="sm">
{{ __('baseline-compare.evidence_gap_bucket_transient', ['count' => (int) $bucket['transient_count']]) }}
</span>
</x-filament::badge>
@endif
</div>

View File

@ -32,6 +32,7 @@
$reason = is_array($state['reason'] ?? null) ? $state['reason'] : [];
$nextSteps = is_array($reason['nextSteps'] ?? null) ? $reason['nextSteps'] : [];
$operatorExplanation = is_array($state['operatorExplanation'] ?? null) ? $state['operatorExplanation'] : [];
$surface = is_string($surface ?? null) && trim($surface) !== '' ? trim($surface) : 'summary';
$evaluationSpec = is_string($operatorExplanation['evaluationResult'] ?? null)
? BadgeCatalog::spec(BadgeDomain::OperatorExplanationEvaluationResult, $operatorExplanation['evaluationResult'])
: null;
@ -39,158 +40,328 @@
? BadgeCatalog::spec(BadgeDomain::OperatorExplanationTrustworthiness, $operatorExplanation['trustworthinessLevel'])
: null;
$operatorCounts = collect(is_array($operatorExplanation['countDescriptors'] ?? null) ? $operatorExplanation['countDescriptors'] : []);
$normalizeArtifactTruthText = static function (mixed $value): ?string {
if (! is_string($value)) {
return null;
}
$value = trim((string) preg_replace('/\s+/', ' ', $value));
return $value !== '' ? $value : null;
};
$uniqueArtifactTruthParagraphs = static function (array $values) use ($normalizeArtifactTruthText): array {
$paragraphs = [];
$seen = [];
foreach ($values as $value) {
$normalized = $normalizeArtifactTruthText($value);
if ($normalized === null) {
continue;
}
$key = mb_strtolower($normalized);
if (array_key_exists($key, $seen)) {
continue;
}
$seen[$key] = true;
$paragraphs[] = $normalized;
}
return $paragraphs;
};
$decisionSummaryArtifactTruthParagraph = $normalizeArtifactTruthText(
$operatorExplanation['reliabilityStatement'] ?? ($state['primaryExplanation'] ?? null)
);
$expandedArtifactTruthParagraphs = $uniqueArtifactTruthParagraphs([
$state['primaryExplanation'] ?? null,
$operatorExplanation['reliabilityStatement'] ?? null,
data_get($operatorExplanation, 'dominantCause.explanation'),
]);
$expandedArtifactTruthParagraphs = array_values(array_filter(
$expandedArtifactTruthParagraphs,
static fn (string $paragraph): bool => $decisionSummaryArtifactTruthParagraph === null
|| mb_strtolower($paragraph) !== mb_strtolower($decisionSummaryArtifactTruthParagraph),
));
$summaryArtifactTruthParagraphs = $uniqueArtifactTruthParagraphs([
$operatorExplanation['reliabilityStatement'] ?? ($state['primaryExplanation'] ?? null),
data_get($operatorExplanation, 'dominantCause.explanation'),
]);
@endphp
<div class="space-y-4">
<div class="rounded-xl border border-gray-200 bg-white p-4 shadow-sm dark:border-gray-800 dark:bg-gray-900">
<div class="flex flex-wrap items-start gap-2">
@if ($surface === 'expanded')
<div class="space-y-4">
<div class="rounded-xl border border-gray-200 bg-white p-4 shadow-sm dark:border-gray-800 dark:bg-gray-900">
<div class="text-xs font-semibold uppercase tracking-[0.16em] text-gray-500 dark:text-gray-400">
Detailed artifact-truth context
</div>
<div class="mt-3 space-y-2">
@foreach ($expandedArtifactTruthParagraphs as $index => $paragraph)
<p class="text-sm {{ $index === 0 ? 'text-gray-700 dark:text-gray-200' : 'text-gray-600 dark:text-gray-300' }}">
{{ $paragraph }}
</p>
@endforeach
@if (is_string($operatorExplanation['coverageStatement'] ?? null) && trim($operatorExplanation['coverageStatement']) !== '')
<p class="text-xs uppercase tracking-wide text-gray-500 dark:text-gray-400">
Coverage: {{ $operatorExplanation['coverageStatement'] }}
</p>
@endif
@if (is_string($state['diagnosticLabel'] ?? null) && trim($state['diagnosticLabel']) !== '')
<p class="text-xs uppercase tracking-wide text-gray-500 dark:text-gray-400">
Diagnostic: {{ $state['diagnosticLabel'] }}
</p>
@endif
</div>
</div>
<dl class="grid gap-3 sm:grid-cols-2 xl:grid-cols-4">
@if ($evaluationSpec && $evaluationSpec->label !== 'Unknown')
<x-filament::badge :color="$evaluationSpec->color" :icon="$evaluationSpec->icon" size="sm">
{{ $evaluationSpec->label }}
</x-filament::badge>
<div class="rounded-md border border-gray-100 bg-gray-50 px-3 py-2 dark:border-gray-800 dark:bg-gray-950/60">
<dt class="text-[11px] font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">Result meaning</dt>
<dd class="mt-1">
<x-filament::badge :color="$evaluationSpec->color" :icon="$evaluationSpec->icon" size="sm">
{{ $evaluationSpec->label }}
</x-filament::badge>
</dd>
</div>
@endif
@if ($trustSpec && $trustSpec->label !== 'Unknown')
<x-filament::badge :color="$trustSpec->color" :icon="$trustSpec->icon" size="sm">
{{ $trustSpec->label }}
</x-filament::badge>
<div class="rounded-md border border-gray-100 bg-gray-50 px-3 py-2 dark:border-gray-800 dark:bg-gray-950/60">
<dt class="text-[11px] font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">Result trust</dt>
<dd class="mt-1">
<x-filament::badge :color="$trustSpec->color" :icon="$trustSpec->icon" size="sm">
{{ $trustSpec->label }}
</x-filament::badge>
</dd>
</div>
@endif
@if ($primarySpec)
<x-filament::badge :color="$primarySpec->color" :icon="$primarySpec->icon" size="sm">
{{ $primarySpec->label }}
</x-filament::badge>
@endif
<div class="rounded-md border border-gray-100 bg-gray-50 px-3 py-2 dark:border-gray-800 dark:bg-gray-950/60">
<dt class="text-[11px] font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">Primary artifact state</dt>
<dd class="mt-1">
<x-filament::badge :color="$primarySpec->color" :icon="$primarySpec->icon" size="sm">
{{ $primarySpec->label }}
</x-filament::badge>
</dd>
</div>
@endif
@if ($actionabilitySpec)
<x-filament::badge :color="$actionabilitySpec->color" :icon="$actionabilitySpec->icon" size="sm">
{{ $actionabilitySpec->label }}
</x-filament::badge>
<div class="rounded-md border border-gray-100 bg-gray-50 px-3 py-2 dark:border-gray-800 dark:bg-gray-950/60">
<dt class="text-[11px] font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">Actionability</dt>
<dd class="mt-1">
<x-filament::badge :color="$actionabilitySpec->color" :icon="$actionabilitySpec->icon" size="sm">
{{ $actionabilitySpec->label }}
</x-filament::badge>
</dd>
</div>
@endif
</div>
<div class="mt-3 space-y-2">
<div class="text-sm font-medium text-gray-950 dark:text-gray-100">
{{ $operatorExplanation['headline'] ?? ($state['primaryLabel'] ?? 'Artifact truth') }}
@if ($existenceSpec)
<div class="rounded-md border border-gray-100 bg-gray-50 px-3 py-2 dark:border-gray-800 dark:bg-gray-950/60">
<dt class="text-[11px] font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">Artifact exists</dt>
<dd class="mt-1">
<x-filament::badge :color="$existenceSpec->color" :icon="$existenceSpec->icon" size="sm">
{{ $existenceSpec->label }}
</x-filament::badge>
</dd>
</div>
@endif
@if ($freshnessSpec)
<div class="rounded-md border border-gray-100 bg-gray-50 px-3 py-2 dark:border-gray-800 dark:bg-gray-950/60">
<dt class="text-[11px] font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">Freshness</dt>
<dd class="mt-1">
<x-filament::badge :color="$freshnessSpec->color" :icon="$freshnessSpec->icon" size="sm">
{{ $freshnessSpec->label }}
</x-filament::badge>
</dd>
</div>
@endif
@if ($publicationSpec)
<div class="rounded-md border border-gray-100 bg-gray-50 px-3 py-2 dark:border-gray-800 dark:bg-gray-950/60">
<dt class="text-[11px] font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">Publication</dt>
<dd class="mt-1">
<x-filament::badge :color="$publicationSpec->color" :icon="$publicationSpec->icon" size="sm">
{{ $publicationSpec->label }}
</x-filament::badge>
</dd>
</div>
@endif
</dl>
@if ($operatorCounts->isNotEmpty())
<div class="grid gap-3 sm:grid-cols-2 xl:grid-cols-4">
@foreach ($operatorCounts as $count)
@continue(! is_array($count))
<div class="rounded-md border border-gray-100 bg-gray-50 px-3 py-2 dark:border-gray-800 dark:bg-gray-950/60">
<div class="text-[11px] font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">
{{ $count['label'] ?? 'Count' }}
</div>
<div class="mt-1 text-lg font-semibold text-gray-900 dark:text-gray-100">
{{ (int) ($count['value'] ?? 0) }}
</div>
@if (filled($count['qualifier'] ?? null))
<div class="mt-1 text-xs text-gray-500 dark:text-gray-400">
{{ $count['qualifier'] }}
</div>
@endif
</div>
@endforeach
</div>
@if (is_string($operatorExplanation['reliabilityStatement'] ?? null) && trim($operatorExplanation['reliabilityStatement']) !== '')
<p class="text-sm text-gray-600 dark:text-gray-300">
{{ $operatorExplanation['reliabilityStatement'] }}
</p>
@elseif (is_string($state['primaryExplanation'] ?? null) && trim($state['primaryExplanation']) !== '')
<p class="text-sm text-gray-600 dark:text-gray-300">
{{ $state['primaryExplanation'] }}
</p>
@endif
@if (is_string(data_get($operatorExplanation, 'dominantCause.explanation')) && trim(data_get($operatorExplanation, 'dominantCause.explanation')) !== '')
<p class="text-sm text-gray-600 dark:text-gray-300">
{{ data_get($operatorExplanation, 'dominantCause.explanation') }}
</p>
@endif
@if (is_string($operatorExplanation['coverageStatement'] ?? null) && trim($operatorExplanation['coverageStatement']) !== '')
<p class="text-xs uppercase tracking-wide text-gray-500 dark:text-gray-400">
Coverage: {{ $operatorExplanation['coverageStatement'] }}
</p>
@endif
@if (is_string($state['diagnosticLabel'] ?? null) && trim($state['diagnosticLabel']) !== '')
<p class="text-xs uppercase tracking-wide text-gray-500 dark:text-gray-400">
Diagnostic: {{ $state['diagnosticLabel'] }}
</p>
@endif
</div>
@endif
</div>
<dl class="grid gap-3 sm:grid-cols-2 xl:grid-cols-4">
@if ($existenceSpec)
<div class="rounded-md border border-gray-100 bg-gray-50 px-3 py-2 dark:border-gray-800 dark:bg-gray-950/60">
<dt class="text-[11px] font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">Artifact exists</dt>
<dd class="mt-1">
<x-filament::badge :color="$existenceSpec->color" :icon="$existenceSpec->icon" size="sm">
{{ $existenceSpec->label }}
@else
<div class="space-y-4">
<div class="rounded-xl border border-gray-200 bg-white p-4 shadow-sm dark:border-gray-800 dark:bg-gray-900">
<div class="flex flex-wrap items-start gap-2">
@if ($evaluationSpec && $evaluationSpec->label !== 'Unknown')
<x-filament::badge :color="$evaluationSpec->color" :icon="$evaluationSpec->icon" size="sm">
{{ $evaluationSpec->label }}
</x-filament::badge>
</dd>
</div>
@endif
@endif
@if ($freshnessSpec)
<div class="rounded-md border border-gray-100 bg-gray-50 px-3 py-2 dark:border-gray-800 dark:bg-gray-950/60">
<dt class="text-[11px] font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">Freshness</dt>
<dd class="mt-1">
<x-filament::badge :color="$freshnessSpec->color" :icon="$freshnessSpec->icon" size="sm">
{{ $freshnessSpec->label }}
</x-filament::badge>
</dd>
</div>
@endif
@if ($publicationSpec)
<div class="rounded-md border border-gray-100 bg-gray-50 px-3 py-2 dark:border-gray-800 dark:bg-gray-950/60">
<dt class="text-[11px] font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">Publication</dt>
<dd class="mt-1">
<x-filament::badge :color="$publicationSpec->color" :icon="$publicationSpec->icon" size="sm">
{{ $publicationSpec->label }}
</x-filament::badge>
</dd>
</div>
@endif
@if ($trustSpec && $trustSpec->label !== 'Unknown')
<div class="rounded-md border border-gray-100 bg-gray-50 px-3 py-2 dark:border-gray-800 dark:bg-gray-950/60">
<dt class="text-[11px] font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">Result trust</dt>
<dd class="mt-1">
@if ($trustSpec && $trustSpec->label !== 'Unknown')
<x-filament::badge :color="$trustSpec->color" :icon="$trustSpec->icon" size="sm">
{{ $trustSpec->label }}
</x-filament::badge>
@endif
@if ($primarySpec)
<x-filament::badge :color="$primarySpec->color" :icon="$primarySpec->icon" size="sm">
{{ $primarySpec->label }}
</x-filament::badge>
@endif
@if ($actionabilitySpec)
<x-filament::badge :color="$actionabilitySpec->color" :icon="$actionabilitySpec->icon" size="sm">
{{ $actionabilitySpec->label }}
</x-filament::badge>
@endif
</div>
<div class="mt-3 space-y-2">
<div class="text-sm font-medium text-gray-950 dark:text-gray-100">
{{ $operatorExplanation['headline'] ?? ($state['primaryLabel'] ?? 'Artifact truth') }}
</div>
@foreach ($summaryArtifactTruthParagraphs as $paragraph)
<p class="text-sm text-gray-600 dark:text-gray-300">
{{ $paragraph }}
</p>
@endforeach
@if (is_string($operatorExplanation['coverageStatement'] ?? null) && trim($operatorExplanation['coverageStatement']) !== '')
<p class="text-xs uppercase tracking-wide text-gray-500 dark:text-gray-400">
Coverage: {{ $operatorExplanation['coverageStatement'] }}
</p>
@endif
@if (is_string($state['diagnosticLabel'] ?? null) && trim($state['diagnosticLabel']) !== '')
<p class="text-xs uppercase tracking-wide text-gray-500 dark:text-gray-400">
Diagnostic: {{ $state['diagnosticLabel'] }}
</p>
@endif
</div>
</div>
<dl class="grid gap-3 sm:grid-cols-2 xl:grid-cols-4">
@if ($existenceSpec)
<div class="rounded-md border border-gray-100 bg-gray-50 px-3 py-2 dark:border-gray-800 dark:bg-gray-950/60">
<dt class="text-[11px] font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">Artifact exists</dt>
<dd class="mt-1">
<x-filament::badge :color="$existenceSpec->color" :icon="$existenceSpec->icon" size="sm">
{{ $existenceSpec->label }}
</x-filament::badge>
</dd>
</div>
@endif
@if ($freshnessSpec)
<div class="rounded-md border border-gray-100 bg-gray-50 px-3 py-2 dark:border-gray-800 dark:bg-gray-950/60">
<dt class="text-[11px] font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">Freshness</dt>
<dd class="mt-1">
<x-filament::badge :color="$freshnessSpec->color" :icon="$freshnessSpec->icon" size="sm">
{{ $freshnessSpec->label }}
</x-filament::badge>
</dd>
</div>
@endif
@if ($publicationSpec)
<div class="rounded-md border border-gray-100 bg-gray-50 px-3 py-2 dark:border-gray-800 dark:bg-gray-950/60">
<dt class="text-[11px] font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">Publication</dt>
<dd class="mt-1">
<x-filament::badge :color="$publicationSpec->color" :icon="$publicationSpec->icon" size="sm">
{{ $publicationSpec->label }}
</x-filament::badge>
</dd>
</div>
@endif
@if ($trustSpec && $trustSpec->label !== 'Unknown')
<div class="rounded-md border border-gray-100 bg-gray-50 px-3 py-2 dark:border-gray-800 dark:bg-gray-950/60">
<dt class="text-[11px] font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">Result trust</dt>
<dd class="mt-1">
<x-filament::badge :color="$trustSpec->color" :icon="$trustSpec->icon" size="sm">
{{ $trustSpec->label }}
</x-filament::badge>
</dd>
</div>
@endif
<div class="rounded-md border border-gray-100 bg-gray-50 px-3 py-2 dark:border-gray-800 dark:bg-gray-950/60">
<dt class="text-[11px] font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">Next step</dt>
<dd class="mt-1 text-sm text-gray-900 dark:text-gray-100">
{{ data_get($operatorExplanation, 'nextAction.text') ?? ($state['nextActionLabel'] ?? 'No action needed') }}
</dd>
</div>
</dl>
@if ($operatorCounts->isNotEmpty())
<div class="grid gap-3 sm:grid-cols-2 xl:grid-cols-4">
@foreach ($operatorCounts as $count)
@continue(! is_array($count))
<div class="rounded-md border border-gray-100 bg-gray-50 px-3 py-2 dark:border-gray-800 dark:bg-gray-950/60">
<div class="text-[11px] font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">
{{ $count['label'] ?? 'Count' }}
</div>
<div class="mt-1 text-lg font-semibold text-gray-900 dark:text-gray-100">
{{ (int) ($count['value'] ?? 0) }}
</div>
@if (filled($count['qualifier'] ?? null))
<div class="mt-1 text-xs text-gray-500 dark:text-gray-400">
{{ $count['qualifier'] }}
</div>
@endif
</div>
@endforeach
</div>
@endif
<div class="rounded-md border border-gray-100 bg-gray-50 px-3 py-2 dark:border-gray-800 dark:bg-gray-950/60">
<dt class="text-[11px] font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">Next step</dt>
<dd class="mt-1 text-sm text-gray-900 dark:text-gray-100">
{{ data_get($operatorExplanation, 'nextAction.text') ?? ($state['nextActionLabel'] ?? 'No action needed') }}
</dd>
</div>
</dl>
@if ($nextSteps !== [])
<div class="space-y-2">
<div class="text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">Guidance</div>
<ul class="space-y-1 text-sm text-gray-700 dark:text-gray-300">
@foreach ($nextSteps as $step)
@continue(! is_string($step) || trim($step) === '')
@if ($operatorCounts->isNotEmpty())
<div class="grid gap-3 sm:grid-cols-2 xl:grid-cols-4">
@foreach ($operatorCounts as $count)
@continue(! is_array($count))
<div class="rounded-md border border-gray-100 bg-gray-50 px-3 py-2 dark:border-gray-800 dark:bg-gray-950/60">
<div class="text-[11px] font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">
{{ $count['label'] ?? 'Count' }}
</div>
<div class="mt-1 text-lg font-semibold text-gray-900 dark:text-gray-100">
{{ (int) ($count['value'] ?? 0) }}
</div>
@if (filled($count['qualifier'] ?? null))
<div class="mt-1 text-xs text-gray-500 dark:text-gray-400">
{{ $count['qualifier'] }}
</div>
@endif
</div>
@endforeach
</div>
@endif
@if ($nextSteps !== [])
<div class="space-y-2">
<div class="text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">Guidance</div>
<ul class="space-y-1 text-sm text-gray-700 dark:text-gray-300">
@foreach ($nextSteps as $step)
@continue(! is_string($step) || trim($step) === '')
<li class="rounded-md border border-primary-100 bg-primary-50 px-3 py-2 dark:border-primary-900/40 dark:bg-primary-950/30">
{{ $step }}
</li>
@endforeach
</ul>
</div>
@endif
</div>
<li class="rounded-md border border-primary-100 bg-primary-50 px-3 py-2 dark:border-primary-900/40 dark:bg-primary-950/30">
{{ $step }}
</li>
@endforeach
</ul>
</div>
@endif
</div>
@endif

View File

@ -27,9 +27,9 @@
<div class="flex items-start justify-between gap-4">
<div class="min-w-0 space-y-1">
@if ($isAvailable)
<a href="{{ $entry['targetUrl'] }}" class="text-sm font-semibold text-primary-600 hover:text-primary-500 hover:underline dark:text-primary-400">
<x-filament::link :href="$entry['targetUrl']">
{{ $entry['value'] ?? 'Open related record' }}
</a>
</x-filament::link>
@else
<div class="text-sm font-semibold text-gray-900 dark:text-white">
{{ $entry['value'] ?? 'Unavailable' }}
@ -63,12 +63,13 @@
@endunless
@if ($isAvailable && filled($entry['actionLabel'] ?? null))
<a
href="{{ $entry['targetUrl'] }}"
class="text-xs font-medium text-primary-600 hover:text-primary-500 hover:underline dark:text-primary-400"
<x-filament::link
:href="$entry['targetUrl']"
icon="heroicon-m-arrow-top-right-on-square"
size="sm"
>
{{ $entry['actionLabel'] }}
</a>
</x-filament::link>
@endif
</div>
</div>

View File

@ -8,6 +8,7 @@
$duplicateNamePoliciesCountValue = (int) ($duplicateNamePoliciesCount ?? 0);
$duplicateNameSubjectsCountValue = (int) ($duplicateNameSubjectsCount ?? 0);
$explanation = is_array($operatorExplanation ?? null) ? $operatorExplanation : null;
$summary = is_array($summaryAssessment ?? null) ? $summaryAssessment : null;
$explanationCounts = collect(is_array($explanation['countDescriptors'] ?? null) ? $explanation['countDescriptors'] : []);
$evaluationSpec = is_string($explanation['evaluationResult'] ?? null)
? \App\Support\Badges\BadgeCatalog::spec(\App\Support\Badges\BadgeDomain::OperatorExplanationEvaluationResult, $explanation['evaluationResult'])
@ -15,6 +16,14 @@
$trustSpec = is_string($explanation['trustworthinessLevel'] ?? null)
? \App\Support\Badges\BadgeCatalog::spec(\App\Support\Badges\BadgeDomain::OperatorExplanationTrustworthiness, $explanation['trustworthinessLevel'])
: null;
$summaryLabel = match ($summary['stateFamily'] ?? null) {
'positive' => 'Aligned',
'caution' => 'Needs review',
'stale' => 'Refresh recommended',
'action_required' => 'Action required',
'in_progress' => 'In progress',
default => 'Unavailable',
};
@endphp
@if ($duplicateNamePoliciesCountValue > 0)
@ -41,6 +50,12 @@
<x-filament::section>
<div class="space-y-4">
<div class="flex flex-wrap items-start gap-2">
@if ($summary)
<x-filament::badge :color="$summary['tone'] ?? 'gray'" size="sm">
{{ $summaryLabel }}
</x-filament::badge>
@endif
@if ($evaluationSpec && $evaluationSpec->label !== 'Unknown')
<x-filament::badge :color="$evaluationSpec->color" :icon="$evaluationSpec->icon" size="sm">
{{ $evaluationSpec->label }}
@ -56,9 +71,15 @@
<div class="space-y-2">
<div class="text-lg font-semibold text-gray-950 dark:text-white">
{{ $explanation['headline'] ?? 'Compare explanation' }}
{{ $summary['headline'] ?? ($explanation['headline'] ?? 'Compare explanation') }}
</div>
@if (filled($summary['supportingMessage'] ?? null))
<p class="text-sm text-gray-700 dark:text-gray-200">
{{ $summary['supportingMessage'] }}
</p>
@endif
@if (filled($explanation['reliabilityStatement'] ?? null))
<p class="text-sm text-gray-700 dark:text-gray-200">
{{ $explanation['reliabilityStatement'] }}
@ -90,7 +111,7 @@
<div class="rounded-lg border border-gray-200 bg-gray-50 px-4 py-3 dark:border-gray-800 dark:bg-gray-950/50 md:col-span-2">
<dt class="text-[11px] font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">What to do next</dt>
<dd class="mt-1 text-sm font-medium text-gray-900 dark:text-white">
{{ data_get($explanation, 'nextAction.text', 'Review the latest compare run.') }}
{{ data_get($summary, 'nextAction.label') ?? data_get($explanation, 'nextAction.text', 'Review the latest compare run.') }}
</dd>
</div>
</dl>
@ -190,7 +211,15 @@ class="w-fit"
{{ __('baseline-compare.comparing_indicator') }}
</div>
@elseif (($findingsCount ?? 0) === 0 && $state === 'ready')
<span class="text-sm {{ $whyNoFindingsColor }}">{{ $whyNoFindingsMessage ?? $whyNoFindingsFallback }}</span>
<div class="space-y-1">
<span class="text-sm {{ $whyNoFindingsColor }}">{{ $summary['headline'] ?? ($whyNoFindingsMessage ?? $whyNoFindingsFallback) }}</span>
@if (filled($summary['supportingMessage'] ?? null))
<div class="text-xs text-gray-500 dark:text-gray-400">
{{ $summary['supportingMessage'] }}
</div>
@endif
</div>
@endif
</div>
</x-filament::section>

View File

@ -1,3 +1,58 @@
@php
/** @var array<string, mixed>|null $summaryAssessment */
$summary = is_array($summaryAssessment ?? null) ? $summaryAssessment : null;
$summaryState = (string) ($summary['stateFamily'] ?? 'unavailable');
$summaryTone = (string) ($summary['tone'] ?? 'gray');
$findingsCount = (int) ($summary['findingsVisibleCount'] ?? 0);
$highSeverityCount = (int) ($summary['highSeverityCount'] ?? 0);
$nextAction = is_array($summary['nextAction'] ?? null) ? $summary['nextAction'] : ['label' => 'Review baseline compare', 'target' => 'none'];
$summaryLabel = match ($summaryState) {
'positive' => 'Aligned',
'caution' => 'Needs review',
'stale' => 'Refresh recommended',
'action_required' => 'Action required',
'in_progress' => 'In progress',
default => 'Unavailable',
};
[$cardClasses, $iconClasses, $textClasses] = match ($summaryTone) {
'success' => [
'rounded-lg border border-success-300 bg-success-50 p-4 dark:border-success-700 dark:bg-success-950/40',
'h-5 w-5 shrink-0 text-success-600 dark:text-success-400',
'text-success-900 dark:text-success-100',
],
'danger' => [
'rounded-lg border border-danger-300 bg-danger-50 p-4 dark:border-danger-700 dark:bg-danger-950/40',
'h-5 w-5 shrink-0 text-danger-600 dark:text-danger-400',
'text-danger-900 dark:text-danger-100',
],
'info' => [
'rounded-lg border border-info-300 bg-info-50 p-4 dark:border-info-700 dark:bg-info-950/40',
'h-5 w-5 shrink-0 text-info-600 dark:text-info-400',
'text-info-900 dark:text-info-100',
],
'warning' => [
'rounded-lg border border-warning-300 bg-warning-50 p-4 dark:border-warning-700 dark:bg-warning-950/40',
'h-5 w-5 shrink-0 text-warning-600 dark:text-warning-400',
'text-warning-900 dark:text-warning-100',
],
default => [
'rounded-lg border border-gray-200 bg-gray-50 p-4 dark:border-gray-800 dark:bg-white/5',
'h-5 w-5 shrink-0 text-gray-500 dark:text-gray-400',
'text-gray-900 dark:text-white',
],
};
$summaryIcon = match ($summaryState) {
'positive' => 'heroicon-o-check-circle',
'action_required' => 'heroicon-o-exclamation-triangle',
'in_progress' => 'heroicon-o-arrow-path',
'stale' => 'heroicon-o-clock',
default => 'heroicon-o-information-circle',
};
@endphp
<x-filament::section heading="Baseline Governance">
@if ($landingUrl)
<x-slot name="afterHeader">
@ -15,63 +70,67 @@
<div class="mt-0.5 text-sm text-gray-500 dark:text-gray-400">Assign a baseline profile to start monitoring drift.</div>
</div>
</div>
@elseif (($state ?? null) === 'no_snapshot')
<div class="flex items-start gap-3 rounded-lg border border-warning-300 bg-warning-50 p-4 dark:border-warning-700 dark:bg-warning-950/40">
<x-heroicon-o-camera class="mt-0.5 h-5 w-5 shrink-0 text-warning-500 dark:text-warning-400" />
<div>
<div class="text-sm font-medium text-warning-900 dark:text-warning-100">Current Baseline Unavailable</div>
<div class="mt-0.5 text-sm text-warning-800 dark:text-warning-200">{{ $message }}</div>
</div>
</div>
@else
@elseif ($summary)
<div class="flex flex-col gap-3">
{{-- Profile + last compared --}}
<div class="flex items-center justify-between text-sm">
<div class="text-gray-600 dark:text-gray-300">
Baseline: <span class="font-medium text-gray-950 dark:text-white">{{ $profileName }}</span>
</div>
@if ($lastComparedAt)
<div class="text-gray-500 dark:text-gray-400">{{ $lastComparedAt }}</div>
@endif
</div>
{{-- Findings summary --}}
@if ($findingsCount > 0)
{{-- Critical banner (inline) --}}
@if ($highCount > 0)
<div class="flex items-center gap-2 rounded-lg border border-danger-300 bg-danger-50 px-3 py-2 dark:border-danger-700 dark:bg-danger-950/50">
<x-heroicon-s-exclamation-triangle class="h-4 w-4 shrink-0 text-danger-600 dark:text-danger-400" />
<span class="text-sm font-medium text-danger-800 dark:text-danger-200">
{{ $highCount }} high-severity {{ Str::plural('finding', $highCount) }}
</span>
</div>
@endif
<div class="{{ $cardClasses }}">
<div class="flex items-start gap-3">
<x-filament::icon :icon="$summaryIcon" class="{{ $iconClasses }}" />
<div class="flex items-center justify-between">
<div class="flex flex-wrap items-center gap-2">
<x-filament::badge color="danger" size="sm">
{{ $findingsCount }} {{ Str::plural('finding', $findingsCount) }}
</x-filament::badge>
@if ($mediumCount > 0)
<x-filament::badge color="warning" size="sm">
{{ $mediumCount }} medium
<div class="min-w-0 flex-1 space-y-3">
<div class="flex flex-wrap items-center gap-2">
<x-filament::badge :color="$summaryTone" size="sm">
{{ $summaryLabel }}
</x-filament::badge>
@endif
@if ($lowCount > 0)
<x-filament::badge color="gray" size="sm">
{{ $lowCount }} low
</x-filament::badge>
@endif
@if ($findingsCount > 0)
<x-filament::badge color="danger" size="sm">
{{ $findingsCount }} {{ Str::plural('finding', $findingsCount) }}
</x-filament::badge>
@endif
@if ($highSeverityCount > 0)
<x-filament::badge color="danger" size="sm">
{{ $highSeverityCount }} high severity
</x-filament::badge>
@endif
</div>
<div class="space-y-1">
<div class="text-sm font-semibold {{ $textClasses }}">
{{ $summary['headline'] }}
</div>
@if (filled($summary['supportingMessage'] ?? null))
<div class="text-sm text-gray-700 dark:text-gray-200">
{{ $summary['supportingMessage'] }}
</div>
@endif
</div>
<div>
@if (filled($nextActionUrl))
<x-filament::link :href="$nextActionUrl" size="sm" class="font-medium">
{{ $nextAction['label'] }}
</x-filament::link>
@elseif (filled($nextAction['label'] ?? null))
<div class="text-xs font-medium uppercase tracking-wide text-gray-600 dark:text-gray-300">
{{ $nextAction['label'] }}
</div>
@endif
</div>
</div>
</div>
@else
<div class="flex items-center gap-2 rounded-lg bg-success-50 px-3 py-2 dark:bg-success-950/50">
<x-heroicon-o-check-circle class="h-4 w-4 shrink-0 text-success-600 dark:text-success-400" />
<span class="text-sm font-medium text-success-700 dark:text-success-300">No open drift baseline compliant</span>
</div>
@endif
</div>
</div>
@endif
</x-filament::section>

View File

@ -3,53 +3,58 @@
wire:poll.{{ $pollingInterval }}
@endif
>
<x-filament::section heading="Needs Attention">
@if (count($items) === 0)
<div class="flex flex-col gap-3">
<div class="text-sm text-gray-600 dark:text-gray-300">
Everything looks healthy right now.
</div>
<x-filament::section heading="Needs Attention">
@if (count($items) === 0)
<div class="flex flex-col gap-3">
@foreach ($healthyChecks as $check)
<div class="flex items-start gap-3">
<x-filament::icon
icon="heroicon-m-check-circle"
class="mt-0.5 h-5 w-5 text-success-600 dark:text-success-400"
/>
<div class="text-sm text-gray-600 dark:text-gray-300">
Current dashboard signals look trustworthy.
</div>
<div class="flex-1">
<div class="text-sm font-medium text-gray-950 dark:text-white">{{ $check['title'] }}</div>
<div class="mt-0.5 text-sm text-gray-600 dark:text-gray-300">{{ $check['body'] }}</div>
<div class="flex flex-col gap-3">
@foreach ($healthyChecks as $check)
<div class="flex items-start gap-3 rounded-lg bg-gray-50 p-4 dark:bg-white/5">
<x-filament::icon
icon="heroicon-m-check-circle"
class="mt-0.5 h-5 w-5 text-success-600 dark:text-success-400"
/>
<div class="mt-1">
<x-filament::link :href="$check['url']" size="sm">
{{ $check['linkLabel'] }}
</x-filament::link>
<div class="flex-1">
<div class="text-sm font-medium text-gray-950 dark:text-white">{{ $check['title'] }}</div>
<div class="mt-0.5 text-sm text-gray-600 dark:text-gray-300">{{ $check['body'] }}</div>
</div>
</div>
@endforeach
</div>
</div>
@else
<div class="flex flex-col gap-3">
@foreach ($items as $item)
<div class="rounded-lg bg-gray-50 p-4 dark:bg-white/5">
<div class="flex items-start justify-between gap-3">
<div class="min-w-0 flex-1">
<div class="text-sm font-semibold text-gray-950 dark:text-white">{{ $item['title'] }}</div>
<div class="mt-1 text-sm text-gray-600 dark:text-gray-300">{{ $item['body'] }}</div>
@if (filled($item['supportingMessage'] ?? null))
<div class="mt-1 text-sm text-gray-600 dark:text-gray-300">
{{ $item['supportingMessage'] }}
</div>
@endif
@if (filled($item['nextStep'] ?? null))
<div class="mt-2 text-xs font-medium uppercase tracking-wide text-gray-500 dark:text-gray-400">
{{ $item['nextStep'] }}
</div>
@endif
</div>
<x-filament::badge :color="$item['badgeColor']" size="sm">
{{ $item['badge'] }}
</x-filament::badge>
</div>
</div>
@endforeach
</div>
</div>
@else
<div class="flex flex-col gap-3">
@foreach ($items as $item)
<a
href="{{ $item['url'] }}"
class="rounded-lg bg-gray-50 p-4 text-left transition hover:bg-gray-100 dark:bg-white/5 dark:hover:bg-white/10"
>
<div class="flex items-start justify-between gap-3">
<div class="text-sm font-semibold text-gray-950 dark:text-white">{{ $item['title'] }}</div>
<x-filament::badge :color="$item['badgeColor']" size="sm">
{{ $item['badge'] }}
</x-filament::badge>
</div>
<div class="mt-1 text-sm text-gray-600 dark:text-gray-300">{{ $item['body'] }}</div>
</a>
@endforeach
</div>
@endif
</x-filament::section>
@endif
</x-filament::section>
</div>

View File

@ -1,53 +1,55 @@
@php
/** @var bool $shouldShow */
/** @var ?string $runUrl */
/** @var ?string $state */
/** @var ?string $message */
/** @var ?string $coverageStatus */
/** @var ?string $fidelity */
/** @var int $uncoveredTypesCount */
/** @var list<string> $uncoveredTypes */
/** @var array<string, mixed>|null $summaryAssessment */
$summary = is_array($summaryAssessment ?? null) ? $summaryAssessment : null;
$tone = (string) ($summary['tone'] ?? 'warning');
$headline = (string) ($summary['headline'] ?? 'Baseline compare needs review.');
$supportingMessage = $summary['supportingMessage'] ?? null;
$nextAction = is_array($summary['nextAction'] ?? null) ? $summary['nextAction'] : ['label' => 'Review compare detail', 'target' => 'none'];
$coverageHasWarnings = in_array(($coverageStatus ?? null), ['warning', 'unproven'], true);
[$wrapperClasses, $textClasses] = match ($tone) {
'danger' => [
'rounded-lg border border-danger-300 bg-danger-50 p-4 dark:border-danger-700 dark:bg-danger-950/40',
'text-danger-900 dark:text-danger-100',
],
'info' => [
'rounded-lg border border-info-300 bg-info-50 p-4 dark:border-info-700 dark:bg-info-950/40',
'text-info-900 dark:text-info-100',
],
'gray' => [
'rounded-lg border border-gray-200 bg-gray-50 p-4 dark:border-gray-800 dark:bg-white/5',
'text-gray-900 dark:text-white',
],
default => [
'rounded-lg border border-warning-300 bg-warning-50 p-4 dark:border-warning-700 dark:bg-warning-950/40',
'text-warning-900 dark:text-warning-100',
],
};
@endphp
<div>
@if ($shouldShow && ($coverageHasWarnings || ($state ?? null) === 'no_snapshot'))
<div class="rounded-lg border border-warning-300 bg-warning-50 p-4 text-warning-900 dark:border-warning-700 dark:bg-warning-950/40 dark:text-warning-100">
<div class="flex flex-col gap-1">
<div class="text-sm font-semibold">
@if (($state ?? null) === 'no_snapshot')
Current baseline unavailable
@else
Baseline compare coverage warnings
@endif
</div>
<div class="text-sm">
@if (($state ?? null) === 'no_snapshot')
{{ $message }}
@elseif (($coverageStatus ?? null) === 'unproven')
Coverage proof was missing or unreadable for the last baseline comparison, so findings were suppressed for safety.
@else
The last baseline comparison had incomplete coverage for {{ (int) $uncoveredTypesCount }} policy {{ Str::plural('type', (int) $uncoveredTypesCount) }}. Findings may be incomplete.
@endif
@if (filled($fidelity))
<span class="ml-1 text-xs text-warning-800 dark:text-warning-300">Fidelity: {{ Str::title($fidelity) }}</span>
@endif
@if ($shouldShow && $summary)
<div class="{{ $wrapperClasses }}">
<div class="flex flex-col gap-2">
<div class="text-sm font-semibold {{ $textClasses }}">
{{ $headline }}
</div>
@if (! empty($uncoveredTypes))
<div class="mt-1 text-xs">
Uncovered: {{ implode(', ', array_slice($uncoveredTypes, 0, 6)) }}@if (count($uncoveredTypes) > 6)@endif
@if (filled($supportingMessage))
<div class="text-sm">
{{ $supportingMessage }}
</div>
@endif
@if (($state ?? null) !== 'no_snapshot' && filled($runUrl))
<div class="mt-2">
<a class="text-sm font-medium text-primary-600 hover:underline dark:text-primary-400" href="{{ $runUrl }}">
View run
@if (filled($nextActionUrl))
<div class="mt-1">
<a class="text-sm font-medium text-primary-600 hover:underline dark:text-primary-400" href="{{ $nextActionUrl }}">
{{ $nextAction['label'] }}
</a>
</div>
@elseif (filled($nextAction['label'] ?? null))
<div class="text-xs font-medium uppercase tracking-wide">
{{ $nextAction['label'] }}
</div>
@endif
</div>
</div>

View File

@ -0,0 +1,36 @@
# Specification Quality Checklist: Operation Run Detail Hierarchy, Deduplication, and Decision Guidance Hardening
**Purpose**: Validate specification completeness and quality before proceeding to planning
**Created**: 2026-03-26
**Feature**: [spec.md](../spec.md)
## Content Quality
- [x] No implementation details (languages, frameworks, APIs)
- [x] Focused on user value and business needs
- [x] Written for non-technical stakeholders
- [x] All mandatory sections completed
## Requirement Completeness
- [x] No [NEEDS CLARIFICATION] markers remain
- [x] Requirements are testable and unambiguous
- [x] Success criteria are measurable
- [x] Success criteria are technology-agnostic (no implementation details)
- [x] All acceptance scenarios are defined
- [x] Edge cases are identified
- [x] Scope is clearly bounded
- [x] Dependencies and assumptions identified
## Feature Readiness
- [x] All functional requirements have clear acceptance criteria
- [x] User scenarios cover primary flows
- [x] Feature meets measurable outcomes defined in Success Criteria
- [x] No implementation details leak into specification
## Notes
- Validation pass 1 completed with no open issues.
- The spec stays scoped to the canonical run-detail decision surface and keeps route, RBAC, and domain semantics unchanged.
- The spec is ready for `/speckit.plan`.

View File

@ -0,0 +1,303 @@
openapi: 3.1.0
info:
title: Operation Run Detail Page Contract
version: 1.0.0
description: >-
Internal reference contract for the canonical operation-run detail page. The route
still returns rendered HTML; the structured schema below documents the decision-first
page payload that must be derivable before rendering. This is not a public API commitment.
paths:
/admin/operations/{run}:
get:
summary: Canonical operation-run detail page
description: >-
Returns the rendered canonical run-detail page. The vendor media type documents
the internal structured page model used to drive the enterprise-detail surface.
parameters:
- name: run
in: path
required: true
schema:
type: integer
responses:
'200':
description: Rendered canonical run detail page
content:
text/html:
schema:
type: string
application/vnd.tenantpilot.operation-run-detail+json:
schema:
$ref: '#/components/schemas/OperationRunDetailPage'
'403':
description: Viewer is in scope but lacks the required capability
'404':
description: Run is not visible because it does not exist or tenant/workspace entitlement is missing
components:
schemas:
OperationRunDetailPage:
type: object
required:
- header
- decisionZone
- supportingGroups
- mainSections
- technicalSections
properties:
header:
$ref: '#/components/schemas/PageHeader'
decisionZone:
$ref: '#/components/schemas/DecisionZone'
supportingGroups:
type: array
items:
$ref: '#/components/schemas/SupportingGroup'
mainSections:
type: array
items:
$ref: '#/components/schemas/DetailSection'
technicalSections:
type: array
items:
$ref: '#/components/schemas/DetailSection'
attentionBanners:
type: array
items:
$ref: '#/components/schemas/AttentionBanner'
PageHeader:
type: object
required:
- title
- statusBadges
- keyFacts
properties:
title:
type: string
subtitle:
type:
- string
- 'null'
statusBadges:
type: array
items:
$ref: '#/components/schemas/Badge'
keyFacts:
type: array
items:
$ref: '#/components/schemas/Fact'
primaryActions:
type: array
items:
$ref: '#/components/schemas/PageAction'
descriptionHint:
type:
- string
- 'null'
DecisionZone:
type: object
required:
- executionState
- outcome
- primaryNextStep
properties:
executionState:
$ref: '#/components/schemas/Fact'
outcome:
$ref: '#/components/schemas/Fact'
artifactTruth:
oneOf:
- $ref: '#/components/schemas/Fact'
- type: 'null'
resultMeaning:
oneOf:
- $ref: '#/components/schemas/Fact'
- type: 'null'
resultTrust:
oneOf:
- $ref: '#/components/schemas/Fact'
- type: 'null'
primaryNextStep:
$ref: '#/components/schemas/PrimaryNextStep'
compactCounts:
oneOf:
- $ref: '#/components/schemas/CountPresentation'
- type: 'null'
attentionNote:
type:
- string
- 'null'
PrimaryNextStep:
type: object
required:
- text
- source
properties:
text:
type: string
source:
type: string
enum:
- operator_explanation
- artifact_truth
- blocked_reason
- lifecycle_attention
- ops_ux
- none_required
secondaryGuidance:
type: array
items:
type: string
CountPresentation:
type: object
properties:
summaryLine:
type:
- string
- 'null'
primaryFacts:
type: array
items:
$ref: '#/components/schemas/Fact'
diagnosticFacts:
type: array
items:
$ref: '#/components/schemas/Fact'
SupportingGroup:
type: object
required:
- kind
- title
properties:
kind:
type: string
enum:
- guidance
- lifecycle
- timing
- metadata
title:
type: string
description:
type:
- string
- 'null'
items:
type: array
items:
$ref: '#/components/schemas/Fact'
DetailSection:
type: object
required:
- id
- title
properties:
id:
type: string
kind:
type:
- string
- 'null'
title:
type: string
description:
type:
- string
- 'null'
collapsible:
type: boolean
collapsed:
type: boolean
items:
type: array
items:
$ref: '#/components/schemas/Fact'
AttentionBanner:
type: object
required:
- tone
- title
- body
properties:
tone:
type: string
enum:
- info
- amber
- rose
- slate
title:
type: string
body:
type: string
Fact:
type: object
required:
- label
- value
properties:
label:
type: string
value:
type: string
hint:
type:
- string
- 'null'
badge:
oneOf:
- $ref: '#/components/schemas/Badge'
- type: 'null'
Badge:
type: object
required:
- label
properties:
label:
type: string
color:
type:
- string
- 'null'
icon:
type:
- string
- 'null'
iconColor:
type:
- string
- 'null'
PageAction:
type: object
required:
- label
- destructive
- requiresConfirmation
- visible
- openInNewTab
properties:
label:
type: string
placement:
type:
- string
- 'null'
url:
type:
- string
- 'null'
actionName:
type:
- string
- 'null'
destructive:
type: boolean
requiresConfirmation:
type: boolean
visible:
type: boolean
icon:
type:
- string
- 'null'
openInNewTab:
type: boolean

View File

@ -0,0 +1,182 @@
# Data Model: Operation Run Detail Hierarchy, Deduplication, and Decision Guidance Hardening
## Overview
This feature does not add or change persisted domain entities. It introduces a stricter derived page model for the canonical operation-run detail surface using existing `OperationRun`, artifact-truth, operator-explanation, summary-count, and lifecycle data.
The core design task is to transform an existing run record into a decision-first presentation contract without changing:
- `OperationRun` persistence
- route identity
- RBAC semantics
- status and outcome lifecycle ownership
- type-specific payload availability
## Existing Persistent Entity
### OperationRun
- Purpose: Canonical workspace-owned execution record for operational work tracked in Monitoring.
- Persistent fields used by this feature:
- `id`
- `workspace_id`
- `tenant_id`
- `type`
- `status`
- `outcome`
- `initiator_name`
- `summary_counts`
- `failure_summary`
- `context`
- `created_at`
- `started_at`
- `completed_at`
- Existing relationships used by this feature:
- `workspace`
- `tenant`
- `user`
## Derived View Models
### 1. RunDetailPageModel
Top-level page payload consumed by the enterprise-detail layout and viewer wrapper.
| Field | Type | Source | Notes |
|---|---|---|---|
| `header` | object | Existing `SummaryHeaderData` | Run identity, badges, key facts, existing page-level actions |
| `decisionZone` | object | New derived payload | First-class operator summary immediately after header |
| `supportingGroups` | list<object> | Existing supporting-card inputs regrouped | Replaces or restructures the current dense `Current state` card |
| `mainSections` | list<object> | Existing section builder output | Used for supporting detail and type-specific detail after the decision zone |
| `technicalSections` | list<object> | Existing technical-detail output | Diagnostics-last content such as context JSON |
| `attentionBanners` | list<object> | Existing viewer wrapper helpers | Context mismatch, blocked prerequisite, stale or reconciled lifecycle attention |
## 2. DecisionZoneModel
The primary operator reading block. It must answer: what happened, is the result trustworthy enough to use, and what should happen next.
| Field | Type | Source | Required | Notes |
|---|---|---|---|---|
| `executionState` | badge or labeled fact | `status` plus centralized badge rendering | Yes | Lifecycle state, not result quality |
| `outcome` | badge or labeled fact | `outcome` plus centralized badge rendering | Yes | Execution result, not artifact usability |
| `artifactTruth` | object | `ArtifactTruthPresenter::forOperationRun()` when available | Conditional | Required when the run has artifact-relevant operator meaning |
| `resultMeaning` | string | operator explanation headline or evaluation label | Conditional | Human-readable interpretation of what the result means |
| `resultTrust` | string or badge | operator explanation trust level | Conditional | Confidence or reliability meaning stays distinct from outcome |
| `primaryNextStep` | object | Derived precedence chain | Yes | Exactly one primary next step |
| `compactCounts` | object or null | `SummaryCountsNormalizer` output | Optional | Only when counts add decision value and do not duplicate diagnostics |
| `attentionNote` | string or null | lifecycle or caveat helper | Optional | Short exceptional-state caveat if needed in-zone |
### PrimaryNextStepModel
| Field | Type | Source | Notes |
|---|---|---|---|
| `text` | string | Derived from prioritized guidance sources | Canonical operator action statement |
| `source` | enum-like string | `operator_explanation`, `artifact_truth`, `ops_ux`, `blocked_reason`, `lifecycle_attention` | Debugging and testability only |
| `secondaryGuidance` | list<string> | Non-primary guidance sources | Must not render as equal-priority duplicates |
### Proposed precedence for `primaryNextStep`
1. Operator explanation next action when present
2. Artifact-truth next step when it is more specific than generic run guidance
3. Blocked-execution guidance when outcome is `blocked`
4. Lifecycle guidance when stale or reconciled state requires explicit attention
5. Generic `OperationUxPresenter::surfaceGuidance()`
6. Fallback `No action needed` only when the result is explicitly trustworthy and no follow-up is indicated
## 3. SupportingGroupModel
Semantically grouped supporting context that sits below or beside the decision zone but above diagnostics.
| Group | Purpose | Typical fields |
|---|---|---|
| `guidance` | Secondary operator context that supports the primary next step | coverage statement, contextual caveat, related follow-up links |
| `lifecycle` | Freshness, reconciliation, tenant lifecycle, and contextual caveats | freshness, lifecycle truth, reconciled at, reconciled by |
| `timing` | Operational timestamps and elapsed timing | created, started, completed, elapsed |
| `metadata` | Secondary facts that do not drive the first decision | target scope, initiator, viewer-context notes |
Rules:
- The same semantic fact must not appear in multiple supporting groups unless the representation serves a genuinely different purpose.
- Supporting groups must not restate the same primary next step as the decision zone.
- Supporting groups may explain why the primary next step exists, but not compete with it.
## 4. CountPresentationModel
| Field | Type | Purpose |
|---|---|---|
| `summaryLine` | string or null | Compact operator-facing count hint |
| `primaryFacts` | list<object> | Single main count presentation if counts materially help first-pass understanding |
| `diagnosticFacts` | list<object> | Optional deeper breakdown when the detailed count grid serves investigation |
Rules:
- `primaryFacts` and `diagnosticFacts` must not be identical renderings of the same normalized counts.
- If the detailed count grid remains in main content, the compact summary line must be materially different and lighter-weight.
- If counts are low-signal for the run, both may be omitted.
## 5. TypeSpecificDetailModel
Represents operation-type-specific content that follows the canonical decision summary.
| Operation Type | Existing section examples | Ordering rule |
|---|---|---|
| `baseline_compare` | compare facts, evidence gap details, compare evidence | After decision zone and supporting groups |
| `baseline_capture` | capture evidence | After decision zone and supporting groups |
| verification-capable runs | verification report | After decision zone and supporting groups |
| any run with failures | failures section | After decision zone and supporting groups |
| reconciled runs | lifecycle reconciliation payload | After decision zone and supporting groups |
Rules:
- Type-specific sections may deepen or explain the decision, but they must not replace the canonical summary.
- Raw JSON remains valid only as diagnostics or deep detail.
## 6. DiagnosticSectionModel
Lower-priority technical or investigative information.
| Section | Source | Default posture |
|---|---|---|
| `failures` | `failure_summary` | Visible after decision and supporting context |
| `reconciliation` | `context.reconciliation` | Visible only when relevant; diagnostic |
| `context` | redacted `context` payload | Collapsed by default |
| raw evidence payloads | baseline compare or capture payload arrays | Secondary or collapsible |
Rules:
- Diagnostic sections must remain available for support and investigation.
- Diagnostic sections must never be required to answer the primary operator questions.
## Render Ordering Contract
The surface must obey this order:
1. Attention banners that indicate exceptional context without duplicating neutral page facts
2. Header identity and route-level actions
3. Primary decision zone
4. Supporting semantic groups
5. Type-specific supporting detail
6. Diagnostic and raw context sections
## Validation Rules
- Exactly one primary next-step statement is rendered at top-level priority.
- No identical normalized count block appears more than once in the main content hierarchy.
- `Outcome`, `Artifact truth`, and `Result trust` remain independently visible when the run supplies those semantics.
- Diagnostic JSON or raw technical context cannot appear before the decision zone.
- Type-specific detail cannot render above the canonical decision zone.
## State Notes
There are no new persisted state transitions in this feature.
The relevant page-state modes that must remain representable are:
- completed and trustworthy
- completed with follow-up required
- failed
- blocked
- stale
- reconciled
- artifact unavailable or limited confidence
- type-specific diagnostic-heavy runs

View File

@ -0,0 +1,257 @@
# Implementation Plan: Operation Run Detail Hierarchy, Deduplication, and Decision Guidance Hardening
**Branch**: `164-run-detail-hardening` | **Date**: 2026-03-26 | **Spec**: `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/specs/164-run-detail-hardening/spec.md`
**Input**: Feature specification from `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/specs/164-run-detail-hardening/spec.md`
## Summary
Harden the canonical operation-run detail page into a decision-first enterprise surface without changing `OperationRun` domain semantics, routing, or authorization. The implementation introduces an explicit primary decision zone, centralizes the leading next step and count presentation, separates supporting context from diagnostics, preserves artifact truth and trust as distinct signals, keeps type-specific detail below the canonical summary layer, and validates the result with focused Pest feature and Livewire coverage.
Key approach: work inside the existing `OperationRunResource::enterpriseDetailPage()` and `TenantlessOperationRunViewer` seams, extend the `EnterpriseDetail` payload shape only where necessary, preserve the current canonical route and permission model, and rely on regrouping and prioritization rather than on new persistence or new screen families.
## Technical Context
**Language/Version**: PHP 8.4, Laravel 12, Blade views, Alpine via Filament v5 / Livewire v4
**Primary Dependencies**: Filament v5, Livewire v4, Pest v4, Laravel Sail, existing `OperationRunResource`, `TenantlessOperationRunViewer`, `EnterpriseDetailBuilder`, `ArtifactTruthPresenter`, `OperationUxPresenter`, and `SummaryCountsNormalizer`
**Storage**: PostgreSQL with existing `operation_runs` JSONB-backed `context`, `summary_counts`, and `failure_summary`; no schema change planned
**Testing**: Pest feature tests, Livewire page tests, and existing enterprise-detail unit coverage run through Sail
**Target Platform**: Laravel web application in Sail locally and containerized Linux deployment in staging and production
**Project Type**: Laravel monolith web application
**Performance Goals**: Keep canonical run detail DB-only at render and poll time, preserve 510 second operator scanability above the fold, avoid additional render-time data loading or external calls, and keep type-specific sections progressively reachable without layout thrash
**Constraints**: No new tables or domain models, no route changes, no RBAC drift, no Graph/render side effects, no semantic collapse between execution status and artifact truth, no duplicate primary next-step surface, and no new global Filament assets
**Scale/Scope**: One canonical operator-facing detail page with existing type-specific extensions, a small set of builder and Blade seams, and focused regression coverage across completed, partial, failed, stale or reconciled, and artifact-limited runs
## Constitution Check
*GATE: Passed before Phase 0 research. Re-checked after Phase 1 design and still passing.*
| Principle | Status | Notes |
|-----------|--------|-------|
| Inventory-first | Pass | No inventory or snapshot ownership semantics change; this is a presentation hardening slice only |
| Read/write separation | Pass | No new mutation path is introduced; the surface remains read-only |
| Graph contract path | Pass | No new Graph calls or registry changes; render stays DB-only |
| Deterministic capabilities | Pass | No new capability derivation or role logic |
| RBAC-UX planes and 404 vs 403 | Pass | Canonical `/admin/operations/{run}` behavior remains policy-driven and tenant-safe |
| Workspace isolation | Pass | No workspace-context broadening; canonical viewer stays workspace-member gated |
| Tenant isolation | Pass | Tenant-linked related context and follow-up links remain entitlement-checked |
| Destructive confirmation | Pass | No new destructive action; any existing destructive-like action remains under existing confirmation rules |
| Global search safety | Pass | No searchability or global-search behavior changes |
| Run observability | Pass | Existing `OperationRun` lifecycle, monitoring role, and DB-only render contract remain unchanged |
| Ops-UX 3-surface feedback | Pass | No toast, progress, or terminal notification behavior changes |
| Ops-UX lifecycle ownership | Pass | `status` and `outcome` remain service-owned; this feature is display-only |
| Ops-UX summary counts | Pass | Counts continue to come from `OperationSummaryKeys::all()`-backed normalization; display dedupe only |
| Ops-UX guards | Pass | Existing lifecycle guards remain intact; new tests focus on decision-surface regressions |
| Data minimization | Pass | No new raw payload exposure; diagnostics remain secondary and redaction behavior stays in place |
| Badge semantics (BADGE-001) | Pass | Status, outcome, trust, and artifact-truth badges remain centralized through existing badge systems |
| UI naming (UI-NAMING-001) | Pass | Domain-first vocabulary remains `Outcome`, `Artifact truth`, `Result trust`, `Next step`, and related operator wording |
| Operator surfaces (OPSURF-001) | Pass | The feature explicitly strengthens operator-first default-visible content and pushes diagnostics later |
| Filament Action Surface Contract | Pass | No action inventory expansion; only hierarchy and grouping change on an existing detail page |
| Filament UX-001 | Pass with documented variance | The page remains a custom enterprise detail view rather than a stock infolist layout, but still satisfies the sectioned, operator-first intent |
| Filament v5 / Livewire v4 compliance | Pass | The work remains inside the current Filament v5 + Livewire v4 stack |
| Provider registration location | Pass | No panel/provider changes; Laravel 11+ provider registration stays in `bootstrap/providers.php` |
| Global-search hard rule | Pass | No resource search changes are proposed |
| Asset strategy | Pass | No new panel assets or published views required; existing Blade/Tailwind/Filament primitives are sufficient |
## Phase 0 Research
Research outcomes are captured in `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/specs/164-run-detail-hardening/research.md`.
Key decisions:
- Introduce an explicit decision-zone section rather than trying to repurpose the header or the existing sidebar as the sole operator summary.
- Derive one canonical primary next step from existing explanation and guidance sources, with a defined precedence and explicit secondary-guidance fallback.
- Centralize counts into one primary presentation and move any extra count detail into diagnostics-only or purposefully different surfaces.
- Treat lifecycle attention as contextual supporting information or a banner-level exception, not as a duplicated fact across multiple equal-priority zones.
- Preserve type-specific sections, but force them below the generic decision and supporting layers.
- Keep raw payloads, JSON, detailed failures, and reconciliation evidence available through technical or diagnostic sections without deleting them.
## Phase 1 Design
Design artifacts are created under `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/specs/164-run-detail-hardening/`:
- `data-model.md`: derived run-detail view model and section-order contract
- `contracts/operation-run-detail-page.openapi.yaml`: internal page-contract schema for the canonical run detail surface and its structured payload
- `quickstart.md`: focused verification workflow for manual and automated validation
Design decisions:
- No schema migration is required; the design uses existing `OperationRun`, `ArtifactTruthPresenter`, `OperationUxPresenter`, and `EnterpriseDetail` data.
- The main implementation seam remains `OperationRunResource::enterpriseDetailPage()` plus the `EnterpriseDetail` Blade layout and supporting-card rendering.
- The design introduces a first-class decision zone and explicit supporting groups, rather than relying on a single dense supporting facts card.
- The design formalizes one primary next-step slot, one primary count slot, and a diagnostics-last ordering contract.
- Regression coverage expands around hierarchy, duplication, special-state clarity, and coexistence with baseline-compare and other type-specific detail.
## Project Structure
### Documentation (this feature)
```text
specs/164-run-detail-hardening/
├── spec.md
├── plan.md
├── research.md
├── data-model.md
├── quickstart.md
├── contracts/
│ └── operation-run-detail-page.openapi.yaml
├── checklists/
│ └── requirements.md
└── tasks.md
```
### Source Code (repository root)
```text
app/
├── Filament/
│ ├── Pages/
│ │ └── Operations/
│ │ └── TenantlessOperationRunViewer.php
│ └── Resources/
│ └── OperationRunResource.php
├── Support/
│ ├── OpsUx/
│ │ ├── OperationUxPresenter.php
│ │ └── SummaryCountsNormalizer.php
│ └── Ui/
│ ├── EnterpriseDetail/
│ │ ├── EnterpriseDetailBuilder.php
│ │ ├── EnterpriseDetailPageData.php
│ │ └── EnterpriseDetailSectionFactory.php
│ └── GovernanceArtifactTruth/
│ └── ArtifactTruthPresenter.php
resources/
└── views/
└── filament/
├── infolists/
│ └── entries/
│ ├── enterprise-detail/
│ │ ├── header.blade.php
│ │ ├── layout.blade.php
│ │ ├── section-items.blade.php
│ │ ├── supporting-card.blade.php
│ │ └── technical-detail.blade.php
│ └── governance-artifact-truth.blade.php
└── pages/
└── operations/
└── tenantless-operation-run-viewer.blade.php
tests/
├── Feature/
│ ├── Filament/
│ │ ├── OperationRunBaselineTruthSurfaceTest.php
│ │ └── OperationRunEnterpriseDetailPageTest.php
│ └── Operations/
│ └── TenantlessOperationRunViewerTest.php
└── Unit/
└── Support/
└── Ui/
└── EnterpriseDetail/
└── EnterpriseDetailBuilderTest.php
```
**Structure Decision**: Standard Laravel monolith. The change is concentrated in one canonical page composer, one viewer wrapper, a small enterprise-detail presentation layer, and focused Pest coverage. No new base directories or architectural layers are required.
## Implementation Strategy
### Phase A — Introduce A First-Class Decision Zone
**Goal**: Add an explicit, top-priority decision summary that answers outcome, artifact truth, trust, and the one primary next step without relying on the dense supporting card.
| Step | File | Change |
|------|------|--------|
| A.1 | `app/Filament/Resources/OperationRunResource.php` | Refactor `enterpriseDetailPage()` to assemble a first-class decision-zone payload before generic sections and supporting cards |
| A.2 | `app/Support/Ui/EnterpriseDetail/EnterpriseDetailBuilder.php` and `app/Support/Ui/EnterpriseDetail/EnterpriseDetailPageData.php` | Extend the enterprise-detail payload contract so the builder can carry an explicit decision zone and grouped supporting content end-to-end |
| A.3 | `app/Support/Ui/EnterpriseDetail/EnterpriseDetailSectionFactory.php` | Add or refine helpers needed to construct decision-zone and grouped-support payloads without page-local array drift |
| A.4 | `resources/views/filament/infolists/entries/enterprise-detail/layout.blade.php` | Render the decision zone immediately after the header and before the main grid |
| A.5 | `resources/views/filament/infolists/entries/governance-artifact-truth.blade.php` or a new dedicated decision-zone partial | Reuse existing artifact-truth semantics without duplicating the same top-level meaning elsewhere |
### Phase B — Canonicalize Primary Next Step And Count Placement
**Goal**: Remove semantically duplicated next-step and count presentations while preserving deeper diagnostic value.
| Step | File | Change |
|------|------|--------|
| B.1 | `app/Filament/Resources/OperationRunResource.php` | Define a single primary-next-step resolution order across operator explanation, artifact truth, blocked guidance, and `OperationUxPresenter` |
| B.2 | `app/Filament/Resources/OperationRunResource.php` | Remove duplicate count blocks and keep one primary count surface plus optional diagnostics-only detail |
| B.3 | `app/Support/OpsUx/OperationUxPresenter.php` and existing helpers if needed | Keep guidance sourcing centralized rather than inventing page-local text rules |
| B.4 | Relevant enterprise-detail partials | Ensure the same next-step statement is not rendered in both decision and supporting zones |
### Phase C — Rebuild Supporting Context As Semantic Groups
**Goal**: Replace the current fact-dump sidebar with grouped supporting context for guidance, lifecycle caveats, timing, and secondary metadata.
| Step | File | Change |
|------|------|--------|
| C.1 | `app/Filament/Resources/OperationRunResource.php` | Split the existing `Current state` card into semantically grouped cards or grouped payload sections |
| C.2 | `resources/views/filament/infolists/entries/enterprise-detail/supporting-card.blade.php` | Support richer grouping or presentation treatment if the current generic fact card is insufficient |
| C.3 | `app/Filament/Pages/Operations/TenantlessOperationRunViewer.php` | Keep mismatch, blocked, and lifecycle banners as contextual attention surfaces only where they add non-duplicative value |
| C.4 | `resources/views/filament/pages/operations/tenantless-operation-run-viewer.blade.php` | Preserve banner-before-detail ordering while avoiding redundant repetition of the same lifecycle message inside neutral summary facts |
### Phase D — Preserve Type-Specific Detail And Diagnostics-Last Ordering
**Goal**: Keep baseline compare, baseline capture, verification, failure, reconciliation, and raw context sections available but clearly lower in the reading order.
| Step | File | Change |
|------|------|--------|
| D.1 | `app/Filament/Resources/OperationRunResource.php` | Reorder type-specific sections below the canonical decision and supporting layers |
| D.2 | `resources/views/filament/infolists/entries/enterprise-detail/technical-detail.blade.php` | Keep technical detail progressive and collapsed where appropriate |
| D.3 | Existing type-specific partials and JSON sections | Preserve data availability while ensuring raw payloads do not compete with the primary decision zone |
### Phase E — Regression Protection And Focused Validation
**Goal**: Lock the new hierarchy into tests and preserve route, RBAC, and semantic non-regression.
| Step | File | Change |
|------|------|--------|
| E.1 | `tests/Feature/Filament/OperationRunEnterpriseDetailPageTest.php` | Add hierarchy, count-deduplication, next-step singularity, diagnostics-order, and required scenario-matrix assertions |
| E.2 | `tests/Feature/Filament/OperationRunBaselineTruthSurfaceTest.php` | Protect the distinction between outcome, artifact truth, trust, and next action on artifact-heavy runs, including summary-versus-expansion non-duplication |
| E.3 | `tests/Feature/Operations/TenantlessOperationRunViewerTest.php` | Preserve banner behavior, canonical view accessibility, and positive or negative tenant-safe related-context and diagnostic-visibility semantics |
| E.4 | `tests/Unit/Support/Ui/EnterpriseDetail/EnterpriseDetailBuilderTest.php` | Extend payload-shape assertions if the builder gains decision-zone or grouped-supporting semantics |
| E.5 | `vendor/bin/sail bin pint --dirty --format agent` and focused Pest runs | Required formatting and targeted verification before task completion |
## Key Design Decisions
### D-001 — Decision summary must be explicit, not inferred from header plus sidebar
The current page spreads the meaning across header badges, a run-summary section, artifact truth, and the dense `Current state` card. A dedicated decision zone creates one predictable operator reading path without changing the underlying domain semantics.
### D-002 — One primary next step needs a deterministic precedence rule
The current page can expose `Artifact next step`, `Next step`, and lifecycle guidance at the same level. The design resolves one primary next-step slot first and demotes all other guidance to secondary context.
### D-003 — Counts should exist once as a decision aid and optionally again only as diagnostics
The current page can show counts in the main sections and in the supporting card. The design keeps one operator-facing count summary and treats any deeper count grid as diagnostics-only when genuinely useful.
### D-004 — Lifecycle attention belongs in contextual emphasis, not in repeated fact rows
Blocked, stale, and reconciled states are important, but they should appear as clear contextual attention surfaces or grouped caveats instead of as duplicate headline facts in multiple page regions.
### D-005 — Type-specific sections remain important, but only after the generic decision layer
Baseline compare, baseline capture, verification, and similar operation-type detail should deepen the operator understanding after the page has already answered the generic triage questions.
## Risk Assessment
| Risk | Impact | Likelihood | Mitigation |
|------|--------|------------|------------|
| Decision-zone refactor unintentionally hides useful diagnostics | Medium | Medium | Use progressive disclosure and keep raw sections intact below the primary summary |
| Next-step consolidation oversimplifies special cases | Medium | Medium | Use explicit precedence plus preserved secondary guidance rather than dropping alternative guidance |
| Count deduplication removes meaningful drill-down context | Medium | Low | Keep one diagnostics-only detailed count surface when it has a different purpose |
| Artifact truth and outcome become visually merged | High | Low | Preserve separate labels, badges, and assertions in both the plan and tests |
| Type-specific sections drift upward and reintroduce hierarchy noise | Medium | Medium | Lock ordering in feature tests and keep type-specific sections appended after canonical summary layers |
## Test Strategy
- Extend existing canonical run-detail feature coverage instead of creating a brand-new test architecture.
- Add scenario coverage for completed success, completed with follow-up or partial, failed, blocked, stale or reconciled, and artifact-limited runs.
- Add explicit assertions for no duplicate next-step statement at equal priority and no duplicate main-content count block.
- Add explicit assertions that the decision-zone artifact-truth summary is not simply restated by a later artifact-truth section.
- Preserve canonical-view non-regression: tenant-safe access, positive or negative related-context and diagnostic-visibility semantics, 404 versus 403 behavior, and no render-time external calls.
- Keep Livewire v4-compatible page tests for the canonical viewer and Pest feature tests for rendered ordering and section presence.
## Complexity Tracking
No constitution violations or justified complexity exceptions were identified.

View File

@ -0,0 +1,114 @@
# Quickstart: Operation Run Detail Hierarchy, Deduplication, and Decision Guidance Hardening
## Goal
Validate that the canonical operation-run detail page now behaves as a decision-first surface without changing route semantics, authorization, or underlying `OperationRun` meaning.
## Prerequisites
1. Start Sail if it is not already running.
2. Ensure the workspace has representative `OperationRun` fixtures for:
- completed success
- completed partial or follow-up required
- failed
- blocked
- stale or reconciled
- artifact-heavy baseline capture or baseline compare
3. Ensure the acting user is a valid workspace member and, for tenant-bound runs, entitled to the referenced tenant.
## Focused Automated Verification
Run only the tests that guard this surface first:
```bash
vendor/bin/sail artisan test --compact tests/Feature/Filament/OperationRunEnterpriseDetailPageTest.php
vendor/bin/sail artisan test --compact tests/Feature/Filament/OperationRunBaselineTruthSurfaceTest.php
vendor/bin/sail artisan test --compact tests/Feature/Operations/TenantlessOperationRunViewerTest.php
vendor/bin/sail artisan test --compact tests/Unit/Support/Ui/EnterpriseDetail/EnterpriseDetailBuilderTest.php
```
If new spec-scoped tests are added during implementation, run them alongside the existing files with `--filter` or direct file paths.
## Manual Validation Pass
For each representative run, open the canonical detail page and check the following in order.
### 1. First-screen decision quality
Confirm the first visible page height answers:
- what run this is
- how it ended
- whether the visible result is trustworthy enough to use
- what the operator should do next
### 2. Next-step singularity
Confirm there is exactly one leading next-step statement in the decision zone.
Confirm that:
- the same next-step text is not repeated in a sidebar card
- the same next-step text is not repeated in a run summary section
- any extra guidance is visibly secondary
### 3. Count deduplication
Confirm there is no duplicate count block in main content.
Allowed:
- one primary count summary
- one clearly diagnostic count breakdown with a different purpose
Not allowed:
- the same normalized counts shown twice as equal-priority summary content
### 4. Semantic separation
For artifact-heavy runs, confirm the page still distinguishes:
- execution status
- outcome
- artifact truth
- result trust
- next action
A successful execution outcome must still be able to coexist with limited trust or weak artifact usability.
### 5. Special-state behavior
For blocked, stale, or reconciled runs, confirm:
- the exceptional state is visible
- the explanation is understandable without opening JSON
- the same lifecycle warning is not repeated as equal-priority content across banner, decision zone, and supporting facts
### 6. Type-specific coexistence
For baseline compare, baseline capture, or verification-heavy runs, confirm:
- the generic decision zone appears first
- type-specific detail appears afterward
- raw payloads and JSON remain secondary
## Non-Regression Checks
Confirm the feature did not change:
- canonical route identity for `/admin/operations/{run}`
- workspace or tenant authorization behavior
- 404 versus 403 semantics
- DB-only render behavior on the page
- existing related-context links and canonical navigation patterns
## Formatting And Final Verification
Before finalizing implementation work:
```bash
vendor/bin/sail bin pint --dirty --format agent
```
Then rerun the smallest affected test set. If the user wants a broader confidence pass afterward, offer the full suite.

View File

@ -0,0 +1,57 @@
# Research: Operation Run Detail Hierarchy, Deduplication, and Decision Guidance Hardening
## Decision 1: Introduce an explicit primary decision zone above the current main/supporting split
- Decision: Add a first-class decision zone immediately below the page header and before the current main grid, instead of relying on the header, `Run summary`, and `Current state` card together to communicate the operator judgment.
- Rationale: The current page spreads the decisive meaning across multiple regions. The rendering analysis shows that header badges, `Run summary`, `Artifact truth`, and the dense supporting card currently compete with each other. A dedicated decision zone gives the operator one predictable place to read the outcome, artifact truth, trust meaning, and next step in one pass.
- Alternatives considered:
- Keep the current header and only simplify the sidebar. Rejected because it leaves the main decision meaning fragmented across header, main content, and sidebar.
- Push all decision meaning into the header. Rejected because the current header payload is designed for identity and key facts, not a deeper semantic summary with artifact truth and trust nuance.
## Decision 2: Resolve exactly one primary next step from existing guidance sources
- Decision: Define a precedence order for the leading next step and surface only that one in the decision zone. Secondary guidance remains available in supporting or diagnostic areas only when it adds context.
- Rationale: The current page can show `Artifact next step`, `Next step`, blocked guidance, and lifecycle guidance at the same weight. That dilutes operator confidence. The source data already exists in `ArtifactTruthPresenter`, operator explanation payloads, `OperationUxPresenter`, and blocked/lifecycle helpers, so the right solution is selection and prioritization, not new copy systems.
- Alternatives considered:
- Continue rendering all next-step variants and rely on wording differences. Rejected because the same operator question still gets multiple simultaneous answers.
- Remove artifact-specific guidance entirely in favor of generic guidance. Rejected because artifact-heavy runs need artifact-truth-specific follow-up to remain accurate.
## Decision 3: Centralize count presentation and treat additional count detail as diagnostics-only
- Decision: Keep one primary count representation and allow a second count representation only when it serves a different purpose, such as a detailed breakdown in diagnostics.
- Rationale: The current page renders count meaning in both the supporting facts card and a dedicated `Counts` section. That redundancy adds noise without improving understanding. The summary-count system is already normalized through `SummaryCountsNormalizer`, so the issue is presentation duplication rather than source inconsistency.
- Alternatives considered:
- Remove all count detail from the top half of the page. Rejected because some runs still need a compact quantitative summary to judge scale or completeness.
- Keep both current count surfaces. Rejected because the spec explicitly forbids identical or effectively identical duplicate count blocks.
## Decision 4: Treat lifecycle and reconciliation states as contextual attention, not as equal-priority summary duplication
- Decision: Keep mismatch, blocked, stale, and reconciled signals visible through banners or grouped lifecycle context, but stop repeating the same meaning inside neutral summary facts at the same priority level.
- Rationale: The viewer already has banner helpers in `TenantlessOperationRunViewer` and lifecycle facts inside the supporting card. The issue is not lack of visibility but repeated visibility at the same weight. Contextual attention should sharpen the decision without producing three competing truth surfaces.
- Alternatives considered:
- Remove banners and show everything as facts in the supporting area. Rejected because exceptional states deserve a stronger visual emphasis than ordinary metadata.
- Keep banner, supporting facts, and diagnostic section all equally explicit. Rejected because this is the duplication problem the spec is trying to solve.
## Decision 5: Preserve artifact truth and trust as distinct semantics inside the decision zone
- Decision: Keep `Outcome`, `Artifact truth`, `Result meaning`, and `Result trust` as separate but adjacent decision signals instead of collapsing them into one generic health message.
- Rationale: The underlying domain is already strong precisely because execution lifecycle and result usability are distinct. The artifact-truth partial and operator explanation payload already model those dimensions separately, and the spec explicitly forbids semantic collapse.
- Alternatives considered:
- Replace the four dimensions with one overall success or warning headline. Rejected because it would hide whether the run completed successfully but produced a weak or unusable artifact.
- Move artifact truth fully out of the primary decision zone. Rejected because artifact-heavy runs depend on that signal for safe operator action.
## Decision 6: Keep type-specific detail after the canonical decision zone and supporting context
- Decision: Preserve type-specific sections such as baseline compare evidence, baseline capture evidence, verification report, failures, and reconciliation details, but reorder them below the generic decision-first hierarchy.
- Rationale: The current `OperationRunResource::enterpriseDetailPage()` already appends type-specific sections after generic sections. The feature should strengthen that ordering contract and ensure future refactors do not let type-specific content dominate the first screen height.
- Alternatives considered:
- Fold type-specific detail into the decision zone. Rejected because it would make the canonical top-level summary too wide and operation-type-dependent.
- Hide type-specific detail behind separate navigation. Rejected because operators still need one canonical page with deeper evidence available in-place.
## Decision 7: Extend existing feature and Livewire tests rather than introducing a new UI test harness
- Decision: Build on `OperationRunEnterpriseDetailPageTest`, `OperationRunBaselineTruthSurfaceTest`, `TenantlessOperationRunViewerTest`, and the enterprise-detail builder unit tests for regression coverage.
- Rationale: The relevant seams already have useful test coverage for hierarchy, artifact truth, tenant-safe viewing, and DB-only render behavior. The hardening work is a refinement of the existing canonical page, so extending those tests is lower-risk and keeps the assertions close to the current architecture.
- Alternatives considered:
- Add only manual visual verification. Rejected because this spec is explicitly about preventing hierarchy and duplication regressions.
- Create a separate browser-test suite as the primary guard. Rejected because the current repo already has faster, targeted feature coverage around this page structure, and the required assertions are mostly structural and textual.

View File

@ -0,0 +1,203 @@
# Feature Specification: Operation Run Detail Hierarchy, Deduplication, and Decision Guidance Hardening
**Feature Branch**: `164-run-detail-hardening`
**Created**: 2026-03-26
**Status**: Draft
**Input**: User description: "Spec 164 — Operation Run Detail Hierarchy, Deduplication, and Decision Guidance Hardening"
## Spec Scope Fields *(mandatory)*
- **Scope**: canonical-view
- **Primary Routes**:
- `/admin/operations/{run}`
- Existing in-product deep links that resolve to the canonical operation-run detail page
- **Data Ownership**:
- `OperationRun` remains the canonical workspace-owned execution record even when it references a tenant or related artifact
- Related artifacts, tenant context, and operation-type-specific evidence remain owned by their existing domain records
- This feature changes presentation hierarchy and operator guidance only; it does not change ownership, persistence, or route identity
- **RBAC**:
- Existing workspace membership remains required to reach the canonical run detail page
- Existing tenant entitlement rules continue to govern tenant-linked related context and any tenant-bound follow-up links
- Existing operation history or monitoring capabilities remain authoritative for viewing the run detail experience
For canonical-view specs, the spec MUST define:
- **Default filter behavior when tenant-context is active**: Remembered tenant context may continue to influence the origin surface or back-navigation convenience, but it must not change the legitimacy, hierarchy, or visible decision contract of the canonical run detail page itself.
- **Explicit entitlement checks preventing cross-tenant leakage**: The page must continue to resolve from the run and authorized related records only. Non-members or actors without tenant entitlement for tenant-linked context must receive deny-as-not-found behavior for inaccessible targets, and the detail surface must not leak inaccessible related records, counts, or hints through summary, diagnostics, or navigation.
## Operator Surface Contract *(mandatory when operator-facing surfaces are changed)*
If this feature adds a new operator-facing page or materially refactors one, fill out one row per affected page/surface.
| Surface | Primary Persona | Surface Type | Primary Operator Question | Default-visible Information | Diagnostics-only Information | Status Dimensions Used | Mutation Scope | Primary Actions | Dangerous Actions |
|---|---|---|---|---|---|---|---|---|---|
| Canonical operation-run detail | Workspace operator or entitled tenant operator | Canonical detail | What happened in this run, is the result trustworthy enough to use, and what should I do next? | Run identity, execution state, outcome, artifact truth, result meaning or trust, one primary next step, high-signal lifecycle caveats, compact supporting facts | Detailed count breakdowns, raw failure fragments, large context blocks, JSON payloads, technical reason evidence, low-level reconciliation detail | execution status, outcome, artifact truth, trust or confidence, lifecycle or freshness, next-action readiness | Read-only surface; any contextual links continue to use their existing mutation-scope messaging outside this spec | View run, follow the primary next step, open related context, refresh the page | None introduced by this spec |
## User Scenarios & Testing *(mandatory)*
### User Story 1 - Triage a run in one scan (Priority: P1)
As an operator opening a single operation run, I want the first visible area of the page to tell me what happened, whether the result is usable, and what I should do next, so that I do not need to assemble the answer from multiple sections.
**Why this priority**: The page is the canonical decision surface for a run. If the operator cannot answer the core triage questions quickly, the surface fails even when the domain model is correct.
**Independent Test**: Can be fully tested by opening completed, partial, and failed runs and verifying that execution state, outcome, artifact truth, trust meaning, and one primary next step are visible before diagnostic sections.
**Acceptance Scenarios**:
1. **Given** a completed run with follow-up required, **When** an operator opens the canonical run detail page, **Then** the first visible zone shows the run result, its trustworthiness, and exactly one primary next step without requiring scrolling.
2. **Given** a completed run with a trustworthy result and no urgent follow-up, **When** an operator opens the page, **Then** the first visible zone communicates that the result is usable and does not bury that message beneath counts or raw detail.
3. **Given** a failed or blocked run, **When** an operator opens the page, **Then** the first visible zone explains the failure character and the most sensible follow-up action before diagnostic evidence.
---
### User Story 2 - Understand special-state caveats without confusion (Priority: P2)
As an operator reviewing a stale, reconciled, partial, or artifact-limited run, I want the page to explain why the result needs caution without repeating the same warning in multiple places, so that I can trust the page's interpretation.
**Why this priority**: Special states are operationally important, but they should sharpen the decision rather than flood the page with duplicate warnings.
**Independent Test**: Can be fully tested by opening runs with stale, reconciled, blocked, and limited-confidence states and verifying that caveats are visible, contextual, and not rendered as competing top-level truths.
**Acceptance Scenarios**:
1. **Given** a stale or lifecycle-reconciled run, **When** the page renders, **Then** the operator sees why the lifecycle is unusual and what follow-up is appropriate without the page repeating that state as equal-priority summary facts, banners, and duplicate cards.
2. **Given** a run whose artifact exists but has limited trust or incomplete evidence, **When** the page renders, **Then** artifact truth remains distinct from outcome and the operator can see the caution without opening diagnostics.
3. **Given** a run with no artifact or no trustworthy result to use, **When** the page renders, **Then** the page makes that limitation explicit and points the operator to the next reasonable action.
---
### User Story 3 - Keep deep detail without losing the decision hierarchy (Priority: P3)
As an operator or support engineer, I want type-specific detail and diagnostics to remain available after the main decision summary, so that I can continue from triage into investigation without losing the canonical page structure.
**Why this priority**: The feature must improve scanability without flattening the strong domain depth that makes the page useful for investigation.
**Independent Test**: Can be fully tested by opening runs with baseline-compare or other type-specific sections and confirming that those sections still render, but only after the canonical decision and supporting context.
**Acceptance Scenarios**:
1. **Given** a run with type-specific evidence or findings, **When** the operator opens the page, **Then** the generic decision summary appears first and the type-specific detail appears afterwards as deeper context.
2. **Given** a run with extensive counts, reasons, or raw context, **When** the operator opens the page, **Then** diagnostics remain available but do not appear at the same priority as the decision summary.
3. **Given** a run with both high-signal summary facts and technical evidence, **When** the page renders, **Then** the page preserves a clear reading order of decision first, supporting detail second, diagnostics third.
### Edge Cases
- A completed run may have an apparently successful execution outcome but an artifact-truth or trust statement that makes the result unsafe to use; the page must show both meanings without collapsing one into the other.
- A stale or lifecycle-reconciled run may require attention even when it is not technically failed; the page must explain the situation without duplicating the same message across multiple equal-priority sections.
- A run may contain large count sets, failure summaries, or raw payload context; the page must keep those details available without letting them crowd out the primary decision zone.
- A run may have no meaningful counts at all; the page must still present a coherent decision surface instead of reserving high-value space for empty or low-signal sections.
- A run may include operation-type-specific detail such as baseline compare evidence, restore outcomes, or artifact-heavy context; those details must remain usable without displacing the canonical summary-first hierarchy.
- A run may be opened from a tenant-related entry point while still being a canonical workspace run; the hierarchy and visible meaning must remain stable and permission-safe.
## Requirements *(mandatory)*
**Constitution alignment (required):** This feature introduces no new Microsoft Graph calls, no new change workflow, and no new long-running job type. It hardens the presentation and operator guidance of an existing canonical monitoring record. Existing `OperationRun` creation, audit behavior, and safe-execution rules for any already-existing follow-up actions remain authoritative and unchanged.
**Constitution alignment (OPS-UX):** This feature reuses existing `OperationRun` records as a read surface only. The Ops-UX 3-surface feedback contract remains unchanged. `OperationRun.status` and `OperationRun.outcome` remain service-owned through the existing run service. `summary_counts` continues to honor `OperationSummaryKeys::all()` and numeric-only normalization. Scheduled or system-run behavior does not change. Regression coverage for this feature must protect decision hierarchy, next-step singularity, count deduplication, special-state clarity, type-specific coexistence, and navigation or authorization non-regression.
**Constitution alignment (RBAC-UX):** This feature stays in the admin canonical-view plane and may display tenant-linked context. Non-members or actors lacking tenant entitlement for tenant-linked context remain deny-as-not-found. Members who are otherwise in scope but lack the capability to inspect the run remain forbidden. Authorization continues to be enforced server-side through existing policies, Gates, and canonical capability registries. No raw capability strings or role-string shortcuts may be introduced. Global search behavior remains non-member-safe. This feature introduces no new destructive action; any existing destructive-like action exposed from the page must continue to require confirmation and existing authorization.
**Constitution alignment (OPS-EX-AUTH-001):** Not applicable. Monitoring and canonical run detail remain outside any `/auth/*` exception path.
**Constitution alignment (BADGE-001):** Any status-like badges shown or elevated by this feature must continue to come from centralized badge semantics. Execution status, outcome, artifact truth, trust or confidence, and lifecycle or freshness meaning must not be mapped ad hoc on the page.
**Constitution alignment (UI-NAMING-001):** The target object is the operation run. Primary operator verbs remain consistent with the existing vocabulary, including `View run` and other established follow-up language. The page must preserve domain terms such as `Outcome`, `Artifact truth`, `Trust`, `Follow-up`, and `Next step` where they carry distinct meaning, and must avoid replacing them with vague implementation-first or oversimplified labels.
**Constitution alignment (OPSURF-001):** The default-visible content must remain operator-first and answer the decision questions before raw implementation detail. Diagnostics must be explicitly secondary. The page must continue to show execution outcome, artifact truth, trust or confidence, and lifecycle or freshness as separate status dimensions because operators need each dimension for sound decisions. No new mutation is introduced by this spec. Workspace and tenant context remain explicit in page meaning and related navigation. The surface contract is defined above for the canonical run detail page.
**Constitution alignment (Filament Action Surfaces):** This feature materially refactors an existing Filament-backed detail surface. The Action Surface Contract remains satisfied because the refactor changes hierarchy, grouping, and repetition rather than expanding the action inventory. The UI Action Matrix below documents the affected surface. No exemption is required.
**Constitution alignment (UX-001 — Layout & Information Architecture):** The canonical run detail page remains a structured view surface, not a disabled edit form. The page must keep information inside clearly named sections or cards, keep badges centralized, and use progressive disclosure for diagnostics. Because this is an existing custom enterprise detail view rather than a standard resource infolist, the current custom-view composition remains an explicit exemption from the default view-page pattern, but the feature must still satisfy the operator-first hierarchy intent of UX-001.
### Functional Requirements
- **FR-164-001**: The system MUST give the canonical run detail page one explicit primary decision zone in the immediately visible upper portion of the page.
- **FR-164-002**: The primary decision zone MUST present execution or run state, outcome, artifact truth, result meaning or trust, and exactly one primary next step in one coherent reading path.
- **FR-164-003**: The primary decision zone MUST answer, without scrolling, what happened in the run, whether the result is usable, and what the operator should do next.
- **FR-164-004**: The page MUST show exactly one leading next-step statement as the authoritative operator action, and any additional guidance MUST appear only as secondary or context-specific guidance.
- **FR-164-005**: The page MUST NOT repeat the same next-step statement in multiple equal-priority sections, cards, or grids.
- **FR-164-006**: The page MUST provide one primary count presentation when `summary_counts` adds decision value, and any secondary count display MUST serve a clearly different purpose such as compact hinting or deeper diagnostics.
- **FR-164-007**: The page MUST NOT render identical or effectively identical count blocks more than once in the main content hierarchy.
- **FR-164-008**: Any run-summary section that remains on the page MUST add information not already adequately conveyed by the header and primary decision zone; otherwise that section MUST be removed or absorbed into another structure.
- **FR-164-009**: The supporting area MUST be reorganized from a flat fact dump into semantically grouped supporting information so that guidance, lifecycle caveats, and secondary metadata are distinguishable at a glance.
- **FR-164-010**: Artifact truth MUST remain a distinct first-class statement whenever result usability depends on it, and it MUST NOT be collapsed into outcome, execution lifecycle, or generic success or failure wording.
- **FR-164-011**: Artifact truth MAY be summarized in the primary decision zone and expanded later in the page, but the same artifact-truth meaning MUST NOT be repeated in multiple equal-priority areas.
- **FR-164-012**: Stale, reconciled, blocked, partial, or otherwise special lifecycle conditions MUST remain visible and must explain why the run landed in that state and what follow-up is sensible.
- **FR-164-013**: Special lifecycle conditions MUST be presented contextually and MUST NOT dominate the normal page layout through redundant repetition across banner, sidebar, and summary zones.
- **FR-164-014**: The page MUST follow a stable reading order of decision first, supporting operational detail second, and diagnostics or raw context third.
- **FR-164-015**: Large context blocks, raw payloads, JSON fragments, detailed reason evidence, and other low-frequency technical information MUST remain secondary to decision content and MUST be explicitly disclosed or placed later on the page.
- **FR-164-016**: The first visible page height MUST allow an operator to understand the run identity, how it ended, whether the visible result is trustworthy enough to use, and whether immediate action is needed within a short scan.
- **FR-164-017**: The page MUST preserve the semantic distinction between execution status, outcome, artifact truth, trust or confidence, and next action even while simplifying the visible hierarchy.
- **FR-164-018**: The page MUST continue to support operation-type-specific detail sections, but those sections MUST appear after the canonical decision and supporting summary layers.
- **FR-164-019**: The page MUST preserve the existing canonical run-detail route, deep-link behavior, and workspace-versus-tenant meaning of the record.
- **FR-164-020**: The page MUST preserve existing RBAC and visibility semantics and MUST NOT widen access through summary, diagnostics, or related-context presentation.
- **FR-164-021**: The page MUST use progressive disclosure or lower-priority placement for support and investigation detail rather than removing information that operators or support staff still need.
### Non-Functional Requirements
- **NFR-164-001**: The feature SHOULD be deliverable without new tables, new domain entities, or required persistence-model changes.
- **NFR-164-002**: Operator-facing copy may become shorter or clearer, but it MUST NOT flatten or blur the existing domain semantics around follow-up, artifact truth, trust, or limited confidence.
- **NFR-164-003**: Existing canonical route identity and existing deep links to the page MUST remain stable.
- **NFR-164-004**: Existing RBAC and view-permission behavior MUST remain intact.
- **NFR-164-005**: De-prioritized technical content MUST remain available for investigation through lower-priority sections or progressive disclosure.
### Non-Goals
- Redesigning the operations list, queue view, or monitoring filter logic
- Changing notification copy, toast behavior, or persistent notification flows
- Reworking dashboard widgets, floating progress surfaces, or global monitoring shells
- Introducing new outcome enums, execution-status enums, or new artifact-governance models
- Changing `OperationRun` persistence, record ownership, or the underlying domain state model
- Performing a broad Filament design-system overhaul beyond this single decision surface
### Assumptions
- The current `OperationRun` domain model, outcome semantics, artifact-truth logic, and operator explanation layer are already strong enough that the primary need is surface hardening rather than conceptual redesign.
- The canonical run detail page already contains the necessary information for a stronger decision hierarchy, but currently presents too many facts at the same visual weight.
- Existing type-specific sections, related context, and diagnostic payloads remain valuable and should be preserved behind a better primary reading order.
- Existing navigation, canonical links, and authorization behavior remain the source of truth and should not be redefined by this feature.
### Dependencies
- Existing canonical `OperationRun` model and its separation of execution status, outcome, and context
- Existing artifact-truth, trust or confidence, and operator-explanation semantics already available to the run detail experience
- Existing canonical operation-run navigation and tenant-safe related-context resolution
- Existing RBAC and visibility rules for workspace membership, tenant entitlement, and run inspection
- Existing type-specific operation detail content that must continue to coexist with the canonical page hierarchy
### Risks
- The page could become calmer but less useful if diagnostic depth is removed instead of deliberately de-prioritized.
- A layout-driven simplification could blur the distinction between outcome, artifact truth, and trust, which would weaken operator judgment.
- A stronger generic decision zone could accidentally crowd out type-specific detail for artifact-heavy or evidence-heavy runs if the hierarchy is not enforced carefully.
## UI Action Matrix *(mandatory when Filament is changed)*
If this feature adds/modifies any Filament Resource / RelationManager / Page, fill out the matrix below.
For each surface, list the exact action labels, whether they are destructive (confirmation? typed confirmation?),
RBAC gating (capability + enforcement helper), and whether the mutation writes an audit log.
| Surface | Location | Header Actions | Inspect Affordance (List/Table) | Row Actions (max 2 visible) | Bulk Actions (grouped) | Empty-State CTA(s) | View Header Actions | Create/Edit Save+Cancel | Audit log? | Notes / Exemptions |
|---|---|---|---|---|---|---|---|---|---|---|
| Canonical operation-run detail | Existing canonical operation-run detail page | Existing page-level navigation and non-destructive follow-up actions remain; no new destructive header action is introduced by this spec | Existing `View run` affordances from operations and related surfaces remain the entry point | `View run` remains the relevant row action on upstream list surfaces | None added by this spec | Not applicable on the detail page | Existing view-page actions remain, but the page must expose one primary next-step statement and demote secondary guidance | Not applicable | No new audit event introduced by this spec | Action Surface Contract satisfied. Any already-existing destructive or run-triggering follow-up action remains governed by existing confirmation, authorization, and audit rules outside this hierarchy-hardening change. |
### Key Entities *(include if feature involves data)*
- **Operation Run**: The canonical execution record whose detail page is the authoritative operator view for what happened, how it ended, and what to do next.
- **Primary Decision Zone**: The first visible summary area that expresses execution state, outcome, artifact truth, trust meaning, and one primary next step.
- **Supporting Operational Context**: Secondary page content that explains lifecycle caveats, timing, freshness, reconciliation, and compact supporting facts without competing with the primary decision.
- **Diagnostic Surface**: Lower-priority detail such as raw payloads, detailed counts, failure evidence, and technical context used for investigation after the initial decision is made.
- **Type-Specific Detail Block**: Operation-specific content that remains important for deeper understanding, but must sit below the canonical decision and supporting layers.
## Success Criteria *(mandatory)*
### Measurable Outcomes
- **SC-164-001**: In acceptance review, operators can identify the run result, its trustworthiness, and the primary next step from the first visible page height for every covered scenario in 10 seconds or less.
- **SC-164-002**: In covered scenarios, the page presents exactly one primary next-step statement and does not repeat the same next-step text in multiple equal-priority regions.
- **SC-164-003**: In covered scenarios, the main content hierarchy contains no duplicate or effectively duplicate count block.
- **SC-164-004**: In covered completed, partial, failed, stale or reconciled, and artifact-limited scenarios, operators can tell whether immediate action is needed without opening diagnostic sections.
- **SC-164-005**: Type-specific operation detail remains available for covered run types, but always appears after the canonical decision and supporting summary layers.
- **SC-164-006**: Navigation, RBAC behavior, and the semantic separation between execution status, outcome, artifact truth, trust, and next action remain unchanged in regression review.

View File

@ -0,0 +1,224 @@
# Tasks: Operation Run Detail Hierarchy, Deduplication, and Decision Guidance Hardening
**Input**: Design documents from `/specs/164-run-detail-hardening/`
**Prerequisites**: `plan.md` (required), `spec.md` (required for user stories), `research.md`, `data-model.md`, `contracts/`
**Tests**: Tests are REQUIRED for this feature. Use Pest coverage in `tests/Feature/Filament/OperationRunEnterpriseDetailPageTest.php`, `tests/Feature/Filament/OperationRunBaselineTruthSurfaceTest.php`, `tests/Feature/Operations/TenantlessOperationRunViewerTest.php`, and `tests/Unit/Support/Ui/EnterpriseDetail/EnterpriseDetailBuilderTest.php`.
**Operations**: This feature reuses existing `OperationRun` records as a read-only canonical surface. No new run creation, lifecycle transition, notification, or `summary_counts` producer work is introduced.
**RBAC**: Existing canonical run-view authorization and 404 vs 403 semantics must remain unchanged. Tests must prove no regression for workspace membership, tenant entitlement, and capability enforcement on `/admin/operations/{run}`.
**Operator Surfaces**: The canonical operation-run detail page must remain operator-first, with one primary decision zone, grouped supporting context, and diagnostics later.
**Filament UI Action Surfaces**: No new actions are added. Existing view-page actions, row inspection affordances, and confirmation behavior must remain intact while the page hierarchy changes.
**Filament UI UX-001**: The page remains a custom enterprise detail view with an explicit operator-first hierarchy; diagnostics and JSON stay secondary.
**Badges**: Execution status, outcome, artifact truth, trust, and lifecycle badges must continue to use centralized badge semantics.
**Organization**: Tasks are grouped by user story so each story can be implemented and tested as an independent increment after the shared presentation scaffolding is in place.
## Phase 1: Setup (Shared Presentation Contract)
**Purpose**: Add the shared enterprise-detail primitives needed by all user stories.
- [X] T001 Extend `EnterpriseDetailBuilder` to carry `decisionZone` and `supportingGroups` in `app/Support/Ui/EnterpriseDetail/EnterpriseDetailBuilder.php`
- [X] T002 [P] Extend the enterprise-detail payload contract for `decisionZone` and `supportingGroups` in `app/Support/Ui/EnterpriseDetail/EnterpriseDetailPageData.php`
- [X] T003 [P] Add or refine enterprise-detail helpers for decision-zone and grouped-support payload assembly in `app/Support/Ui/EnterpriseDetail/EnterpriseDetailSectionFactory.php`
- [X] T004 [P] Create the decision-zone rendering partial in `resources/views/filament/infolists/entries/enterprise-detail/decision-zone.blade.php`
- [X] T005 [P] Extend payload-shape coverage for the new enterprise-detail contract in `tests/Unit/Support/Ui/EnterpriseDetail/EnterpriseDetailBuilderTest.php`
---
## Phase 2: Foundational (Blocking Layout Prerequisites)
**Purpose**: Wire the new shared page shape into the reusable enterprise-detail layout before story-specific behavior changes.
**⚠️ CRITICAL**: No user story work should begin until this phase is complete.
- [X] T006 Update enterprise-detail rendering order to `header -> decision zone -> supporting groups -> main sections -> technical sections` in `resources/views/filament/infolists/entries/enterprise-detail/layout.blade.php`
- [X] T007 [P] Update grouped supporting-card rendering in `resources/views/filament/infolists/entries/enterprise-detail/supporting-card.blade.php`
- [X] T008 [P] Adjust fact-grid rendering so summary and diagnostic presentations can diverge cleanly in `resources/views/filament/infolists/entries/enterprise-detail/section-items.blade.php`
**Checkpoint**: The reusable enterprise-detail shell can now host a first-class decision zone and semantically grouped supporting content.
---
## Phase 3: User Story 1 - Triage a Run in One Scan (Priority: P1) 🎯 MVP
**Goal**: Make the first visible page area answer what happened, whether the result is usable, and what the operator should do next.
**Independent Test**: Open completed-success, partial or completed-with-follow-up, failed, and blocked runs and verify that execution state, outcome, artifact truth, trust meaning, and exactly one primary next step appear before diagnostic sections.
### Tests for User Story 1
- [X] T009 [P] [US1] Extend first-screen hierarchy assertions for completed success, partial or completed-with-follow-up, failed, and blocked runs plus count deduplication in `tests/Feature/Filament/OperationRunEnterpriseDetailPageTest.php`
- [X] T010 [P] [US1] Extend artifact-truth versus outcome versus trust assertions and artifact-truth summary-versus-expansion non-duplication coverage in `tests/Feature/Filament/OperationRunBaselineTruthSurfaceTest.php`
### Implementation for User Story 1
- [X] T011 [US1] Refactor `enterpriseDetailPage()` to assemble the primary decision-zone payload from status, outcome, artifact truth, and trust sources in `app/Filament/Resources/OperationRunResource.php`
- [X] T012 [US1] Remove or absorb redundant run-summary and equal-priority next-step duplication in `app/Filament/Resources/OperationRunResource.php`
- [X] T013 [US1] Centralize one primary count presentation and one diagnostics-only fallback in `app/Filament/Resources/OperationRunResource.php`
- [X] T014 [US1] Render execution state, outcome, artifact truth, result meaning, result trust, and one primary next step in `resources/views/filament/infolists/entries/enterprise-detail/decision-zone.blade.php`
- [X] T015 [US1] Ensure later artifact-truth rendering adds deeper explanation instead of restating the decision-zone summary in `resources/views/filament/infolists/entries/governance-artifact-truth.blade.php` and `app/Filament/Resources/OperationRunResource.php`
- [X] T016 [US1] Run the focused P1 regression pack in `tests/Feature/Filament/OperationRunEnterpriseDetailPageTest.php` and `tests/Feature/Filament/OperationRunBaselineTruthSurfaceTest.php`
**Checkpoint**: The canonical run detail page is now a usable decision surface for ordinary completed, partial, failed, and blocked runs.
---
## Phase 4: User Story 2 - Understand Special-State Caveats Without Confusion (Priority: P2)
**Goal**: Make stale, reconciled, blocked, and artifact-limited states visible and understandable without duplicating the same warning across multiple equal-priority regions.
**Independent Test**: Open stale, reconciled, blocked, and artifact-limited runs and verify that caveats are clear, contextual, and do not appear as competing top-level truths.
### Tests for User Story 2
- [X] T017 [P] [US2] Add stale, reconciled, blocked, and artifact-limited hierarchy assertions in `tests/Feature/Filament/OperationRunEnterpriseDetailPageTest.php`
- [X] T018 [P] [US2] Add positive and negative tenant-linked related-context, diagnostic-visibility, and canonical deep-link entry regressions for special-state runs in `tests/Feature/Operations/TenantlessOperationRunViewerTest.php`
### Implementation for User Story 2
- [X] T019 [US2] Split current-state facts into semantic `guidance`, `lifecycle`, `timing`, and `metadata` supporting groups in `app/Filament/Resources/OperationRunResource.php`
- [X] T020 [US2] Keep mismatch, blocked, and lifecycle attention contextual and non-duplicative in `app/Filament/Pages/Operations/TenantlessOperationRunViewer.php`
- [X] T021 [US2] Update special-state banner and grouped-support copy treatment in `resources/views/filament/pages/operations/tenantless-operation-run-viewer.blade.php` and `resources/views/filament/infolists/entries/enterprise-detail/supporting-card.blade.php`
- [X] T022 [US2] Run the focused P2 regression pack in `tests/Feature/Filament/OperationRunEnterpriseDetailPageTest.php` and `tests/Feature/Operations/TenantlessOperationRunViewerTest.php`
**Checkpoint**: Exceptional lifecycle and artifact-confidence states are visible, decision-grade, and no longer over-repeated.
---
## Phase 5: User Story 3 - Keep Deep Detail Without Losing the Decision Hierarchy (Priority: P3)
**Goal**: Preserve type-specific detail and diagnostics while forcing them below the canonical decision and supporting layers.
**Independent Test**: Open baseline-compare, baseline-capture, verification-heavy, and diagnostic-heavy runs and confirm that type-specific sections remain available only after the decision zone and supporting context.
### Tests for User Story 3
- [X] T023 [P] [US3] Add type-specific ordering, diagnostics-last, and no-duplicate-main-count assertions for baseline-compare, baseline-capture, and verification-heavy runs in `tests/Feature/Filament/OperationRunEnterpriseDetailPageTest.php`
- [X] T024 [P] [US3] Extend enterprise-detail payload and ordering assertions for `decisionZone` and `supportingGroups` in `tests/Unit/Support/Ui/EnterpriseDetail/EnterpriseDetailBuilderTest.php`
### Implementation for User Story 3
- [X] T025 [US3] Reorder failures, reconciliation, baseline compare, baseline capture, verification, and context sections after the canonical summary layers in `app/Filament/Resources/OperationRunResource.php`
- [X] T026 [US3] Keep technical and raw JSON sections progressively disclosed in `resources/views/filament/infolists/entries/enterprise-detail/technical-detail.blade.php` and `resources/views/filament/infolists/entries/snapshot-json.blade.php`
- [X] T027 [US3] Preserve type-specific coexistence and authorized related-context behavior beneath the decision zone in `resources/views/filament/infolists/entries/enterprise-detail/layout.blade.php` and `resources/views/filament/infolists/entries/related-context.blade.php`
- [X] T028 [US3] Run the focused P3 regression pack in `tests/Feature/Filament/OperationRunEnterpriseDetailPageTest.php` and `tests/Unit/Support/Ui/EnterpriseDetail/EnterpriseDetailBuilderTest.php`
**Checkpoint**: Type-specific detail and investigation depth remain intact, but they no longer compete with the top-level operator decision.
---
## Phase 6: Polish & Cross-Cutting Concerns
**Purpose**: Final consistency, formatting, and focused verification across all stories.
- [X] T029 [P] Review and align operator-facing decision-surface copy in `app/Filament/Resources/OperationRunResource.php` and `app/Filament/Pages/Operations/TenantlessOperationRunViewer.php`
- [X] T030 Run formatting for touched implementation files using `vendor/bin/sail bin pint --dirty --format agent` guided by `specs/164-run-detail-hardening/quickstart.md`
- [X] T031 Run the final focused verification pack from `specs/164-run-detail-hardening/quickstart.md`, including canonical deep-link entry checks, against `tests/Feature/Filament/OperationRunEnterpriseDetailPageTest.php`, `tests/Feature/Filament/OperationRunBaselineTruthSurfaceTest.php`, `tests/Feature/Operations/TenantlessOperationRunViewerTest.php`, and `tests/Unit/Support/Ui/EnterpriseDetail/EnterpriseDetailBuilderTest.php`
---
## Phase 7: Visual Consistency & Enterprise Polish
**Purpose**: Address remaining visual and UX inconsistencies identified during browser review. Aligns with FR-164-005, FR-164-007, FR-164-009, FR-164-014, FR-164-016.
- [X] T032 Remove identical reason-card description text (`evidence_gap_bucket_help`) and replace with per-reason contextual descriptions in `lang/en/baseline-compare.php` and `resources/views/filament/infolists/entries/evidence-gap-subjects.blade.php`
- [X] T033 [P] Remove monospace font from `subject_key` column in `app/Livewire/BaselineCompareEvidenceGapTable.php` — values are human-readable labels, not code identifiers
- [X] T034 [P] Add conditional color treatment to stat-grid items (danger for failed > 0, success for errors = 0) in `resources/views/filament/infolists/entries/enterprise-detail/section-items.blade.php` and `app/Support/Ui/EnterpriseDetail/EnterpriseDetailSectionFactory.php`
- [X] T035 [P] Standardize grid column count — supporting groups always 2-col, stat grids inside decision zone always 2-col, artifact truth detail 4-col, count diagnostics 4-col — in `resources/views/filament/infolists/entries/enterprise-detail/section-items.blade.php`
- [X] T036 Add Filament `groups()` to the evidence-gap table to group rows by reason in `app/Livewire/BaselineCompareEvidenceGapTable.php`
- [X] T037 Run formatting with `vendor/bin/sail bin pint --dirty --format agent`
- [X] T038 Run focused regression pack against `tests/Feature/Filament/OperationRunEnterpriseDetailPageTest.php`, `tests/Feature/Filament/OperationRunBaselineTruthSurfaceTest.php`, and `tests/Unit/Support/Ui/EnterpriseDetail/EnterpriseDetailBuilderTest.php`
---
## Dependencies & Execution Order
### Phase Dependencies
- **Setup (Phase 1)**: Starts immediately and establishes the shared page payload contract.
- **Foundational (Phase 2)**: Depends on Setup and blocks all story work until the layout can render the new page shape.
- **User Story 1 (Phase 3)**: Starts after Foundational and delivers the MVP decision surface.
- **User Story 2 (Phase 4)**: Starts after User Story 1 because it refines the decision zone with special-state caveats.
- **User Story 3 (Phase 5)**: Starts after User Story 1; it can overlap with User Story 2 once the decision-zone contract is stable.
- **Polish (Phase 6)**: Starts after the desired user stories are complete.
- **Visual Consistency (Phase 7)**: Starts after Phase 6; addresses browser-identified visual inconsistencies.
### User Story Dependencies
- **US1**: Depends only on Setup and Foundational work.
- **US2**: Depends on the decision-zone contract from US1 and then focuses on special-state grouping and banner discipline.
- **US3**: Depends on the decision-zone contract from US1 and then focuses on ordering and coexistence of type-specific detail.
### Within Each User Story
- Tests should be updated before or alongside the relevant implementation tasks and must fail before the behavior change is considered complete.
- Resource composer changes in `app/Filament/Resources/OperationRunResource.php` should land before Blade partial cleanup for the same story.
- Focused story-level test runs should complete before moving on to the next story.
### Parallel Opportunities
- `T002`, `T003`, `T004`, and `T005` can run in parallel once the builder extension target is clear.
- `T007` and `T008` can run in parallel after the main layout order is defined.
- `T009` and `T010` can run in parallel for US1.
- `T017` and `T018` can run in parallel for US2.
- `T023` and `T024` can run in parallel for US3.
- `T029` can run in parallel with final regression execution once all code changes are finished.
---
## Parallel Example: User Story 1
```bash
# Story 1 tests in parallel:
Task: T009 tests/Feature/Filament/OperationRunEnterpriseDetailPageTest.php
Task: T010 tests/Feature/Filament/OperationRunBaselineTruthSurfaceTest.php
# Story 1 implementation split after test expectations are clear:
Task: T011 app/Filament/Resources/OperationRunResource.php
Task: T014 resources/views/filament/infolists/entries/enterprise-detail/decision-zone.blade.php
```
## Parallel Example: User Story 2
```bash
# Story 2 test coverage in parallel:
Task: T017 tests/Feature/Filament/OperationRunEnterpriseDetailPageTest.php
Task: T018 tests/Feature/Operations/TenantlessOperationRunViewerTest.php
# Story 2 implementation split after banner rules are locked:
Task: T019 app/Filament/Resources/OperationRunResource.php
Task: T021 resources/views/filament/pages/operations/tenantless-operation-run-viewer.blade.php
```
## Parallel Example: User Story 3
```bash
# Story 3 regression work in parallel:
Task: T023 tests/Feature/Filament/OperationRunEnterpriseDetailPageTest.php
Task: T024 tests/Unit/Support/Ui/EnterpriseDetail/EnterpriseDetailBuilderTest.php
# Story 3 implementation split after ordering assertions are defined:
Task: T025 app/Filament/Resources/OperationRunResource.php
Task: T026 resources/views/filament/infolists/entries/enterprise-detail/technical-detail.blade.php
```
---
## Implementation Strategy
### MVP First
- Complete Phase 1 and Phase 2.
- Deliver User Story 1 as the MVP.
- Validate that the canonical page now answers the main operator questions within the first visible page height.
### Incremental Delivery
- Add User Story 2 next to harden stale, reconciled, blocked, and artifact-limited states without duplicate warnings.
- Add User Story 3 last to preserve deep diagnostic and type-specific detail beneath the new decision hierarchy.
### Verification Finish
- Run Pint on touched files.
- Run the focused regression pack from `quickstart.md`.
- If broader confidence is needed after focused verification, run the wider suite separately.

View File

@ -0,0 +1,36 @@
# Specification Quality Checklist: Baseline Compare Summary Trust Propagation & Compliance Claim Hardening
**Purpose**: Validate specification completeness and quality before proceeding to planning
**Created**: 2026-03-26
**Feature**: [spec.md](../spec.md)
## Content Quality
- [x] No implementation details (languages, frameworks, APIs)
- [x] Focused on user value and business needs
- [x] Written for non-technical stakeholders
- [x] All mandatory sections completed
## Requirement Completeness
- [x] No [NEEDS CLARIFICATION] markers remain
- [x] Requirements are testable and unambiguous
- [x] Success criteria are measurable
- [x] Success criteria are technology-agnostic (no implementation details)
- [x] All acceptance scenarios are defined
- [x] Edge cases are identified
- [x] Scope is clearly bounded
- [x] Dependencies and assumptions identified
## Feature Readiness
- [x] All functional requirements have clear acceptance criteria
- [x] User scenarios cover primary flows
- [x] Feature meets measurable outcomes defined in Success Criteria
- [x] No implementation details leak into specification
## Notes
- Validation completed in one pass.
- No clarification markers were needed; the supplied feature description was specific enough to define scope, risks, and measurable outcomes.
- The spec stays focused on summary-truth propagation and explicitly excludes backend compare-engine or persistence redesign.

View File

@ -0,0 +1,225 @@
openapi: 3.1.0
info:
title: Baseline Summary Surface Contract
version: 1.0.0
description: >-
Internal contract for baseline compare summary semantics across tenant dashboard,
Baseline Compare landing, findings-adjacent warning surfaces, and canonical run-detail alignment.
servers:
- url: /
paths:
/admin:
get:
summary: Tenant dashboard baseline summary contract
description: >-
HTML tenant dashboard surface whose embedded baseline summary semantics must obey the shared summary assessment.
responses:
'200':
description: Dashboard rendered with an embedded baseline summary assessment
content:
application/json:
schema:
$ref: '#/components/schemas/TenantDashboardBaselineSummary'
/admin/baseline-compare:
get:
summary: Baseline Compare landing summary contract
description: >-
Landing surface for the latest baseline compare result. The primary summary must not exceed the trust carried by the underlying explanation.
responses:
'200':
description: Landing page rendered with primary summary assessment and diagnostics links
content:
application/json:
schema:
$ref: '#/components/schemas/BaselineLandingSummary'
/admin/findings:
get:
summary: Findings-adjacent baseline warning contract
description: >-
Findings context that may display a coverage or evidence caution banner derived from the same summary assessment.
responses:
'200':
description: Findings page rendered with optional baseline summary caution
content:
application/json:
schema:
$ref: '#/components/schemas/BaselineBannerSummary'
/admin/operations/{run}:
get:
summary: Canonical baseline compare run-detail reference contract
description: >-
Canonical run detail remains the deepest truth surface. Compact summaries may not be more optimistic than this contract.
parameters:
- name: run
in: path
required: true
schema:
type: integer
responses:
'200':
description: Run detail rendered with baseline compare truth semantics
content:
application/json:
schema:
$ref: '#/components/schemas/BaselineRunDetailReference'
components:
schemas:
SummaryStateFamily:
type: string
enum:
- positive
- caution
- stale
- action_required
- unavailable
- in_progress
TrustworthinessLevel:
type: string
enum:
- trustworthy
- limited_confidence
- diagnostic_only
- unusable
EvaluationResult:
type: string
description: >-
`failed_result` represents a compare execution that completed without a usable decision-grade artifact
and therefore must map to an investigation-oriented `action_required` summary state rather than
`unavailable`.
enum:
- full_result
- no_result
- incomplete_result
- failed_result
- suppressed_result
- unavailable
EvidenceImpact:
type: string
enum:
- none
- coverage_warning
- evidence_gap
- stale_result
- suppressed_output
- unavailable
NextAction:
type: object
required:
- label
- target
properties:
label:
type: string
target:
type: string
enum:
- landing
- findings
- run
- none
BaselineSummaryAssessment:
type: object
description: >-
Shared compact-summary contract. If `evaluationResult` is `failed_result`, `stateFamily` must be
`action_required` and `nextAction.target` must not be `none`.
required:
- stateFamily
- headline
- positiveClaimAllowed
- trustworthinessLevel
- evaluationResult
- evidenceImpact
- nextAction
properties:
stateFamily:
$ref: '#/components/schemas/SummaryStateFamily'
headline:
type: string
supportingMessage:
type: string
nullable: true
tone:
type: string
positiveClaimAllowed:
type: boolean
trustworthinessLevel:
$ref: '#/components/schemas/TrustworthinessLevel'
evaluationResult:
$ref: '#/components/schemas/EvaluationResult'
evidenceImpact:
$ref: '#/components/schemas/EvidenceImpact'
findingsVisibleCount:
type: integer
minimum: 0
highSeverityCount:
type: integer
minimum: 0
reasonCode:
type: string
nullable: true
lastComparedLabel:
type: string
nullable: true
nextAction:
$ref: '#/components/schemas/NextAction'
TenantDashboardBaselineSummary:
type: object
required:
- widget
- needsAttention
properties:
widget:
$ref: '#/components/schemas/BaselineSummaryAssessment'
needsAttention:
$ref: '#/components/schemas/BaselineSummaryAssessment'
kpiCards:
type: array
description: Quantitative indicators only; not claim-bearing semantics.
items:
type: object
required:
- label
- value
properties:
label:
type: string
value:
type: integer
minimum: 0
BaselineLandingSummary:
type: object
required:
- primarySummary
properties:
primarySummary:
$ref: '#/components/schemas/BaselineSummaryAssessment'
diagnosticsAvailable:
type: boolean
runLinkAvailable:
type: boolean
findingsLinkAvailable:
type: boolean
BaselineBannerSummary:
type: object
required:
- shouldShow
properties:
shouldShow:
type: boolean
bannerSummary:
allOf:
- $ref: '#/components/schemas/BaselineSummaryAssessment'
nullable: true
BaselineRunDetailReference:
type: object
required:
- primarySummary
properties:
primarySummary:
$ref: '#/components/schemas/BaselineSummaryAssessment'
semanticCeiling:
type: boolean
description: Always true for the canonical detail; compact surfaces may not exceed this confidence.
x-tenantpilot-notes:
- These routes render HTML in practice; the schema models the internal summary payload that their views must honor.
- No new HTTP endpoints are introduced by this feature.

View File

@ -0,0 +1,183 @@
# Data Model: Baseline Compare Summary Trust Propagation & Compliance Claim Hardening
## Overview
This feature introduces no new persistence model. The data model is a derived view-model contract that lifts existing compare truth, explanation, and evidence signals into compact summary surfaces.
## Derived Entities
### 1. BaselineCompareStats
- Type: existing immutable support DTO
- Source: `app/Support/Baselines/BaselineCompareStats`
- Responsibility: canonical aggregate of baseline assignment state, latest compare run state, findings totals, severity counts, coverage status, reason code, evidence-gap totals, diagnostics, and related run identity
- Relevant fields for this feature:
- `state`
- `message`
- `reasonCode`
- `reasonMessage`
- `operationRunId`
- `findingsCount`
- `severityCounts`
- `coverageStatus`
- `uncoveredTypesCount`
- `uncoveredTypes`
- `fidelity`
- `evidenceGapsCount`
- `evidenceGapDetails`
- `lastComparedHuman`
- `lastComparedIso`
### 2. OperatorExplanationPattern
- Type: existing derived explanation object
- Source: `BaselineCompareExplanationRegistry::forStats()` via `BaselineCompareStats::operatorExplanation()`
- Responsibility: translates stats into explanation family, evaluation result, trustworthiness, reliability statement, coverage statement, dominant cause, and next action
- Relevant fields for this feature:
- `family`
- `headline`
- `executionOutcome`
- `evaluationResult`
- `trustworthinessLevel`
- `reliabilityStatement`
- `coverageStatement`
- `dominantCauseCode`
- `dominantCauseLabel`
- `nextActionCategory`
- `nextActionText`
- `countDescriptors`
### 3. BaselineSummaryAssessment
- Type: new derived support-layer contract
- Persistence: none
- Responsibility: one compact summary-safe interpretation of the current baseline compare posture that can be rendered consistently across dashboard, banner, and landing summary surfaces
- Documentation note: `not-ready` in the spec or task wording is not an extra enum value; it must resolve to either `in_progress` or `unavailable` in the formal contract.
#### Proposed Fields
- `stateFamily`: one of `positive`, `caution`, `stale`, `action_required`, `unavailable`, `in_progress`
- `headline`: strongest safe primary statement for the current compare posture
- `supportingMessage`: short secondary explanation, optional
- `tone`: centralized tone or badge domain for rendering emphasis
- `positiveClaimAllowed`: boolean guard used to block compliant or no-drift wording
- `trustworthinessLevel`: copied or normalized from operator explanation
- `evaluationResult`: copied or normalized from operator explanation, including `failed_result` when compare execution failed or produced no decision-grade artifact
- `evidenceImpact`: enum-like derived label such as `none`, `coverage_warning`, `evidence_gap`, `stale_result`, `suppressed_output`, `unavailable`
- `findingsVisibleCount`: numeric descriptor, not itself the verdict
- `highSeverityCount`: numeric descriptor for compact severity emphasis
- `nextAction`: structured object with:
- `label`: concise action cue
- `target`: one of `landing`, `findings`, `run`, `none`
- `lastComparedLabel`: relative-time summary where applicable
- `reasonCode`: current dominant reason code when one exists
#### Validation Rules
- `positiveClaimAllowed` may be `true` only when:
- a usable compare result exists
- trustworthiness is decision-grade
- evaluation result is not incomplete, suppressed, failed, or unavailable
- there is no material evidence or coverage limitation undermining the claim
- `stateFamily = positive` requires `positiveClaimAllowed = true`
- `stateFamily = caution` requires a usable but limited result
- `stateFamily = stale` requires a usable but no-longer-fresh result whose age materially limits the safety of an all-clear claim
- `stateFamily = action_required` requires confirmed drift, failed compare execution, or another follow-up-critical state
- `stateFamily = unavailable` requires no usable result, no snapshot, compare never run, or equivalent pre-execution unavailability
- `headline` must never be semantically stronger than the current explanation family and trustworthiness combination
### 4. Surface Consumption Profile
- Type: new derived rendering hint, optionally implicit rather than a dedicated class
- Persistence: none
- Responsibility: allows the same summary assessment to render at different compactness levels without changing its semantic meaning
#### Candidate Variants
- `dashboard_widget`
- `needs_attention`
- `coverage_banner`
- `landing_summary`
- `canonical_detail_reference`
#### Expected Behavior
- All variants share the same `stateFamily`, `headline`, and `positiveClaimAllowed`
- Variants may differ in verbosity, badge count, and which next-action link is most prominent
- No variant may upgrade a cautionary or unavailable assessment into a positive assessment
## Relationships
- `BaselineCompareStats` -> `OperatorExplanationPattern`
- `BaselineCompareStats` + `OperatorExplanationPattern` -> `BaselineSummaryAssessment`
- `BaselineSummaryAssessment` + `Surface Consumption Profile` -> rendered widget, banner, or landing summary output
- Canonical run detail remains the deeper truth surface that validates the same underlying explanation and reason semantics
## State Families
### Positive
- Meaning: no confirmed drift and the result is safe to treat as trustworthy
- Allowed only when positive-claim guard passes
- Example outcomes:
- trustworthy no-result with no material evidence limitation
### Caution
- Meaning: no all-clear claim is allowed because the result is limited, incomplete, or partially reliable
- Typical drivers:
- evidence gaps
- coverage warnings
- suppressed output
- limited-confidence explanation family
### Stale
- Meaning: a previously usable compare result exists, but its freshness is no longer strong enough to support a current-state all-clear claim
- Typical drivers:
- aged compare history beyond the current freshness threshold
- no newer compare since a material tenant or baseline change
### Action Required
- Meaning: confirmed drift, failed compare requiring review, or another immediately actionable posture
- Typical drivers:
- visible open drift findings
- failed compare with explicit investigation path
### Unavailable
- Meaning: no usable compare result currently exists
- Typical drivers:
- no assignment
- no snapshot
- compare never run
- blocked prerequisite
### In Progress
- Meaning: compare is queued or running and current numbers are diagnostic only
- Typical drivers:
- active compare operation run
## State Transitions
The summary-state family is derived, not persisted. Expected transition patterns:
- `unavailable` -> `in_progress` when a compare starts
- `in_progress` -> `positive` when the compare completes with trustworthy no-result semantics
- `in_progress` -> `caution` when the compare completes with limited-confidence or suppressed-result semantics
- `positive` or `caution` -> `stale` when freshness decays below the decision-grade threshold without a newer compare
- `stale` -> `in_progress` when a refresh compare starts
- `in_progress` -> `action_required` when the compare records open drift findings or a failure state demanding review
- `positive` -> `caution` if later evidence or coverage limits undercut trustworthiness
- `positive` or `caution` -> `unavailable` when no consumable snapshot or no usable result remains available
## Test-Critical Invariants
- `0 findings` must not force `stateFamily = positive`
- `positiveClaimAllowed = false` must block `Compliant`, `No drift`, `No open drift`, and equivalent copy on all compact surfaces
- Evidence gaps must be able to move a surface from `positive` to `caution` even when `coverageStatus = ok`
- Stale compare history must not collapse into `unavailable` or `positive`; it needs its own compact summary semantics
- Dashboard and landing surfaces consuming the same assessment must not disagree on the primary state family
- Compact surfaces may omit details, but not semantic qualifiers

View File

@ -0,0 +1,277 @@
# Implementation Plan: Baseline Compare Summary Trust Propagation & Compliance Claim Hardening
**Branch**: `165-baseline-summary-trust` | **Date**: 2026-03-26 | **Spec**: `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/specs/165-baseline-summary-trust/spec.md`
**Input**: Feature specification from `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/specs/165-baseline-summary-trust/spec.md`
## Summary
Harden all in-scope baseline and drift summary surfaces so that no compact widget, KPI-adjacent summary, banner, or landing headline can imply `Compliant`, `No drift`, or an equivalent all-clear unless the underlying compare result is genuinely trustworthy. The implementation will introduce a shared baseline summary-state contract derived from the existing baseline compare truth and explanation layers, replace findings-count shortcuts on the tenant dashboard, propagate evidence-gap and coverage limitations into summary claims, keep the landing surface and run drilldown semantically aligned, and lock the behavior down with focused Pest and Livewire coverage.
Key approach: reuse the current baseline domain seams already present in `BaselineCompareStats`, `BaselineCompareExplanationRegistry`, reason translation, and badge semantics; add one reusable summary assessment layer for compact surfaces; preserve the existing `Compare now` action, routes, and DB-only render behavior; and avoid any model, enum, or schema changes.
## Technical Context
**Language/Version**: PHP 8.4, Laravel 12, Blade, Filament v5, Livewire v4
**Primary Dependencies**: Filament v5, Livewire v4, Pest v4, Laravel Sail, existing `BaselineCompareStats`, `BaselineCompareExplanationRegistry`, `ReasonPresenter`, `BadgeCatalog` or `BadgeRenderer`, `UiEnforcement`, and `OperationRunLinks`
**Storage**: PostgreSQL with existing baseline, findings, and `operation_runs` tables plus JSONB-backed compare context; no schema change planned
**Testing**: Pest feature tests, Livewire component tests, dashboard DB-only render regression, all executed through Sail
**Target Platform**: Laravel web application in Sail locally and containerized Linux deployment in staging and production
**Project Type**: Laravel monolith web application
**Performance Goals**: Keep dashboard and landing renders DB-only, preserve existing lazy-widget behavior, avoid new outbound HTTP or background dispatch during render, and keep summary claims understandable within one short scan of each surface
**Constraints**: No new database tables, no new outcome enums, no compare-engine rewrite, no route or RBAC drift, no new global assets, no dashboard summary more optimistic than landing or run detail, and no new ad hoc status-color language
**Scale/Scope**: Four primary summary surface families, one shared support-layer contract, one tenant landing page, one canonical drilldown alignment path, and focused regression coverage across trustworthy, limited, failed, missing, and evidence-gap-affected compare scenarios
## Constitution Check
*GATE: Passed before Phase 0 research. Re-checked after Phase 1 design and still passing.*
| Principle | Status | Notes |
|-----------|--------|-------|
| Inventory-first | Pass | No inventory, snapshot, or evidence ownership semantics change; the work is presentation hardening only |
| Read/write separation | Pass | No new mutation path is introduced; the feature remains read-only except for the already-existing guarded `Compare now` action |
| Graph contract path | Pass | No new Graph call path, contract-registry entry, or render-time network access is introduced |
| Deterministic capabilities | Pass | No new capability derivation; existing capability and tenant-view checks remain authoritative |
| RBAC-UX planes and 404 vs 403 | Pass | All covered surfaces stay in the tenant/admin plane except the existing canonical run drilldown, which keeps current tenant-safe access rules |
| Workspace isolation | Pass | No workspace-context broadening; tenant summary surfaces still require an established workspace context |
| Tenant isolation | Pass | Covered surfaces remain tenant-scoped, and canonical run drilldown remains entitlement-checked before revealing tenant-linked evidence |
| Destructive confirmation | Pass | No new destructive action; existing `Compare now` already uses confirmation and capability gating |
| Global search safety | Pass | No global-search behavior or searchable resource configuration changes are part of this feature |
| Run observability | Pass | Existing baseline compare `OperationRun` behavior remains unchanged; the feature only reads and interprets current run evidence |
| Ops-UX 3-surface feedback | Pass | No new toasts, progress surfaces, or terminal notifications are introduced |
| Ops-UX lifecycle ownership | Pass | `OperationRun.status` and `OperationRun.outcome` remain service-owned and untouched by this feature |
| Ops-UX summary counts | Pass | Existing `summary_counts` rules stay unchanged; the feature consumes result meaning rather than redefining counts |
| Ops-UX guards | Pass | Existing lifecycle guards remain intact; new tests will focus on summary truth and cross-surface consistency |
| Data minimization | Pass | No new secrets, raw Graph payloads, or low-level diagnostics are elevated into default-visible summaries |
| Badge semantics (BADGE-001) | Pass | Status tones and badges must continue to come from central badge or shared primitive semantics rather than page-local green or warning shortcuts |
| Filament-native UI (UI-FIL-001) | Pass | Widgets, banners, and landing summaries continue to use Filament sections, badges, links, and shared primitives rather than bespoke status components |
| UI naming (UI-NAMING-001) | Pass | Operator-facing copy remains domain-first and must avoid false-calming phrases when the result is not decision-grade |
| Operator surfaces (OPSURF-001) | Pass | The feature explicitly strengthens operator-first meaning by carrying governance result, evidence completeness, and next step into compact surfaces |
| Filament Action Surface Contract | Pass | Existing action inventory stays stable; only summary semantics and wording change |
| Filament UX-001 | Pass with documented variance | The landing page remains a custom enterprise layout rather than a stock infolist, but it still honors sectioning, centralized badges, and operator-first hierarchy |
| Filament v5 / Livewire v4 compliance | Pass | All work stays within the current Filament v5 and Livewire v4 stack |
| Provider registration location | Pass | No panel or provider changes are required; Laravel 11+ provider registration remains in `bootstrap/providers.php` |
| Global-search hard rule | Pass | No globally searchable resource is added or modified; no Edit/View-page requirement changes are triggered |
| Asset strategy | Pass | No new Filament assets are planned; deployment expectations for `php artisan filament:assets` remain unchanged because no asset registration changes are introduced |
## Phase 0 Research
Research outcomes are captured in `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/specs/165-baseline-summary-trust/research.md`.
Key decisions:
- Use one shared summary assessment derived from `BaselineCompareStats` and `operatorExplanation()` instead of findings-count-only widget logic.
- Treat the current `BaselineCompareNow` success pill and the `NeedsAttention` healthy state as the highest-risk false-calm surfaces and harden them first.
- Propagate evidence gaps into summary semantics even when uncovered-types coverage warnings are absent.
- Keep KPI cards quantitative only; they may link to deeper surfaces but must not become semantic all-clear claims.
- Extend existing landing, widget, and baseline-truth tests rather than creating a separate UI harness.
## Phase 1 Design
Design artifacts are created under `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/specs/165-baseline-summary-trust/`:
- `data-model.md`: derived summary-state entities, fields, and state-family rules
- `contracts/baseline-summary-surface.openapi.yaml`: internal surface-contract schema for compact baseline compare claims across dashboard, landing, banner, and run drilldown
- `quickstart.md`: focused verification workflow for manual and automated validation
Design decisions:
- No schema migration is required; the design uses existing baseline compare stats, reason translation, operator explanation, findings, and operation-run evidence.
- The primary implementation seam is a new shared support-layer summary assessment in `app/Support/Baselines`, consumed by dashboard widgets, the landing page, and any summary-adjacent banner or headline.
- The existing `BaselineCompareStats::forWidget()` shortcut is too lossy for trust propagation, so covered summary surfaces must consume either the richer tenant stats or a derived contract built from them.
- `BaselineCompareNow` and `NeedsAttention` must stop deriving healthy or compliant claims from zero findings alone.
- The coverage banner must consider evidence gaps as summary-limiting signals, not only uncovered policy types and missing snapshots.
- Canonical run detail remains the deepest truth surface and becomes the semantic ceiling: compact surfaces may be equally cautious or more cautious, never more optimistic.
## Project Structure
### Documentation (this feature)
```text
specs/165-baseline-summary-trust/
├── spec.md
├── plan.md
├── research.md
├── data-model.md
├── quickstart.md
├── contracts/
│ └── baseline-summary-surface.openapi.yaml
├── checklists/
│ └── requirements.md
└── tasks.md
```
### Source Code (repository root)
```text
app/
├── Filament/
│ ├── Pages/
│ │ └── BaselineCompareLanding.php
│ └── Widgets/
│ ├── Dashboard/
│ │ ├── BaselineCompareNow.php
│ │ ├── DashboardKpis.php
│ │ └── NeedsAttention.php
│ └── Tenant/
│ └── BaselineCompareCoverageBanner.php
├── Support/
│ ├── Baselines/
│ │ ├── BaselineCompareStats.php
│ │ ├── BaselineCompareExplanationRegistry.php
│ │ ├── BaselineCompareEvidenceGapDetails.php
│ │ └── BaselineCompareReasonCode.php
│ ├── Badges/
│ │ ├── BadgeCatalog.php
│ │ └── BadgeRenderer.php
│ └── ReasonTranslation/
│ └── ReasonTranslator.php
resources/
└── views/
└── filament/
├── pages/
│ └── baseline-compare-landing.blade.php
└── widgets/
├── dashboard/
│ ├── baseline-compare-now.blade.php
│ └── needs-attention.blade.php
└── tenant/
└── baseline-compare-coverage-banner.blade.php
tests/
├── Feature/
│ ├── Baselines/
│ │ ├── BaselineCompareStatsTest.php
│ │ ├── BaselineCompareSummaryAssessmentTest.php
│ │ ├── BaselineCompareExplanationFallbackTest.php
│ │ └── BaselineCompareWhyNoFindingsReasonCodeTest.php
│ ├── ReasonTranslation/
│ │ └── ReasonTranslationExplanationTest.php
│ └── Filament/
│ ├── BaselineCompareCoverageBannerTest.php
│ ├── BaselineCompareExplanationSurfaceTest.php
│ ├── BaselineCompareLandingWhyNoFindingsTest.php
│ ├── BaselineCompareLandingStartSurfaceTest.php
│ ├── BaselineCompareNowWidgetTest.php
│ ├── BaselineCompareSummaryConsistencyTest.php
│ ├── NeedsAttentionWidgetTest.php
│ ├── OperationRunBaselineTruthSurfaceTest.php
│ └── TenantDashboardDbOnlyTest.php
```
**Structure Decision**: Standard Laravel monolith. The feature is confined to the existing support-layer baseline truth objects, a small number of tenant-facing Filament widgets and pages, and focused Pest coverage. No new base directories or architectural layers are required beyond a shared compact-summary support seam inside `app/Support/Baselines`.
## Implementation Strategy
### Phase A — Establish One Shared Summary Truth Contract
**Goal**: Derive one reusable summary-state assessment from existing baseline compare truth and explanation layers so widgets and landing summaries stop improvising their own semantics.
| Step | File | Change |
|------|------|--------|
| A.1 | `app/Support/Baselines/BaselineCompareStats.php` | Refactor or extend the compact-summary seam so covered surfaces can consume trustworthiness, evidence completeness, reason semantics, and result availability instead of findings counts only |
| A.2 | `app/Support/Baselines/BaselineCompareExplanationRegistry.php` and an adjacent new support type | Introduce a shared summary assessment or presenter that maps stats plus explanation into summary state family, safe headline, tone, and next step |
| A.3 | `app/Support/Baselines/BaselineCompareReasonCode.php` and reason translation seams if needed | Ensure positive claim eligibility and limited-confidence semantics stay aligned with current explanation-family and trustworthiness rules |
| A.4 | Shared badge or UI support helpers if needed | Keep badge or tone selection centralized and avoid page-local success shortcuts |
### Phase B — Harden Tenant Dashboard Summary Surfaces
**Goal**: Remove the most dangerous false-calm claims from the tenant dashboard without breaking lazy loading or DB-only behavior, while keeping stale, failed, missing, in-progress, and unavailable compare states visibly distinct.
| Step | File | Change |
|------|------|--------|
| B.1 | `app/Filament/Widgets/Dashboard/BaselineCompareNow.php` | Replace the findings-only widget payload with the shared summary assessment contract |
| B.2 | `resources/views/filament/widgets/dashboard/baseline-compare-now.blade.php` | Replace `No open drift — baseline compliant` with contract-driven positive, cautionary, stale, unavailable, in-progress, or review-oriented states |
| B.3 | `app/Filament/Widgets/Dashboard/NeedsAttention.php` | Feed healthy-check and attention-item generation from the shared summary contract so limited, stale, in-progress, unavailable, or incomplete compare results cannot fall through to `Everything looks healthy right now.` |
| B.4 | `resources/views/filament/widgets/dashboard/needs-attention.blade.php` | Keep the widget compact while showing truthful caution and next-step language when compare evidence is limited |
| B.5 | `app/Filament/Widgets/Dashboard/DashboardKpis.php` | Verify that KPI cards remain quantitative-only and do not imply stronger semantic claims than the shared contract allows |
### Phase C — Align Landing And Banner Surfaces With The Same Claim Guard
**Goal**: Ensure the Baseline Compare landing surface and findings-adjacent banner use the same claim-strength rules as the dashboard, including distinct stale, in-progress, and unavailable result handling.
| Step | File | Change |
|------|------|--------|
| C.1 | `app/Filament/Pages/BaselineCompareLanding.php` | Expose the shared summary assessment to the Blade view alongside existing explanation and diagnostics payloads |
| C.2 | `resources/views/filament/pages/baseline-compare-landing.blade.php` | Make the visible headline and zero-findings explanation obey the hardened positive-claim rules rather than findings count alone, including distinct stale, in-progress, and unavailable states |
| C.3 | `app/Filament/Widgets/Tenant/BaselineCompareCoverageBanner.php` | Expand the banner trigger and text so evidence gaps and limited-confidence results can influence the summary, not only uncovered types or missing snapshots |
| C.4 | `resources/views/filament/widgets/tenant/baseline-compare-coverage-banner.blade.php` | Preserve compact warning language while clearly distinguishing incomplete evidence, suppressed output, and baseline unavailability |
### Phase D — Keep Canonical Drilldown As The Semantic Ceiling
**Goal**: Preserve the operation-run detail surface as the deepest truth surface and ensure summary surfaces cannot out-claim it.
| Step | File | Change |
|------|------|--------|
| D.1 | Existing baseline compare run-detail presentation seams | Verify that compact summary wording does not become stronger than current artifact-truth and operator-explanation wording on the run detail surface |
| D.2 | Shared reason or explanation helpers if needed | Reuse the same explanation-family semantics across summary and detail instead of duplicating widget-only logic |
| D.3 | No route or action change | Keep existing dashboard, banner, and landing drilldowns to `Compare now`, `View run`, and `Open findings` intact so limited states have a clear resolution path, and keep `Needs Attention` explicitly non-navigational if it exposes no existing drilldown |
### Phase E — Regression Protection And Focused Validation
**Goal**: Lock the summary truth contract into tests, including the dashboard false-calm case that currently passes as compliant.
| Step | File | Change |
|------|------|--------|
| E.1 | `tests/Feature/Filament/BaselineCompareNowWidgetTest.php` | Replace the current compliant assertion with scenario coverage for trustworthy, limited-confidence, stale, failed, in-progress, and unavailable summary states |
| E.2 | `tests/Feature/Filament/NeedsAttentionWidgetTest.php` | Cover `NeedsAttention` healthy-state fallback and evidence-gap-, stale-, in-progress-, and unavailable-driven caution on the dashboard |
| E.3 | `tests/Feature/Filament/BaselineCompareExplanationSurfaceTest.php`, `tests/Feature/Filament/BaselineCompareLandingWhyNoFindingsTest.php`, and `tests/Feature/Filament/BaselineCompareCoverageBannerTest.php` | Extend landing and banner assertions so zero findings plus limited evidence, stale history, or in-progress or unavailable compare state never becomes an all-clear claim |
| E.4 | `tests/Feature/Baselines/BaselineCompareSummaryAssessmentTest.php` and adjacent explanation tests | Add or adjust support-layer assertions around positive-claim eligibility, stale-versus-not-ready distinction, and summary-state derivation |
| E.5 | `tests/Feature/ReasonTranslation/ReasonTranslationExplanationTest.php` | Preserve reason-translation trust-impact and absence-pattern semantics for compact summary claims and deeper artifact-truth surfaces |
| E.6 | `tests/Feature/Filament/BaselineCompareNowWidgetTest.php`, `tests/Feature/Filament/NeedsAttentionWidgetTest.php`, `tests/Feature/Filament/BaselineCompareCoverageBannerTest.php`, `tests/Feature/Filament/BaselineCompareLandingStartSurfaceTest.php`, and `tests/Feature/Filament/BaselineCompareSummaryConsistencyTest.php` | Preserve deny-as-not-found semantics, compare-now capability gating, dashboard, banner, and landing summary-to-run-detail or findings drilldown expectations, and the intentionally non-navigational `Needs Attention` behavior while summary wording changes |
| E.7 | `tests/Feature/Filament/OperationRunBaselineTruthSurfaceTest.php` and `tests/Feature/Filament/TenantDashboardDbOnlyTest.php` | Preserve cross-surface semantic consistency, drilldown parity, and DB-only dashboard render behavior |
| E.8 | `vendor/bin/sail bin pint --dirty --format agent` and focused Pest runs | Required formatting and targeted verification before implementation is considered complete |
## Key Design Decisions
### D-001 — The summary contract must originate from the truth layer, not from widget-local counts
`BaselineCompareNow` currently consumes `BaselineCompareStats::forWidget()`, which only knows counts, assignment, snapshot presence, and last compare time. That shortcut is too weak for trust propagation. The design therefore promotes a shared summary contract built from the richer compare truth and explanation seams.
### D-002 — Zero findings is a count descriptor, not a governance verdict
The existing landing explanation layer already distinguishes trustworthy no-result, suppressed output, incomplete result, unavailable result, and blocked or missing inputs. The compact summary contract must preserve that distinction instead of translating `0 findings` directly into `baseline compliant`.
### D-003 — Dashboard healthy states are part of the truth surface, not decorative filler
`NeedsAttention` currently falls back to `Everything looks healthy right now.` whenever no high-severity findings, stale compare, failure, or active runs are present. That fallback is itself a semantic claim and must be driven by the shared compare summary contract.
### D-004 — Coverage gaps and evidence gaps both qualify summary truth
The current coverage banner understands uncovered types and missing snapshots, but evidence-gap-driven incompleteness can still remain invisible. The plan therefore treats evidence gaps as first-class summary-limiting inputs even when coverage proof technically exists.
### D-005 — KPI cards stay numeric; claim-bearing surfaces carry the semantic burden
The KPI cards can remain simple quantitative indicators so long as they do not add healthy or compliant phrasing. This keeps the plan focused on the surfaces that actually communicate reassurance.
### D-006 — Stale and not-ready are separate operator states, not generic unavailability
The spec explicitly distinguishes empty, missing, failed, stale, and not-ready compare situations. The shared summary contract therefore must keep stale-history separate from the formal `in_progress` and `unavailable` cases so operators can tell whether they should rerun, wait, or inspect deeper evidence.
### D-007 — Summary hardening must preserve guardrails and drilldowns, not just wording
Because the feature changes meaning on operator-facing surfaces, it must also preserve the existing landing guard contract: deny-as-not-found for non-members, capability-gated `Compare now`, and the current drilldown paths to landing, findings, and canonical run detail.
## Risk Assessment
| Risk | Impact | Likelihood | Mitigation |
|------|--------|------------|------------|
| Shared summary contract becomes another parallel truth model | High | Medium | Derive it directly from existing stats plus operator explanation instead of inventing a new independent state machine |
| Dashboard widgets become too noisy or verbose | Medium | Medium | Use compact state families and one primary next step rather than dumping diagnostics into widgets |
| Landing and widget wording drift apart again over time | Medium | Medium | Centralize claim eligibility and state-family mapping in the shared support layer and cover it with tests |
| Evidence gaps over-trigger warnings and hide genuinely trustworthy no-drift states | Medium | Low | Keep positive claims allowed when trustworthiness is decision-grade and no material limitation is present |
| Summary hardening accidentally introduces extra queries or render-time side effects | Medium | Low | Reuse existing DB-only stats paths, preserve lazy widgets, and keep dashboard DB-only regression coverage |
## Test Strategy
- Extend existing baseline compare feature and Livewire tests rather than introducing a new UI test harness.
- Add explicit scenario coverage for trustworthy no-drift, limited-confidence zero-findings, incomplete evidence, stale compare history, failed compare, in-progress states, and unavailable no-result-yet or no-snapshot states.
- Add at least one cross-surface consistency assertion ensuring a dashboard or banner summary is never more optimistic than the landing or canonical run detail for the same compare state.
- Preserve and extend existing reason-translation assertions so compact summary claims reuse the same trust-impact and absence-pattern semantics as deeper artifact-truth surfaces.
- Preserve existing compare-start and access assertions so the feature does not regress deny-as-not-found behavior, `Compare now` confirmation, capability gating, or summary-to-detail drilldown language.
- Preserve `TenantDashboardDbOnlyTest` so dashboard hardening cannot introduce outbound HTTP or background work during render.
- Run the minimum focused Pest subset through Sail for touched files and ask separately before running the full suite.
## Complexity Tracking
No constitution violations or justified complexity exceptions were identified.

View File

@ -0,0 +1,67 @@
# Quickstart: Baseline Compare Summary Trust Propagation & Compliance Claim Hardening
## Goal
Verify that compact baseline and drift summary surfaces stop issuing false compliant or all-clear claims when the underlying compare result is limited, incomplete, stale, in progress, unavailable, suppressed, or otherwise not decision-grade.
## Preconditions
1. Start Sail and ensure the tenant panel is accessible.
2. Use a tenant with an assigned baseline profile and an active baseline snapshot.
3. Prepare representative compare scenarios using existing factories or fixtures:
- trustworthy no-drift result
- limited-confidence zero-findings result
- evidence-gap-affected result with no open findings
- stale compare history with no new confirmed drift
- failed compare result
- no compare yet, compare in progress, or no snapshot result
## Manual Verification Flow
### Scenario 1: Trustworthy no-drift result
1. Open the tenant dashboard.
2. Confirm the baseline summary widget may show a positive aligned state and still links to the deeper Baseline Compare or run-detail path.
3. Confirm the same tenant's Baseline Compare landing page shows compatible no-drift semantics and preserves its findings or run-detail drilldowns.
4. Confirm the canonical run detail for the same compare is equally confident or more detailed, never less aligned.
### Scenario 2: Limited-confidence zero-findings result
1. Open the tenant dashboard for a compare result with zero visible findings but limited confidence or suppressed output.
2. Confirm the baseline summary widget does not show compliant or all-clear wording.
3. Confirm `Needs Attention` does not fall back to a blanket healthy message and does not introduce a new drilldown path if the surface is intentionally non-navigational.
4. Open the landing page and verify the primary explanation remains cautionary while its drilldowns still resolve to the expected findings or run-detail surface.
5. Open the run detail and confirm the summary was not more optimistic than the detail surface.
### Scenario 3: Evidence gaps with no open findings
1. Open a tenant with evidence gaps recorded but no open drift findings.
2. Confirm a compact summary surface visibly signals caution or review.
3. Confirm the coverage or evidence banner appears when appropriate and offers the expected drilldown path to landing or run detail.
4. Confirm the landing page still exposes deeper evidence-gap detail and diagnostics.
### Scenario 4: Missing, stale, or unusable result
1. Verify the stale-history state stays distinct from no-result and does not render as healthy.
2. Verify the compare-in-progress state is visibly in progress rather than unavailable or healthy.
3. Verify the no-snapshot or no-compare-yet state remains unavailable rather than in progress or healthy.
4. Verify the failed-compare state gives an investigation-oriented next step.
5. Verify the existing `Compare now` action remains available only where already authorized and correctly guarded.
## Automated Verification
Run the same focused verification pack referenced by `tasks.md` through Sail:
1. `vendor/bin/sail artisan test --compact tests/Feature/Baselines/BaselineCompareSummaryAssessmentTest.php tests/Feature/Baselines/BaselineCompareWhyNoFindingsReasonCodeTest.php tests/Feature/Baselines/BaselineCompareStatsTest.php tests/Feature/Baselines/BaselineCompareExplanationFallbackTest.php`
2. `vendor/bin/sail artisan test --compact tests/Feature/Filament/BaselineCompareNowWidgetTest.php tests/Feature/Filament/NeedsAttentionWidgetTest.php`
3. `vendor/bin/sail artisan test --compact tests/Feature/Filament/BaselineCompareExplanationSurfaceTest.php tests/Feature/Filament/BaselineCompareLandingWhyNoFindingsTest.php tests/Feature/Filament/BaselineCompareCoverageBannerTest.php`
4. `vendor/bin/sail artisan test --compact tests/Feature/Filament/BaselineCompareSummaryConsistencyTest.php tests/Feature/Filament/BaselineCompareLandingStartSurfaceTest.php tests/Feature/ReasonTranslation/ReasonTranslationExplanationTest.php`
5. `vendor/bin/sail artisan test --compact tests/Feature/Filament/OperationRunBaselineTruthSurfaceTest.php tests/Feature/Filament/TenantDashboardDbOnlyTest.php`
6. `vendor/bin/sail bin pint --dirty --format agent`
## Expected Outcome
- No in-scope summary surface presents compliant or equivalent all-clear copy for limited-confidence, incomplete-evidence, stale, in-progress, suppressed-result, failed, or unavailable scenarios.
- Trustworthy no-drift scenarios can still present a positive aligned state.
- Dashboard, landing, banner, and canonical detail surfaces remain semantically aligned.
- Existing compare action behavior, lazy widget behavior, and DB-only dashboard rendering remain intact.

View File

@ -0,0 +1,49 @@
# Research: Baseline Compare Summary Trust Propagation & Compliance Claim Hardening
## Decision 1: Derive compact summary claims from existing compare truth and explanation seams
- Decision: Build the compact summary contract from `BaselineCompareStats` plus `operatorExplanation()` rather than from findings counts or widget-local conditions.
- Rationale: The current landing surface already understands explanation family, trustworthiness, coverage statements, reliability statements, reason codes, and next steps. The widget path is currently too lossy because `BaselineCompareStats::forWidget()` collapses the compare state into assignment, snapshot, counts, and last-compare timing. Reusing the richer truth layer ensures summary surfaces do not invent a stronger meaning than the deeper surfaces already carry.
- Alternatives considered:
- Patch each widget with bespoke `if` conditions around `findingsCount`, `coverageStatus`, and `evidenceGapsCount`. Rejected because that would create another parallel truth model and would drift from the explanation layer over time.
- Re-architect compare persistence or introduce new result enums. Rejected because the spec explicitly rules out backend or model rewrites and the current truth signals already exist.
## Decision 2: Treat zero findings as an output count, never as automatic compliance
- Decision: Positive all-clear wording is allowed only when the shared summary contract marks the compare result as trustworthy and free from material evidence or coverage limitations.
- Rationale: Existing baseline compare explanation logic already distinguishes trustworthy no-result, completed-but-limited, suppressed output, unavailable, and blocked states. The specific false-calm bug is that compact summaries translate `0 findings` into `baseline compliant` even when the reason code, coverage proof, or evidence gaps make that interpretation unsafe.
- Alternatives considered:
- Keep the `No open drift` wording and just add a small warning badge nearby. Rejected because the primary claim would still be too strong and operators would continue to read the surface as an all-clear.
- Remove all positive wording entirely from summary surfaces. Rejected because the product still needs a truthful positive state when the compare result is genuinely decision-grade.
## Decision 3: Harden the dashboard first because it contains the strongest false-calm claims
- Decision: Prioritize `BaselineCompareNow` and `NeedsAttention` as the first summary consumers of the new contract.
- Rationale: `BaselineCompareNow` currently renders `No open drift — baseline compliant` whenever `findingsCount` is zero. `NeedsAttention` falls back to `Everything looks healthy right now.` when no attention items are generated, even though it does not currently incorporate compare trust or evidence completeness. These are the highest-risk reassurance surfaces because they sit on the tenant dashboard and are read at a glance.
- Alternatives considered:
- Fix only the Baseline Compare landing page. Rejected because the landing page already has richer explanation semantics and is not the primary false-calm entry point.
- Patch the dashboard copy only. Rejected because wording alone would still be backed by inconsistent state-selection logic.
## Decision 4: Evidence gaps must influence compact summaries even without uncovered-type coverage warnings
- Decision: Treat evidence gaps as first-class summary-limiting inputs on banners and compact summaries, not merely as deep-diagnostic detail.
- Rationale: The current coverage banner shows when coverage is `warning` or `unproven` or when there is no snapshot, but it does not surface evidence-gap-driven partiality when coverage proof exists. The spec explicitly requires evidence gaps to influence summary semantics, so the banner and other compact summaries need the same visibility into evidence limitations that the landing page already has.
- Alternatives considered:
- Keep evidence gaps only on the landing page and canonical run detail. Rejected because the summary-truth contract would still fail on the dashboard and findings-adjacent surfaces.
- Promote all evidence-gap diagnostics into the summary surface. Rejected because compact surfaces need cautionary meaning and next action, not full bucket-level diagnostics.
## Decision 5: KPI cards stay quantitative and should not be promoted into semantic health claims
- Decision: Keep dashboard KPI cards as numeric indicators and ensure any semantic reassurance comes only from the shared summary contract on claim-bearing surfaces.
- Rationale: The KPI cards currently show counts such as open drift findings and high-severity drift. They are not the source of the false compliant claim, and keeping them numeric avoids unnecessary redesign. The feature should harden claim-bearing summaries, not turn every count card into a mini explanation surface.
- Alternatives considered:
- Add semantic healthy or compliant captions to KPI cards. Rejected because that would widen the surface area of the problem.
- Remove KPI cards from scope entirely. Rejected because the spec includes KPI-adjacent summaries and they still need to remain semantically subordinate to the hardened truth contract.
## Decision 6: Extend existing Pest and Livewire tests instead of creating a new browser harness
- Decision: Expand the existing baseline compare widget, landing, stats, and run-detail tests with scenario-specific summary-truth assertions.
- Rationale: The repository already has strong feature coverage around `BaselineCompareStats`, explanation families, landing explanations, and the widget claim that currently asserts `No open drift — baseline compliant`. Updating those tests keeps the regression guard close to the implementation seams and preserves the current Sail-first workflow.
- Alternatives considered:
- Rely on manual QA alone. Rejected because the bug is semantic and cross-surface, so it needs automated regression protection.
- Introduce browser tests as the primary guard. Rejected because the affected logic is mainly view-model and rendered-text behavior already well-covered by feature and Livewire tests.

View File

@ -0,0 +1,194 @@
# Feature Specification: Baseline Compare Summary Trust Propagation & Compliance Claim Hardening
**Feature Branch**: `165-baseline-summary-trust`
**Created**: 2026-03-26
**Status**: Draft
**Input**: User description: "Spec 165 — Baseline Compare Summary Trust Propagation & Compliance Claim Hardening"
## Spec Scope Fields *(mandatory)*
- **Scope**: tenant
- **Primary Routes**:
- Existing tenant dashboard at `/admin`, including the baseline compare summary widget, needs-attention summary, and drift-related KPI cards
- Existing tenant Baseline Compare landing page
- Existing tenant findings surfaces that summarize baseline compare coverage or evidence limitations
- Existing canonical operation-run drilldowns reached from baseline compare summaries
- **Data Ownership**:
- Baseline profiles and baseline snapshots remain workspace-owned standards artifacts
- Baseline compare results, drift findings, evidence gaps, and tenant-linked operation runs remain tenant-owned operational evidence
- This feature changes summary interpretation, claim strength, and operator guidance only; it does not change ownership, persistence, or route identity
- **RBAC**:
- Existing workspace membership and tenant membership remain required for tenant-context summary surfaces
- Existing tenant-view permissions remain authoritative for inspecting baseline, drift, and compare summaries
- Existing compare-start permissions remain authoritative for any existing compare action exposed from the landing surface
- Non-members remain deny-as-not-found, and members in scope but lacking an action capability remain forbidden for that action
## Operator Surface Contract *(mandatory when operator-facing surfaces are changed)*
If this feature adds a new operator-facing page or materially refactors one, fill out one row per affected page/surface.
| Surface | Primary Persona | Surface Type | Primary Operator Question | Default-visible Information | Diagnostics-only Information | Status Dimensions Used | Mutation Scope | Primary Actions | Dangerous Actions |
|---|---|---|---|---|---|---|---|---|---|
| Tenant dashboard baseline summaries | Tenant operator | Dashboard summary | Can I safely treat this tenant as aligned, or do I need to review the compare result more closely? | Assigned baseline state, strongest safe summary claim, open drift counts, freshness or availability state, and the clearest next drilldown | Detailed evidence-gap reasons, coverage breakdowns, and raw compare diagnostics | governance result, evidence completeness, freshness, availability | Read-only summary surface | Open Baseline Compare, View findings, View run when available | None introduced by this spec |
| Baseline Compare landing summary | Tenant operator | Tenant landing/detail | What does the latest compare actually prove, and what should I do next? | Primary compare meaning, trustworthiness, evidence limitations, drift confirmation state, and one obvious next step | Detailed diagnostics, evidence-gap breakdowns, and low-level supporting facts | governance result, trust or confidence, evidence completeness, lifecycle or freshness | Existing compare-start action remains unchanged; summary itself is read-only | Compare now, View run, Open findings | No new dangerous action; existing guarded actions remain under current confirmation and authorization rules |
| Findings coverage banner and adjacent summary copy | Tenant operator | Banner summary | Are current findings enough to trust the absence of drift? | Coverage caveat, evidence limitation, and the safest follow-up cue | Detailed gap reasons and underlying compare evidence | governance result, evidence completeness, availability | Read-only summary surface | Review coverage details, Open findings, View run when relevant | None introduced by this spec |
| Canonical operation-run drilldown for baseline compare | Workspace or tenant operator with access | Canonical detail | Is the underlying compare result trustworthy enough to support the summary claim? | Run outcome, artifact truth, result meaning, trustworthiness, and the primary next action | Raw payload fragments, diagnostics, and detailed count breakdowns | execution outcome, artifact truth, evidence completeness, next-action readiness | Read-only drilldown | View run details and follow linked next steps | None introduced by this spec |
## User Scenarios & Testing *(mandatory)*
### User Story 1 - Trust dashboard summary claims (Priority: P1)
As a tenant operator, I want summary surfaces to avoid false calm when the last baseline compare is incomplete or only partially trustworthy, so that I do not mistake missing findings for a reliable governance conclusion.
**Why this priority**: False reassurance on dashboard and compact summaries is the core trust risk. If this is wrong, operators can deprioritize real follow-up work.
**Independent Test**: Can be fully tested by rendering covered summary surfaces for scenarios with zero visible findings but limited confidence, incomplete evidence, suppressed results, or other trust limitations and verifying that none of them present a compliant or all-clear claim.
**Acceptance Scenarios**:
1. **Given** a tenant with zero visible drift findings but a limited-confidence or evidence-gap-affected compare result, **When** a dashboard summary renders, **Then** it shows a cautionary or review-oriented state instead of `Compliant`, `No drift`, or an equivalent all-clear claim.
2. **Given** a tenant with a trustworthy compare result, no contradictory evidence limitation, and no confirmed drift, **When** a dashboard summary renders, **Then** it may show a positive aligned state without contradicting deeper surfaces.
---
### User Story 2 - Triage constrained compare results safely (Priority: P2)
As a tenant operator, I want landing and compact baseline compare surfaces to tell me whether the latest result is reliable enough to use and what I should review next, so that I can act appropriately when the result is incomplete, stale, unavailable, or only diagnostically useful.
**Why this priority**: Operators need more than a count. They need an honest statement of what the compare result does and does not prove.
**Independent Test**: Can be fully tested by opening the covered landing and summary surfaces for incomplete, suppressed, stale, failed, and no-compare-yet scenarios and verifying that each one presents the correct state family and a logical next step.
**Acceptance Scenarios**:
1. **Given** no open drift findings and incomplete or partial evidence, **When** the operator opens the landing summary or compact summary, **Then** the surface presents a limited-confidence or incomplete-evidence state with a drilldown hint instead of an all-clear claim.
2. **Given** no usable compare result is available because the compare is missing or not ready, or the compare failed and requires investigation, **When** a covered summary surface renders, **Then** it communicates `unavailable`, `in_progress`, or `action required` rather than a healthy posture, with failed compare results mapping to an investigation-oriented action-required state.
---
### User Story 3 - See one truth across summary and detail (Priority: P3)
As an operator moving from dashboard to landing to run detail, I want the same compare result to keep the same underlying meaning across all surfaces, so that deeper inspection confirms the summary rather than correcting it.
**Why this priority**: The feature fails if different surfaces describe the same compare result in conflicting ways.
**Independent Test**: Can be fully tested by comparing the same covered scenario across dashboard, landing, findings-adjacent summary, and canonical run detail and confirming that the deeper surface is equally cautious or more cautious, but never less cautious.
**Acceptance Scenarios**:
1. **Given** the same compare result appears on widget, landing, and run-detail surfaces, **When** the operator navigates across them, **Then** the primary claim stays semantically consistent and the deeper surface is never less cautious than the summary.
2. **Given** a summary surface cannot honestly give an all-clear claim, **When** it renders, **Then** it exposes a next action or drilldown that leads to the supporting detail needed to resolve the uncertainty.
### Edge Cases
- A compare result may show zero visible findings while still carrying limited confidence, incomplete evidence, suppressed evaluation, or material evidence gaps.
- A compare artifact may exist while the result is still too incomplete or untrustworthy to justify a compliance or no-drift claim.
- Coverage limitations and evidence gaps may coexist with reassuring counts, and summary surfaces must surface the limitation rather than hiding it behind the counts.
- A tenant may have stale compare history, failed compare history, no compare history, no assigned baseline, or no consumable snapshot; each case must land in a distinct stale, action-required, or unavailable state rather than a healthy state.
- Different summary surfaces may emphasize different slices of the same result, but none may become more optimistic than the landing or drilldown truth.
## Requirements *(mandatory)*
**Constitution alignment (required):** This feature introduces no new Microsoft Graph call path, no new mutation workflow, and no new long-running job type. It hardens summary interpretation and operator copy for existing baseline compare evidence. Existing compare execution, confirmation, audit, and run-observability behavior remain authoritative.
**Constitution alignment (OPS-UX):** This feature reuses existing compare and operation-run semantics as read surfaces only. The existing Ops-UX 3-surface feedback contract for compare execution remains unchanged. `OperationRun.status` and `OperationRun.outcome` remain service-owned, existing `summary_counts` normalization remains authoritative, and scheduled or system-run behavior is unaffected. Regression tests for this feature must focus on summary claim safety, cross-surface consistency, and evidence-gap propagation rather than new lifecycle behavior.
**Constitution alignment (RBAC-UX):** This feature does not introduce new authorization rules. It remains in the tenant/admin plane for dashboard, findings, and landing surfaces, with canonical drilldowns continuing to enforce existing workspace and tenant entitlement checks. Non-members remain deny-as-not-found, members remain subject to existing capability checks for guarded actions, and no raw capability strings or role shortcuts may be introduced through summary hardening.
**Constitution alignment (OPS-EX-AUTH-001):** Not applicable. No `/auth/*` handshake path is involved.
**Constitution alignment (BADGE-001):** Any status-like badge, color, or tone used by a covered summary surface must continue to come from centralized semantics for state, trust, severity, or availability. The feature must not introduce page-local green-success shortcuts that imply a stronger claim than the underlying result supports.
**Constitution alignment (UI-FIL-001):** Covered dashboard widgets, landing summaries, and related operator surfaces must continue to rely on Filament widgets, shared badges, shared alerts, and existing surface primitives rather than introducing a local status language. If a compact custom summary block remains necessary, it must still consume shared status semantics instead of ad hoc page-local styling rules.
**Constitution alignment (UI-NAMING-001):** The target object is the tenant's latest baseline compare posture. Primary operator copy must preserve truthful domain language such as aligned, limited confidence, incomplete evidence, result unavailable, review details, and open findings. Implementation-first terms or false-calming phrases must not appear as primary labels when the result is not decision-grade.
**Constitution alignment (OPSURF-001):** Default-visible content on covered surfaces must remain operator-first, communicating governance result, evidence completeness, freshness or availability, and next action without requiring diagnostic detail. Diagnostics remain secondary and explicitly deeper. Existing compare-start actions keep their current mutation-scope messaging and safe-execution behavior. No new dangerous action is introduced by this feature.
**Constitution alignment (Filament Action Surfaces):** This feature modifies existing Filament-backed operator surfaces, including a tenant page and summary widgets, without expanding the action inventory. The Action Surface Contract remains satisfied because the landing page keeps its current guarded `Compare now` action, read-only summary widgets remain non-mutating, and the summary hardening changes interpretation rather than action topology. UI-FIL-001 remains satisfied because the feature is expected to reuse existing Filament or shared status primitives. No exemption is required.
**Constitution alignment (UX-001 — Layout & Information Architecture):** The feature changes summary semantics on existing widgets, banners, and a landing page rather than introducing create or edit screens. Covered surfaces must keep clear sections or cards, meaningful empty or unavailable states, and centralized status presentation. The landing page may continue using its existing custom enterprise layout so long as it preserves the operator-first hierarchy and avoids conflicting summary claims.
### Functional Requirements
- **FR-165-001**: The system MUST prevent every in-scope summary surface from showing `Compliant`, `Baseline compliant`, `No drift`, `No open drift`, `All clear`, or a semantically equivalent all-clear claim unless the underlying compare result is trustworthy enough to support that claim.
- **FR-165-002**: The primary state of every in-scope baseline or drift summary surface MUST be derived from the combined meaning of drift confirmation, trustworthiness or confidence, evidence completeness, result availability, and any material coverage limitation rather than from findings counts alone.
- **FR-165-003**: The system MUST treat `0 findings` or `no open findings` as insufficient on their own to justify a compliance or no-drift claim.
- **FR-165-004**: When no open drift is confirmed but the compare result is limited-confidence, incomplete, suppressed, diagnostically useful only, or otherwise not decision-grade, the surface MUST use a cautionary or review-oriented state family instead of a positive all-clear family.
- **FR-165-005**: A positive summary state may be used only when a usable compare result is available, the result is trustworthy enough for operator decision-making, no material evidence limitation undercuts the claim, and the result meaning does not contradict the positive claim.
- **FR-165-006**: Evidence gaps, resolver limitations, coverage warnings, stale compare conditions, missing compare results, and failed compare results MUST visibly influence the summary state, its wording, or both.
- **FR-165-007**: Every in-scope summary surface MUST present one clear primary statement that answers whether drift is confirmed, whether the result is limited or incomplete, whether no usable result is available, or whether follow-up is required.
- **FR-165-008**: Every in-scope summary surface that cannot safely present a positive all-clear claim MUST offer a logical next step, drilldown, or review cue that follows directly from the limited or unavailable state.
- **FR-165-009**: The system MUST preserve a semantic distinction between `no findings visible`, `no confirmed drift`, `limited confidence`, `incomplete evidence`, `result unavailable`, and `tenant compliant` rather than collapsing them into one visual or linguistic state.
- **FR-165-010**: Two different in-scope summary surfaces describing the same compare result MUST NOT present materially conflicting primary claims.
- **FR-165-011**: A compact summary surface MAY be equally cautious or more cautious than a deeper landing or drilldown surface, but it MUST never be more optimistic than the deeper truth surface.
- **FR-165-012**: Covered summary surfaces MUST consume the existing trust, explanation, evidence, and result-meaning foundations rather than inventing an isolated widget-only truth model.
- **FR-165-013**: Existing navigation from summary surfaces to Baseline Compare, findings, or run detail MUST remain intact so that the operator can resolve uncertainty quickly.
- **FR-165-014**: Empty, missing, failed, stale, and not-ready compare situations MUST be represented as intentionally distinct state families rather than falling through to healthy, aligned, or compliant language. For this feature, `not-ready` is an umbrella term that MUST resolve into the formal `in_progress` or `unavailable` state family depending on whether an active compare is underway.
- **FR-165-015**: Compact dashboard and headline surfaces MUST favor truthful caution over visual calm whenever the result meaning is ambiguous or evidence is materially limited.
### Non-Functional Requirements
- **NFR-165-001**: The feature MUST be deliverable without introducing new database tables, new persistent result models, or new outcome enums.
- **NFR-165-002**: Existing landing and detail surfaces that already expose richer trust or evidence semantics MUST not be flattened, weakened, or contradicted.
- **NFR-165-003**: Existing tenant dashboard, findings, landing, and run-drilldown navigation paths MUST remain stable.
- **NFR-165-004**: Existing authorization and tenant-isolation behavior for all covered surfaces MUST remain intact.
- **NFR-165-005**: The UI may become more conservative, but it must remain compact and readable rather than turning every limited result into alarm-heavy noise.
### Non-Goals
- Rewriting the compare engine, compare execution workflow, or compare persistence model
- Introducing new evidence-gap storage structures, new result enums, or a new backend outcome taxonomy
- Re-implementing the full baseline compare landing page or operation-run detail page beyond the summary-truth contract they expose
- Changing reporting, exports, risk acceptance, exceptions handling, or time-series drift tracking
- Redesigning unrelated dashboard or monitoring surfaces outside the baseline or drift summary problem
### Assumptions
- Existing baseline compare truth, explanation, and evidence foundations are already strong enough that the primary gap is summary propagation rather than backend semantics.
- Existing landing and detail surfaces already communicate limited confidence and evidence limitations better than the compact summary surfaces do today.
- Operators benefit more from conservative governance language than from visually calm but semantically overstated positive states.
- Existing compare-start actions, findings drilldowns, and run drilldowns remain the correct next-step paths and do not need a new execution model for this feature.
### Dependencies
- Existing baseline compare truth and explanation foundations
- Existing evidence-gap and coverage semantics
- Existing tenant dashboard, findings, Baseline Compare landing, and canonical operation-run drilldown surfaces
- Existing tenant authorization and action-guard patterns
### Risks
- Summary surfaces may feel stricter than before, which could initially be perceived as noisier even though the semantics are safer.
- Different compact surfaces could drift into slightly different cautionary phrasing if the shared summary contract is not applied consistently.
- A surface-level fix that only patches one widget could reintroduce semantic drift elsewhere if shared summary rules are not reused.
## UI Action Matrix *(mandatory when Filament is changed)*
If this feature adds/modifies any Filament Resource / RelationManager / Page, fill out the matrix below.
For each surface, list the exact action labels, whether they are destructive (confirmation? typed confirmation?),
RBAC gating (capability + enforcement helper), and whether the mutation writes an audit log.
| Surface | Location | Header Actions | Inspect Affordance (List/Table) | Row Actions (max 2 visible) | Bulk Actions (grouped) | Empty-State CTA(s) | View Header Actions | Create/Edit Save+Cancel | Audit log? | Notes / Exemptions |
|---|---|---|---|---|---|---|---|---|---|---|
| Baseline Compare landing page | Existing tenant Baseline Compare page | `Compare now` remains the existing header action and keeps current confirmation plus capability gating | Not a record-list inspect surface | None introduced by this spec | None | Existing missing-assignment, missing-snapshot, and unavailable-state guidance remains | `Compare now`, existing `View run` or `Open findings` drilldowns where already available | Not applicable | Existing compare-start audit and run-observability behavior remains unchanged | Action Surface Contract satisfied. This feature changes summary interpretation and wording, not action topology. |
| Tenant dashboard summary widgets | Existing tenant dashboard widgets and summary cards | None added by this spec | Existing links to Baseline Compare, findings, and operations remain the inspect path | None | None | Existing dashboard empty or unavailable states remain, but their summary claims must obey the hardened contract | Not applicable | Not applicable | No new audit event | Read-only widget surfaces. No exemption required because no new action surface is introduced. |
### Key Entities *(include if feature involves data)*
- **Baseline summary claim**: The strongest safe statement a compact surface makes about the tenant's current baseline or drift posture.
- **Compare result trust signal**: The combined meaning of trustworthiness, confidence, artifact usability, and result quality that determines how strong a summary claim may be.
- **Evidence completeness signal**: The availability, coverage, and evidence-gap posture that can limit or qualify a summary claim even when findings counts look calm.
- **Summary state family**: The operator-facing state category used by compact surfaces, such as positive, cautionary, unavailable, or action-required.
- **Primary next step**: The clearest follow-up action or drilldown the operator should take when the summary cannot safely present an all-clear claim.
## Success Criteria *(mandatory)*
### Measurable Outcomes
- **SC-165-001**: In 100% of covered limited-confidence, incomplete-evidence, suppressed-result, or evidence-gap-affected scenarios, in-scope summary surfaces avoid compliant or all-clear claims.
- **SC-165-002**: In 100% of covered trustworthy and fully usable no-drift scenarios, in-scope summary surfaces may present a positive aligned state without contradicting deeper surfaces.
- **SC-165-003**: In acceptance review of covered scenarios, the same compare result produces no materially conflicting primary claim across dashboard summary, landing summary, findings-adjacent summary, and run drilldown.
- **SC-165-004**: In every covered cautionary or unavailable scenario, an operator can identify the correct next step or drilldown from the visible summary in 10 seconds or less.
- **SC-165-005**: In regression review, existing richer landing and detail surfaces continue to expose trust, evidence-gap, and result-meaning nuance without being simplified into findings-only semantics.

View File

@ -0,0 +1,216 @@
# Tasks: Baseline Compare Summary Trust Propagation & Compliance Claim Hardening
**Input**: Design documents from `/specs/165-baseline-summary-trust/`
**Prerequisites**: `plan.md` (required), `spec.md` (required for user stories), `research.md`, `data-model.md`, `contracts/`
**Tests**: Tests are REQUIRED for this feature. Use Pest and Livewire coverage in `tests/Feature/Baselines/BaselineCompareStatsTest.php`, `tests/Feature/Baselines/BaselineCompareExplanationFallbackTest.php`, `tests/Feature/Baselines/BaselineCompareSummaryAssessmentTest.php`, `tests/Feature/Baselines/BaselineCompareWhyNoFindingsReasonCodeTest.php`, `tests/Feature/Filament/BaselineCompareNowWidgetTest.php`, `tests/Feature/Filament/NeedsAttentionWidgetTest.php`, `tests/Feature/Filament/BaselineCompareExplanationSurfaceTest.php`, `tests/Feature/Filament/BaselineCompareLandingWhyNoFindingsTest.php`, `tests/Feature/Filament/BaselineCompareCoverageBannerTest.php`, `tests/Feature/Filament/BaselineCompareSummaryConsistencyTest.php`, `tests/Feature/Filament/BaselineCompareLandingStartSurfaceTest.php`, `tests/Feature/Filament/OperationRunBaselineTruthSurfaceTest.php`, `tests/Feature/ReasonTranslation/ReasonTranslationExplanationTest.php`, and focused DB-only dashboard coverage in `tests/Feature/Filament/TenantDashboardDbOnlyTest.php`.
**Operations**: This feature reads existing baseline compare `OperationRun` evidence only. No new run creation, lifecycle transition, notification, or `summary_counts` producer work is introduced.
**RBAC**: Existing tenant-view access and existing capability-gated `Compare now` behavior must remain unchanged. Tests must prove no regression for tenant membership, deny-as-not-found semantics, and capability-gated compare execution.
**Operator Surfaces**: Dashboard widget, dashboard attention summary, coverage banner, and landing summary must remain operator-first and must never claim a stronger governance state than the canonical run detail.
**Filament UI Action Surfaces**: No new actions are added. Existing links and the guarded `Compare now` action must remain intact while summary wording and state selection change.
**Filament UI UX-001**: Existing widgets, banner, and landing layout remain compact and sectioned; diagnostics stay secondary.
**Badges**: Any changed status-like badge or tone must continue to use centralized semantics rather than page-local success or warning mappings.
**Organization**: Tasks are grouped by user story so each story can be implemented and validated as an incremental slice once the shared summary contract is in place.
## Phase 1: Setup (Shared Summary Contract)
**Purpose**: Create the reusable support-layer contract that all in-scope summary surfaces will consume.
- [X] T001 Create the compact summary DTO in `app/Support/Baselines/BaselineCompareSummaryAssessment.php`
- [X] T002 [P] Create the shared summary assessor that derives state family, headline, tone, and next action from stats plus explanation in `app/Support/Baselines/BaselineCompareSummaryAssessor.php`
- [X] T003 [P] Add support-layer scenario coverage for the shared summary contract in `tests/Feature/Baselines/BaselineCompareSummaryAssessmentTest.php`
---
## Phase 2: Foundational (Blocking Truth Propagation Prerequisites)
**Purpose**: Wire the summary contract into the existing baseline truth layer before touching any operator-facing surface.
**⚠️ CRITICAL**: No user story work should begin until this phase is complete.
- [X] T004 Expose the shared summary assessment from baseline compare stats in `app/Support/Baselines/BaselineCompareStats.php`
- [X] T005 [P] Align compact-surface explanation family, stale-versus-not-ready distinction, and next-action derivation in `app/Support/Baselines/BaselineCompareExplanationRegistry.php`
- [X] T006 [P] Keep positive-claim reason semantics explicit for compact summaries in `app/Support/Baselines/BaselineCompareReasonCode.php` and `app/Support/ReasonTranslation/ReasonTranslator.php`
**Checkpoint**: The support layer now provides one reusable, trust-aware summary assessment for all covered surfaces.
---
## Phase 3: User Story 1 - Trust Dashboard Summary Claims (Priority: P1) 🎯 MVP
**Goal**: Remove false calm from tenant-dashboard summary surfaces so zero findings no longer implies a compliant or all-clear state when evidence is limited.
**Independent Test**: Render dashboard summary surfaces for trustworthy, limited-confidence, evidence-gap-affected, stale, failed, in-progress, and unavailable compare scenarios and verify that only genuinely trustworthy no-drift results can present a positive state.
### Tests for User Story 1
- [X] T007 [P] [US1] Replace the false compliant widget assertion and add trustworthy, limited-confidence, stale, failed, in-progress, and unavailable scenarios plus widget drilldown expectations in `tests/Feature/Filament/BaselineCompareNowWidgetTest.php`
- [X] T008 [P] [US1] Add dashboard attention-summary coverage for limited-confidence, evidence-gap, stale, in-progress, and unavailable compare states while keeping `Needs Attention` explicitly non-navigational in `tests/Feature/Filament/NeedsAttentionWidgetTest.php`
### Implementation for User Story 1
- [X] T009 [US1] Feed the shared summary assessment into the dashboard baseline widget in `app/Filament/Widgets/Dashboard/BaselineCompareNow.php`
- [X] T010 [US1] Render contract-driven positive, cautionary, stale, unavailable, and in-progress states in `resources/views/filament/widgets/dashboard/baseline-compare-now.blade.php`
- [X] T011 [US1] Rebuild dashboard healthy and attention item selection from the shared summary contract in `app/Filament/Widgets/Dashboard/NeedsAttention.php`
- [X] T012 [US1] Render truthful caution and next-step fallback copy in `resources/views/filament/widgets/dashboard/needs-attention.blade.php`
- [X] T013 [US1] Keep dashboard KPI cards quantitative-only and free of implicit all-clear claims in `app/Filament/Widgets/Dashboard/DashboardKpis.php`
- [X] T014 [US1] Run the focused dashboard regression pack in `tests/Feature/Filament/BaselineCompareNowWidgetTest.php`, `tests/Feature/Filament/NeedsAttentionWidgetTest.php`, and `tests/Feature/Filament/TenantDashboardDbOnlyTest.php`
**Checkpoint**: Tenant dashboard summary surfaces can no longer issue a false compliant or healthy claim when compare trust is limited.
---
## Phase 4: User Story 2 - Triage Constrained Compare Results Safely (Priority: P2)
**Goal**: Ensure landing and banner surfaces present limited, suppressed, failed, stale, in-progress, or unavailable compare results honestly and with a clear next step.
**Independent Test**: Open the landing page and coverage banner for limited-confidence, suppressed-output, evidence-gap, failed, stale-history, no-snapshot, in-progress, and no-result-yet scenarios and verify that each surface stays cautionary, in-progress, or unavailable rather than healthy.
### Tests for User Story 2
- [X] T015 [P] [US2] Extend limited-confidence, suppressed-output, and failed-result landing coverage in `tests/Feature/Filament/BaselineCompareExplanationSurfaceTest.php`
- [X] T016 [P] [US2] Extend zero-findings, evidence-gap, stale-history, in-progress, and unavailable landing coverage in `tests/Feature/Filament/BaselineCompareLandingWhyNoFindingsTest.php`
- [X] T017 [P] [US2] Add coverage-banner truth propagation, failed-result handling, and banner drilldown coverage in `tests/Feature/Filament/BaselineCompareCoverageBannerTest.php`
### Implementation for User Story 2
- [X] T018 [US2] Expose the shared summary assessment to landing-page view data in `app/Filament/Pages/BaselineCompareLanding.php`
- [X] T019 [US2] Replace findings-only all-clear fallback and keep stale-history, in-progress, and unavailable summary copy distinct in `resources/views/filament/pages/baseline-compare-landing.blade.php`
- [X] T020 [US2] Propagate evidence-gap-aware and limited-confidence summary state into the coverage banner widget in `app/Filament/Widgets/Tenant/BaselineCompareCoverageBanner.php`
- [X] T021 [US2] Render compact banner messaging for incomplete evidence, suppressed output, failed-result, and unavailable baseline states in `resources/views/filament/widgets/tenant/baseline-compare-coverage-banner.blade.php`
- [X] T022 [US2] Align landing summary translations and operator-facing copy with the hardened claim contract in `lang/en/baseline-compare.php`
- [X] T023 [US2] Run the focused landing and banner regression pack in `tests/Feature/Filament/BaselineCompareExplanationSurfaceTest.php`, `tests/Feature/Filament/BaselineCompareLandingWhyNoFindingsTest.php`, and `tests/Feature/Filament/BaselineCompareCoverageBannerTest.php`
**Checkpoint**: Landing and banner surfaces now distinguish trustworthy no-drift, limited confidence, incomplete evidence, failed compare, and unavailable compare states.
---
## Phase 5: User Story 3 - See One Truth Across Summary and Detail (Priority: P3)
**Goal**: Keep dashboard, landing, banner, and canonical run detail semantically aligned so compact surfaces never out-claim the deeper truth surface.
**Independent Test**: Compare the same baseline compare scenarios across dashboard, landing, banner, and canonical run detail and verify that compact surfaces are equally cautious or more cautious, never more optimistic, while guardrails and drilldowns remain intact.
### Tests for User Story 3
- [X] T024 [P] [US3] Add cross-surface consistency coverage for shared compare scenarios in `tests/Feature/Filament/BaselineCompareSummaryConsistencyTest.php`
- [X] T025 [P] [US3] Extend canonical baseline truth surface assertions so compact summaries never out-claim run detail in `tests/Feature/Filament/OperationRunBaselineTruthSurfaceTest.php`
- [X] T026 [P] [US3] Preserve DB-only tenant dashboard rendering while summary logic changes in `tests/Feature/Filament/TenantDashboardDbOnlyTest.php`
- [X] T027 [P] [US3] Preserve deny-as-not-found access, compare-now capability gating, dashboard, banner, and landing drilldowns into canonical run detail or findings, and the intentionally non-navigational `Needs Attention` summary behavior in `tests/Feature/Filament/BaselineCompareNowWidgetTest.php`, `tests/Feature/Filament/NeedsAttentionWidgetTest.php`, `tests/Feature/Filament/BaselineCompareCoverageBannerTest.php`, `tests/Feature/Filament/BaselineCompareLandingStartSurfaceTest.php`, and `tests/Feature/Filament/BaselineCompareSummaryConsistencyTest.php`
- [X] T028 [P] [US3] Extend reason-translation and why-no-findings regression coverage for compact summary trust semantics in `tests/Feature/ReasonTranslation/ReasonTranslationExplanationTest.php` and `tests/Feature/Baselines/BaselineCompareWhyNoFindingsReasonCodeTest.php`
### Implementation for User Story 3
- [X] T029 [US3] Finalize cross-surface headline, tone, stale-versus-not-ready state families, and next-step selection in `app/Support/Baselines/BaselineCompareSummaryAssessor.php`
- [X] T030 [US3] Keep reason-translation fallback labels aligned with summary-versus-detail parity in `app/Support/ReasonTranslation/ReasonTranslator.php`
- [X] T031 [US3] Run the focused cross-surface, RBAC, and reason-translation regression pack in `tests/Feature/Filament/BaselineCompareSummaryConsistencyTest.php`, `tests/Feature/Filament/OperationRunBaselineTruthSurfaceTest.php`, `tests/Feature/Filament/TenantDashboardDbOnlyTest.php`, `tests/Feature/Filament/BaselineCompareLandingStartSurfaceTest.php`, `tests/Feature/ReasonTranslation/ReasonTranslationExplanationTest.php`, and `tests/Feature/Baselines/BaselineCompareWhyNoFindingsReasonCodeTest.php`
**Checkpoint**: Compact surfaces and canonical drilldowns now share one truthful semantic contract.
---
## Phase 6: Polish & Cross-Cutting Concerns
**Purpose**: Final copy alignment, formatting, and focused verification across all stories.
- [X] T032 [P] Review and align operator-facing summary copy for compliant, no-drift, limited-confidence, stale, in-progress, unavailable, and next-step wording in `resources/views/filament/widgets/dashboard/baseline-compare-now.blade.php`, `resources/views/filament/widgets/dashboard/needs-attention.blade.php`, `resources/views/filament/widgets/tenant/baseline-compare-coverage-banner.blade.php`, `resources/views/filament/pages/baseline-compare-landing.blade.php`, and `lang/en/baseline-compare.php`
- [X] T033 Run formatting on touched files with `vendor/bin/sail bin pint --dirty --format agent`
- [X] T034 Run the final focused verification pack from `specs/165-baseline-summary-trust/quickstart.md` against `tests/Feature/Baselines/BaselineCompareSummaryAssessmentTest.php`, `tests/Feature/Baselines/BaselineCompareWhyNoFindingsReasonCodeTest.php`, `tests/Feature/Baselines/BaselineCompareStatsTest.php`, `tests/Feature/Baselines/BaselineCompareExplanationFallbackTest.php`, `tests/Feature/Filament/BaselineCompareNowWidgetTest.php`, `tests/Feature/Filament/NeedsAttentionWidgetTest.php`, `tests/Feature/Filament/BaselineCompareExplanationSurfaceTest.php`, `tests/Feature/Filament/BaselineCompareLandingWhyNoFindingsTest.php`, `tests/Feature/Filament/BaselineCompareCoverageBannerTest.php`, `tests/Feature/Filament/BaselineCompareSummaryConsistencyTest.php`, `tests/Feature/Filament/BaselineCompareLandingStartSurfaceTest.php`, `tests/Feature/Filament/OperationRunBaselineTruthSurfaceTest.php`, `tests/Feature/ReasonTranslation/ReasonTranslationExplanationTest.php`, and `tests/Feature/Filament/TenantDashboardDbOnlyTest.php`
---
## Dependencies & Execution Order
### Phase Dependencies
- **Setup (Phase 1)**: Starts immediately and establishes the shared summary contract.
- **Foundational (Phase 2)**: Depends on Setup and blocks all user story work until summary truth can be derived centrally.
- **User Story 1 (Phase 3)**: Starts after Foundational and delivers the MVP by removing false calm from the tenant dashboard.
- **User Story 2 (Phase 4)**: Starts after Foundational and can proceed after the shared summary contract is stable; it hardens landing and banner semantics.
- **User Story 3 (Phase 5)**: Starts after User Stories 1 and 2 have established the compact summary contract on covered surfaces.
- **Polish (Phase 6)**: Starts after all desired stories are complete.
### User Story Dependencies
- **User Story 1 (P1)**: Depends only on the shared summary contract from Phases 1 and 2.
- **User Story 2 (P2)**: Depends on the same shared contract but can be validated independently on landing and banner surfaces.
- **User Story 3 (P3)**: Depends on User Stories 1 and 2 because it verifies and finalizes cross-surface semantic consistency.
### Within Each User Story
- Tests should be written or updated before the related implementation tasks and should fail before the feature behavior is considered complete.
- Support-layer contract changes should land before widget or landing template rewrites that depend on them.
- Focused story-level test runs should complete before moving to the next story.
### Parallel Opportunities
- `T002` and `T003` can run in parallel after the DTO shape in `T001` is clear.
- `T005` and `T006` can run in parallel after `T004` wires the shared summary seam.
- `T007` and `T008` can run in parallel for User Story 1.
- `T015`, `T016`, and `T017` can run in parallel for User Story 2.
- `T024`, `T025`, `T026`, `T027`, and `T028` can run in parallel for User Story 3.
- `T032` can run in parallel with the final verification prep once implementation is complete.
---
## Parallel Example: User Story 1
```bash
# Story 1 dashboard tests in parallel:
Task: T007 tests/Feature/Filament/BaselineCompareNowWidgetTest.php
Task: T008 tests/Feature/Filament/NeedsAttentionWidgetTest.php
# Story 1 implementation split after summary contract wiring:
Task: T009 app/Filament/Widgets/Dashboard/BaselineCompareNow.php
Task: T011 app/Filament/Widgets/Dashboard/NeedsAttention.php
```
## Parallel Example: User Story 2
```bash
# Story 2 landing and banner tests in parallel:
Task: T015 tests/Feature/Filament/BaselineCompareExplanationSurfaceTest.php
Task: T016 tests/Feature/Filament/BaselineCompareLandingWhyNoFindingsTest.php
Task: T017 tests/Feature/Filament/BaselineCompareCoverageBannerTest.php
# Story 2 implementation split after test expectations are clear:
Task: T018 app/Filament/Pages/BaselineCompareLanding.php
Task: T020 app/Filament/Widgets/Tenant/BaselineCompareCoverageBanner.php
```
## Parallel Example: User Story 3
```bash
# Story 3 consistency checks in parallel:
Task: T024 tests/Feature/Filament/BaselineCompareSummaryConsistencyTest.php
Task: T025 tests/Feature/Filament/OperationRunBaselineTruthSurfaceTest.php
Task: T026 tests/Feature/Filament/TenantDashboardDbOnlyTest.php
Task: T027 tests/Feature/Filament/BaselineCompareLandingStartSurfaceTest.php
Task: T028 tests/Feature/ReasonTranslation/ReasonTranslationExplanationTest.php + tests/Feature/Baselines/BaselineCompareWhyNoFindingsReasonCodeTest.php
# Story 3 implementation split after consistency assertions are defined:
Task: T029 app/Support/Baselines/BaselineCompareSummaryAssessor.php
Task: T030 app/Support/ReasonTranslation/ReasonTranslator.php
```
---
## Implementation Strategy
### MVP First
- Complete Phase 1 and Phase 2.
- Deliver User Story 1 as the MVP.
- Validate that the tenant dashboard no longer issues false compliant or healthy claims when compare evidence is limited.
### Incremental Delivery
- Add User Story 2 next to harden the landing summary and coverage banner.
- Add User Story 3 last to guarantee cross-surface semantic consistency against the canonical run detail.
### Verification Finish
- Run Pint on touched files.
- Run the focused verification pack from `quickstart.md`.
- If broader confidence is needed after focused verification, run the wider suite separately.

View File

@ -37,6 +37,7 @@
$stats = BaselineCompareStats::forTenant($tenant);
$explanation = $stats->operatorExplanation();
$summary = $stats->summaryAssessment();
expect($stats->state)->toBe('idle')
->and($explanation->family)->toBe(ExplanationFamily::Unavailable)
@ -44,7 +45,7 @@
Livewire::actingAs($user)
->test(BaselineCompareLanding::class)
->assertSee($explanation->headline)
->assertSee($explanation->nextActionText)
->assertSee($summary->headline)
->assertSee($summary->nextActionLabel())
->assertSee($explanation->coverageStatement ?? '');
});

View File

@ -0,0 +1,201 @@
<?php
declare(strict_types=1);
use App\Models\BaselineProfile;
use App\Models\BaselineSnapshot;
use App\Models\BaselineTenantAssignment;
use App\Models\Finding;
use App\Models\OperationRun;
use App\Support\Baselines\BaselineCompareReasonCode;
use App\Support\Baselines\BaselineCompareStats;
use App\Support\Baselines\BaselineCompareSummaryAssessment;
use App\Support\OperationRunOutcome;
use App\Support\OperationRunStatus;
use App\Support\OperationRunType;
function createAssignedBaselineTenant(): array
{
[$user, $tenant] = createUserWithTenant(role: 'owner');
$profile = BaselineProfile::factory()->active()->create([
'workspace_id' => (int) $tenant->workspace_id,
]);
$snapshot = BaselineSnapshot::factory()->create([
'workspace_id' => (int) $tenant->workspace_id,
'baseline_profile_id' => (int) $profile->getKey(),
]);
$profile->update(['active_snapshot_id' => (int) $snapshot->getKey()]);
BaselineTenantAssignment::factory()->create([
'workspace_id' => (int) $tenant->workspace_id,
'tenant_id' => (int) $tenant->getKey(),
'baseline_profile_id' => (int) $profile->getKey(),
]);
return [$tenant, $profile, $snapshot];
}
it('marks trustworthy no-drift results as positive and eligible for an aligned claim', function (): void {
[$tenant, $profile, $snapshot] = createAssignedBaselineTenant();
OperationRun::factory()->create([
'tenant_id' => (int) $tenant->getKey(),
'workspace_id' => (int) $tenant->workspace_id,
'type' => OperationRunType::BaselineCompare->value,
'status' => OperationRunStatus::Completed->value,
'outcome' => OperationRunOutcome::Succeeded->value,
'completed_at' => now(),
'context' => [
'baseline_profile_id' => (int) $profile->getKey(),
'baseline_snapshot_id' => (int) $snapshot->getKey(),
'baseline_compare' => [
'reason_code' => BaselineCompareReasonCode::NoDriftDetected->value,
'coverage' => [
'effective_types' => ['deviceConfiguration'],
'covered_types' => ['deviceConfiguration'],
'uncovered_types' => [],
'proof' => true,
],
],
],
]);
$assessment = BaselineCompareStats::forTenant($tenant)->summaryAssessment();
expect($assessment->stateFamily)->toBe(BaselineCompareSummaryAssessment::STATE_POSITIVE)
->and($assessment->positiveClaimAllowed)->toBeTrue()
->and($assessment->evaluationResult)->toBe('no_result')
->and($assessment->headline)->toBe('No confirmed drift in the latest baseline compare.');
});
it('keeps limited-confidence zero findings in a cautionary state', function (): void {
[$tenant, $profile, $snapshot] = createAssignedBaselineTenant();
OperationRun::factory()->create([
'tenant_id' => (int) $tenant->getKey(),
'workspace_id' => (int) $tenant->workspace_id,
'type' => OperationRunType::BaselineCompare->value,
'status' => OperationRunStatus::Completed->value,
'outcome' => OperationRunOutcome::PartiallySucceeded->value,
'completed_at' => now(),
'context' => [
'baseline_profile_id' => (int) $profile->getKey(),
'baseline_snapshot_id' => (int) $snapshot->getKey(),
'baseline_compare' => [
'reason_code' => BaselineCompareReasonCode::CoverageUnproven->value,
'coverage' => [
'effective_types' => ['deviceConfiguration', 'deviceCompliancePolicy'],
'covered_types' => ['deviceConfiguration'],
'uncovered_types' => ['deviceCompliancePolicy'],
'proof' => false,
],
],
],
]);
$assessment = BaselineCompareStats::forTenant($tenant)->summaryAssessment();
expect($assessment->stateFamily)->toBe(BaselineCompareSummaryAssessment::STATE_CAUTION)
->and($assessment->positiveClaimAllowed)->toBeFalse()
->and($assessment->evaluationResult)->toBe('suppressed_result')
->and($assessment->nextActionTarget())->toBe(BaselineCompareSummaryAssessment::NEXT_TARGET_RUN);
});
it('treats failed compare runs as action required with a failed-result semantic', function (): void {
[$tenant, $profile, $snapshot] = createAssignedBaselineTenant();
OperationRun::factory()->create([
'tenant_id' => (int) $tenant->getKey(),
'workspace_id' => (int) $tenant->workspace_id,
'type' => OperationRunType::BaselineCompare->value,
'status' => OperationRunStatus::Completed->value,
'outcome' => OperationRunOutcome::Failed->value,
'completed_at' => now(),
'context' => [
'baseline_profile_id' => (int) $profile->getKey(),
'baseline_snapshot_id' => (int) $snapshot->getKey(),
],
]);
$assessment = BaselineCompareStats::forTenant($tenant)->summaryAssessment();
expect($assessment->stateFamily)->toBe(BaselineCompareSummaryAssessment::STATE_ACTION_REQUIRED)
->and($assessment->evaluationResult)->toBe('failed_result')
->and($assessment->nextActionLabel())->toBe('Review the failed run');
});
it('treats stale compare history as a stale summary state instead of positive', function (): void {
[$tenant, $profile, $snapshot] = createAssignedBaselineTenant();
OperationRun::factory()->create([
'tenant_id' => (int) $tenant->getKey(),
'workspace_id' => (int) $tenant->workspace_id,
'type' => OperationRunType::BaselineCompare->value,
'status' => OperationRunStatus::Completed->value,
'outcome' => OperationRunOutcome::Succeeded->value,
'completed_at' => now()->subDays(9),
'context' => [
'baseline_profile_id' => (int) $profile->getKey(),
'baseline_snapshot_id' => (int) $snapshot->getKey(),
'baseline_compare' => [
'reason_code' => BaselineCompareReasonCode::NoDriftDetected->value,
'coverage' => [
'effective_types' => ['deviceConfiguration'],
'covered_types' => ['deviceConfiguration'],
'uncovered_types' => [],
'proof' => true,
],
],
],
]);
$assessment = BaselineCompareStats::forTenant($tenant)->summaryAssessment();
expect($assessment->stateFamily)->toBe(BaselineCompareSummaryAssessment::STATE_STALE)
->and($assessment->positiveClaimAllowed)->toBeFalse()
->and($assessment->evidenceImpact)->toBe(BaselineCompareSummaryAssessment::EVIDENCE_STALE_RESULT);
});
it('keeps open findings action-required even when compare evidence is otherwise usable', function (): void {
[$tenant, $profile, $snapshot] = createAssignedBaselineTenant();
OperationRun::factory()->create([
'tenant_id' => (int) $tenant->getKey(),
'workspace_id' => (int) $tenant->workspace_id,
'type' => OperationRunType::BaselineCompare->value,
'status' => OperationRunStatus::Completed->value,
'outcome' => OperationRunOutcome::Succeeded->value,
'completed_at' => now(),
'context' => [
'baseline_profile_id' => (int) $profile->getKey(),
'baseline_snapshot_id' => (int) $snapshot->getKey(),
'baseline_compare' => [
'coverage' => [
'effective_types' => ['deviceConfiguration'],
'covered_types' => ['deviceConfiguration'],
'uncovered_types' => [],
'proof' => true,
],
],
],
]);
Finding::factory()->create([
'workspace_id' => (int) $tenant->workspace_id,
'tenant_id' => (int) $tenant->getKey(),
'finding_type' => Finding::FINDING_TYPE_DRIFT,
'source' => 'baseline.compare',
'scope_key' => 'baseline_profile:'.$profile->getKey(),
'severity' => Finding::SEVERITY_HIGH,
'status' => Finding::STATUS_NEW,
]);
$assessment = BaselineCompareStats::forTenant($tenant)->summaryAssessment();
expect($assessment->stateFamily)->toBe(BaselineCompareSummaryAssessment::STATE_ACTION_REQUIRED)
->and($assessment->findingsVisibleCount)->toBe(1)
->and($assessment->nextActionTarget())->toBe(BaselineCompareSummaryAssessment::NEXT_TARGET_FINDINGS);
});

View File

@ -0,0 +1,130 @@
<?php
declare(strict_types=1);
use App\Filament\Widgets\Tenant\BaselineCompareCoverageBanner;
use App\Models\BaselineProfile;
use App\Models\BaselineSnapshot;
use App\Models\BaselineTenantAssignment;
use App\Models\OperationRun;
use App\Support\Baselines\BaselineCompareReasonCode;
use App\Support\OperationRunOutcome;
use App\Support\OperationRunStatus;
use App\Support\OperationRunType;
use Filament\Facades\Filament;
use Livewire\Livewire;
function createCoverageBannerTenant(): array
{
[$user, $tenant] = createUserWithTenant(role: 'owner');
$profile = BaselineProfile::factory()->active()->create([
'workspace_id' => (int) $tenant->workspace_id,
]);
$snapshot = BaselineSnapshot::factory()->create([
'workspace_id' => (int) $tenant->workspace_id,
'baseline_profile_id' => (int) $profile->getKey(),
]);
$profile->update(['active_snapshot_id' => (int) $snapshot->getKey()]);
BaselineTenantAssignment::factory()->create([
'workspace_id' => (int) $tenant->workspace_id,
'tenant_id' => (int) $tenant->getKey(),
'baseline_profile_id' => (int) $profile->getKey(),
]);
return [$user, $tenant, $profile, $snapshot];
}
it('shows a cautionary coverage banner for suppressed baseline compare results', function (): void {
[$user, $tenant, $profile, $snapshot] = createCoverageBannerTenant();
$this->actingAs($user);
OperationRun::factory()->create([
'tenant_id' => (int) $tenant->getKey(),
'workspace_id' => (int) $tenant->workspace_id,
'type' => OperationRunType::BaselineCompare->value,
'status' => OperationRunStatus::Completed->value,
'outcome' => OperationRunOutcome::PartiallySucceeded->value,
'completed_at' => now(),
'context' => [
'baseline_profile_id' => (int) $profile->getKey(),
'baseline_snapshot_id' => (int) $snapshot->getKey(),
'baseline_compare' => [
'reason_code' => BaselineCompareReasonCode::CoverageUnproven->value,
'coverage' => [
'effective_types' => ['deviceConfiguration', 'deviceCompliancePolicy'],
'covered_types' => ['deviceConfiguration'],
'uncovered_types' => ['deviceCompliancePolicy'],
'proof' => false,
],
],
],
]);
Filament::setCurrentPanel(Filament::getPanel('tenant'));
Filament::setTenant($tenant, true);
Livewire::test(BaselineCompareCoverageBanner::class)
->assertSee('The last compare finished, but normal result output was suppressed.')
->assertSee('Review compare detail');
});
it('shows an unavailable banner when no current baseline snapshot can be consumed', function (): void {
[$user, $tenant] = createUserWithTenant(role: 'owner');
$this->actingAs($user);
$profile = BaselineProfile::factory()->active()->create([
'workspace_id' => (int) $tenant->workspace_id,
'active_snapshot_id' => null,
]);
BaselineTenantAssignment::factory()->create([
'workspace_id' => (int) $tenant->workspace_id,
'tenant_id' => (int) $tenant->getKey(),
'baseline_profile_id' => (int) $profile->getKey(),
]);
Filament::setCurrentPanel(Filament::getPanel('tenant'));
Filament::setTenant($tenant, true);
Livewire::test(BaselineCompareCoverageBanner::class)
->assertSee('The current baseline snapshot is not available for compare.')
->assertSee('Review baseline prerequisites');
});
it('does not render the banner for trustworthy no-drift results', function (): void {
[$user, $tenant, $profile, $snapshot] = createCoverageBannerTenant();
$this->actingAs($user);
OperationRun::factory()->create([
'tenant_id' => (int) $tenant->getKey(),
'workspace_id' => (int) $tenant->workspace_id,
'type' => OperationRunType::BaselineCompare->value,
'status' => OperationRunStatus::Completed->value,
'outcome' => OperationRunOutcome::Succeeded->value,
'completed_at' => now(),
'context' => [
'baseline_profile_id' => (int) $profile->getKey(),
'baseline_snapshot_id' => (int) $snapshot->getKey(),
'baseline_compare' => [
'reason_code' => BaselineCompareReasonCode::NoDriftDetected->value,
'coverage' => [
'effective_types' => ['deviceConfiguration'],
'covered_types' => ['deviceConfiguration'],
'uncovered_types' => [],
'proof' => true,
],
],
],
]);
Filament::setCurrentPanel(Filament::getPanel('tenant'));
Filament::setTenant($tenant, true);
Livewire::test(BaselineCompareCoverageBanner::class)
->assertDontSee('No confirmed drift in the latest baseline compare.')
->assertDontSee('Review compare detail');
});

View File

@ -73,14 +73,15 @@
$stats = BaselineCompareStats::forTenant($tenant);
$explanation = $stats->operatorExplanation();
$summary = $stats->summaryAssessment();
expect($explanation->family)->toBe(ExplanationFamily::SuppressedOutput);
Livewire::actingAs($user)
->test(BaselineCompareLanding::class)
->assertSee($explanation->headline)
->assertSee($summary->headline)
->assertSee($explanation->trustworthinessLabel())
->assertSee($explanation->nextActionText)
->assertSee($summary->nextActionLabel())
->assertSee('Findings shown')
->assertSee('Evidence gaps');
});

View File

@ -6,6 +6,7 @@
use App\Models\BaselineTenantAssignment;
use App\Models\OperationRun;
use App\Support\Baselines\BaselineCompareReasonCode;
use App\Support\Baselines\BaselineCompareStats;
use App\Support\OperationRunOutcome;
use App\Support\OperationRunStatus;
use App\Support\OperationRunType;
@ -59,8 +60,11 @@
],
]);
$summary = BaselineCompareStats::forTenant($tenant)->summaryAssessment();
Livewire::test(BaselineCompareLanding::class)
->assertSee(BaselineCompareReasonCode::NoDriftDetected->message());
->assertSee($summary->headline)
->assertSee('Aligned');
});
it('shows explicit missing-detail fallback when evidence gaps were counted without recorded subject rows', function (): void {

View File

@ -7,16 +7,18 @@
use App\Models\BaselineSnapshot;
use App\Models\BaselineTenantAssignment;
use App\Models\OperationRun;
use App\Support\Baselines\BaselineCompareReasonCode;
use App\Support\OperationRunOutcome;
use App\Support\OperationRunStatus;
use App\Support\OperationRunType;
use Filament\Facades\Filament;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Livewire\Livewire;
uses(RefreshDatabase::class);
it('renders the tenant dashboard when a baseline assignment exists (regression: missing BaselineCompareRun model)', function (): void {
function createBaselineCompareWidgetTenant(): array
{
[$user, $tenant] = createUserWithTenant(role: 'owner');
$profile = BaselineProfile::factory()->create([
$profile = BaselineProfile::factory()->active()->create([
'workspace_id' => (int) $tenant->workspace_id,
'name' => 'Baseline A',
]);
@ -34,15 +36,30 @@
'baseline_profile_id' => (int) $profile->getKey(),
]);
return [$user, $tenant, $profile, $snapshot];
}
it('renders a trustworthy no-drift dashboard summary without compliance shorthand', function (): void {
[$user, $tenant, $profile, $snapshot] = createBaselineCompareWidgetTenant();
OperationRun::factory()->create([
'tenant_id' => (int) $tenant->getKey(),
'workspace_id' => (int) $tenant->workspace_id,
'type' => 'baseline_compare',
'status' => 'completed',
'outcome' => 'succeeded',
'initiator_name' => 'System',
'type' => OperationRunType::BaselineCompare->value,
'status' => OperationRunStatus::Completed->value,
'outcome' => OperationRunOutcome::Succeeded->value,
'context' => [
'baseline_profile_id' => (int) $profile->getKey(),
'baseline_snapshot_id' => (int) $snapshot->getKey(),
'baseline_compare' => [
'reason_code' => BaselineCompareReasonCode::NoDriftDetected->value,
'coverage' => [
'effective_types' => ['deviceConfiguration'],
'covered_types' => ['deviceConfiguration'],
'uncovered_types' => [],
'proof' => true,
],
],
],
'completed_at' => now()->subDay(),
]);
@ -55,5 +72,135 @@
Livewire::test(BaselineCompareNow::class)
->assertSee('Baseline Governance')
->assertSee('Baseline A')
->assertSee('No open drift — baseline compliant');
->assertSee('Aligned')
->assertSee('No confirmed drift in the latest baseline compare.')
->assertSee('No action needed')
->assertDontSee('baseline compliant');
});
it('renders limited-confidence zero findings as a cautionary widget state', function (): void {
[$user, $tenant, $profile, $snapshot] = createBaselineCompareWidgetTenant();
OperationRun::factory()->create([
'tenant_id' => (int) $tenant->getKey(),
'workspace_id' => (int) $tenant->workspace_id,
'type' => OperationRunType::BaselineCompare->value,
'status' => OperationRunStatus::Completed->value,
'outcome' => OperationRunOutcome::PartiallySucceeded->value,
'context' => [
'baseline_profile_id' => (int) $profile->getKey(),
'baseline_snapshot_id' => (int) $snapshot->getKey(),
'baseline_compare' => [
'reason_code' => BaselineCompareReasonCode::CoverageUnproven->value,
'coverage' => [
'effective_types' => ['deviceConfiguration', 'deviceCompliancePolicy'],
'covered_types' => ['deviceConfiguration'],
'uncovered_types' => ['deviceCompliancePolicy'],
'proof' => false,
],
'evidence_gaps' => [
'count' => 2,
'by_reason' => [
BaselineCompareReasonCode::CoverageUnproven->value => 2,
],
],
],
],
'completed_at' => now(),
]);
$this->actingAs($user);
Filament::setCurrentPanel(Filament::getPanel('tenant'));
Filament::setTenant($tenant, true);
Livewire::test(BaselineCompareNow::class)
->assertSee('Needs review')
->assertSee('The last compare finished, but normal result output was suppressed.')
->assertSee('Review compare detail')
->assertDontSee('Aligned')
->assertDontSee('baseline compliant');
});
it('renders failed compare runs as action required with a run drilldown', function (): void {
[$user, $tenant, $profile, $snapshot] = createBaselineCompareWidgetTenant();
OperationRun::factory()->create([
'tenant_id' => (int) $tenant->getKey(),
'workspace_id' => (int) $tenant->workspace_id,
'type' => OperationRunType::BaselineCompare->value,
'status' => OperationRunStatus::Completed->value,
'outcome' => OperationRunOutcome::Failed->value,
'context' => [
'baseline_profile_id' => (int) $profile->getKey(),
'baseline_snapshot_id' => (int) $snapshot->getKey(),
],
'failure_summary' => ['message' => 'Graph API timeout'],
'completed_at' => now()->subMinutes(30),
]);
$this->actingAs($user);
Filament::setCurrentPanel(Filament::getPanel('tenant'));
Filament::setTenant($tenant, true);
Livewire::test(BaselineCompareNow::class)
->assertSee('Action required')
->assertSee('The latest baseline compare failed before it produced a usable result.')
->assertSee('Review the failed run')
->assertDontSee('Aligned');
});
it('renders in-progress compare runs without claiming an all-clear', function (): void {
[$user, $tenant, $profile, $snapshot] = createBaselineCompareWidgetTenant();
OperationRun::factory()->create([
'tenant_id' => (int) $tenant->getKey(),
'workspace_id' => (int) $tenant->workspace_id,
'type' => OperationRunType::BaselineCompare->value,
'status' => OperationRunStatus::Queued->value,
'outcome' => OperationRunOutcome::Pending->value,
'context' => [
'baseline_profile_id' => (int) $profile->getKey(),
'baseline_snapshot_id' => (int) $snapshot->getKey(),
],
]);
$this->actingAs($user);
Filament::setCurrentPanel(Filament::getPanel('tenant'));
Filament::setTenant($tenant, true);
Livewire::test(BaselineCompareNow::class)
->assertSee('In progress')
->assertSee('Baseline compare is in progress.')
->assertSee('View run')
->assertDontSee('Aligned');
});
it('renders snapshot-unavailable posture as unavailable rather than healthy', function (): void {
[$user, $tenant] = createUserWithTenant(role: 'owner');
$profile = BaselineProfile::factory()->active()->create([
'workspace_id' => (int) $tenant->workspace_id,
'name' => 'Baseline A',
'active_snapshot_id' => null,
]);
BaselineTenantAssignment::factory()->create([
'workspace_id' => (int) $tenant->workspace_id,
'tenant_id' => (int) $tenant->getKey(),
'baseline_profile_id' => (int) $profile->getKey(),
]);
$this->actingAs($user);
Filament::setCurrentPanel(Filament::getPanel('tenant'));
Filament::setTenant($tenant, true);
Livewire::test(BaselineCompareNow::class)
->assertSee('Unavailable')
->assertSee('The current baseline snapshot is not available for compare.')
->assertSee('Review baseline prerequisites')
->assertDontSee('Aligned');
});

View File

@ -0,0 +1,79 @@
<?php
declare(strict_types=1);
use App\Filament\Pages\BaselineCompareLanding;
use App\Filament\Widgets\Dashboard\BaselineCompareNow;
use App\Filament\Widgets\Tenant\BaselineCompareCoverageBanner;
use App\Models\BaselineProfile;
use App\Models\BaselineSnapshot;
use App\Models\BaselineTenantAssignment;
use App\Models\OperationRun;
use App\Support\Baselines\BaselineCompareReasonCode;
use App\Support\OperationRunOutcome;
use App\Support\OperationRunStatus;
use App\Support\OperationRunType;
use Filament\Facades\Filament;
use Livewire\Livewire;
it('keeps widget, landing, and banner equally cautious for the same limited-confidence compare result', function (): void {
[$user, $tenant] = createUserWithTenant(role: 'owner');
$this->actingAs($user);
$profile = BaselineProfile::factory()->active()->create([
'workspace_id' => (int) $tenant->workspace_id,
]);
$snapshot = BaselineSnapshot::factory()->create([
'workspace_id' => (int) $tenant->workspace_id,
'baseline_profile_id' => (int) $profile->getKey(),
]);
$profile->update(['active_snapshot_id' => (int) $snapshot->getKey()]);
BaselineTenantAssignment::factory()->create([
'workspace_id' => (int) $tenant->workspace_id,
'tenant_id' => (int) $tenant->getKey(),
'baseline_profile_id' => (int) $profile->getKey(),
]);
OperationRun::factory()->create([
'tenant_id' => (int) $tenant->getKey(),
'workspace_id' => (int) $tenant->workspace_id,
'type' => OperationRunType::BaselineCompare->value,
'status' => OperationRunStatus::Completed->value,
'outcome' => OperationRunOutcome::PartiallySucceeded->value,
'completed_at' => now(),
'context' => [
'baseline_profile_id' => (int) $profile->getKey(),
'baseline_snapshot_id' => (int) $snapshot->getKey(),
'baseline_compare' => [
'reason_code' => BaselineCompareReasonCode::CoverageUnproven->value,
'coverage' => [
'effective_types' => ['deviceConfiguration', 'deviceCompliancePolicy'],
'covered_types' => ['deviceConfiguration'],
'uncovered_types' => ['deviceCompliancePolicy'],
'proof' => false,
],
],
],
]);
Filament::setCurrentPanel(Filament::getPanel('tenant'));
Filament::setTenant($tenant, true);
Livewire::test(BaselineCompareNow::class)
->assertSee('Needs review')
->assertSee('The last compare finished, but normal result output was suppressed.')
->assertDontSee('Aligned');
Livewire::test(BaselineCompareLanding::class)
->assertSee('Needs review')
->assertSee('The last compare finished, but normal result output was suppressed.')
->assertSee('Limited confidence')
->assertDontSee('Aligned');
Livewire::test(BaselineCompareCoverageBanner::class)
->assertSee('The last compare finished, but normal result output was suppressed.')
->assertSee('Review compare detail');
});

View File

@ -91,5 +91,5 @@
$this->get(route('admin.operations.view', ['run' => (int) $run->getKey()]))
->assertOk()
->assertSeeInOrder(['Policy sync', 'Run summary', 'Context']);
->assertSeeInOrder(['Policy sync', 'Decision', 'Count diagnostics', 'Context']);
});

View File

@ -0,0 +1,172 @@
<?php
declare(strict_types=1);
use App\Filament\Widgets\Dashboard\NeedsAttention;
use App\Models\BaselineProfile;
use App\Models\BaselineSnapshot;
use App\Models\BaselineTenantAssignment;
use App\Models\OperationRun;
use App\Support\Baselines\BaselineCompareReasonCode;
use App\Support\OperationRunOutcome;
use App\Support\OperationRunStatus;
use App\Support\OperationRunType;
use Filament\Facades\Filament;
use Livewire\Livewire;
function createNeedsAttentionTenant(): array
{
[$user, $tenant] = createUserWithTenant(role: 'owner');
$profile = BaselineProfile::factory()->active()->create([
'workspace_id' => (int) $tenant->workspace_id,
]);
$snapshot = BaselineSnapshot::factory()->create([
'workspace_id' => (int) $tenant->workspace_id,
'baseline_profile_id' => (int) $profile->getKey(),
]);
$profile->update(['active_snapshot_id' => (int) $snapshot->getKey()]);
BaselineTenantAssignment::factory()->create([
'workspace_id' => (int) $tenant->workspace_id,
'tenant_id' => (int) $tenant->getKey(),
'baseline_profile_id' => (int) $profile->getKey(),
]);
return [$user, $tenant, $profile, $snapshot];
}
it('shows a cautionary baseline posture in needs-attention when compare trust is limited', function (): void {
[$user, $tenant, $profile, $snapshot] = createNeedsAttentionTenant();
$this->actingAs($user);
OperationRun::factory()->create([
'tenant_id' => (int) $tenant->getKey(),
'workspace_id' => (int) $tenant->workspace_id,
'type' => OperationRunType::BaselineCompare->value,
'status' => OperationRunStatus::Completed->value,
'outcome' => OperationRunOutcome::PartiallySucceeded->value,
'completed_at' => now(),
'context' => [
'baseline_profile_id' => (int) $profile->getKey(),
'baseline_snapshot_id' => (int) $snapshot->getKey(),
'baseline_compare' => [
'reason_code' => BaselineCompareReasonCode::EvidenceCaptureIncomplete->value,
'coverage' => [
'effective_types' => ['deviceConfiguration'],
'covered_types' => ['deviceConfiguration'],
'uncovered_types' => [],
'proof' => true,
],
'evidence_gaps' => [
'count' => 2,
'by_reason' => [
'policy_record_missing' => 2,
],
],
],
],
]);
Filament::setCurrentPanel(Filament::getPanel('tenant'));
Filament::setTenant($tenant, true);
$component = Livewire::test(NeedsAttention::class)
->assertSee('Needs Attention')
->assertSee('Baseline compare posture')
->assertSee('The last compare finished, but normal result output was suppressed.')
->assertSee('Review compare detail')
->assertDontSee('Current dashboard signals look trustworthy.');
expect($component->html())->not->toContain('href=');
});
it('keeps needs-attention non-navigational and healthy only for trustworthy compare results', function (): void {
[$user, $tenant, $profile, $snapshot] = createNeedsAttentionTenant();
$this->actingAs($user);
OperationRun::factory()->create([
'tenant_id' => (int) $tenant->getKey(),
'workspace_id' => (int) $tenant->workspace_id,
'type' => OperationRunType::BaselineCompare->value,
'status' => OperationRunStatus::Completed->value,
'outcome' => OperationRunOutcome::Succeeded->value,
'completed_at' => now()->subHour(),
'context' => [
'baseline_profile_id' => (int) $profile->getKey(),
'baseline_snapshot_id' => (int) $snapshot->getKey(),
'baseline_compare' => [
'reason_code' => BaselineCompareReasonCode::NoDriftDetected->value,
'coverage' => [
'effective_types' => ['deviceConfiguration'],
'covered_types' => ['deviceConfiguration'],
'uncovered_types' => [],
'proof' => true,
],
],
],
]);
Filament::setCurrentPanel(Filament::getPanel('tenant'));
Filament::setTenant($tenant, true);
$component = Livewire::test(NeedsAttention::class)
->assertSee('Current dashboard signals look trustworthy.')
->assertSee('Baseline compare looks trustworthy')
->assertSee('No confirmed drift in the latest baseline compare.')
->assertDontSee('Baseline compare posture');
expect($component->html())->not->toContain('href=');
});
it('surfaces stale compare posture instead of a healthy fallback', function (): void {
[$user, $tenant, $profile, $snapshot] = createNeedsAttentionTenant();
$this->actingAs($user);
OperationRun::factory()->create([
'tenant_id' => (int) $tenant->getKey(),
'workspace_id' => (int) $tenant->workspace_id,
'type' => OperationRunType::BaselineCompare->value,
'status' => OperationRunStatus::Completed->value,
'outcome' => OperationRunOutcome::Succeeded->value,
'completed_at' => now()->subDays(10),
'context' => [
'baseline_profile_id' => (int) $profile->getKey(),
'baseline_snapshot_id' => (int) $snapshot->getKey(),
'baseline_compare' => [
'reason_code' => BaselineCompareReasonCode::NoDriftDetected->value,
'coverage' => [
'effective_types' => ['deviceConfiguration'],
'covered_types' => ['deviceConfiguration'],
'uncovered_types' => [],
'proof' => true,
],
],
],
]);
Filament::setCurrentPanel(Filament::getPanel('tenant'));
Filament::setTenant($tenant, true);
Livewire::test(NeedsAttention::class)
->assertSee('Baseline compare posture')
->assertSee('The latest baseline compare result is stale.')
->assertSee('Open Baseline Compare')
->assertDontSee('Current dashboard signals look trustworthy.');
});
it('surfaces compare unavailability instead of a healthy fallback when no result exists yet', function (): void {
[$user, $tenant] = createNeedsAttentionTenant();
$this->actingAs($user);
Filament::setCurrentPanel(Filament::getPanel('tenant'));
Filament::setTenant($tenant, true);
Livewire::test(NeedsAttention::class)
->assertSee('Baseline compare posture')
->assertSee('A current baseline compare result is not available yet.')
->assertSee('Open Baseline Compare')
->assertDontSee('Current dashboard signals look trustworthy.');
});

View File

@ -5,13 +5,28 @@
use App\Filament\Pages\Operations\TenantlessOperationRunViewer;
use App\Models\BaselineProfile;
use App\Models\BaselineSnapshot;
use App\Models\BaselineTenantAssignment;
use App\Models\OperationRun;
use App\Support\Baselines\BaselineCompareReasonCode;
use App\Support\Baselines\BaselineCompareStats;
use App\Support\Baselines\BaselineReasonCodes;
use App\Support\Ui\GovernanceArtifactTruth\ArtifactTruthPresenter;
use App\Support\Workspaces\WorkspaceContext;
use Filament\Facades\Filament;
use Livewire\Features\SupportTesting\Testable;
use Livewire\Livewire;
function visibleLivewireText(Testable $component): string
{
$html = $component->html();
$html = preg_replace('/<script\b[^>]*>.*?<\/script>/is', '', $html) ?? $html;
$html = preg_replace('/<style\b[^>]*>.*?<\/style>/is', '', $html) ?? $html;
$html = preg_replace('/\s+wire:snapshot="[^"]*"/', '', $html) ?? $html;
$html = preg_replace('/\s+wire:effects="[^"]*"/', '', $html) ?? $html;
return trim((string) preg_replace('/\s+/', ' ', strip_tags($html)));
}
it('shows run outcome and baseline artifact truth as separate facts on the run detail page', function (): void {
[$user, $tenant] = createUserWithTenant(role: 'owner');
@ -52,8 +67,9 @@
$this->withSession([WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id]);
session([WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id]);
Livewire::actingAs($user)
$component = Livewire::actingAs($user)
->test(TenantlessOperationRunViewer::class, ['run' => $run])
->assertSee('Decision')
->assertSee('Outcome')
->assertSee('Artifact truth')
->assertSee('Execution failed')
@ -61,8 +77,16 @@
->assertSee($explanation?->evaluationResultLabel() ?? '')
->assertSee($explanation?->trustworthinessLabel() ?? '')
->assertSee('Artifact not usable')
->assertSee('Artifact next step')
->assertSee('Inspect the related capture diagnostics before using this snapshot');
->assertSee('Primary next step')
->assertSee('Artifact truth details')
->assertSee('Inspect the related capture diagnostics before using this snapshot')
->assertDontSee('Artifact next step');
$pageText = visibleLivewireText($component);
expect(mb_substr_count($pageText, 'Primary next step'))->toBe(1)
->and(mb_substr_count($pageText, 'Inspect the related capture diagnostics before using this snapshot'))->toBe(1)
->and(mb_strpos($pageText, 'Decision'))->toBeLessThan(mb_strpos($pageText, 'Artifact truth details'));
});
it('shows operator explanation facts for baseline compare runs with nested compare reason context', function (): void {
@ -100,16 +124,127 @@
$this->withSession([WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id]);
session([WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id]);
Livewire::actingAs($user)
$component = Livewire::actingAs($user)
->test(TenantlessOperationRunViewer::class, ['run' => $run])
->assertSee('Decision')
->assertSee('Artifact truth')
->assertSee('Result meaning')
->assertSee('Result trust')
->assertSee('Artifact next step')
->assertSee('Primary next step')
->assertSee('Artifact truth details')
->assertSee($explanation?->headline ?? '')
->assertSee($explanation?->evaluationResultLabel() ?? '')
->assertSee($explanation?->trustworthinessLabel() ?? '')
->assertSee($explanation?->nextActionText ?? '')
->assertSee('The run completed, but normal output was intentionally suppressed.')
->assertSee('Resume or rerun evidence capture before relying on this compare result.');
->assertSee('Resume or rerun evidence capture before relying on this compare result.')
->assertDontSee('Artifact next step');
$pageText = visibleLivewireText($component);
expect(mb_substr_count($pageText, 'Primary next step'))->toBe(1)
->and(mb_substr_count($pageText, 'Resume or rerun evidence capture before relying on this compare result.'))->toBe(1)
->and(mb_strpos($pageText, 'Decision'))->toBeLessThan(mb_strpos($pageText, 'Artifact truth details'));
});
it('deduplicates repeated artifact truth explanation text for follow-up runs without a usable artifact', function (): void {
[$user, $tenant] = createUserWithTenant(role: 'owner');
$run = OperationRun::factory()->create([
'tenant_id' => (int) $tenant->getKey(),
'workspace_id' => (int) $tenant->workspace_id,
'type' => 'baseline_compare',
'status' => 'completed',
'outcome' => 'partially_succeeded',
'summary_counts' => [
'total' => 50,
'processed' => 47,
'failed' => 3,
],
'context' => [],
'completed_at' => now(),
]);
Filament::setTenant(null, true);
$this->withSession([WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id]);
session([WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id]);
$component = Livewire::actingAs($user)
->test(TenantlessOperationRunViewer::class, ['run' => $run])
->assertSee('Decision')
->assertSee('Artifact truth details')
->assertSee('The run finished without a usable artifact result.');
$pageText = visibleLivewireText($component);
expect(mb_substr_count($pageText, 'The run finished without a usable artifact result.'))->toBe(1)
->and(mb_strpos($pageText, 'Decision'))->toBeLessThan(mb_strpos($pageText, 'Artifact truth details'));
});
it('keeps the compact tenant summary at least as cautious as the canonical run detail for suppressed compare results', function (): void {
[$user, $tenant] = createUserWithTenant(role: 'owner');
$profile = BaselineProfile::factory()->active()->create([
'workspace_id' => (int) $tenant->workspace_id,
]);
$snapshot = BaselineSnapshot::factory()->create([
'workspace_id' => (int) $tenant->workspace_id,
'baseline_profile_id' => (int) $profile->getKey(),
]);
$profile->update(['active_snapshot_id' => (int) $snapshot->getKey()]);
BaselineTenantAssignment::factory()->create([
'workspace_id' => (int) $tenant->workspace_id,
'tenant_id' => (int) $tenant->getKey(),
'baseline_profile_id' => (int) $profile->getKey(),
]);
$run = OperationRun::factory()->create([
'tenant_id' => (int) $tenant->getKey(),
'workspace_id' => (int) $tenant->workspace_id,
'type' => 'baseline_compare',
'status' => 'completed',
'outcome' => 'partially_succeeded',
'context' => [
'baseline_profile_id' => (int) $profile->getKey(),
'baseline_snapshot_id' => (int) $snapshot->getKey(),
'baseline_compare' => [
'reason_code' => BaselineCompareReasonCode::CoverageUnproven->value,
'coverage' => [
'effective_types' => ['deviceConfiguration', 'deviceCompliancePolicy'],
'covered_types' => ['deviceConfiguration'],
'uncovered_types' => ['deviceCompliancePolicy'],
'proof' => false,
],
],
],
'summary_counts' => [
'total' => 0,
'processed' => 0,
'errors_recorded' => 2,
],
'completed_at' => now(),
]);
$summary = BaselineCompareStats::forTenant($tenant)->summaryAssessment();
$truth = app(ArtifactTruthPresenter::class)->forOperationRun($run->fresh());
$explanation = $truth->operatorExplanation;
expect($summary->stateFamily)->not->toBe('positive')
->and($summary->evaluationResult)->toBe('suppressed_result')
->and($summary->headline)->toBe('The last compare finished, but normal result output was suppressed.')
->and($explanation?->evaluationResult)->toBe('suppressed_result');
Filament::setTenant(null, true);
$this->withSession([WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id]);
session([WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id]);
Livewire::actingAs($user)
->test(TenantlessOperationRunViewer::class, ['run' => $run])
->assertSee($explanation?->headline ?? '')
->assertSee($explanation?->evaluationResultLabel() ?? '')
->assertSee($explanation?->trustworthinessLabel() ?? '')
->assertDontSee('No confirmed drift in the latest baseline compare.');
});

View File

@ -90,7 +90,7 @@ function baselineCompareGapContext(array $overrides = []): array
], $overrides);
}
it('renders operation runs with summary content before counts and technical context', function (): void {
it('renders decision-first hierarchy before main sections and technical diagnostics', function (): void {
[$user, $tenant] = createUserWithTenant(role: 'owner');
Filament::setTenant(null, true);
@ -119,31 +119,42 @@ function baselineCompareGapContext(array $overrides = []): array
->withSession([WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id])
->get(route('admin.operations.view', ['run' => (int) $run->getKey()]))
->assertOk()
->assertSee('Current state')
->assertSee('Decision')
->assertSee('Timing')
->assertSee('Metadata')
->assertSee('Count diagnostics')
->assertSee('Contoso');
$pageText = visiblePageText($response);
$policySyncPosition = mb_strpos($pageText, 'Policy sync');
$runSummaryPosition = mb_strpos($pageText, 'Run summary');
$decisionPosition = mb_strpos($pageText, 'Decision');
$timingPosition = mb_strpos($pageText, 'Timing');
$metadataPosition = mb_strpos($pageText, 'Metadata');
$relatedContextPosition = mb_strpos($pageText, 'Related context');
$countsPosition = mb_strpos($pageText, 'Counts');
$countDiagnosticsPosition = mb_strpos($pageText, 'Count diagnostics');
$identityHashPosition = mb_strpos($pageText, 'Identity hash');
expect($policySyncPosition)->not->toBeFalse()
->and($runSummaryPosition)->not->toBeFalse()
->and($decisionPosition)->not->toBeFalse()
->and($timingPosition)->not->toBeFalse()
->and($metadataPosition)->not->toBeFalse()
->and($relatedContextPosition)->not->toBeFalse()
->and($countsPosition)->not->toBeFalse()
->and($countDiagnosticsPosition)->not->toBeFalse()
->and($identityHashPosition)->not->toBeFalse()
->and($policySyncPosition)->toBeLessThan($runSummaryPosition)
->and($runSummaryPosition)->toBeLessThan($relatedContextPosition)
->and($relatedContextPosition)->toBeLessThan($countsPosition)
->and($countsPosition)->toBeLessThan($identityHashPosition);
->and($policySyncPosition)->toBeLessThan($decisionPosition)
->and($decisionPosition)->toBeLessThan($timingPosition)
->and($timingPosition)->toBeLessThan($metadataPosition)
->and($metadataPosition)->toBeLessThan($relatedContextPosition)
->and($relatedContextPosition)->toBeLessThan($countDiagnosticsPosition)
->and($countDiagnosticsPosition)->toBeLessThan($identityHashPosition);
expect((string) $response->getContent())
->toMatch('/fi-section-header-heading[^>]*>\s*Current state\s*</')
->toMatch('/fi-section-header-heading[^>]*>\s*Decision\s*</')
->toMatch('/fi-section-header-heading[^>]*>\s*Timing\s*</');
$response->assertDontSee('Current state')
->assertDontSee('Run summary');
});
it('keeps header navigation and related context visible for tenant-bound operation runs', function (): void {
@ -208,17 +219,17 @@ function baselineCompareGapContext(array $overrides = []): array
->get(route('admin.operations.view', ['run' => (int) $run->getKey()]))
->assertOk()
->assertSee('Current tenant context differs from this run')
->assertSee('Run summary')
->assertSee('Decision')
->assertSee('Related context');
$pageText = visiblePageText($response);
$bannerPosition = mb_strpos($pageText, 'Current tenant context differs from this run');
$summaryPosition = mb_strpos($pageText, 'Run summary');
$decisionPosition = mb_strpos($pageText, 'Decision');
expect($bannerPosition)->not->toBeFalse()
->and($summaryPosition)->not->toBeFalse()
->and($bannerPosition)->toBeLessThan($summaryPosition);
->and($decisionPosition)->not->toBeFalse()
->and($bannerPosition)->toBeLessThan($decisionPosition);
});
it('renders explicit sparse-data fallbacks for operation runs', function (): void {
@ -302,6 +313,11 @@ function baselineCompareGapContext(array $overrides = []): array
'type' => 'baseline_compare',
'status' => 'completed',
'outcome' => 'partially_succeeded',
'summary_counts' => [
'total' => 50,
'processed' => 47,
'failed' => 3,
],
'context' => baselineCompareGapContext(),
'completed_at' => now(),
]);
@ -310,6 +326,9 @@ function baselineCompareGapContext(array $overrides = []): array
->withSession([WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id])
->get(route('admin.operations.view', ['run' => (int) $run->getKey()]))
->assertOk()
->assertSee('Decision')
->assertSee('Primary next step')
->assertSee('Count diagnostics')
->assertSee('Evidence gap details')
->assertSee('Search gap details')
->assertSee('Search by reason, type, class, outcome, action, or subject key')
@ -325,6 +344,24 @@ function baselineCompareGapContext(array $overrides = []): array
->assertSee('Outcome')
->assertSee('Next action')
->assertSee('Subject key');
$pageText = visiblePageText($response);
$decisionPosition = mb_strpos($pageText, 'Decision');
$timingPosition = mb_strpos($pageText, 'Timing');
$searchGapDetailsPosition = mb_strpos($pageText, 'Search gap details');
$gapDetailsPosition = mb_strpos($pageText, 'Evidence gap details');
$countDiagnosticsPosition = mb_strpos($pageText, 'Count diagnostics');
expect($decisionPosition)->not->toBeFalse()
->and($timingPosition)->not->toBeFalse()
->and($searchGapDetailsPosition)->not->toBeFalse()
->and($gapDetailsPosition)->not->toBeFalse()
->and($countDiagnosticsPosition)->not->toBeFalse()
->and($decisionPosition)->toBeLessThan($timingPosition)
->and($timingPosition)->toBeLessThan($gapDetailsPosition)
->and($gapDetailsPosition)->toBeLessThan($searchGapDetailsPosition)
->and($gapDetailsPosition)->toBeLessThan($countDiagnosticsPosition);
});
it('renders baseline compare evidence-gap details without invoking graph during canonical run detail render', function (): void {

View File

@ -255,6 +255,17 @@
->assertSee('Automatically reconciled')
->assertSee('Infrastructure ended the run')
->assertSee('Review worker health and logs before retrying this operation.');
$response = $this->actingAs($user)
->withSession([
WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id,
])
->get(route('admin.operations.view', ['run' => (int) $run->getKey()]))
->assertSuccessful();
$pageText = trim((string) preg_replace('/\s+/', ' ', strip_tags((string) $response->getContent())));
expect(mb_substr_count($pageText, 'Automatically reconciled'))->toBe(1);
});
it('keeps a canonical run viewer accessible when the remembered tenant differs from the run tenant', function (): void {

View File

@ -19,6 +19,11 @@
->and($envelope?->absencePattern)->toBe($expectedAbsencePattern)
->and(app(ReasonPresenter::class)->dominantCauseExplanation($envelope))->not->toBe('');
})->with([
'trustworthy no-drift compare result' => [
BaselineCompareReasonCode::NoDriftDetected->value,
TrustworthinessLevel::Trustworthy->value,
'true_no_result',
],
'suppressed compare result' => [
BaselineCompareReasonCode::CoverageUnproven->value,
TrustworthinessLevel::LimitedConfidence->value,

View File

@ -26,6 +26,11 @@
new PageActionData(label: 'Hidden', url: '/admin/operations/hidden', visible: false),
],
))
->decisionZone([
'title' => 'Decision',
'facts' => [['label' => 'Outcome', 'value' => 'Succeeded']],
'primaryNextStep' => ['text' => 'No action needed.', 'source' => 'none_required'],
])
->addSection(
new DetailSectionData(
id: 'counts',
@ -72,10 +77,12 @@
->toArray();
expect($page['header']['primaryActions'])->toHaveCount(1)
->and($page['decisionZone'])->not->toBeNull()
->and($page['decisionZone']['title'])->toBe('Decision')
->and($page['mainSections'])->toHaveCount(1)
->and($page['mainSections'][0]['title'])->toBe('Counts')
->and($page['supportingCards'])->toHaveCount(1)
->and($page['supportingCards'][0]['title'])->toBe('Timing')
->and($page['supportingGroups'])->toHaveCount(1)
->and($page['supportingGroups'][0]['title'])->toBe('Timing')
->and($page['technicalSections'])->toHaveCount(1)
->and($page['technicalSections'][0]['title'])->toBe('Context');
});