Compare commits

..

2 Commits

Author SHA1 Message Date
Ahmed Darrazi
557bff5028 merge: agent session work 2026-03-25 13:35:27 +01:00
Ahmed Darrazi
4275c23cbc feat: implement baseline subject resolution semantics 2026-03-25 13:35:17 +01:00
39 changed files with 544 additions and 2856 deletions

View File

@ -106,8 +106,6 @@ ## 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) - 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 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) - 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.15 (feat/005-bulk-operations) - PHP 8.4.15 (feat/005-bulk-operations)
@ -127,8 +125,8 @@ ## Code Style
PHP 8.4.15: Follow standard conventions PHP 8.4.15: Follow standard conventions
## Recent Changes ## Recent Changes
- 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-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 - 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 START -->
<!-- MANUAL ADDITIONS END --> <!-- MANUAL ADDITIONS END -->

View File

@ -1,22 +1,19 @@
<!-- <!--
Sync Impact Report Sync Impact Report
- Version change: 1.12.0 → 1.13.0 - Version change: 1.11.0 → 1.12.0
- Modified principles: - Modified principles:
- None - None
- Added sections: - Added sections:
- Filament Native First / No Ad-hoc Styling (UI-FIL-001) - Operator Surface Principles (OPSURF-001)
- Removed sections: None - Removed sections: None
- Templates requiring updates: - Templates requiring updates:
- ✅ .specify/memory/constitution.md
- ✅ .specify/templates/spec-template.md - ✅ .specify/templates/spec-template.md
- ✅ .specify/templates/plan-template.md - ✅ .specify/templates/plan-template.md
- ✅ .specify/templates/tasks-template.md - ✅ .specify/templates/tasks-template.md
- ✅ docs/product/principles.md - ✅ docs/product/principles.md
- ✅ docs/product/standards/README.md - ✅ docs/product/standards/README.md
- ✅ docs/HANDOVER.md - ✅ docs/HANDOVER.md
- Commands checked:
- N/A `.specify/templates/commands/*.md` directory is not present in this repo
- Follow-up TODOs: - Follow-up TODOs:
- None. - None.
--> -->
@ -419,39 +416,6 @@ ### 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. - 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. - 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) ### Incremental UI Standards Enforcement (UI-STD-001)
- UI consistency is enforced incrementally, not by recurring cleanup passes. - 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. - New tables, filters, and list surfaces MUST follow established Filament-native standards from the first implementation.
@ -487,4 +451,4 @@ ### Versioning Policy (SemVer)
- **MINOR**: new principle/section or materially expanded guidance. - **MINOR**: new principle/section or materially expanded guidance.
- **MAJOR**: removing/redefining principles in a backward-incompatible way. - **MAJOR**: removing/redefining principles in a backward-incompatible way.
**Version**: 1.13.0 | **Ratified**: 2026-01-03 | **Last Amended**: 2026-03-26 **Version**: 1.12.0 | **Ratified**: 2026-01-03 | **Last Amended**: 2026-03-21

View File

@ -49,7 +49,6 @@ ## Constitution Check
- Automation: queued/scheduled ops use locks + idempotency; handle 429/503 with backoff+jitter - 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 - 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 - 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 - 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): `/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 - 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,12 +127,6 @@ ## Requirements *(mandatory)*
**Constitution alignment (BADGE-001):** If this feature changes status-like badges (status/outcome/severity/risk/availability/boolean), **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. 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, **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: notifications, audit prose, or related helper copy, the spec MUST describe:
- the target object, - the target object,
@ -153,7 +147,6 @@ ## Requirements *(mandatory)*
**Constitution alignment (Filament Action Surfaces):** If this feature adds or modifies any Filament Resource / RelationManager / Page, **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. 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. 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, **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 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 (no naked inputs), View pages use Infolists (not disabled edit forms), status badges use BADGE-001, empty states have a specific

View File

@ -53,9 +53,6 @@ # Tasks: [FEATURE NAME]
- grouping bulk actions via BulkActionGroup, - grouping bulk actions via BulkActionGroup,
- adding confirmations for destructive actions (and typed confirmation where required by scale), - adding confirmations for destructive actions (and typed confirmation where required by scale),
- adding `AuditLog` entries for relevant mutations, - 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. - 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: **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)), - ensuring Create/Edit pages use Main/Aside layout (3-col grid, Main=columnSpan(2), Aside=columnSpan(1)),

View File

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

View File

@ -33,9 +33,7 @@
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceInspectAffordance; use App\Support\Ui\ActionSurface\Enums\ActionSurfaceInspectAffordance;
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceProfile; use App\Support\Ui\ActionSurface\Enums\ActionSurfaceProfile;
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceSlot; use App\Support\Ui\ActionSurface\Enums\ActionSurfaceSlot;
use App\Support\Ui\GovernanceArtifactTruth\ArtifactTruthEnvelope;
use App\Support\Ui\GovernanceArtifactTruth\ArtifactTruthPresenter; use App\Support\Ui\GovernanceArtifactTruth\ArtifactTruthPresenter;
use App\Support\Ui\OperatorExplanation\OperatorExplanationPattern;
use App\Support\Workspaces\WorkspaceContext; use App\Support\Workspaces\WorkspaceContext;
use BackedEnum; use BackedEnum;
use Filament\Actions; use Filament\Actions;
@ -259,7 +257,7 @@ private static function enterpriseDetailPage(OperationRun $record): \App\Support
$statusSpec = BadgeRenderer::spec(BadgeDomain::OperationRunStatus, static::statusBadgeState($record)); $statusSpec = BadgeRenderer::spec(BadgeDomain::OperationRunStatus, static::statusBadgeState($record));
$outcomeSpec = BadgeRenderer::spec(BadgeDomain::OperationRunOutcome, static::outcomeBadgeState($record)); $outcomeSpec = BadgeRenderer::spec(BadgeDomain::OperationRunOutcome, static::outcomeBadgeState($record));
$targetScope = static::targetScopeDisplay($record) ?? 'No target scope details were recorded for this run.'; $targetScope = static::targetScopeDisplay($record);
$summaryLine = \App\Support\OpsUx\SummaryCountsNormalizer::renderSummaryLine(is_array($record->summary_counts) ? $record->summary_counts : []); $summaryLine = \App\Support\OpsUx\SummaryCountsNormalizer::renderSummaryLine(is_array($record->summary_counts) ? $record->summary_counts : []);
$referencedTenantLifecycle = $record->tenant instanceof Tenant $referencedTenantLifecycle = $record->tenant instanceof Tenant
? ReferencedTenantLifecyclePresentation::forOperationRun($record->tenant) ? ReferencedTenantLifecyclePresentation::forOperationRun($record->tenant)
@ -268,14 +266,14 @@ private static function enterpriseDetailPage(OperationRun $record): \App\Support
? app(ArtifactTruthPresenter::class)->forOperationRun($record) ? app(ArtifactTruthPresenter::class)->forOperationRun($record)
: null; : null;
$operatorExplanation = $artifactTruth?->operatorExplanation; $operatorExplanation = $artifactTruth?->operatorExplanation;
$primaryNextStep = static::resolvePrimaryNextStep($record, $artifactTruth, $operatorExplanation); $artifactTruthBadge = $artifactTruth !== null
$supportingGroups = static::supportingGroups( ? $factory->statusBadge(
record: $record, $artifactTruth->primaryBadgeSpec()->label,
factory: $factory, $artifactTruth->primaryBadgeSpec()->color,
referencedTenantLifecycle: $referencedTenantLifecycle, $artifactTruth->primaryBadgeSpec()->icon,
operatorExplanation: $operatorExplanation, $artifactTruth->primaryBadgeSpec()->iconColor,
primaryNextStep: $primaryNextStep, )
); : null;
$builder = \App\Support\Ui\EnterpriseDetail\EnterpriseDetailBuilder::make('operation_run', 'workspace-context') $builder = \App\Support\Ui\EnterpriseDetail\EnterpriseDetailBuilder::make('operation_run', 'workspace-context')
->header(new \App\Support\Ui\EnterpriseDetail\SummaryHeaderData( ->header(new \App\Support\Ui\EnterpriseDetail\SummaryHeaderData(
@ -286,59 +284,34 @@ private static function enterpriseDetailPage(OperationRun $record): \App\Support
$factory->statusBadge($outcomeSpec->label, $outcomeSpec->color, $outcomeSpec->icon, $outcomeSpec->iconColor), $factory->statusBadge($outcomeSpec->label, $outcomeSpec->color, $outcomeSpec->icon, $outcomeSpec->iconColor),
], ],
keyFacts: [ keyFacts: [
$factory->keyFact('Target', $targetScope), $factory->keyFact('Target', $targetScope ?? 'No target scope details were recorded for this run.'),
$factory->keyFact('Initiator', $record->initiator_name),
$factory->keyFact('Elapsed', RunDurationInsights::elapsedHuman($record)), $factory->keyFact('Elapsed', RunDurationInsights::elapsedHuman($record)),
$factory->keyFact('Expected', RunDurationInsights::expectedHuman($record)),
], ],
descriptionHint: 'Decision guidance and high-signal context stay ahead of diagnostic payloads and raw JSON.', descriptionHint: 'Run identity, outcome, scope, and related next steps stay ahead of stored payloads and diagnostic fragments.',
)) ))
->decisionZone($factory->decisionZone( ->addSection(
facts: array_values(array_filter([ $factory->factsSection(
$factory->keyFact( id: 'run_summary',
'Execution state', kind: 'core_details',
$statusSpec->label, title: 'Run summary',
badge: $factory->statusBadge($statusSpec->label, $statusSpec->color, $statusSpec->icon, $statusSpec->iconColor), 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)),
],
), ),
$factory->keyFact( $factory->viewSection(
'Outcome', id: 'artifact_truth',
$outcomeSpec->label, kind: 'current_status',
badge: $factory->statusBadge($outcomeSpec->label, $outcomeSpec->color, $outcomeSpec->icon, $outcomeSpec->iconColor), 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.',
), ),
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'],
),
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( $factory->viewSection(
id: 'related_context', id: 'related_context',
kind: 'related_context', kind: 'related_context',
@ -348,216 +321,23 @@ private static function enterpriseDetailPage(OperationRun $record): \App\Support
->detailEntries(CrossResourceNavigationMatrix::SOURCE_OPERATION_RUN, $record)], ->detailEntries(CrossResourceNavigationMatrix::SOURCE_OPERATION_RUN, $record)],
emptyState: $factory->emptyState('No related context is available for this record.'), emptyState: $factory->emptyState('No related context is available for this record.'),
), ),
$factory->viewSection( )
id: 'artifact_truth', ->addSupportingCard(
kind: 'supporting_detail', $factory->supportingFactsCard(
title: 'Artifact truth details', kind: 'status',
view: 'filament.infolists.entries.governance-artifact-truth', title: 'Current state',
viewData: [ items: array_values(array_filter([
'artifactTruthState' => $artifactTruth?->toArray(), $factory->keyFact('Status', $statusSpec->label, badge: $factory->statusBadge($statusSpec->label, $statusSpec->color, $statusSpec->icon, $statusSpec->iconColor)),
'surface' => 'expanded', $factory->keyFact('Outcome', $outcomeSpec->label, badge: $factory->statusBadge($outcomeSpec->label, $outcomeSpec->color, $outcomeSpec->icon, $outcomeSpec->iconColor)),
], $artifactTruth !== null
visible: $artifactTruth !== null, ? $factory->keyFact('Artifact truth', $artifactTruth->primaryLabel, badge: $artifactTruthBadge)
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->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->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->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)],
collapsible: true,
collapsed: true,
),
);
}
if ((string) $record->type === 'baseline_compare') {
$baselineCompareFacts = static::baselineCompareFacts($record, $factory);
$baselineCompareEvidence = static::baselineCompareEvidencePayload($record);
$gapDetails = BaselineCompareEvidenceGapDetails::fromOperationRun($record);
$gapSummary = is_array($gapDetails['summary'] ?? null) ? $gapDetails['summary'] : [];
$gapBuckets = is_array($gapDetails['buckets'] ?? null) ? $gapDetails['buckets'] : [];
if ($baselineCompareFacts !== []) {
$builder->addSection(
$factory->factsSection(
id: 'baseline_compare',
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,
),
);
}
if (($gapSummary['detail_state'] ?? 'no_gaps') !== 'no_gaps') {
$builder->addSection(
$factory->viewSection(
id: 'baseline_compare_gap_details',
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',
viewData: [
'summary' => $gapSummary,
'buckets' => $gapBuckets,
'searchId' => 'baseline-compare-gap-search-'.$record->getKey(),
],
collapsible: true,
collapsed: true,
),
);
}
if ($baselineCompareEvidence !== []) {
$builder->addSection(
$factory->viewSection(
id: 'baseline_compare_evidence',
kind: 'type_specific_detail',
title: 'Baseline compare evidence',
view: 'filament.infolists.entries.snapshot-json',
viewData: ['payload' => $baselineCompareEvidence],
collapsible: true,
collapsed: true,
),
);
}
}
if ((string) $record->type === 'baseline_capture') {
$baselineCaptureEvidence = static::baselineCaptureEvidencePayload($record);
if ($baselineCaptureEvidence !== []) {
$builder->addSection(
$factory->viewSection(
id: 'baseline_capture_evidence',
kind: 'type_specific_detail',
title: 'Baseline capture evidence',
view: 'filament.infolists.entries.snapshot-json',
viewData: ['payload' => $baselineCaptureEvidence],
collapsible: true,
collapsed: true,
),
);
}
}
if (VerificationReportViewer::shouldRenderForRun($record)) {
$builder->addSection(
$factory->viewSection(
id: 'verification_report',
kind: 'type_specific_detail',
title: 'Verification report',
view: 'filament.components.verification-report-viewer',
viewData: static::verificationReportViewData($record),
),
);
}
$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, : null,
$operatorExplanation instanceof OperatorExplanationPattern && $operatorExplanation->diagnosticsSummary !== null $operatorExplanation !== null
? $factory->keyFact('Diagnostics summary', $operatorExplanation->diagnosticsSummary) ? $factory->keyFact('Result meaning', $operatorExplanation->evaluationResultLabel())
: null, : null,
...array_map( $operatorExplanation !== null
static fn (array $guidance): array => $factory->keyFact($guidance['label'], $guidance['text']), ? $factory->keyFact('Result trust', $operatorExplanation->trustworthinessLabel())
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, : 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 $referencedTenantLifecycle !== null
? $factory->keyFact( ? $factory->keyFact(
'Tenant lifecycle', 'Tenant lifecycle',
@ -576,10 +356,10 @@ private static function supportingGroups(
$referencedTenantLifecycle?->contextNote !== null $referencedTenantLifecycle?->contextNote !== null
? $factory->keyFact('Viewer context', $referencedTenantLifecycle->contextNote) ? $factory->keyFact('Viewer context', $referencedTenantLifecycle->contextNote)
: null, : null,
! $hasElevatedLifecycleState && static::freshnessLabel($record) !== null static::freshnessLabel($record) !== null
? $factory->keyFact('Freshness', (string) static::freshnessLabel($record)) ? $factory->keyFact('Freshness', (string) static::freshnessLabel($record))
: null, : null,
! $hasElevatedLifecycleState && static::reconciliationHeadline($record) !== null static::reconciliationHeadline($record) !== null
? $factory->keyFact('Lifecycle truth', (string) static::reconciliationHeadline($record)) ? $factory->keyFact('Lifecycle truth', (string) static::reconciliationHeadline($record))
: null, : null,
static::reconciledAtLabel($record) !== null static::reconciledAtLabel($record) !== null
@ -588,221 +368,172 @@ private static function supportingGroups(
static::reconciliationSourceLabel($record) !== null static::reconciliationSourceLabel($record) !== null
? $factory->keyFact('Reconciled by', (string) static::reconciliationSourceLabel($record)) ? $factory->keyFact('Reconciled by', (string) static::reconciliationSourceLabel($record))
: null, : null,
])); $operatorExplanation !== null
? $factory->keyFact('Artifact next step', $operatorExplanation->nextActionText)
if ($lifecycleItems !== []) { : ($artifactTruth !== null
$groups[] = $factory->supportingFactsCard( ? $factory->keyFact('Artifact next step', $artifactTruth->nextStepText())
kind: 'lifecycle', : null),
title: 'Lifecycle', $operatorExplanation !== null && $operatorExplanation->coverageStatement !== null
items: $lifecycleItems, ? $factory->keyFact('Coverage', $operatorExplanation->coverageStatement)
description: 'Lifecycle context explains freshness, reconciliation, and tenant-scoped caveats.', : null,
); OperationUxPresenter::surfaceGuidance($record) !== null
} ? $factory->keyFact('Next step', OperationUxPresenter::surfaceGuidance($record))
: null,
$timingItems = [ $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('Created', static::formatDetailTimestamp($record->created_at)),
$factory->keyFact('Started', static::formatDetailTimestamp($record->started_at)), $factory->keyFact('Started', static::formatDetailTimestamp($record->started_at)),
$factory->keyFact('Completed', static::formatDetailTimestamp($record->completed_at)), $factory->keyFact('Completed', static::formatDetailTimestamp($record->completed_at)),
$factory->keyFact('Elapsed', RunDurationInsights::elapsedHuman($record)), $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), ),
)
->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)],
),
); );
return [ $counts = static::summaryCountFacts($record, $factory);
'text' => $primary['text'],
'source' => $primarySource,
'secondaryGuidance' => $secondaryGuidance,
];
}
/** if ($counts !== []) {
* @param array<int, array{text: string, source: string, normalized: string}> $candidates $builder->addSection(
*/ $factory->factsSection(
private static function pushNextStepCandidate(array &$candidates, ?string $text, string $source): void id: 'counts',
{ kind: 'current_status',
$formattedText = static::formatGuidanceText($text); title: 'Counts',
items: $counts,
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 if (! empty($record->failure_summary)) {
{ $builder->addSection(
return null; $factory->viewSection(
id: 'failures',
kind: 'operational_context',
title: (string) $record->outcome === OperationRunOutcome::Blocked->value ? 'Blocked execution details' : 'Failures',
view: 'filament.infolists.entries.snapshot-json',
viewData: ['payload' => $record->failure_summary ?? []],
),
);
} }
private static function detailHintUnlessDuplicate(?string $hint, ?string $duplicateOf): ?string if (static::reconciliationPayload($record) !== []) {
{ $builder->addSection(
$normalizedHint = static::normalizeDetailText($hint); $factory->viewSection(
id: 'reconciliation',
if ($normalizedHint === null) { kind: 'operational_context',
return null; title: 'Lifecycle reconciliation',
view: 'filament.infolists.entries.snapshot-json',
viewData: ['payload' => static::reconciliationPayload($record)],
description: 'Lifecycle reconciliation is diagnostic evidence showing when TenantPilot force-resolved the run.',
),
);
} }
if ($normalizedHint === static::normalizeDetailText($duplicateOf)) { if ((string) $record->type === 'baseline_compare') {
return null; $baselineCompareFacts = static::baselineCompareFacts($record, $factory);
$baselineCompareEvidence = static::baselineCompareEvidencePayload($record);
$gapDetails = BaselineCompareEvidenceGapDetails::fromOperationRun($record);
$gapSummary = is_array($gapDetails['summary'] ?? null) ? $gapDetails['summary'] : [];
$gapBuckets = is_array($gapDetails['buckets'] ?? null) ? $gapDetails['buckets'] : [];
if ($baselineCompareFacts !== []) {
$builder->addSection(
$factory->factsSection(
id: 'baseline_compare',
kind: 'operational_context',
title: 'Baseline compare',
items: $baselineCompareFacts,
),
);
} }
return trim($hint ?? ''); if (($gapSummary['detail_state'] ?? 'no_gaps') !== 'no_gaps') {
$builder->addSection(
$factory->viewSection(
id: 'baseline_compare_gap_details',
kind: 'operational_context',
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',
viewData: [
'summary' => $gapSummary,
'buckets' => $gapBuckets,
'searchId' => 'baseline-compare-gap-search-'.$record->getKey(),
],
collapsible: true,
collapsed: false,
),
);
} }
private static function normalizeDetailText(?string $value): ?string if ($baselineCompareEvidence !== []) {
{ $builder->addSection(
if (! is_string($value)) { $factory->viewSection(
return null; id: 'baseline_compare_evidence',
kind: 'operational_context',
title: 'Baseline compare evidence',
view: 'filament.infolists.entries.snapshot-json',
viewData: ['payload' => $baselineCompareEvidence],
),
);
}
} }
$normalized = trim((string) preg_replace('/\s+/', ' ', $value)); if ((string) $record->type === 'baseline_capture') {
$baselineCaptureEvidence = static::baselineCaptureEvidencePayload($record);
if ($normalized === '') { if ($baselineCaptureEvidence !== []) {
return null; $builder->addSection(
$factory->viewSection(
id: 'baseline_capture_evidence',
kind: 'operational_context',
title: 'Baseline capture evidence',
view: 'filament.infolists.entries.snapshot-json',
viewData: ['payload' => $baselineCaptureEvidence],
),
);
}
} }
return mb_strtolower($normalized); if (VerificationReportViewer::shouldRenderForRun($record)) {
$builder->addSection(
$factory->viewSection(
id: 'verification_report',
kind: 'operational_context',
title: 'Verification report',
view: 'filament.components.verification-report-viewer',
viewData: static::verificationReportViewData($record),
),
);
}
return $builder->build();
} }
/** /**
@ -815,29 +546,12 @@ private static function summaryCountFacts(
$counts = \App\Support\OpsUx\SummaryCountsNormalizer::normalize(is_array($record->summary_counts) ? $record->summary_counts : []); $counts = \App\Support\OpsUx\SummaryCountsNormalizer::normalize(is_array($record->summary_counts) ? $record->summary_counts : []);
return array_map( return array_map(
static fn (string $key, int $value): array => $factory->keyFact( static fn (string $key, int $value): array => $factory->keyFact(SummaryCountsNormalizer::label($key), $value),
SummaryCountsNormalizer::label($key),
$value,
tone: self::countTone($key, $value),
),
array_keys($counts), array_keys($counts),
array_values($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 private static function blockedExecutionReasonCode(OperationRun $record): ?string
{ {
if ((string) $record->outcome !== OperationRunOutcome::Blocked->value) { if ((string) $record->outcome !== OperationRunOutcome::Blocked->value) {

View File

@ -13,7 +13,6 @@
use Filament\Tables\Table; use Filament\Tables\Table;
use Filament\Tables\TableComponent; use Filament\Tables\TableComponent;
use Illuminate\Contracts\View\View; use Illuminate\Contracts\View\View;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Pagination\LengthAwarePaginator; use Illuminate\Pagination\LengthAwarePaginator;
use Illuminate\Support\Collection; use Illuminate\Support\Collection;
use Illuminate\Support\Str; use Illuminate\Support\Str;
@ -92,8 +91,7 @@ public function table(Table $table): Table
->label(__('baseline-compare.evidence_gap_reason')) ->label(__('baseline-compare.evidence_gap_reason'))
->searchable() ->searchable()
->sortable() ->sortable()
->wrap() ->wrap(),
->toggleable(isToggledHiddenByDefault: true),
TextColumn::make('policy_type') TextColumn::make('policy_type')
->label(__('baseline-compare.evidence_gap_policy_type')) ->label(__('baseline-compare.evidence_gap_policy_type'))
->badge() ->badge()
@ -124,7 +122,8 @@ public function table(Table $table): Table
->label(__('baseline-compare.evidence_gap_subject_key')) ->label(__('baseline-compare.evidence_gap_subject_key'))
->searchable() ->searchable()
->sortable() ->sortable()
->wrap(), ->wrap()
->extraAttributes(['class' => 'font-mono text-xs']),
]) ])
->actions([]) ->actions([])
->bulkActions([]) ->bulkActions([])
@ -205,12 +204,7 @@ private function paginateRows(Collection $rows, int $page, int $recordsPerPage):
$perPage = max(1, $recordsPerPage); $perPage = max(1, $recordsPerPage);
$currentPage = max(1, $page); $currentPage = max(1, $page);
$total = $rows->count(); $total = $rows->count();
$items = $rows->forPage($currentPage, $perPage) $items = $rows->forPage($currentPage, $perPage)->values();
->values()
->map(fn (array $row, int $index): Model => $this->toTableRecord(
row: $row,
index: (($currentPage - 1) * $perPage) + $index,
));
return new LengthAwarePaginator( return new LengthAwarePaginator(
$items, $items,
@ -219,36 +213,4 @@ private function paginateRows(Collection $rows, int $page, int $recordsPerPage):
$currentPage, $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

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

View File

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

View File

@ -8,11 +8,9 @@ final class EnterpriseDetailSectionFactory
{ {
/** /**
* @param array{label: string, color?: string, icon?: ?string, iconColor?: ?string}|null $badge * @param array{label: string, color?: string, icon?: ?string, iconColor?: ?string}|null $badge
* @param 'default'|'danger'|'success'|'warning'|null $tone Optional color tone for the card border/value * @return array{label: string, value: string, hint?: ?string, badge?: ?array{label: string, color?: string, icon?: ?string, iconColor?: ?string}}
* @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, ?string $tone = null, bool $mono = false): array public function keyFact(string $label, mixed $value, ?string $hint = null, ?array $badge = null): array
{ {
$displayValue = match (true) { $displayValue = match (true) {
is_bool($value) => $value ? 'Yes' : 'No', is_bool($value) => $value ? 'Yes' : 'No',
@ -26,8 +24,6 @@ public function keyFact(string $label, mixed $value, ?string $hint = null, ?arra
'value' => $displayValue, 'value' => $displayValue,
'hint' => $hint, 'hint' => $hint,
'badge' => $badge, 'badge' => $badge,
'tone' => $tone,
'mono' => $mono ?: null,
], static fn (mixed $item): bool => $item !== null); ], static fn (mixed $item): bool => $item !== null);
} }
@ -56,92 +52,6 @@ public function emptyState(string $title, ?string $description = null, ?string $
], static fn (mixed $item): bool => $item !== null); ], 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 * @param list<array<string, mixed>> $items
*/ */
@ -264,7 +174,6 @@ public function technicalDetail(
bool $visible = true, bool $visible = true,
bool $collapsible = true, bool $collapsible = true,
bool $collapsed = true, bool $collapsed = true,
string $variant = 'technical',
): TechnicalDetailData { ): TechnicalDetailData {
return new TechnicalDetailData( return new TechnicalDetailData(
title: $title, title: $title,
@ -276,7 +185,6 @@ public function technicalDetail(
view: $view, view: $view,
viewData: $viewData, viewData: $viewData,
emptyState: $emptyState, emptyState: $emptyState,
variant: $variant,
); );
} }
} }

View File

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

View File

@ -120,7 +120,7 @@ ### Missing (no code, no spec beyond brainstorming)
## Architecture & Principles (Non-Negotiables) ## Architecture & Principles (Non-Negotiables)
Source: [.specify/memory/constitution.md](.specify/memory/constitution.md) (v1.13.0) Source: [.specify/memory/constitution.md](.specify/memory/constitution.md) (v1.12.0)
### Core Principles ### Core Principles
@ -131,7 +131,6 @@ ### Core Principles
5. **Workspace Isolation** — Non-member → 404; workspace is primary session context. Enforced via `DenyNonMemberTenantAccess` middleware + `EnsureFilamentTenantSelected`. 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. 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. 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 ### RBAC-UX Rules
@ -159,7 +158,6 @@ ### Filament Standards
- **Action Surface Contract**: List/View/Create/Edit pages each have defined required surfaces (Spec 082, 090). - **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. - **Layout**: Main/Aside layout, sections required, view pages use Infolists.
- **Badge Semantics**: Centralized via `BadgeCatalog`/`BadgeRenderer` (Specs 059, 060). - **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. - **No naked forms**: Everything in sections/cards with proper enterprise IA.
### Provider Gateway ### Provider Gateway

View File

@ -3,7 +3,7 @@ # Product Principles
> Permanent product principles that govern every spec, every UI decision, and every architectural choice. > 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. > New specs must align with these. If a principle needs to change, update this file first.
**Last reviewed**: 2026-03-26 **Last reviewed**: 2026-03-21
--- ---
@ -93,11 +93,6 @@ ### Action Surface Contract (non-negotiable)
### Badge semantics centralized ### Badge semantics centralized
All status badges via `BadgeCatalog` / `BadgeRenderer`. No ad-hoc badge mappings. 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 ### Canonical navigation and terminology
Consistent naming, consistent routing, consistent mental model. Consistent naming, consistent routing, consistent mental model.
No competing terms for the same concept. No competing terms for the same concept.

View File

@ -47,25 +47,6 @@ ### Baseline Drift Engine (Cutover)
**Active specs**: 119 (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) ## Planned (Next Quarter)

View File

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

View File

@ -34,12 +34,7 @@
'evidence_gap_search_label' => 'Search gap details', 'evidence_gap_search_label' => 'Search gap details',
'evidence_gap_search_placeholder' => 'Search by reason, type, class, outcome, action, or subject key', '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_search_help' => 'Filter matches across reason, policy type, subject class, outcome, next action, and subject key.',
'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' => 'Reason summaries stay separate from the detailed row table below.',
'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' => 'Reason',
'evidence_gap_reason_affected' => ':count affected', 'evidence_gap_reason_affected' => ':count affected',
'evidence_gap_reason_recorded' => ':count recorded', 'evidence_gap_reason_recorded' => ':count recorded',

View File

@ -1,69 +0,0 @@
@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,45 +9,9 @@
$primaryActions = array_values(array_filter($header['primaryActions'] ?? [], 'is_array')); $primaryActions = array_values(array_filter($header['primaryActions'] ?? [], 'is_array'));
@endphp @endphp
<x-filament::section <div class="rounded-2xl border border-gray-200 bg-white/90 p-5 shadow-sm dark:border-gray-800 dark:bg-gray-900/80">
:heading="$header['title'] ?? 'Detail'" <div class="flex flex-col gap-5 xl:flex-row xl:items-start xl:justify-between">
:description="$header['subtitle'] ?? null" <div class="space-y-3">
>
@if ($primaryActions !== [])
<x-slot name="afterHeader">
<div class="flex flex-wrap items-center gap-2">
@foreach ($primaryActions as $action)
@if (filled($action['url'] ?? null))
@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 !== []) @if ($statusBadges !== [])
<div class="flex flex-wrap items-center gap-2"> <div class="flex flex-wrap items-center gap-2">
@foreach ($statusBadges as $badge) @foreach ($statusBadges as $badge)
@ -62,17 +26,81 @@
</div> </div>
@endif @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)) @if (filled($header['descriptionHint'] ?? null))
<div class="max-w-3xl text-sm text-gray-600 dark:text-gray-300"> <div class="max-w-3xl text-sm text-gray-600 dark:text-gray-300">
{{ $header['descriptionHint'] }} {{ $header['descriptionHint'] }}
</div> </div>
@endif @endif
</div>
</div>
@if ($keyFacts !== []) @if ($primaryActions !== [])
@include('filament.infolists.entries.enterprise-detail.section-items', [ <div class="flex flex-wrap items-center gap-2">
'items' => $keyFacts, @foreach ($primaryActions as $action)
'variant' => 'header', @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>
@endif
@endforeach
</div>
@endif @endif
</div> </div>
</x-filament::section>
@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>

View File

@ -2,9 +2,8 @@
$detail = isset($getState) ? $getState() : ($detail ?? null); $detail = isset($getState) ? $getState() : ($detail ?? null);
$detail = is_array($detail) ? $detail : []; $detail = is_array($detail) ? $detail : [];
$decisionZone = is_array($detail['decisionZone'] ?? null) ? $detail['decisionZone'] : [];
$mainSections = array_values(array_filter($detail['mainSections'] ?? [], 'is_array')); $mainSections = array_values(array_filter($detail['mainSections'] ?? [], 'is_array'));
$supportingGroups = array_values(array_filter($detail['supportingGroups'] ?? [], 'is_array')); $supportingCards = array_values(array_filter($detail['supportingCards'] ?? [], 'is_array'));
$technicalSections = array_values(array_filter($detail['technicalSections'] ?? [], 'is_array')); $technicalSections = array_values(array_filter($detail['technicalSections'] ?? [], 'is_array'));
$emptyStateNotes = array_values(array_filter($detail['emptyStateNotes'] ?? [], 'is_array')); $emptyStateNotes = array_values(array_filter($detail['emptyStateNotes'] ?? [], 'is_array'));
@endphp @endphp
@ -14,12 +13,6 @@
'header' => is_array($detail['header'] ?? null) ? $detail['header'] : [], 'header' => is_array($detail['header'] ?? null) ? $detail['header'] : [],
]) ])
@if ($decisionZone !== [])
@include('filament.infolists.entries.enterprise-detail.decision-zone', [
'decisionZone' => $decisionZone,
])
@endif
@if ($emptyStateNotes !== []) @if ($emptyStateNotes !== [])
<div class="space-y-3"> <div class="space-y-3">
@foreach ($emptyStateNotes as $state) @foreach ($emptyStateNotes as $state)
@ -28,15 +21,8 @@
</div> </div>
@endif @endif
@if ($supportingGroups !== []) <div class="grid gap-6 xl:grid-cols-3">
<div class="grid gap-4 xl:grid-cols-2"> <div class="{{ $supportingCards === [] ? 'xl:col-span-3' : 'xl:col-span-2' }} space-y-6">
@foreach ($supportingGroups as $card)
@include('filament.infolists.entries.enterprise-detail.supporting-card', ['card' => $card])
@endforeach
</div>
@endif
<div class="space-y-6">
@foreach ($mainSections as $section) @foreach ($mainSections as $section)
@php @php
$view = is_string($section['view'] ?? null) && trim($section['view']) !== '' ? trim($section['view']) : null; $view = is_string($section['view'] ?? null) && trim($section['view']) !== '' ? trim($section['view']) : null;
@ -64,6 +50,15 @@
@endforeach @endforeach
</div> </div>
@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>
@if ($technicalSections !== []) @if ($technicalSections !== [])
<div class="space-y-4"> <div class="space-y-4">
@foreach ($technicalSections as $section) @foreach ($technicalSections as $section)

View File

@ -5,44 +5,23 @@
$items = is_array($items) ? array_values(array_filter($items, 'is_array')) : []; $items = is_array($items) ? array_values(array_filter($items, 'is_array')) : [];
$action = $action ?? null; $action = $action ?? null;
$action = 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 @endphp
<div class="space-y-4"> <div class="space-y-4">
<div class="{{ $gridClasses }}"> <div class="grid gap-3 sm:grid-cols-2">
@foreach ($items as $item) @foreach ($items as $item)
@php @php
$displayValue = FactPresentation::value($item); $displayValue = FactPresentation::value($item);
$badge = is_array($item['badge'] ?? null) ? $item['badge'] : null; $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 @endphp
<div class="{{ $cardClasses }}"> <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="text-xs font-semibold uppercase tracking-[0.16em] text-gray-500 dark:text-gray-400"> <div class="text-xs font-semibold uppercase tracking-[0.16em] text-gray-500 dark:text-gray-400">
{{ $item['label'] ?? 'Detail' }} {{ $item['label'] ?? 'Detail' }}
</div> </div>
<div class="mt-2 flex min-w-0 flex-wrap items-center gap-2 text-sm font-medium {{ $toneValueClasses }}"> <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) @if ($displayValue !== null)
<span class="min-w-0 break-all whitespace-normal {{ $mono ? 'font-mono text-xs' : '' }}">{{ $displayValue }}</span> <span class="min-w-0 break-all whitespace-normal">{{ $displayValue }}</span>
@endif @endif
@if ($badge !== null) @if ($badge !== null)
@ -68,25 +47,16 @@
@if ($action !== null && filled($action['url'] ?? null)) @if ($action !== null && filled($action['url'] ?? null))
<div> <div>
@if (($action['openInNewTab'] ?? false) === true) <a
<x-filament::link href="{{ $action['url'] }}"
:href="$action['url']" @if (($action['openInNewTab'] ?? false) === true) target="_blank" rel="noreferrer noopener" @endif
:icon="$action['icon'] ?? null" 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' }}"
size="sm"
target="_blank"
rel="noreferrer noopener"
> >
{{ $action['label'] }} @if (filled($action['icon'] ?? null))
</x-filament::link> <x-filament::icon :icon="$action['icon']" class="h-4 w-4" />
@else
<x-filament::link
:href="$action['url']"
:icon="$action['icon'] ?? null"
size="sm"
>
{{ $action['label'] }}
</x-filament::link>
@endif @endif
{{ $action['label'] }}
</a>
</div> </div>
@endif @endif
</div> </div>

View File

@ -6,45 +6,21 @@
$items = is_array($card['items'] ?? null) ? $card['items'] : []; $items = is_array($card['items'] ?? null) ? $card['items'] : [];
$emptyState = is_array($card['emptyState'] ?? null) ? $card['emptyState'] : null; $emptyState = is_array($card['emptyState'] ?? null) ? $card['emptyState'] : null;
$action = is_array($card['action'] ?? null) ? $card['action'] : 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 @endphp
<div class="space-y-2" @if (filled($card['kind'] ?? null)) data-supporting-group-kind="{{ $card['kind'] }}" @endif> <x-filament::section
<div class="text-[11px] font-semibold uppercase tracking-[0.18em] text-gray-500 dark:text-gray-400">
{{ $eyebrow }}
</div>
<x-filament::section
:heading="$card['title'] ?? 'Supporting detail'" :heading="$card['title'] ?? 'Supporting detail'"
:description="$card['description'] ?? null" :description="$card['description'] ?? null"
> >
@if ($action !== null && filled($action['url'] ?? null)) @if ($action !== null && filled($action['url'] ?? null))
<x-slot name="afterHeader"> <x-slot name="headerEnd">
@if (($action['openInNewTab'] ?? false) === true) <a
<x-filament::link href="{{ $action['url'] }}"
:href="$action['url']" @if (($action['openInNewTab'] ?? false) === true) target="_blank" rel="noreferrer noopener" @endif
:icon="$action['icon'] ?? null" 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' }}"
size="sm"
target="_blank"
rel="noreferrer noopener"
> >
{{ $action['label'] }} {{ $action['label'] }}
</x-filament::link> </a>
@else
<x-filament::link
:href="$action['url']"
:icon="$action['icon'] ?? null"
size="sm"
>
{{ $action['label'] }}
</x-filament::link>
@endif
</x-slot> </x-slot>
@endif @endif
@ -52,13 +28,9 @@
@if ($view !== null) @if ($view !== null)
{!! view($view, is_array($card['viewData'] ?? null) ? $card['viewData'] : [])->render() !!} {!! view($view, is_array($card['viewData'] ?? null) ? $card['viewData'] : [])->render() !!}
@elseif ($items !== []) @elseif ($items !== [])
@include('filament.infolists.entries.enterprise-detail.section-items', [ @include('filament.infolists.entries.enterprise-detail.section-items', ['items' => $items])
'items' => $items,
'variant' => 'supporting',
])
@elseif ($emptyState !== null) @elseif ($emptyState !== null)
@include('filament.infolists.entries.enterprise-detail.empty-state', ['state' => $emptyState]) @include('filament.infolists.entries.enterprise-detail.empty-state', ['state' => $emptyState])
@endif @endif
</div> </div>
</x-filament::section> </x-filament::section>
</div>

View File

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

View File

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

View File

@ -32,7 +32,6 @@
$reason = is_array($state['reason'] ?? null) ? $state['reason'] : []; $reason = is_array($state['reason'] ?? null) ? $state['reason'] : [];
$nextSteps = is_array($reason['nextSteps'] ?? null) ? $reason['nextSteps'] : []; $nextSteps = is_array($reason['nextSteps'] ?? null) ? $reason['nextSteps'] : [];
$operatorExplanation = is_array($state['operatorExplanation'] ?? null) ? $state['operatorExplanation'] : []; $operatorExplanation = is_array($state['operatorExplanation'] ?? null) ? $state['operatorExplanation'] : [];
$surface = is_string($surface ?? null) && trim($surface) !== '' ? trim($surface) : 'summary';
$evaluationSpec = is_string($operatorExplanation['evaluationResult'] ?? null) $evaluationSpec = is_string($operatorExplanation['evaluationResult'] ?? null)
? BadgeCatalog::spec(BadgeDomain::OperatorExplanationEvaluationResult, $operatorExplanation['evaluationResult']) ? BadgeCatalog::spec(BadgeDomain::OperatorExplanationEvaluationResult, $operatorExplanation['evaluationResult'])
: null; : null;
@ -40,188 +39,9 @@
? BadgeCatalog::spec(BadgeDomain::OperatorExplanationTrustworthiness, $operatorExplanation['trustworthinessLevel']) ? BadgeCatalog::spec(BadgeDomain::OperatorExplanationTrustworthiness, $operatorExplanation['trustworthinessLevel'])
: null; : null;
$operatorCounts = collect(is_array($operatorExplanation['countDescriptors'] ?? null) ? $operatorExplanation['countDescriptors'] : []); $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 @endphp
@if ($surface === 'expanded') <div class="space-y-4">
<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')
<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')
<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)
<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)
<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
@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>
@endif
</div>
@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="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"> <div class="flex flex-wrap items-start gap-2">
@if ($evaluationSpec && $evaluationSpec->label !== 'Unknown') @if ($evaluationSpec && $evaluationSpec->label !== 'Unknown')
@ -254,11 +74,21 @@
{{ $operatorExplanation['headline'] ?? ($state['primaryLabel'] ?? 'Artifact truth') }} {{ $operatorExplanation['headline'] ?? ($state['primaryLabel'] ?? 'Artifact truth') }}
</div> </div>
@foreach ($summaryArtifactTruthParagraphs as $paragraph) @if (is_string($operatorExplanation['reliabilityStatement'] ?? null) && trim($operatorExplanation['reliabilityStatement']) !== '')
<p class="text-sm text-gray-600 dark:text-gray-300"> <p class="text-sm text-gray-600 dark:text-gray-300">
{{ $paragraph }} {{ $operatorExplanation['reliabilityStatement'] }}
</p> </p>
@endforeach @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']) !== '') @if (is_string($operatorExplanation['coverageStatement'] ?? null) && trim($operatorExplanation['coverageStatement']) !== '')
<p class="text-xs uppercase tracking-wide text-gray-500 dark:text-gray-400"> <p class="text-xs uppercase tracking-wide text-gray-500 dark:text-gray-400">
@ -363,5 +193,4 @@
</ul> </ul>
</div> </div>
@endif @endif
</div> </div>
@endif

View File

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

View File

@ -1,36 +0,0 @@
# 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

@ -1,303 +0,0 @@
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

@ -1,182 +0,0 @@
# 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

@ -1,257 +0,0 @@
# 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

@ -1,114 +0,0 @@
# 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

@ -1,57 +0,0 @@
# 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

@ -1,203 +0,0 @@
# 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

@ -1,224 +0,0 @@
# 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

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

View File

@ -10,20 +10,8 @@
use App\Support\Ui\GovernanceArtifactTruth\ArtifactTruthPresenter; use App\Support\Ui\GovernanceArtifactTruth\ArtifactTruthPresenter;
use App\Support\Workspaces\WorkspaceContext; use App\Support\Workspaces\WorkspaceContext;
use Filament\Facades\Filament; use Filament\Facades\Filament;
use Livewire\Features\SupportTesting\Testable;
use Livewire\Livewire; 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 { it('shows run outcome and baseline artifact truth as separate facts on the run detail page', function (): void {
[$user, $tenant] = createUserWithTenant(role: 'owner'); [$user, $tenant] = createUserWithTenant(role: 'owner');
@ -64,9 +52,8 @@ function visibleLivewireText(Testable $component): string
$this->withSession([WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id]); $this->withSession([WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id]);
session([WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id]); session([WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id]);
$component = Livewire::actingAs($user) Livewire::actingAs($user)
->test(TenantlessOperationRunViewer::class, ['run' => $run]) ->test(TenantlessOperationRunViewer::class, ['run' => $run])
->assertSee('Decision')
->assertSee('Outcome') ->assertSee('Outcome')
->assertSee('Artifact truth') ->assertSee('Artifact truth')
->assertSee('Execution failed') ->assertSee('Execution failed')
@ -74,16 +61,8 @@ function visibleLivewireText(Testable $component): string
->assertSee($explanation?->evaluationResultLabel() ?? '') ->assertSee($explanation?->evaluationResultLabel() ?? '')
->assertSee($explanation?->trustworthinessLabel() ?? '') ->assertSee($explanation?->trustworthinessLabel() ?? '')
->assertSee('Artifact not usable') ->assertSee('Artifact not usable')
->assertSee('Primary next step') ->assertSee('Artifact next step')
->assertSee('Artifact truth details') ->assertSee('Inspect the related capture diagnostics before using this snapshot');
->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 { it('shows operator explanation facts for baseline compare runs with nested compare reason context', function (): void {
@ -121,59 +100,16 @@ function visibleLivewireText(Testable $component): string
$this->withSession([WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id]); $this->withSession([WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id]);
session([WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id]); session([WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id]);
$component = Livewire::actingAs($user) Livewire::actingAs($user)
->test(TenantlessOperationRunViewer::class, ['run' => $run]) ->test(TenantlessOperationRunViewer::class, ['run' => $run])
->assertSee('Decision')
->assertSee('Artifact truth') ->assertSee('Artifact truth')
->assertSee('Result meaning') ->assertSee('Result meaning')
->assertSee('Result trust') ->assertSee('Result trust')
->assertSee('Primary next step') ->assertSee('Artifact next step')
->assertSee('Artifact truth details')
->assertSee($explanation?->headline ?? '') ->assertSee($explanation?->headline ?? '')
->assertSee($explanation?->evaluationResultLabel() ?? '') ->assertSee($explanation?->evaluationResultLabel() ?? '')
->assertSee($explanation?->trustworthinessLabel() ?? '') ->assertSee($explanation?->trustworthinessLabel() ?? '')
->assertSee($explanation?->nextActionText ?? '') ->assertSee($explanation?->nextActionText ?? '')
->assertSee('The run completed, but normal output was intentionally suppressed.') ->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'));
}); });

View File

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

View File

@ -255,17 +255,6 @@
->assertSee('Automatically reconciled') ->assertSee('Automatically reconciled')
->assertSee('Infrastructure ended the run') ->assertSee('Infrastructure ended the run')
->assertSee('Review worker health and logs before retrying this operation.'); ->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 { it('keeps a canonical run viewer accessible when the remembered tenant differs from the run tenant', function (): void {

View File

@ -26,11 +26,6 @@
new PageActionData(label: 'Hidden', url: '/admin/operations/hidden', visible: false), 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( ->addSection(
new DetailSectionData( new DetailSectionData(
id: 'counts', id: 'counts',
@ -77,12 +72,10 @@
->toArray(); ->toArray();
expect($page['header']['primaryActions'])->toHaveCount(1) 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'])->toHaveCount(1)
->and($page['mainSections'][0]['title'])->toBe('Counts') ->and($page['mainSections'][0]['title'])->toBe('Counts')
->and($page['supportingGroups'])->toHaveCount(1) ->and($page['supportingCards'])->toHaveCount(1)
->and($page['supportingGroups'][0]['title'])->toBe('Timing') ->and($page['supportingCards'][0]['title'])->toBe('Timing')
->and($page['technicalSections'])->toHaveCount(1) ->and($page['technicalSections'])->toHaveCount(1)
->and($page['technicalSections'][0]['title'])->toBe('Context'); ->and($page['technicalSections'][0]['title'])->toBe('Context');
}); });