Compare commits
3 Commits
156-operat
...
dev
| Author | SHA1 | Date | |
|---|---|---|---|
| e7c9b4b853 | |||
| 92f39d9749 | |||
| 3c3daae405 |
10
.github/agents/copilot-instructions.md
vendored
10
.github/agents/copilot-instructions.md
vendored
@ -96,6 +96,10 @@ ## Active Technologies
|
||||
- PostgreSQL with new tenant-owned exception tables and JSONB-backed supporting metadata (001-finding-risk-acceptance)
|
||||
- PHP 8.4, Laravel 12, Livewire 4, Filament 5 + Filament resources/pages/actions, Eloquent models, queued Laravel jobs, existing `EvidenceSnapshotService`, existing `ReviewPackService`, capability registry, `OperationRunService` (155-tenant-review-layer)
|
||||
- PostgreSQL with JSONB-backed summary payloads and tenant/workspace ownership columns (155-tenant-review-layer)
|
||||
- PostgreSQL-backed existing domain records; no new business-domain table is required for the first slice; shared taxonomy reference will live in repository documentation and code-level metadata (156-operator-outcome-taxonomy)
|
||||
- PostgreSQL-backed existing records such as `operation_runs`, tenant governance records, onboarding workflow state, and provider connection state; no new business-domain table is required for the first slice (157-reason-code-translation)
|
||||
- PHP 8.4.15 + Laravel 12, Filament v5, Livewire v4, Pest v4, existing `BadgeCatalog` / `BadgeRenderer` / `OperatorOutcomeTaxonomy`, `ReasonPresenter`, `OperationRunService`, `TenantReviewReadinessGate`, existing baseline/evidence/review/review-pack resources and canonical pages (158-artifact-truth-semantics)
|
||||
- PostgreSQL with existing JSONB-backed `summary`, `summary_jsonb`, and `context` payloads on baseline snapshots, evidence snapshots, tenant reviews, review packs, and operation runs; no new primary storage required for the first slice (158-artifact-truth-semantics)
|
||||
|
||||
- PHP 8.4.15 (feat/005-bulk-operations)
|
||||
|
||||
@ -115,8 +119,8 @@ ## Code Style
|
||||
PHP 8.4.15: Follow standard conventions
|
||||
|
||||
## Recent Changes
|
||||
- 155-tenant-review-layer: Added PHP 8.4, Laravel 12, Livewire 4, Filament 5 + Filament resources/pages/actions, Eloquent models, queued Laravel jobs, existing `EvidenceSnapshotService`, existing `ReviewPackService`, capability registry, `OperationRunService`
|
||||
- 001-finding-risk-acceptance: Added PHP 8.4.15 + Laravel 12, Filament v5, Livewire v4, Pest v4, existing Finding, AuditLog, EvidenceSnapshot, CapabilityResolver, WorkspaceCapabilityResolver, and UiEnforcement patterns
|
||||
- 153-evidence-domain-foundation: Added PHP 8.4.15 + Laravel 12, Filament v5, Livewire v4, Pest v4, existing `StoredReport`, `Finding`, `OperationRun`, and `AuditLog` infrastructure
|
||||
- 158-artifact-truth-semantics: Added PHP 8.4.15 + Laravel 12, Filament v5, Livewire v4, Pest v4, existing `BadgeCatalog` / `BadgeRenderer` / `OperatorOutcomeTaxonomy`, `ReasonPresenter`, `OperationRunService`, `TenantReviewReadinessGate`, existing baseline/evidence/review/review-pack resources and canonical pages
|
||||
- 157-reason-code-translation: Added PHP 8.4.15 + Laravel 12, Filament v5, Livewire v4, PostgreSQL, Laravel Sail, Pest v4
|
||||
- 156-operator-outcome-taxonomy: Added PHP 8.4.15 + Laravel 12, Filament v5, Livewire v4, PostgreSQL, Laravel Sail, Pest v4
|
||||
<!-- MANUAL ADDITIONS START -->
|
||||
<!-- MANUAL ADDITIONS END -->
|
||||
|
||||
@ -3,12 +3,17 @@
|
||||
|
||||
- Version change: 1.11.0 → 1.12.0
|
||||
- Modified principles:
|
||||
- Scope & Ownership Clarification (SCOPE-001)
|
||||
- Added sections:
|
||||
- None
|
||||
- Added sections:
|
||||
- Operator Surface Principles (OPSURF-001)
|
||||
- Removed sections: None
|
||||
- Templates requiring updates:
|
||||
- None
|
||||
- ✅ .specify/templates/spec-template.md
|
||||
- ✅ .specify/templates/plan-template.md
|
||||
- ✅ .specify/templates/tasks-template.md
|
||||
- ✅ docs/product/principles.md
|
||||
- ✅ docs/product/standards/README.md
|
||||
- ✅ docs/HANDOVER.md
|
||||
- Follow-up TODOs:
|
||||
- None.
|
||||
-->
|
||||
@ -330,6 +335,65 @@ ### Operator-facing UI Naming Standards (UI-NAMING-001)
|
||||
- The visible run label for that action MUST be `Policy sync`.
|
||||
- The audit prose for that action MUST be `{actor} queued policy sync`.
|
||||
|
||||
### Operator Surface Principles (OPSURF-001)
|
||||
|
||||
Goal: operator-facing surfaces MUST optimize for the primary working audience rather than raw implementation visibility.
|
||||
|
||||
Operator-first default surfaces
|
||||
- `/admin` is operator-first.
|
||||
- Default-visible content MUST use operator-facing language, clear scope, and actionable status communication.
|
||||
- Raw implementation details MUST NOT be default-visible on primary operator surfaces.
|
||||
|
||||
Progressive disclosure for diagnostics
|
||||
- Diagnostic detail MAY be available where needed, but it MUST be secondary and explicitly revealed.
|
||||
- JSON payloads, raw IDs, internal field names, provider error details, and low-level technical metadata belong in diagnostics surfaces, drawers, tabs, accordions, or modals rather than primary content.
|
||||
- A surface MUST NOT require operators to parse raw JSON or provider-specific field names to understand the primary state or next action.
|
||||
|
||||
Distinct status dimensions
|
||||
- Operator-facing surfaces MUST distinguish at least the following dimensions when they all exist in the domain:
|
||||
- execution outcome
|
||||
- data completeness
|
||||
- governance result
|
||||
- lifecycle or readiness state
|
||||
- These dimensions MUST NOT be collapsed into a single ambiguous status model.
|
||||
- If a surface summarizes multiple status dimensions, the default-visible presentation MUST label each dimension explicitly.
|
||||
|
||||
Explicit mutation scope
|
||||
- Every action that changes state MUST communicate before execution whether it affects:
|
||||
- TenantPilot only
|
||||
- the Microsoft tenant
|
||||
- simulation only
|
||||
- Mutation scope MUST be understandable from the action label, helper text, confirmation copy, preview, or nearby status copy before the operator commits.
|
||||
- A mutating action MUST NOT rely on hidden implementation knowledge to communicate its blast radius.
|
||||
|
||||
Safe execution for dangerous actions
|
||||
- Dangerous actions MUST follow a consistent safe-execution pattern:
|
||||
- configuration
|
||||
- safety checks or simulation
|
||||
- preview
|
||||
- hard confirmation where required
|
||||
- execute
|
||||
- One-click destructive actions are not acceptable for high-blast-radius operations.
|
||||
- When a full multi-step flow is not feasible, the spec MUST document the explicit exemption and the replacement safeguards.
|
||||
|
||||
Explicit workspace and tenant context
|
||||
- Workspace and tenant scope MUST remain explicit in navigation, actions, and page semantics.
|
||||
- Tenant-scoped surfaces MUST NOT silently expose workspace-wide actions.
|
||||
- Canonical workspace views that reference tenant-owned records MUST make the workspace and tenant context legible before the operator acts.
|
||||
|
||||
Page contract requirement
|
||||
- Every new or materially refactored operator-facing page MUST define:
|
||||
- primary persona
|
||||
- surface type
|
||||
- primary operator question
|
||||
- default-visible information
|
||||
- diagnostics-only information
|
||||
- status dimensions used
|
||||
- mutation scope
|
||||
- primary actions
|
||||
- dangerous actions
|
||||
- This page contract MUST be recorded in the governing spec and kept in sync when the page semantics materially change.
|
||||
|
||||
Spec Scope Fields (SCOPE-002)
|
||||
|
||||
- Every feature spec MUST declare:
|
||||
@ -387,4 +451,4 @@ ### Versioning Policy (SemVer)
|
||||
- **MINOR**: new principle/section or materially expanded guidance.
|
||||
- **MAJOR**: removing/redefining principles in a backward-incompatible way.
|
||||
|
||||
**Version**: 1.11.0 | **Ratified**: 2026-01-03 | **Last Amended**: 2026-03-10
|
||||
**Version**: 1.12.0 | **Ratified**: 2026-01-03 | **Last Amended**: 2026-03-21
|
||||
|
||||
@ -50,6 +50,12 @@ ## Constitution Check
|
||||
- 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
|
||||
- UI naming (UI-NAMING-001): operator-facing labels use `Verb + Object`; scope (`Workspace`, `Tenant`) is never the primary action label; source/domain is secondary unless disambiguation is required; runs/toasts/audit prose use the same domain vocabulary; implementation-first terms do not appear in primary operator UI
|
||||
- Operator surfaces (OPSURF-001): `/admin` defaults are operator-first; default-visible content avoids raw implementation detail; diagnostics are explicitly revealed secondarily
|
||||
- Operator surfaces (OPSURF-001): execution outcome, data completeness, governance result, and lifecycle/readiness are modeled as distinct status dimensions when all apply; they are not collapsed into one ambiguous status
|
||||
- Operator surfaces (OPSURF-001): every mutating action communicates whether it changes TenantPilot only, the Microsoft tenant, or simulation only before execution
|
||||
- Operator surfaces (OPSURF-001): dangerous actions follow configuration → safety checks/simulation → preview → hard confirmation where required → execute, unless a spec documents an explicit exemption and replacement safeguards
|
||||
- Operator surfaces (OPSURF-001): workspace and tenant context remain explicit in navigation, actions, and page semantics; tenant surfaces do not silently expose workspace-wide actions
|
||||
- Operator surfaces (OPSURF-001): each new or materially refactored operator-facing page defines a page contract covering persona, surface type, operator question, default-visible info, diagnostics-only info, status dimensions, mutation scope, primary actions, and dangerous actions
|
||||
- Filament UI Action Surface Contract: for any new/modified Filament Resource/RelationManager/Page, define Header/Row/Bulk/Empty-State actions, ensure every List/Table has a record inspection affordance (prefer `recordUrl()` clickable rows; do not render a lone View row action), keep max 2 visible row actions with the rest in “More”, group bulk actions, require confirmations for destructive actions (typed confirmation for large/bulk where applicable), write audit logs for mutations, enforce RBAC via central helpers (non-member 404, member missing capability 403), and ensure CI blocks merges if the contract is violated or not explicitly exempted
|
||||
- Filament UI UX-001 (Layout & IA): Create/Edit uses Main/Aside (3-col grid, Main=columnSpan(2), Aside=columnSpan(1)); all fields inside Sections/Cards (no naked inputs); View uses Infolists (not disabled edit forms); status badges use BADGE-001; empty states have specific title + explanation + 1 CTA; max 1 primary + 1 secondary header action; tables provide search/sort/filters for core dimensions; shared layout builders preferred for consistency
|
||||
## Project Structure
|
||||
|
||||
@ -17,6 +17,14 @@ ## Spec Scope Fields *(mandatory)*
|
||||
- **Default filter behavior when tenant-context is active**: [e.g., prefilter to current tenant]
|
||||
- **Explicit entitlement checks preventing cross-tenant leakage**: [Describe checks]
|
||||
|
||||
## 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 |
|
||||
|---|---|---|---|---|---|---|---|---|---|
|
||||
| e.g. Tenant policies page | Tenant operator | List/detail | What needs action right now? | Policy health, drift, assignment coverage | Raw payloads, provider IDs, low-level API details | lifecycle, data completeness, governance result | TenantPilot only / Microsoft tenant / simulation only | Sync policies, View policy | Restore policy |
|
||||
|
||||
## User Scenarios & Testing *(mandatory)*
|
||||
|
||||
<!--
|
||||
@ -127,6 +135,15 @@ ## Requirements *(mandatory)*
|
||||
- how the same domain vocabulary is preserved across button labels, modal titles, run titles, notifications, and audit prose,
|
||||
- and how implementation-first terms are kept out of primary operator-facing labels.
|
||||
|
||||
**Constitution alignment (OPSURF-001):** If this feature adds or materially refactors an operator-facing surface, the spec MUST describe:
|
||||
- how the default-visible content stays operator-first on `/admin` and avoids raw implementation detail,
|
||||
- which diagnostics are secondary and how they are explicitly revealed,
|
||||
- which status dimensions are shown separately (execution outcome, data completeness, governance result, lifecycle/readiness) and why,
|
||||
- how each mutating action communicates its mutation scope before execution (`TenantPilot only`, `Microsoft tenant`, or `simulation only`),
|
||||
- how dangerous actions follow the safe-execution pattern (configuration, safety checks/simulation, preview, hard confirmation where required, execute),
|
||||
- how workspace and tenant context remain explicit in navigation, action copy, and page semantics,
|
||||
- and the page contract for each new or materially refactored operator-facing 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.
|
||||
If the contract is not satisfied, the spec MUST include an explicit exemption with rationale.
|
||||
|
||||
@ -38,6 +38,13 @@ # Tasks: [FEATURE NAME]
|
||||
- using source/domain terms only where same-screen disambiguation is required,
|
||||
- aligning button labels, modal titles, run titles, notifications, and audit prose to the same domain vocabulary,
|
||||
- removing implementation-first wording from primary operator-facing copy.
|
||||
**Operator Surfaces**: If this feature adds or materially refactors an operator-facing page or flow, tasks MUST include:
|
||||
- filling the spec’s Operator Surface Contract for every affected page,
|
||||
- making default-visible content operator-first and moving JSON payloads, raw IDs, internal field names, provider error details, and low-level metadata into explicitly revealed diagnostics surfaces,
|
||||
- modeling execution outcome, data completeness, governance result, and lifecycle/readiness as distinct status dimensions when applicable,
|
||||
- making mutation scope legible before execution for every state-changing action (`TenantPilot only`, `Microsoft tenant`, or `simulation only`),
|
||||
- implementing the safe-execution flow for dangerous actions (configuration, safety checks/simulation, preview, hard confirmation where required, execute) or documenting an approved exemption,
|
||||
- keeping workspace and tenant context explicit in navigation, actions, and page semantics so tenant pages do not silently expose workspace-wide actions.
|
||||
**Filament UI Action Surfaces**: If this feature adds/modifies any Filament Resource / RelationManager / Page, tasks MUST include:
|
||||
- filling the spec’s “UI Action Matrix” for all changed surfaces,
|
||||
- implementing required action surfaces (header/row/bulk/empty-state CTA for lists; header actions for view; consistent save/cancel on create/edit),
|
||||
|
||||
@ -8,10 +8,13 @@
|
||||
use App\Models\EvidenceSnapshot;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
use App\Support\Badges\BadgeCatalog;
|
||||
use App\Support\Badges\BadgeDomain;
|
||||
use App\Support\Ui\ActionSurface\ActionSurfaceDeclaration;
|
||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceInspectAffordance;
|
||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceProfile;
|
||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceSlot;
|
||||
use App\Support\Ui\GovernanceArtifactTruth\ArtifactTruthPresenter;
|
||||
use App\Support\Workspaces\WorkspaceContext;
|
||||
use BackedEnum;
|
||||
use Filament\Actions\Action;
|
||||
@ -87,6 +90,9 @@ public function mount(): void
|
||||
$snapshots = $query->get()->unique('tenant_id')->values();
|
||||
|
||||
$this->rows = $snapshots->map(function (EvidenceSnapshot $snapshot): array {
|
||||
$truth = app(ArtifactTruthPresenter::class)->forEvidenceSnapshot($snapshot);
|
||||
$freshnessSpec = BadgeCatalog::spec(BadgeDomain::GovernanceArtifactFreshness, $truth->freshnessState);
|
||||
|
||||
return [
|
||||
'tenant_name' => $snapshot->tenant?->name ?? 'Unknown tenant',
|
||||
'tenant_id' => (int) $snapshot->tenant_id,
|
||||
@ -95,7 +101,21 @@ public function mount(): void
|
||||
'generated_at' => $snapshot->generated_at?->toDateTimeString(),
|
||||
'missing_dimensions' => (int) (($snapshot->summary['missing_dimensions'] ?? 0)),
|
||||
'stale_dimensions' => (int) (($snapshot->summary['stale_dimensions'] ?? 0)),
|
||||
'view_url' => EvidenceSnapshotResource::getUrl('index', tenant: $snapshot->tenant),
|
||||
'artifact_truth' => [
|
||||
'label' => $truth->primaryLabel,
|
||||
'color' => $truth->primaryBadgeSpec()->color,
|
||||
'icon' => $truth->primaryBadgeSpec()->icon,
|
||||
'explanation' => $truth->primaryExplanation,
|
||||
],
|
||||
'freshness' => [
|
||||
'label' => $freshnessSpec->label,
|
||||
'color' => $freshnessSpec->color,
|
||||
'icon' => $freshnessSpec->icon,
|
||||
],
|
||||
'next_step' => $truth->nextStepText(),
|
||||
'view_url' => $snapshot->tenant
|
||||
? EvidenceSnapshotResource::getUrl('view', ['record' => $snapshot], tenant: $snapshot->tenant)
|
||||
: null,
|
||||
];
|
||||
})->all();
|
||||
}
|
||||
|
||||
@ -172,6 +172,9 @@ private function applyActiveTab(Builder $query): Builder
|
||||
OperationRunStatus::Queued->value,
|
||||
OperationRunStatus::Running->value,
|
||||
]),
|
||||
'blocked' => $query
|
||||
->where('status', OperationRunStatus::Completed->value)
|
||||
->where('outcome', OperationRunOutcome::Blocked->value),
|
||||
'succeeded' => $query
|
||||
->where('status', OperationRunStatus::Completed->value)
|
||||
->where('outcome', OperationRunOutcome::Succeeded->value),
|
||||
|
||||
@ -19,6 +19,7 @@
|
||||
use App\Support\OpsUx\OperationUxPresenter;
|
||||
use App\Support\OpsUx\OpsUxBrowserEvents;
|
||||
use App\Support\OpsUx\RunDetailPolling;
|
||||
use App\Support\ReasonTranslation\ReasonPresenter;
|
||||
use App\Support\RedactionIntegrity;
|
||||
use App\Support\Tenants\ReferencedTenantLifecyclePresentation;
|
||||
use App\Support\Tenants\TenantInteractionLane;
|
||||
@ -169,21 +170,16 @@ public function blockedExecutionBanner(): ?array
|
||||
return null;
|
||||
}
|
||||
|
||||
$context = is_array($this->run->context) ? $this->run->context : [];
|
||||
$reasonCode = data_get($context, 'reason_code');
|
||||
|
||||
if (! is_string($reasonCode) || trim($reasonCode) === '') {
|
||||
$reasonCode = data_get($context, 'execution_legitimacy.reason_code');
|
||||
}
|
||||
|
||||
$reasonCode = is_string($reasonCode) && trim($reasonCode) !== '' ? trim($reasonCode) : 'unknown_error';
|
||||
$message = $this->run->failure_summary[0]['message'] ?? null;
|
||||
$message = is_string($message) && trim($message) !== '' ? trim($message) : 'The queued run was refused before side effects could begin.';
|
||||
$reasonEnvelope = app(ReasonPresenter::class)->forOperationRun($this->run, 'run_detail');
|
||||
$lines = $reasonEnvelope?->toBodyLines() ?? [
|
||||
OperationUxPresenter::surfaceFailureDetail($this->run) ?? 'The queued run was refused before side effects could begin.',
|
||||
OperationUxPresenter::surfaceGuidance($this->run) ?? 'Review the blocked prerequisite before retrying.',
|
||||
];
|
||||
|
||||
return [
|
||||
'tone' => 'amber',
|
||||
'title' => 'Execution blocked',
|
||||
'body' => sprintf('Reason code: %s. %s', $reasonCode, $message),
|
||||
'title' => 'Blocked by prerequisite',
|
||||
'body' => implode(' ', $lines),
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
@ -11,15 +11,18 @@
|
||||
use App\Models\Workspace;
|
||||
use App\Services\TenantReviews\TenantReviewRegisterService;
|
||||
use App\Support\Auth\Capabilities;
|
||||
use App\Support\Badges\BadgeCatalog;
|
||||
use App\Support\Badges\BadgeDomain;
|
||||
use App\Support\Badges\BadgeRenderer;
|
||||
use App\Support\Filament\CanonicalAdminTenantFilterState;
|
||||
use App\Support\Filament\FilterPresets;
|
||||
use App\Support\Filament\TablePaginationProfiles;
|
||||
use App\Support\TenantReviewCompletenessState;
|
||||
use App\Support\Ui\ActionSurface\ActionSurfaceDeclaration;
|
||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceInspectAffordance;
|
||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceProfile;
|
||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceSlot;
|
||||
use App\Support\Ui\GovernanceArtifactTruth\ArtifactTruthPresenter;
|
||||
use App\Support\Workspaces\WorkspaceContext;
|
||||
use BackedEnum;
|
||||
use Filament\Actions\Action;
|
||||
@ -112,6 +115,15 @@ public function table(Table $table): Table
|
||||
->color(BadgeRenderer::color(BadgeDomain::TenantReviewStatus))
|
||||
->icon(BadgeRenderer::icon(BadgeDomain::TenantReviewStatus))
|
||||
->iconColor(BadgeRenderer::iconColor(BadgeDomain::TenantReviewStatus)),
|
||||
TextColumn::make('artifact_truth')
|
||||
->label('Artifact truth')
|
||||
->badge()
|
||||
->getStateUsing(fn (TenantReview $record): string => app(ArtifactTruthPresenter::class)->forTenantReview($record)->primaryLabel)
|
||||
->color(fn (TenantReview $record): string => app(ArtifactTruthPresenter::class)->forTenantReview($record)->primaryBadgeSpec()->color)
|
||||
->icon(fn (TenantReview $record): ?string => app(ArtifactTruthPresenter::class)->forTenantReview($record)->primaryBadgeSpec()->icon)
|
||||
->iconColor(fn (TenantReview $record): ?string => app(ArtifactTruthPresenter::class)->forTenantReview($record)->primaryBadgeSpec()->iconColor)
|
||||
->description(fn (TenantReview $record): ?string => app(ArtifactTruthPresenter::class)->forTenantReview($record)->primaryExplanation)
|
||||
->wrap(),
|
||||
TextColumn::make('completeness_state')
|
||||
->label('Completeness')
|
||||
->badge()
|
||||
@ -121,15 +133,29 @@ public function table(Table $table): Table
|
||||
->iconColor(BadgeRenderer::iconColor(BadgeDomain::TenantReviewCompleteness)),
|
||||
TextColumn::make('generated_at')->dateTime()->placeholder('—')->sortable(),
|
||||
TextColumn::make('published_at')->dateTime()->placeholder('—')->sortable(),
|
||||
TextColumn::make('summary.publish_blockers')
|
||||
->label('Publish blockers')
|
||||
->formatStateUsing(static function (mixed $state): string {
|
||||
if (! is_array($state) || $state === []) {
|
||||
return '0';
|
||||
}
|
||||
|
||||
return (string) count($state);
|
||||
}),
|
||||
TextColumn::make('publication_truth')
|
||||
->label('Publication')
|
||||
->badge()
|
||||
->getStateUsing(fn (TenantReview $record): string => BadgeCatalog::spec(
|
||||
BadgeDomain::GovernanceArtifactPublicationReadiness,
|
||||
app(ArtifactTruthPresenter::class)->forTenantReview($record)->publicationReadiness ?? 'internal_only',
|
||||
)->label)
|
||||
->color(fn (TenantReview $record): string => BadgeCatalog::spec(
|
||||
BadgeDomain::GovernanceArtifactPublicationReadiness,
|
||||
app(ArtifactTruthPresenter::class)->forTenantReview($record)->publicationReadiness ?? 'internal_only',
|
||||
)->color)
|
||||
->icon(fn (TenantReview $record): ?string => BadgeCatalog::spec(
|
||||
BadgeDomain::GovernanceArtifactPublicationReadiness,
|
||||
app(ArtifactTruthPresenter::class)->forTenantReview($record)->publicationReadiness ?? 'internal_only',
|
||||
)->icon)
|
||||
->iconColor(fn (TenantReview $record): ?string => BadgeCatalog::spec(
|
||||
BadgeDomain::GovernanceArtifactPublicationReadiness,
|
||||
app(ArtifactTruthPresenter::class)->forTenantReview($record)->publicationReadiness ?? 'internal_only',
|
||||
)->iconColor),
|
||||
TextColumn::make('artifact_next_step')
|
||||
->label('Next step')
|
||||
->getStateUsing(fn (TenantReview $record): string => app(ArtifactTruthPresenter::class)->forTenantReview($record)->nextStepText())
|
||||
->wrap(),
|
||||
])
|
||||
->filters([
|
||||
SelectFilter::make('tenant_id')
|
||||
@ -148,12 +174,7 @@ public function table(Table $table): Table
|
||||
]),
|
||||
SelectFilter::make('completeness_state')
|
||||
->label('Completeness')
|
||||
->options([
|
||||
'complete' => 'Complete',
|
||||
'partial' => 'Partial',
|
||||
'missing' => 'Missing',
|
||||
'stale' => 'Stale',
|
||||
]),
|
||||
->options(BadgeCatalog::options(BadgeDomain::TenantReviewCompleteness, TenantReviewCompletenessState::values())),
|
||||
SelectFilter::make('published_state')
|
||||
->label('Published state')
|
||||
->options([
|
||||
|
||||
@ -2882,9 +2882,12 @@ public function startVerification(): void
|
||||
break;
|
||||
}
|
||||
|
||||
$reasonEnvelope = app(\App\Support\ReasonTranslation\ReasonPresenter::class)->forOperationRun($result->run, 'notification');
|
||||
$bodyLines = $reasonEnvelope?->toBodyLines() ?? ['Blocked by provider configuration.'];
|
||||
|
||||
Notification::make()
|
||||
->title('Verification blocked')
|
||||
->body("Blocked by provider configuration ({$reasonCode}).")
|
||||
->body(implode("\n", $bodyLines))
|
||||
->warning()
|
||||
->actions($actions)
|
||||
->send();
|
||||
|
||||
@ -9,7 +9,10 @@
|
||||
use App\Models\BaselineSnapshot;
|
||||
use App\Models\User;
|
||||
use App\Models\Workspace;
|
||||
use App\Services\Baselines\SnapshotRendering\FidelityState;
|
||||
use App\Support\Auth\Capabilities;
|
||||
use App\Support\Badges\BadgeCatalog;
|
||||
use App\Support\Badges\BadgeDomain;
|
||||
use App\Support\Filament\FilterPresets;
|
||||
use App\Support\Navigation\CrossResourceNavigationMatrix;
|
||||
use App\Support\Navigation\RelatedContextEntry;
|
||||
@ -18,6 +21,8 @@
|
||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceInspectAffordance;
|
||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceProfile;
|
||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceSlot;
|
||||
use App\Support\Ui\GovernanceArtifactTruth\ArtifactTruthEnvelope;
|
||||
use App\Support\Ui\GovernanceArtifactTruth\ArtifactTruthPresenter;
|
||||
use App\Support\Workspaces\WorkspaceContext;
|
||||
use BackedEnum;
|
||||
use Filament\Actions\Action;
|
||||
@ -165,15 +170,30 @@ public static function table(Table $table): Table
|
||||
->label('Captured')
|
||||
->since()
|
||||
->sortable(),
|
||||
TextColumn::make('artifact_truth')
|
||||
->label('Artifact truth')
|
||||
->badge()
|
||||
->getStateUsing(static fn (BaselineSnapshot $record): string => self::truthEnvelope($record)->primaryLabel)
|
||||
->color(static fn (BaselineSnapshot $record): string => self::truthEnvelope($record)->primaryBadgeSpec()->color)
|
||||
->icon(static fn (BaselineSnapshot $record): ?string => self::truthEnvelope($record)->primaryBadgeSpec()->icon)
|
||||
->iconColor(static fn (BaselineSnapshot $record): ?string => self::truthEnvelope($record)->primaryBadgeSpec()->iconColor)
|
||||
->description(static fn (BaselineSnapshot $record): ?string => self::truthEnvelope($record)->primaryExplanation)
|
||||
->wrap(),
|
||||
TextColumn::make('fidelity_summary')
|
||||
->label('Fidelity')
|
||||
->getStateUsing(static fn (BaselineSnapshot $record): string => self::fidelitySummary($record))
|
||||
->wrap(),
|
||||
TextColumn::make('artifact_next_step')
|
||||
->label('Next step')
|
||||
->getStateUsing(static fn (BaselineSnapshot $record): string => self::truthEnvelope($record)->nextStepText())
|
||||
->wrap(),
|
||||
TextColumn::make('snapshot_state')
|
||||
->label('State')
|
||||
->badge()
|
||||
->getStateUsing(static fn (BaselineSnapshot $record): string => self::stateLabel($record))
|
||||
->color(static fn (BaselineSnapshot $record): string => self::hasGaps($record) ? 'warning' : 'success'),
|
||||
->color(static fn (BaselineSnapshot $record): string => self::gapSpec($record)->color)
|
||||
->icon(static fn (BaselineSnapshot $record): ?string => self::gapSpec($record)->icon)
|
||||
->iconColor(static fn (BaselineSnapshot $record): ?string => self::gapSpec($record)->iconColor),
|
||||
])
|
||||
->recordUrl(static fn (BaselineSnapshot $record): ?string => static::canView($record)
|
||||
? static::getUrl('view', ['record' => $record])
|
||||
@ -249,10 +269,7 @@ private static function baselineProfileOptions(): array
|
||||
*/
|
||||
private static function snapshotStateOptions(): array
|
||||
{
|
||||
return [
|
||||
'complete' => 'Complete',
|
||||
'with_gaps' => 'Captured with gaps',
|
||||
];
|
||||
return BadgeCatalog::options(BadgeDomain::BaselineSnapshotGapStatus, ['clear', 'gaps_present']);
|
||||
}
|
||||
|
||||
public static function resolveWorkspace(): ?Workspace
|
||||
@ -290,7 +307,13 @@ private static function fidelitySummary(BaselineSnapshot $snapshot): string
|
||||
{
|
||||
$counts = self::fidelityCounts($snapshot);
|
||||
|
||||
return sprintf('Content %d, Meta %d', (int) ($counts['content'] ?? 0), (int) ($counts['meta'] ?? 0));
|
||||
return sprintf(
|
||||
'%s %d, %s %d',
|
||||
BadgeCatalog::spec(BadgeDomain::BaselineSnapshotFidelity, FidelityState::Full->value)->label,
|
||||
(int) ($counts['content'] ?? 0),
|
||||
BadgeCatalog::spec(BadgeDomain::BaselineSnapshotFidelity, FidelityState::ReferenceOnly->value)->label,
|
||||
(int) ($counts['meta'] ?? 0),
|
||||
);
|
||||
}
|
||||
|
||||
private static function gapsCount(BaselineSnapshot $snapshot): int
|
||||
@ -298,6 +321,17 @@ private static function gapsCount(BaselineSnapshot $snapshot): int
|
||||
$summary = self::summary($snapshot);
|
||||
$gaps = $summary['gaps'] ?? null;
|
||||
$gaps = is_array($gaps) ? $gaps : [];
|
||||
$byReason = is_array($gaps['by_reason'] ?? null) ? $gaps['by_reason'] : [];
|
||||
|
||||
if ($byReason !== []) {
|
||||
return array_sum(array_map(
|
||||
static fn (mixed $count, string $reason): int => in_array($reason, ['meta_fallback'], true) || ! is_numeric($count)
|
||||
? 0
|
||||
: (int) $count,
|
||||
$byReason,
|
||||
array_keys($byReason),
|
||||
));
|
||||
}
|
||||
|
||||
$count = $gaps['count'] ?? 0;
|
||||
|
||||
@ -311,7 +345,7 @@ private static function hasGaps(BaselineSnapshot $snapshot): bool
|
||||
|
||||
private static function stateLabel(BaselineSnapshot $snapshot): string
|
||||
{
|
||||
return self::hasGaps($snapshot) ? 'Captured with gaps' : 'Complete';
|
||||
return self::gapSpec($snapshot)->label;
|
||||
}
|
||||
|
||||
private static function applySnapshotStateFilter(Builder $query, mixed $value): Builder
|
||||
@ -323,8 +357,8 @@ private static function applySnapshotStateFilter(Builder $query, mixed $value):
|
||||
$gapCountExpression = self::gapCountExpression($query);
|
||||
|
||||
return match ($value) {
|
||||
'complete' => $query->whereRaw("{$gapCountExpression} = 0"),
|
||||
'with_gaps' => $query->whereRaw("{$gapCountExpression} > 0"),
|
||||
'clear' => $query->whereRaw("{$gapCountExpression} = 0"),
|
||||
'gaps_present' => $query->whereRaw("{$gapCountExpression} > 0"),
|
||||
default => $query,
|
||||
};
|
||||
}
|
||||
@ -332,9 +366,22 @@ private static function applySnapshotStateFilter(Builder $query, mixed $value):
|
||||
private static function gapCountExpression(Builder $query): string
|
||||
{
|
||||
return match ($query->getConnection()->getDriverName()) {
|
||||
'sqlite' => "COALESCE(CAST(json_extract(summary_jsonb, '$.gaps.count') AS INTEGER), 0)",
|
||||
'pgsql' => "COALESCE((summary_jsonb #>> '{gaps,count}')::int, 0)",
|
||||
default => "COALESCE(CAST(JSON_EXTRACT(summary_jsonb, '$.gaps.count') AS UNSIGNED), 0)",
|
||||
'sqlite' => "COALESCE(CAST(json_extract(summary_jsonb, '$.gaps.by_reason.missing_evidence') AS INTEGER), 0) + COALESCE(CAST(json_extract(summary_jsonb, '$.gaps.by_reason.missing_role_definition_version_reference') AS INTEGER), 0) + COALESCE(CAST(json_extract(summary_jsonb, '$.gaps.by_reason.invalid_subject') AS INTEGER), 0) + COALESCE(CAST(json_extract(summary_jsonb, '$.gaps.by_reason.duplicate_subject') AS INTEGER), 0) + COALESCE(CAST(json_extract(summary_jsonb, '$.gaps.by_reason.policy_not_found') AS INTEGER), 0) + COALESCE(CAST(json_extract(summary_jsonb, '$.gaps.by_reason.throttled') AS INTEGER), 0) + COALESCE(CAST(json_extract(summary_jsonb, '$.gaps.by_reason.capture_failed') AS INTEGER), 0) + COALESCE(CAST(json_extract(summary_jsonb, '$.gaps.by_reason.budget_exhausted') AS INTEGER), 0)",
|
||||
'pgsql' => "COALESCE((summary_jsonb #>> '{gaps,by_reason,missing_evidence}')::int, 0) + COALESCE((summary_jsonb #>> '{gaps,by_reason,missing_role_definition_version_reference}')::int, 0) + COALESCE((summary_jsonb #>> '{gaps,by_reason,invalid_subject}')::int, 0) + COALESCE((summary_jsonb #>> '{gaps,by_reason,duplicate_subject}')::int, 0) + COALESCE((summary_jsonb #>> '{gaps,by_reason,policy_not_found}')::int, 0) + COALESCE((summary_jsonb #>> '{gaps,by_reason,throttled}')::int, 0) + COALESCE((summary_jsonb #>> '{gaps,by_reason,capture_failed}')::int, 0) + COALESCE((summary_jsonb #>> '{gaps,by_reason,budget_exhausted}')::int, 0)",
|
||||
default => "COALESCE(CAST(JSON_EXTRACT(summary_jsonb, '$.gaps.by_reason.missing_evidence') AS UNSIGNED), 0) + COALESCE(CAST(JSON_EXTRACT(summary_jsonb, '$.gaps.by_reason.missing_role_definition_version_reference') AS UNSIGNED), 0) + COALESCE(CAST(JSON_EXTRACT(summary_jsonb, '$.gaps.by_reason.invalid_subject') AS UNSIGNED), 0) + COALESCE(CAST(JSON_EXTRACT(summary_jsonb, '$.gaps.by_reason.duplicate_subject') AS UNSIGNED), 0) + COALESCE(CAST(JSON_EXTRACT(summary_jsonb, '$.gaps.by_reason.policy_not_found') AS UNSIGNED), 0) + COALESCE(CAST(JSON_EXTRACT(summary_jsonb, '$.gaps.by_reason.throttled') AS UNSIGNED), 0) + COALESCE(CAST(JSON_EXTRACT(summary_jsonb, '$.gaps.by_reason.capture_failed') AS UNSIGNED), 0) + COALESCE(CAST(JSON_EXTRACT(summary_jsonb, '$.gaps.by_reason.budget_exhausted') AS UNSIGNED), 0)",
|
||||
};
|
||||
}
|
||||
|
||||
private static function gapSpec(BaselineSnapshot $snapshot): \App\Support\Badges\BadgeSpec
|
||||
{
|
||||
return BadgeCatalog::spec(
|
||||
BadgeDomain::BaselineSnapshotGapStatus,
|
||||
self::hasGaps($snapshot) ? 'gaps_present' : 'clear',
|
||||
);
|
||||
}
|
||||
|
||||
private static function truthEnvelope(BaselineSnapshot $snapshot): ArtifactTruthEnvelope
|
||||
{
|
||||
return app(ArtifactTruthPresenter::class)->forBaselineSnapshot($snapshot);
|
||||
}
|
||||
}
|
||||
|
||||
@ -13,8 +13,10 @@
|
||||
use App\Models\User;
|
||||
use App\Services\Evidence\EvidenceSnapshotService;
|
||||
use App\Support\Auth\Capabilities;
|
||||
use App\Support\Badges\BadgeCatalog;
|
||||
use App\Support\Badges\BadgeDomain;
|
||||
use App\Support\Badges\BadgeRenderer;
|
||||
use App\Support\Evidence\EvidenceCompletenessState;
|
||||
use App\Support\Evidence\EvidenceSnapshotStatus;
|
||||
use App\Support\OperationCatalog;
|
||||
use App\Support\OperationRunLinks;
|
||||
@ -26,6 +28,8 @@
|
||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceInspectAffordance;
|
||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceProfile;
|
||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceSlot;
|
||||
use App\Support\Ui\GovernanceArtifactTruth\ArtifactTruthEnvelope;
|
||||
use App\Support\Ui\GovernanceArtifactTruth\ArtifactTruthPresenter;
|
||||
use BackedEnum;
|
||||
use Filament\Actions;
|
||||
use Filament\Facades\Filament;
|
||||
@ -131,6 +135,15 @@ public static function form(Schema $schema): Schema
|
||||
public static function infolist(Schema $schema): Schema
|
||||
{
|
||||
return $schema->schema([
|
||||
Section::make('Artifact truth')
|
||||
->schema([
|
||||
ViewEntry::make('artifact_truth')
|
||||
->hiddenLabel()
|
||||
->view('filament.infolists.entries.governance-artifact-truth')
|
||||
->state(fn (EvidenceSnapshot $record): array => static::truthEnvelope($record)->toArray())
|
||||
->columnSpanFull(),
|
||||
])
|
||||
->columnSpanFull(),
|
||||
Section::make('Snapshot')
|
||||
->schema([
|
||||
TextEntry::make('status')
|
||||
@ -163,8 +176,8 @@ public static function infolist(Schema $schema): Schema
|
||||
TextEntry::make('summary.finding_count')->label('Findings')->placeholder('—'),
|
||||
TextEntry::make('summary.report_count')->label('Reports')->placeholder('—'),
|
||||
TextEntry::make('summary.operation_count')->label('Operations')->placeholder('—'),
|
||||
TextEntry::make('summary.missing_dimensions')->label('Missing dimensions')->placeholder('—'),
|
||||
TextEntry::make('summary.stale_dimensions')->label('Stale dimensions')->placeholder('—'),
|
||||
TextEntry::make('summary.missing_dimensions')->label(static::evidenceCompletenessCountLabel(EvidenceCompletenessState::Missing->value))->placeholder('—'),
|
||||
TextEntry::make('summary.stale_dimensions')->label(static::evidenceCompletenessCountLabel(EvidenceCompletenessState::Stale->value))->placeholder('—'),
|
||||
])
|
||||
->columns(2),
|
||||
Section::make('Evidence dimensions')
|
||||
@ -212,6 +225,15 @@ public static function table(Table $table): Table
|
||||
->icon(BadgeRenderer::icon(BadgeDomain::EvidenceSnapshotStatus))
|
||||
->iconColor(BadgeRenderer::iconColor(BadgeDomain::EvidenceSnapshotStatus))
|
||||
->sortable(),
|
||||
Tables\Columns\TextColumn::make('artifact_truth')
|
||||
->label('Artifact truth')
|
||||
->badge()
|
||||
->getStateUsing(fn (EvidenceSnapshot $record): string => static::truthEnvelope($record)->primaryLabel)
|
||||
->color(fn (EvidenceSnapshot $record): string => static::truthEnvelope($record)->primaryBadgeSpec()->color)
|
||||
->icon(fn (EvidenceSnapshot $record): ?string => static::truthEnvelope($record)->primaryBadgeSpec()->icon)
|
||||
->iconColor(fn (EvidenceSnapshot $record): ?string => static::truthEnvelope($record)->primaryBadgeSpec()->iconColor)
|
||||
->description(fn (EvidenceSnapshot $record): ?string => static::truthEnvelope($record)->primaryExplanation)
|
||||
->wrap(),
|
||||
Tables\Columns\TextColumn::make('completeness_state')
|
||||
->label('Completeness')
|
||||
->badge()
|
||||
@ -222,25 +244,17 @@ public static function table(Table $table): Table
|
||||
->sortable(),
|
||||
Tables\Columns\TextColumn::make('generated_at')->dateTime()->sortable()->placeholder('—'),
|
||||
Tables\Columns\TextColumn::make('summary.finding_count')->label('Findings'),
|
||||
Tables\Columns\TextColumn::make('summary.missing_dimensions')->label('Missing'),
|
||||
Tables\Columns\TextColumn::make('summary.missing_dimensions')->label(static::evidenceCompletenessCountLabel(EvidenceCompletenessState::Missing->value)),
|
||||
Tables\Columns\TextColumn::make('artifact_next_step')
|
||||
->label('Next step')
|
||||
->getStateUsing(fn (EvidenceSnapshot $record): string => static::truthEnvelope($record)->nextStepText())
|
||||
->wrap(),
|
||||
])
|
||||
->filters([
|
||||
Tables\Filters\SelectFilter::make('status')
|
||||
->options([
|
||||
'queued' => 'Queued',
|
||||
'generating' => 'Generating',
|
||||
'active' => 'Active',
|
||||
'superseded' => 'Superseded',
|
||||
'expired' => 'Expired',
|
||||
'failed' => 'Failed',
|
||||
]),
|
||||
->options(BadgeCatalog::options(BadgeDomain::EvidenceSnapshotStatus, EvidenceSnapshotStatus::values())),
|
||||
Tables\Filters\SelectFilter::make('completeness_state')
|
||||
->options([
|
||||
'complete' => 'Complete',
|
||||
'partial' => 'Partial',
|
||||
'missing' => 'Missing',
|
||||
'stale' => 'Stale',
|
||||
]),
|
||||
->options(BadgeCatalog::options(BadgeDomain::EvidenceCompleteness, EvidenceCompletenessState::values())),
|
||||
])
|
||||
->actions([
|
||||
Actions\Action::make('view_snapshot')
|
||||
@ -418,13 +432,16 @@ private static function operationsSummaryPresentation(array $payload): array
|
||||
$failedCount = (int) ($payload['failed_count'] ?? 0);
|
||||
$partialCount = (int) ($payload['partial_count'] ?? 0);
|
||||
$entries = is_array($payload['entries'] ?? null) ? $payload['entries'] : [];
|
||||
$actionSummary = $failedCount === 0 && $partialCount === 0
|
||||
? 'No action needed.'
|
||||
: sprintf('%d execution failures, %d need follow-up.', $failedCount, $partialCount);
|
||||
|
||||
return [
|
||||
'summary' => sprintf('%d operations in the last 30 days, %d failed, %d partial.', $operationCount, $failedCount, $partialCount),
|
||||
'summary' => sprintf('%d operations in the last 30 days. %s', $operationCount, $actionSummary),
|
||||
'highlights' => [
|
||||
['label' => 'Operations', 'value' => (string) $operationCount],
|
||||
['label' => 'Failed operations', 'value' => (string) $failedCount],
|
||||
['label' => 'Partial operations', 'value' => (string) $partialCount],
|
||||
['label' => 'Execution failures', 'value' => (string) $failedCount],
|
||||
['label' => 'Needs follow-up', 'value' => (string) $partialCount],
|
||||
],
|
||||
'items' => collect($entries)
|
||||
->map(fn (mixed $entry): ?string => is_array($entry) ? static::operationEntryLabel($entry) : null)
|
||||
@ -564,20 +581,42 @@ private static function operationEntryStateLabel(array $entry): ?string
|
||||
$outcome = is_string($entry['outcome'] ?? null) ? trim((string) $entry['outcome']) : null;
|
||||
|
||||
return match ($status) {
|
||||
OperationRunStatus::Queued->value => 'Queued',
|
||||
OperationRunStatus::Running->value => 'Running',
|
||||
OperationRunStatus::Queued->value => static::badgeLabel(BadgeDomain::OperationRunStatus, $status),
|
||||
OperationRunStatus::Running->value => static::badgeLabel(BadgeDomain::OperationRunStatus, $status),
|
||||
OperationRunStatus::Completed->value => match ($outcome) {
|
||||
OperationRunOutcome::Succeeded->value => 'Completed',
|
||||
OperationRunOutcome::PartiallySucceeded->value => OperationRunOutcome::uiLabels(true)[OperationRunOutcome::PartiallySucceeded->value],
|
||||
OperationRunOutcome::Blocked->value => OperationRunOutcome::uiLabels(true)[OperationRunOutcome::Blocked->value],
|
||||
OperationRunOutcome::Failed->value => OperationRunOutcome::uiLabels(true)[OperationRunOutcome::Failed->value],
|
||||
OperationRunOutcome::Cancelled->value => OperationRunOutcome::uiLabels(true)[OperationRunOutcome::Cancelled->value],
|
||||
default => 'Completed',
|
||||
OperationRunOutcome::Pending->value => static::badgeLabel(BadgeDomain::OperationRunStatus, $status),
|
||||
OperationRunOutcome::Succeeded->value,
|
||||
OperationRunOutcome::PartiallySucceeded->value,
|
||||
OperationRunOutcome::Blocked->value,
|
||||
OperationRunOutcome::Failed->value,
|
||||
OperationRunOutcome::Cancelled->value => static::badgeLabel(BadgeDomain::OperationRunOutcome, $outcome),
|
||||
default => static::badgeLabel(BadgeDomain::OperationRunStatus, $status),
|
||||
},
|
||||
default => $outcome !== null ? (OperationRunOutcome::uiLabels(true)[$outcome] ?? null) : null,
|
||||
default => $outcome !== null ? static::badgeLabel(BadgeDomain::OperationRunOutcome, $outcome) : null,
|
||||
};
|
||||
}
|
||||
|
||||
private static function evidenceCompletenessCountLabel(string $state): string
|
||||
{
|
||||
return BadgeCatalog::spec(BadgeDomain::EvidenceCompleteness, $state)->label;
|
||||
}
|
||||
|
||||
private static function badgeLabel(BadgeDomain $domain, ?string $state): ?string
|
||||
{
|
||||
if ($state === null || trim($state) === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
$label = BadgeCatalog::spec($domain, $state)->label;
|
||||
|
||||
return $label === 'Unknown' ? null : $label;
|
||||
}
|
||||
|
||||
private static function truthEnvelope(EvidenceSnapshot $record): ArtifactTruthEnvelope
|
||||
{
|
||||
return app(ArtifactTruthPresenter::class)->forEvidenceSnapshot($record);
|
||||
}
|
||||
|
||||
private static function stringifySummaryValue(mixed $value): string
|
||||
{
|
||||
return match (true) {
|
||||
|
||||
@ -8,6 +8,7 @@
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
use App\Models\VerificationCheckAcknowledgement;
|
||||
use App\Support\Badges\BadgeCatalog;
|
||||
use App\Support\Badges\BadgeDomain;
|
||||
use App\Support\Badges\BadgeRenderer;
|
||||
use App\Support\Baselines\BaselineCompareReasonCode;
|
||||
@ -21,13 +22,17 @@
|
||||
use App\Support\OperationRunLinks;
|
||||
use App\Support\OperationRunOutcome;
|
||||
use App\Support\OperationRunStatus;
|
||||
use App\Support\OpsUx\OperationUxPresenter;
|
||||
use App\Support\OpsUx\RunDurationInsights;
|
||||
use App\Support\OpsUx\SummaryCountsNormalizer;
|
||||
use App\Support\ReasonTranslation\ReasonPresenter;
|
||||
use App\Support\Tenants\ReferencedTenantLifecyclePresentation;
|
||||
use App\Support\Ui\ActionSurface\ActionSurfaceDeclaration;
|
||||
use App\Support\Ui\ActionSurface\ActionSurfaceDefaults;
|
||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceInspectAffordance;
|
||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceProfile;
|
||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceSlot;
|
||||
use App\Support\Ui\GovernanceArtifactTruth\ArtifactTruthPresenter;
|
||||
use App\Support\Workspaces\WorkspaceContext;
|
||||
use BackedEnum;
|
||||
use Filament\Actions;
|
||||
@ -152,7 +157,8 @@ public static function table(Table $table): Table
|
||||
->formatStateUsing(BadgeRenderer::label(BadgeDomain::OperationRunOutcome))
|
||||
->color(BadgeRenderer::color(BadgeDomain::OperationRunOutcome))
|
||||
->icon(BadgeRenderer::icon(BadgeDomain::OperationRunOutcome))
|
||||
->iconColor(BadgeRenderer::iconColor(BadgeDomain::OperationRunOutcome)),
|
||||
->iconColor(BadgeRenderer::iconColor(BadgeDomain::OperationRunOutcome))
|
||||
->description(fn (OperationRun $record): ?string => OperationUxPresenter::surfaceGuidance($record)),
|
||||
])
|
||||
->filters([
|
||||
Tables\Filters\SelectFilter::make('tenant_id')
|
||||
@ -205,13 +211,9 @@ public static function table(Table $table): Table
|
||||
return FilterOptionCatalog::operationTypes(array_keys($types));
|
||||
}),
|
||||
Tables\Filters\SelectFilter::make('status')
|
||||
->options([
|
||||
OperationRunStatus::Queued->value => 'Queued',
|
||||
OperationRunStatus::Running->value => 'Running',
|
||||
OperationRunStatus::Completed->value => 'Completed',
|
||||
]),
|
||||
->options(BadgeCatalog::options(BadgeDomain::OperationRunStatus, OperationRunStatus::values())),
|
||||
Tables\Filters\SelectFilter::make('outcome')
|
||||
->options(OperationRunOutcome::uiLabels(includeReserved: false)),
|
||||
->options(BadgeCatalog::options(BadgeDomain::OperationRunOutcome, OperationRunOutcome::values(includeReserved: false))),
|
||||
Tables\Filters\SelectFilter::make('initiator_name')
|
||||
->label('Initiator')
|
||||
->options(function (): array {
|
||||
@ -287,6 +289,15 @@ private static function enterpriseDetailPage(OperationRun $record): \App\Support
|
||||
$factory->keyFact('Expected duration', RunDurationInsights::expectedHuman($record)),
|
||||
],
|
||||
),
|
||||
$factory->viewSection(
|
||||
id: 'artifact_truth',
|
||||
kind: 'current_status',
|
||||
title: 'Artifact truth',
|
||||
view: 'filament.infolists.entries.governance-artifact-truth',
|
||||
viewData: ['artifactTruthState' => app(ArtifactTruthPresenter::class)->forOperationRun($record)->toArray()],
|
||||
visible: $record->isGovernanceArtifactOperation(),
|
||||
description: 'Run lifecycle stays separate from whether the intended governance artifact was actually produced and usable.',
|
||||
),
|
||||
$factory->viewSection(
|
||||
id: 'related_context',
|
||||
kind: 'related_context',
|
||||
@ -322,6 +333,9 @@ private static function enterpriseDetailPage(OperationRun $record): \App\Support
|
||||
$referencedTenantLifecycle?->contextNote !== null
|
||||
? $factory->keyFact('Viewer context', $referencedTenantLifecycle->contextNote)
|
||||
: null,
|
||||
OperationUxPresenter::surfaceGuidance($record) !== null
|
||||
? $factory->keyFact('Next step', OperationUxPresenter::surfaceGuidance($record))
|
||||
: null,
|
||||
$summaryLine !== null ? $factory->keyFact('Counts', $summaryLine) : null,
|
||||
static::blockedExecutionReasonCode($record) !== null
|
||||
? $factory->keyFact('Blocked reason', static::blockedExecutionReasonCode($record))
|
||||
@ -454,7 +468,7 @@ private static function summaryCountFacts(
|
||||
$counts = \App\Support\OpsUx\SummaryCountsNormalizer::normalize(is_array($record->summary_counts) ? $record->summary_counts : []);
|
||||
|
||||
return array_map(
|
||||
static fn (string $key, int $value): array => $factory->keyFact(ucfirst(str_replace('_', ' ', $key)), $value),
|
||||
static fn (string $key, int $value): array => $factory->keyFact(SummaryCountsNormalizer::label($key), $value),
|
||||
array_keys($counts),
|
||||
array_values($counts),
|
||||
);
|
||||
@ -466,8 +480,13 @@ private static function blockedExecutionReasonCode(OperationRun $record): ?strin
|
||||
return null;
|
||||
}
|
||||
|
||||
$context = is_array($record->context) ? $record->context : [];
|
||||
$reasonEnvelope = app(ReasonPresenter::class)->forOperationRun($record, 'run_detail');
|
||||
|
||||
if ($reasonEnvelope !== null) {
|
||||
return $reasonEnvelope->operatorLabel;
|
||||
}
|
||||
|
||||
$context = is_array($record->context) ? $record->context : [];
|
||||
$reasonCode = data_get($context, 'execution_legitimacy.reason_code')
|
||||
?? data_get($context, 'reason_code')
|
||||
?? data_get($record->failure_summary, '0.reason_code');
|
||||
@ -481,6 +500,12 @@ private static function blockedExecutionDetail(OperationRun $record): ?string
|
||||
return null;
|
||||
}
|
||||
|
||||
$reasonEnvelope = app(ReasonPresenter::class)->forOperationRun($record, 'run_detail');
|
||||
|
||||
if ($reasonEnvelope !== null) {
|
||||
return $reasonEnvelope->shortExplanation;
|
||||
}
|
||||
|
||||
$message = data_get($record->failure_summary, '0.message');
|
||||
|
||||
return is_string($message) && trim($message) !== '' ? trim($message) : 'Execution was refused before work began.';
|
||||
|
||||
@ -824,9 +824,12 @@ public static function table(Table $table): Table
|
||||
? (string) $result->run->context['reason_code']
|
||||
: 'unknown_error';
|
||||
|
||||
$reasonEnvelope = app(\App\Support\ReasonTranslation\ReasonPresenter::class)->forOperationRun($result->run, 'notification');
|
||||
$bodyLines = $reasonEnvelope?->toBodyLines() ?? ['Blocked by provider configuration.'];
|
||||
|
||||
Notification::make()
|
||||
->title('Connection check blocked')
|
||||
->body("Blocked by provider configuration ({$reasonCode}).")
|
||||
->body(implode("\n", $bodyLines))
|
||||
->warning()
|
||||
->actions([
|
||||
Actions\Action::make('view_run')
|
||||
@ -921,9 +924,12 @@ public static function table(Table $table): Table
|
||||
? (string) $result->run->context['reason_code']
|
||||
: 'unknown_error';
|
||||
|
||||
$reasonEnvelope = app(\App\Support\ReasonTranslation\ReasonPresenter::class)->forOperationRun($result->run, 'notification');
|
||||
$bodyLines = $reasonEnvelope?->toBodyLines() ?? ['Blocked by provider configuration.'];
|
||||
|
||||
Notification::make()
|
||||
->title('Inventory sync blocked')
|
||||
->body("Blocked by provider configuration ({$reasonCode}).")
|
||||
->body(implode("\n", $bodyLines))
|
||||
->warning()
|
||||
->actions([
|
||||
Actions\Action::make('view_run')
|
||||
@ -1015,9 +1021,12 @@ public static function table(Table $table): Table
|
||||
? (string) $result->run->context['reason_code']
|
||||
: 'unknown_error';
|
||||
|
||||
$reasonEnvelope = app(\App\Support\ReasonTranslation\ReasonPresenter::class)->forOperationRun($result->run, 'notification');
|
||||
$bodyLines = $reasonEnvelope?->toBodyLines() ?? ['Blocked by provider configuration.'];
|
||||
|
||||
Notification::make()
|
||||
->title('Compliance snapshot blocked')
|
||||
->body("Blocked by provider configuration ({$reasonCode}).")
|
||||
->body(implode("\n", $bodyLines))
|
||||
->warning()
|
||||
->actions([
|
||||
Actions\Action::make('view_run')
|
||||
|
||||
@ -278,9 +278,12 @@ protected function getHeaderActions(): array
|
||||
? (string) $result->run->context['reason_code']
|
||||
: 'unknown_error';
|
||||
|
||||
$reasonEnvelope = app(\App\Support\ReasonTranslation\ReasonPresenter::class)->forOperationRun($result->run, 'notification');
|
||||
$bodyLines = $reasonEnvelope?->toBodyLines() ?? ['Blocked by provider configuration.'];
|
||||
|
||||
Notification::make()
|
||||
->title('Connection check blocked')
|
||||
->body("Blocked by provider configuration ({$reasonCode}).")
|
||||
->body(implode("\n", $bodyLines))
|
||||
->warning()
|
||||
->actions([
|
||||
Action::make('view_run')
|
||||
@ -647,9 +650,12 @@ protected function getHeaderActions(): array
|
||||
? (string) $result->run->context['reason_code']
|
||||
: 'unknown_error';
|
||||
|
||||
$reasonEnvelope = app(\App\Support\ReasonTranslation\ReasonPresenter::class)->forOperationRun($result->run, 'notification');
|
||||
$bodyLines = $reasonEnvelope?->toBodyLines() ?? ['Blocked by provider configuration.'];
|
||||
|
||||
Notification::make()
|
||||
->title('Inventory sync blocked')
|
||||
->body("Blocked by provider configuration ({$reasonCode}).")
|
||||
->body(implode("\n", $bodyLines))
|
||||
->warning()
|
||||
->actions([
|
||||
Action::make('view_run')
|
||||
@ -758,9 +764,12 @@ protected function getHeaderActions(): array
|
||||
? (string) $result->run->context['reason_code']
|
||||
: 'unknown_error';
|
||||
|
||||
$reasonEnvelope = app(\App\Support\ReasonTranslation\ReasonPresenter::class)->forOperationRun($result->run, 'notification');
|
||||
$bodyLines = $reasonEnvelope?->toBodyLines() ?? ['Blocked by provider configuration.'];
|
||||
|
||||
Notification::make()
|
||||
->title('Compliance snapshot blocked')
|
||||
->body("Blocked by provider configuration ({$reasonCode}).")
|
||||
->body(implode("\n", $bodyLines))
|
||||
->warning()
|
||||
->actions([
|
||||
Action::make('view_run')
|
||||
|
||||
@ -824,10 +824,10 @@ public static function table(Table $table): Table
|
||||
->label('Total')
|
||||
->state(fn (RestoreRun $record): int => (int) (($record->metadata ?? [])['total'] ?? 0)),
|
||||
Tables\Columns\TextColumn::make('summary_succeeded')
|
||||
->label('Succeeded')
|
||||
->label('Applied')
|
||||
->state(fn (RestoreRun $record): int => (int) (($record->metadata ?? [])['succeeded'] ?? 0)),
|
||||
Tables\Columns\TextColumn::make('summary_failed')
|
||||
->label('Failed')
|
||||
->label('Failed items')
|
||||
->state(fn (RestoreRun $record): int => (int) (($record->metadata ?? [])['failed'] ?? 0)),
|
||||
Tables\Columns\TextColumn::make('started_at')->dateTime()->since()->sortable(),
|
||||
Tables\Columns\TextColumn::make('completed_at')->dateTime()->since()->sortable(),
|
||||
@ -1261,7 +1261,7 @@ public static function infolist(Schema $schema): Schema
|
||||
$succeeded = (int) ($meta['succeeded'] ?? 0);
|
||||
$failed = (int) ($meta['failed'] ?? 0);
|
||||
|
||||
return sprintf('Total: %d • Succeeded: %d • Failed: %d', $total, $succeeded, $failed);
|
||||
return sprintf('Total: %d • Applied: %d • Failed items: %d', $total, $succeeded, $failed);
|
||||
}),
|
||||
Infolists\Components\TextEntry::make('is_dry_run')
|
||||
->label('Dry-run')
|
||||
|
||||
@ -10,6 +10,7 @@
|
||||
use App\Models\User;
|
||||
use App\Services\ReviewPackService;
|
||||
use App\Support\Auth\Capabilities;
|
||||
use App\Support\Badges\BadgeCatalog;
|
||||
use App\Support\Badges\BadgeDomain;
|
||||
use App\Support\Badges\BadgeRenderer;
|
||||
use App\Support\OpsUx\OperationUxPresenter;
|
||||
@ -19,11 +20,14 @@
|
||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceInspectAffordance;
|
||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceProfile;
|
||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceSlot;
|
||||
use App\Support\Ui\GovernanceArtifactTruth\ArtifactTruthEnvelope;
|
||||
use App\Support\Ui\GovernanceArtifactTruth\ArtifactTruthPresenter;
|
||||
use BackedEnum;
|
||||
use Filament\Actions;
|
||||
use Filament\Facades\Filament;
|
||||
use Filament\Forms\Components\Toggle;
|
||||
use Filament\Infolists\Components\TextEntry;
|
||||
use Filament\Infolists\Components\ViewEntry;
|
||||
use Filament\Notifications\Notification;
|
||||
use Filament\Resources\Resource;
|
||||
use Filament\Schemas\Components\Section;
|
||||
@ -111,6 +115,15 @@ public static function infolist(Schema $schema): Schema
|
||||
{
|
||||
return $schema
|
||||
->schema([
|
||||
Section::make('Artifact truth')
|
||||
->schema([
|
||||
ViewEntry::make('artifact_truth')
|
||||
->hiddenLabel()
|
||||
->view('filament.infolists.entries.governance-artifact-truth')
|
||||
->state(fn (ReviewPack $record): array => static::truthEnvelope($record)->toArray())
|
||||
->columnSpanFull(),
|
||||
])
|
||||
->columnSpanFull(),
|
||||
Section::make('Status')
|
||||
->schema([
|
||||
TextEntry::make('status')
|
||||
@ -238,6 +251,15 @@ public static function table(Table $table): Table
|
||||
->icon(BadgeRenderer::icon(BadgeDomain::ReviewPackStatus))
|
||||
->iconColor(BadgeRenderer::iconColor(BadgeDomain::ReviewPackStatus))
|
||||
->sortable(),
|
||||
Tables\Columns\TextColumn::make('artifact_truth')
|
||||
->label('Artifact truth')
|
||||
->badge()
|
||||
->getStateUsing(fn (ReviewPack $record): string => static::truthEnvelope($record)->primaryLabel)
|
||||
->color(fn (ReviewPack $record): string => static::truthEnvelope($record)->primaryBadgeSpec()->color)
|
||||
->icon(fn (ReviewPack $record): ?string => static::truthEnvelope($record)->primaryBadgeSpec()->icon)
|
||||
->iconColor(fn (ReviewPack $record): ?string => static::truthEnvelope($record)->primaryBadgeSpec()->iconColor)
|
||||
->description(fn (ReviewPack $record): ?string => static::truthEnvelope($record)->primaryExplanation)
|
||||
->wrap(),
|
||||
Tables\Columns\TextColumn::make('tenant.name')
|
||||
->label('Tenant')
|
||||
->searchable(),
|
||||
@ -257,6 +279,29 @@ public static function table(Table $table): Table
|
||||
->label('Size')
|
||||
->formatStateUsing(fn ($state): string => $state ? Number::fileSize((int) $state) : '—')
|
||||
->sortable(),
|
||||
Tables\Columns\TextColumn::make('publication_truth')
|
||||
->label('Publication')
|
||||
->badge()
|
||||
->getStateUsing(fn (ReviewPack $record): string => BadgeCatalog::spec(
|
||||
BadgeDomain::GovernanceArtifactPublicationReadiness,
|
||||
static::truthEnvelope($record)->publicationReadiness ?? 'internal_only',
|
||||
)->label)
|
||||
->color(fn (ReviewPack $record): string => BadgeCatalog::spec(
|
||||
BadgeDomain::GovernanceArtifactPublicationReadiness,
|
||||
static::truthEnvelope($record)->publicationReadiness ?? 'internal_only',
|
||||
)->color)
|
||||
->icon(fn (ReviewPack $record): ?string => BadgeCatalog::spec(
|
||||
BadgeDomain::GovernanceArtifactPublicationReadiness,
|
||||
static::truthEnvelope($record)->publicationReadiness ?? 'internal_only',
|
||||
)->icon)
|
||||
->iconColor(fn (ReviewPack $record): ?string => BadgeCatalog::spec(
|
||||
BadgeDomain::GovernanceArtifactPublicationReadiness,
|
||||
static::truthEnvelope($record)->publicationReadiness ?? 'internal_only',
|
||||
)->iconColor),
|
||||
Tables\Columns\TextColumn::make('artifact_next_step')
|
||||
->label('Next step')
|
||||
->getStateUsing(fn (ReviewPack $record): string => static::truthEnvelope($record)->nextStepText())
|
||||
->wrap(),
|
||||
Tables\Columns\TextColumn::make('created_at')
|
||||
->label('Created')
|
||||
->since()
|
||||
@ -352,6 +397,11 @@ public static function getPages(): array
|
||||
];
|
||||
}
|
||||
|
||||
private static function truthEnvelope(ReviewPack $record): ArtifactTruthEnvelope
|
||||
{
|
||||
return app(ArtifactTruthPresenter::class)->forReviewPack($record);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $data
|
||||
*/
|
||||
|
||||
@ -608,9 +608,12 @@ public static function table(Table $table): Table
|
||||
break;
|
||||
}
|
||||
|
||||
$reasonEnvelope = app(\App\Support\ReasonTranslation\ReasonPresenter::class)->forOperationRun($result->run, 'notification');
|
||||
$bodyLines = $reasonEnvelope?->toBodyLines() ?? ['Blocked by provider configuration.'];
|
||||
|
||||
Notification::make()
|
||||
->title('Verification blocked')
|
||||
->body("Blocked by provider configuration ({$reasonCode}).")
|
||||
->body(implode("\n", $bodyLines))
|
||||
->warning()
|
||||
->actions($actions)
|
||||
->send();
|
||||
@ -908,8 +911,20 @@ public static function infolist(Schema $schema): Schema
|
||||
->visible(fn (Tenant $record): bool => filled($record->rbac_status)),
|
||||
Section::make('RBAC Details')
|
||||
->schema([
|
||||
Infolists\Components\TextEntry::make('rbac_status_reason_label')
|
||||
->label('Reason')
|
||||
->state(fn (Tenant $record): ?string => app(\App\Support\ReasonTranslation\ReasonPresenter::class)
|
||||
->primaryLabel(app(\App\Support\ReasonTranslation\ReasonPresenter::class)->forRbacReason($record->rbac_status_reason, 'detail')))
|
||||
->visible(fn (?string $state): bool => filled($state)),
|
||||
Infolists\Components\TextEntry::make('rbac_status_reason_explanation')
|
||||
->label('Explanation')
|
||||
->state(fn (Tenant $record): ?string => app(\App\Support\ReasonTranslation\ReasonPresenter::class)
|
||||
->shortExplanation(app(\App\Support\ReasonTranslation\ReasonPresenter::class)->forRbacReason($record->rbac_status_reason, 'detail')))
|
||||
->visible(fn (?string $state): bool => filled($state))
|
||||
->columnSpanFull(),
|
||||
Infolists\Components\TextEntry::make('rbac_status_reason')
|
||||
->label('Reason'),
|
||||
->label('Diagnostic code')
|
||||
->copyable(),
|
||||
Infolists\Components\TextEntry::make('rbac_role_definition_id')
|
||||
->label('Role definition ID')
|
||||
->copyable(),
|
||||
|
||||
@ -178,9 +178,12 @@ protected function getHeaderActions(): array
|
||||
break;
|
||||
}
|
||||
|
||||
$reasonEnvelope = app(\App\Support\ReasonTranslation\ReasonPresenter::class)->forOperationRun($result->run, 'notification');
|
||||
$bodyLines = $reasonEnvelope?->toBodyLines() ?? ['Blocked by provider configuration.'];
|
||||
|
||||
Notification::make()
|
||||
->title('Verification blocked')
|
||||
->body("Blocked by provider configuration ({$reasonCode}).")
|
||||
->body(implode("\n", $bodyLines))
|
||||
->warning()
|
||||
->actions($actions)
|
||||
->send();
|
||||
|
||||
@ -15,17 +15,21 @@
|
||||
use App\Services\ReviewPackService;
|
||||
use App\Services\TenantReviews\TenantReviewService;
|
||||
use App\Support\Auth\Capabilities;
|
||||
use App\Support\Badges\BadgeCatalog;
|
||||
use App\Support\Badges\BadgeDomain;
|
||||
use App\Support\Badges\BadgeRenderer;
|
||||
use App\Support\OperationRunLinks;
|
||||
use App\Support\OperationRunType;
|
||||
use App\Support\OpsUx\OperationUxPresenter;
|
||||
use App\Support\Rbac\UiEnforcement;
|
||||
use App\Support\TenantReviewCompletenessState;
|
||||
use App\Support\TenantReviewStatus;
|
||||
use App\Support\Ui\ActionSurface\ActionSurfaceDeclaration;
|
||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceInspectAffordance;
|
||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceProfile;
|
||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceSlot;
|
||||
use App\Support\Ui\GovernanceArtifactTruth\ArtifactTruthEnvelope;
|
||||
use App\Support\Ui\GovernanceArtifactTruth\ArtifactTruthPresenter;
|
||||
use BackedEnum;
|
||||
use Filament\Actions;
|
||||
use Filament\Facades\Filament;
|
||||
@ -141,6 +145,15 @@ public static function form(Schema $schema): Schema
|
||||
public static function infolist(Schema $schema): Schema
|
||||
{
|
||||
return $schema->schema([
|
||||
Section::make('Artifact truth')
|
||||
->schema([
|
||||
ViewEntry::make('artifact_truth')
|
||||
->hiddenLabel()
|
||||
->view('filament.infolists.entries.governance-artifact-truth')
|
||||
->state(fn (TenantReview $record): array => static::truthEnvelope($record)->toArray())
|
||||
->columnSpanFull(),
|
||||
])
|
||||
->columnSpanFull(),
|
||||
Section::make('Review')
|
||||
->schema([
|
||||
TextEntry::make('status')
|
||||
@ -237,6 +250,15 @@ public static function table(Table $table): Table
|
||||
->icon(BadgeRenderer::icon(BadgeDomain::TenantReviewStatus))
|
||||
->iconColor(BadgeRenderer::iconColor(BadgeDomain::TenantReviewStatus))
|
||||
->sortable(),
|
||||
Tables\Columns\TextColumn::make('artifact_truth')
|
||||
->label('Artifact truth')
|
||||
->badge()
|
||||
->getStateUsing(fn (TenantReview $record): string => static::truthEnvelope($record)->primaryLabel)
|
||||
->color(fn (TenantReview $record): string => static::truthEnvelope($record)->primaryBadgeSpec()->color)
|
||||
->icon(fn (TenantReview $record): ?string => static::truthEnvelope($record)->primaryBadgeSpec()->icon)
|
||||
->iconColor(fn (TenantReview $record): ?string => static::truthEnvelope($record)->primaryBadgeSpec()->iconColor)
|
||||
->description(fn (TenantReview $record): ?string => static::truthEnvelope($record)->primaryExplanation)
|
||||
->wrap(),
|
||||
Tables\Columns\TextColumn::make('completeness_state')
|
||||
->label('Completeness')
|
||||
->badge()
|
||||
@ -248,10 +270,33 @@ public static function table(Table $table): Table
|
||||
Tables\Columns\TextColumn::make('generated_at')->dateTime()->placeholder('—')->sortable(),
|
||||
Tables\Columns\TextColumn::make('published_at')->dateTime()->placeholder('—')->sortable(),
|
||||
Tables\Columns\TextColumn::make('summary.finding_count')->label('Findings'),
|
||||
Tables\Columns\TextColumn::make('summary.section_state_counts.missing')->label('Missing'),
|
||||
Tables\Columns\TextColumn::make('summary.section_state_counts.missing')->label(static::reviewCompletenessCountLabel(TenantReviewCompletenessState::Missing->value)),
|
||||
Tables\Columns\TextColumn::make('publication_truth')
|
||||
->label('Publication')
|
||||
->badge()
|
||||
->getStateUsing(fn (TenantReview $record): string => BadgeCatalog::spec(
|
||||
BadgeDomain::GovernanceArtifactPublicationReadiness,
|
||||
static::truthEnvelope($record)->publicationReadiness ?? 'internal_only',
|
||||
)->label)
|
||||
->color(fn (TenantReview $record): string => BadgeCatalog::spec(
|
||||
BadgeDomain::GovernanceArtifactPublicationReadiness,
|
||||
static::truthEnvelope($record)->publicationReadiness ?? 'internal_only',
|
||||
)->color)
|
||||
->icon(fn (TenantReview $record): ?string => BadgeCatalog::spec(
|
||||
BadgeDomain::GovernanceArtifactPublicationReadiness,
|
||||
static::truthEnvelope($record)->publicationReadiness ?? 'internal_only',
|
||||
)->icon)
|
||||
->iconColor(fn (TenantReview $record): ?string => BadgeCatalog::spec(
|
||||
BadgeDomain::GovernanceArtifactPublicationReadiness,
|
||||
static::truthEnvelope($record)->publicationReadiness ?? 'internal_only',
|
||||
)->iconColor),
|
||||
Tables\Columns\IconColumn::make('summary.has_ready_export')
|
||||
->label('Export')
|
||||
->boolean(),
|
||||
Tables\Columns\TextColumn::make('artifact_next_step')
|
||||
->label('Next step')
|
||||
->getStateUsing(fn (TenantReview $record): string => static::truthEnvelope($record)->nextStepText())
|
||||
->wrap(),
|
||||
Tables\Columns\TextColumn::make('fingerprint')
|
||||
->toggleable(isToggledHiddenByDefault: true)
|
||||
->searchable(),
|
||||
@ -262,12 +307,7 @@ public static function table(Table $table): Table
|
||||
->mapWithKeys(fn (TenantReviewStatus $status): array => [$status->value => Str::headline($status->value)])
|
||||
->all()),
|
||||
Tables\Filters\SelectFilter::make('completeness_state')
|
||||
->options([
|
||||
'complete' => 'Complete',
|
||||
'partial' => 'Partial',
|
||||
'missing' => 'Missing',
|
||||
'stale' => 'Stale',
|
||||
]),
|
||||
->options(BadgeCatalog::options(BadgeDomain::TenantReviewCompleteness, TenantReviewCompletenessState::values())),
|
||||
\App\Support\Filament\FilterPresets::dateRange('review_date', 'Review date', 'generated_at'),
|
||||
])
|
||||
->actions([
|
||||
@ -503,13 +543,18 @@ private static function evidenceSnapshotOptions(): array
|
||||
(string) $snapshot->getKey() => sprintf(
|
||||
'#%d · %s · %s',
|
||||
(int) $snapshot->getKey(),
|
||||
Str::headline((string) $snapshot->completeness_state),
|
||||
BadgeCatalog::spec(BadgeDomain::EvidenceCompleteness, $snapshot->completeness_state)->label,
|
||||
$snapshot->generated_at?->format('Y-m-d H:i') ?? 'Pending'
|
||||
),
|
||||
])
|
||||
->all();
|
||||
}
|
||||
|
||||
private static function reviewCompletenessCountLabel(string $state): string
|
||||
{
|
||||
return BadgeCatalog::spec(BadgeDomain::TenantReviewCompleteness, $state)->label;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
@ -559,4 +604,9 @@ private static function sectionPresentation(TenantReviewSection $section): array
|
||||
'links' => [],
|
||||
];
|
||||
}
|
||||
|
||||
private static function truthEnvelope(TenantReview $record): ArtifactTruthEnvelope
|
||||
{
|
||||
return app(ArtifactTruthPresenter::class)->forTenantReview($record);
|
||||
}
|
||||
}
|
||||
|
||||
@ -11,6 +11,7 @@
|
||||
use App\Support\OperationCatalog;
|
||||
use App\Support\OperationRunLinks;
|
||||
use App\Support\OpsUx\ActiveRuns;
|
||||
use App\Support\OpsUx\OperationUxPresenter;
|
||||
use Filament\Facades\Filament;
|
||||
use Filament\Tables\Columns\TextColumn;
|
||||
use Filament\Tables\Table;
|
||||
@ -57,7 +58,8 @@ public function table(Table $table): Table
|
||||
->formatStateUsing(BadgeRenderer::label(BadgeDomain::OperationRunOutcome))
|
||||
->color(BadgeRenderer::color(BadgeDomain::OperationRunOutcome))
|
||||
->icon(BadgeRenderer::icon(BadgeDomain::OperationRunOutcome))
|
||||
->iconColor(BadgeRenderer::iconColor(BadgeDomain::OperationRunOutcome)),
|
||||
->iconColor(BadgeRenderer::iconColor(BadgeDomain::OperationRunOutcome))
|
||||
->description(fn (OperationRun $record): ?string => OperationUxPresenter::surfaceGuidance($record)),
|
||||
TextColumn::make('created_at')
|
||||
->label('Started')
|
||||
->sortable()
|
||||
|
||||
@ -134,9 +134,12 @@ public function startVerification(StartVerification $verification): void
|
||||
break;
|
||||
}
|
||||
|
||||
$reasonEnvelope = app(\App\Support\ReasonTranslation\ReasonPresenter::class)->forOperationRun($result->run, 'notification');
|
||||
$bodyLines = $reasonEnvelope?->toBodyLines() ?? ['Blocked by provider configuration.'];
|
||||
|
||||
Notification::make()
|
||||
->title('Verification blocked')
|
||||
->body("Blocked by provider configuration ({$reasonCode}).")
|
||||
->body(implode("\n", $bodyLines))
|
||||
->warning()
|
||||
->actions($actions)
|
||||
->send();
|
||||
|
||||
@ -23,6 +23,7 @@ class WorkspaceRecentOperations extends Widget
|
||||
* status_color: string,
|
||||
* outcome_label: string,
|
||||
* outcome_color: string,
|
||||
* guidance: ?string,
|
||||
* started_at: string,
|
||||
* url: string
|
||||
* }>
|
||||
@ -48,6 +49,7 @@ class WorkspaceRecentOperations extends Widget
|
||||
* status_color: string,
|
||||
* outcome_label: string,
|
||||
* outcome_color: string,
|
||||
* guidance: ?string,
|
||||
* started_at: string,
|
||||
* url: string
|
||||
* }> $operations
|
||||
|
||||
@ -12,6 +12,7 @@
|
||||
use App\Models\User;
|
||||
use App\Services\Baselines\BaselineContentCapturePhase;
|
||||
use App\Services\Baselines\BaselineSnapshotIdentity;
|
||||
use App\Services\Baselines\BaselineSnapshotItemNormalizer;
|
||||
use App\Services\Baselines\CurrentStateHashResolver;
|
||||
use App\Services\Baselines\Evidence\ResolvedEvidence;
|
||||
use App\Services\Baselines\InventoryMetaContract;
|
||||
@ -59,10 +60,12 @@ public function handle(
|
||||
AuditLogger $auditLogger,
|
||||
OperationRunService $operationRunService,
|
||||
?CurrentStateHashResolver $hashResolver = null,
|
||||
?BaselineSnapshotItemNormalizer $snapshotItemNormalizer = null,
|
||||
?BaselineContentCapturePhase $contentCapturePhase = null,
|
||||
?BaselineFullContentRolloutGate $rolloutGate = null,
|
||||
): void {
|
||||
$hashResolver ??= app(CurrentStateHashResolver::class);
|
||||
$snapshotItemNormalizer ??= app(BaselineSnapshotItemNormalizer::class);
|
||||
$contentCapturePhase ??= app(BaselineContentCapturePhase::class);
|
||||
$rolloutGate ??= app(BaselineFullContentRolloutGate::class);
|
||||
|
||||
@ -183,7 +186,12 @@ public function handle(
|
||||
gaps: $captureGaps,
|
||||
);
|
||||
|
||||
$items = $snapshotItems['items'] ?? [];
|
||||
$normalizedItems = $snapshotItemNormalizer->deduplicate($snapshotItems['items'] ?? []);
|
||||
$items = $normalizedItems['items'];
|
||||
|
||||
if (($normalizedItems['duplicates'] ?? 0) > 0) {
|
||||
$captureGaps['duplicate_subject_reference'] = ($captureGaps['duplicate_subject_reference'] ?? 0) + (int) $normalizedItems['duplicates'];
|
||||
}
|
||||
|
||||
$identityHash = $identity->computeIdentity($items);
|
||||
|
||||
|
||||
@ -2,6 +2,7 @@
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use App\Support\OperationCatalog;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
@ -127,4 +128,35 @@ public function setFinishedAtAttribute(mixed $value): void
|
||||
{
|
||||
$this->completed_at = $value;
|
||||
}
|
||||
|
||||
public function isGovernanceArtifactOperation(): bool
|
||||
{
|
||||
return OperationCatalog::isGovernanceArtifactOperation((string) $this->type);
|
||||
}
|
||||
|
||||
public function governanceArtifactFamily(): ?string
|
||||
{
|
||||
return OperationCatalog::governanceArtifactFamily((string) $this->type);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function artifactResultContext(): array
|
||||
{
|
||||
$context = is_array($this->context) ? $this->context : [];
|
||||
$result = is_array($context['result'] ?? null) ? $context['result'] : [];
|
||||
|
||||
return array_merge($context, ['result' => $result]);
|
||||
}
|
||||
|
||||
public function relatedArtifactId(): ?int
|
||||
{
|
||||
return match ($this->governanceArtifactFamily()) {
|
||||
'baseline_snapshot' => is_numeric(data_get($this->context, 'result.snapshot_id'))
|
||||
? (int) data_get($this->context, 'result.snapshot_id')
|
||||
: null,
|
||||
default => null,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@ -7,6 +7,7 @@
|
||||
use App\Models\Tenant;
|
||||
use App\Support\OperationRunLinks;
|
||||
use App\Support\OpsUx\OperationUxPresenter;
|
||||
use App\Support\ReasonTranslation\ReasonPresenter;
|
||||
use App\Support\System\SystemOperationRunLinks;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Notifications\Notification;
|
||||
@ -44,6 +45,14 @@ public function toDatabase(object $notifiable): array
|
||||
->url($runUrl),
|
||||
]);
|
||||
|
||||
return $notification->getDatabaseMessage();
|
||||
$message = $notification->getDatabaseMessage();
|
||||
$reasonEnvelope = app(ReasonPresenter::class)->forOperationRun($this->run, 'notification');
|
||||
|
||||
if ($reasonEnvelope !== null) {
|
||||
$message['reason_translation'] = $reasonEnvelope->toArray();
|
||||
$message['diagnostic_reason_code'] = $reasonEnvelope->diagnosticCode();
|
||||
}
|
||||
|
||||
return $message;
|
||||
}
|
||||
}
|
||||
|
||||
@ -49,8 +49,8 @@ public function toDatabase(object $notifiable): array
|
||||
|
||||
return FilamentNotification::make()
|
||||
->title("{$operationLabel} queued")
|
||||
->body('Queued. Monitor progress in Monitoring → Operations.')
|
||||
->warning()
|
||||
->body('Queued for execution. Open the run for progress and next steps.')
|
||||
->info()
|
||||
->actions([
|
||||
\Filament\Actions\Action::make('view_run')
|
||||
->label('View run')
|
||||
|
||||
90
app/Services/Baselines/BaselineSnapshotItemNormalizer.php
Normal file
90
app/Services/Baselines/BaselineSnapshotItemNormalizer.php
Normal file
@ -0,0 +1,90 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\Baselines;
|
||||
|
||||
final class BaselineSnapshotItemNormalizer
|
||||
{
|
||||
/**
|
||||
* @param list<array{subject_type: string, subject_external_id: string, subject_key: string, policy_type: string, baseline_hash: string, meta_jsonb: array<string, mixed>}> $items
|
||||
* @return array{items: list<array{subject_type: string, subject_external_id: string, subject_key: string, policy_type: string, baseline_hash: string, meta_jsonb: array<string, mixed>}>, duplicates: int}
|
||||
*/
|
||||
public function deduplicate(array $items): array
|
||||
{
|
||||
$uniqueItems = [];
|
||||
$duplicates = 0;
|
||||
|
||||
foreach ($items as $item) {
|
||||
$key = trim((string) ($item['subject_type'] ?? '')).'|'.trim((string) ($item['subject_external_id'] ?? ''));
|
||||
|
||||
if ($key === '|') {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (! array_key_exists($key, $uniqueItems)) {
|
||||
$uniqueItems[$key] = $item;
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$duplicates++;
|
||||
|
||||
if ($this->shouldReplace($uniqueItems[$key], $item)) {
|
||||
$uniqueItems[$key] = $item;
|
||||
}
|
||||
}
|
||||
|
||||
return [
|
||||
'items' => array_values($uniqueItems),
|
||||
'duplicates' => $duplicates,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array{meta_jsonb?: array<string, mixed>, baseline_hash?: string} $current
|
||||
* @param array{meta_jsonb?: array<string, mixed>, baseline_hash?: string} $candidate
|
||||
*/
|
||||
private function shouldReplace(array $current, array $candidate): bool
|
||||
{
|
||||
$currentFidelity = $this->fidelityRank($current);
|
||||
$candidateFidelity = $this->fidelityRank($candidate);
|
||||
|
||||
if ($candidateFidelity !== $currentFidelity) {
|
||||
return $candidateFidelity > $currentFidelity;
|
||||
}
|
||||
|
||||
$currentObservedAt = $this->observedAt($current);
|
||||
$candidateObservedAt = $this->observedAt($candidate);
|
||||
|
||||
if ($candidateObservedAt !== $currentObservedAt) {
|
||||
return $candidateObservedAt > $currentObservedAt;
|
||||
}
|
||||
|
||||
return strcmp((string) ($candidate['baseline_hash'] ?? ''), (string) ($current['baseline_hash'] ?? '')) > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array{meta_jsonb?: array<string, mixed>} $item
|
||||
*/
|
||||
private function fidelityRank(array $item): int
|
||||
{
|
||||
$fidelity = data_get($item, 'meta_jsonb.evidence.fidelity');
|
||||
|
||||
return match ($fidelity) {
|
||||
'content' => 2,
|
||||
'meta' => 1,
|
||||
default => 0,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array{meta_jsonb?: array<string, mixed>} $item
|
||||
*/
|
||||
private function observedAt(array $item): string
|
||||
{
|
||||
$observedAt = data_get($item, 'meta_jsonb.evidence.observed_at');
|
||||
|
||||
return is_string($observedAt) ? $observedAt : '';
|
||||
}
|
||||
}
|
||||
@ -6,6 +6,7 @@
|
||||
|
||||
use App\Models\BaselineSnapshot;
|
||||
use App\Models\BaselineSnapshotItem;
|
||||
use App\Support\Badges\BadgeCatalog;
|
||||
use App\Support\Badges\BadgeDomain;
|
||||
use App\Support\Badges\BadgeRenderer;
|
||||
use App\Support\Inventory\InventoryPolicyTypeMeta;
|
||||
@ -13,6 +14,7 @@
|
||||
use App\Support\Ui\EnterpriseDetail\EnterpriseDetailPageData;
|
||||
use App\Support\Ui\EnterpriseDetail\EnterpriseDetailSectionFactory;
|
||||
use App\Support\Ui\EnterpriseDetail\SummaryHeaderData;
|
||||
use App\Support\Ui\GovernanceArtifactTruth\ArtifactTruthPresenter;
|
||||
use Illuminate\Database\Eloquent\Collection as EloquentCollection;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Illuminate\Support\Collection;
|
||||
@ -57,7 +59,8 @@ public function present(BaselineSnapshot $snapshot): RenderedSnapshot
|
||||
$groups,
|
||||
);
|
||||
|
||||
$overallGapCount = $this->summaryGapCount($summary);
|
||||
$overallGapSummary = $this->summaryGapSummary($summary);
|
||||
$overallGapCount = $overallGapSummary->count;
|
||||
$overallFidelity = FidelityState::fromSummary($summary, $items->isNotEmpty());
|
||||
|
||||
return new RenderedSnapshot(
|
||||
@ -67,7 +70,7 @@ public function present(BaselineSnapshot $snapshot): RenderedSnapshot
|
||||
snapshotIdentityHash: is_string($snapshot->snapshot_identity_hash) && trim($snapshot->snapshot_identity_hash) !== ''
|
||||
? trim($snapshot->snapshot_identity_hash)
|
||||
: null,
|
||||
stateLabel: $overallGapCount > 0 ? 'Captured with gaps' : 'Complete',
|
||||
stateLabel: $this->gapStatusSpec($overallGapCount)->label,
|
||||
fidelitySummary: $this->fidelitySummary($summary),
|
||||
overallFidelity: $overallFidelity,
|
||||
overallGapCount: $overallGapCount,
|
||||
@ -97,9 +100,12 @@ public function presentEnterpriseDetail(BaselineSnapshot $snapshot, array $relat
|
||||
$rendered = $this->present($snapshot);
|
||||
$factory = new EnterpriseDetailSectionFactory;
|
||||
|
||||
$stateSpec = $this->gapStatusSpec($rendered->overallGapCount);
|
||||
$stateBadge = $factory->statusBadge(
|
||||
$rendered->stateLabel,
|
||||
$rendered->overallGapCount > 0 ? 'warning' : 'success',
|
||||
$stateSpec->label,
|
||||
$stateSpec->color,
|
||||
$stateSpec->icon,
|
||||
$stateSpec->iconColor,
|
||||
);
|
||||
|
||||
$fidelitySpec = BadgeRenderer::spec(BadgeDomain::BaselineSnapshotFidelity, $rendered->overallFidelity->value);
|
||||
@ -114,6 +120,7 @@ public function presentEnterpriseDetail(BaselineSnapshot $snapshot, array $relat
|
||||
static fn (array $row): int => (int) ($row['itemCount'] ?? 0),
|
||||
$rendered->summaryRows,
|
||||
));
|
||||
$truth = app(ArtifactTruthPresenter::class)->forBaselineSnapshot($snapshot);
|
||||
|
||||
return EnterpriseDetailBuilder::make('baseline_snapshot', 'workspace')
|
||||
->header(new SummaryHeaderData(
|
||||
@ -129,6 +136,14 @@ public function presentEnterpriseDetail(BaselineSnapshot $snapshot, array $relat
|
||||
descriptionHint: 'Capture context, coverage, and governance links stay ahead of technical payload detail.',
|
||||
))
|
||||
->addSection(
|
||||
$factory->viewSection(
|
||||
id: 'artifact_truth',
|
||||
kind: 'current_status',
|
||||
title: 'Artifact truth',
|
||||
view: 'filament.infolists.entries.governance-artifact-truth',
|
||||
viewData: ['state' => $truth->toArray()],
|
||||
description: 'Trustworthy artifact state stays separate from historical trace and support diagnostics.',
|
||||
),
|
||||
$factory->viewSection(
|
||||
id: 'coverage_summary',
|
||||
kind: 'current_status',
|
||||
@ -223,10 +238,6 @@ private function presentGroup(string $policyType, Collection $items): RenderedSn
|
||||
$renderedItems,
|
||||
));
|
||||
|
||||
if ($renderingError !== null) {
|
||||
$gapSummary = $gapSummary->withMessage($renderingError);
|
||||
}
|
||||
|
||||
$capturedAt = collect($renderedItems)
|
||||
->pluck('observedAt')
|
||||
->filter(static fn (mixed $value): bool => is_string($value) && trim($value) !== '')
|
||||
@ -276,10 +287,7 @@ private function technicalPayload(Collection $items): array
|
||||
*/
|
||||
private function summaryGapCount(array $summary): int
|
||||
{
|
||||
$gaps = is_array($summary['gaps'] ?? null) ? $summary['gaps'] : [];
|
||||
$count = $gaps['count'] ?? 0;
|
||||
|
||||
return is_numeric($count) ? (int) $count : 0;
|
||||
return $this->summaryGapSummary($summary)->count;
|
||||
}
|
||||
|
||||
/**
|
||||
@ -294,7 +302,40 @@ private function fidelitySummary(array $summary): string
|
||||
$content = is_numeric($counts['content'] ?? null) ? (int) $counts['content'] : 0;
|
||||
$meta = is_numeric($counts['meta'] ?? null) ? (int) $counts['meta'] : 0;
|
||||
|
||||
return sprintf('Content %d, Meta %d', $content, $meta);
|
||||
return sprintf(
|
||||
'%s %d, %s %d',
|
||||
BadgeCatalog::spec(BadgeDomain::BaselineSnapshotFidelity, FidelityState::Full->value)->label,
|
||||
$content,
|
||||
BadgeCatalog::spec(BadgeDomain::BaselineSnapshotFidelity, FidelityState::ReferenceOnly->value)->label,
|
||||
$meta,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $summary
|
||||
*/
|
||||
private function summaryGapSummary(array $summary): GapSummary
|
||||
{
|
||||
$gaps = is_array($summary['gaps'] ?? null) ? $summary['gaps'] : [];
|
||||
$byReason = is_array($gaps['by_reason'] ?? null) ? $gaps['by_reason'] : [];
|
||||
$gapSummary = GapSummary::fromReasonMap($byReason);
|
||||
|
||||
if ($byReason !== [] || ! is_numeric($gaps['count'] ?? null) || (int) $gaps['count'] <= 0) {
|
||||
return $gapSummary;
|
||||
}
|
||||
|
||||
return new GapSummary(
|
||||
count: (int) $gaps['count'],
|
||||
messages: ['Coverage gaps need review.'],
|
||||
);
|
||||
}
|
||||
|
||||
private function gapStatusSpec(int $gapCount): \App\Support\Badges\BadgeSpec
|
||||
{
|
||||
return BadgeRenderer::spec(
|
||||
BadgeDomain::BaselineSnapshotGapStatus,
|
||||
$gapCount > 0 ? 'gaps_present' : 'clear',
|
||||
);
|
||||
}
|
||||
|
||||
private function typeLabel(string $policyType): string
|
||||
|
||||
@ -95,9 +95,9 @@ public function coverageHint(): ?string
|
||||
{
|
||||
return match ($this) {
|
||||
self::Full => null,
|
||||
self::Partial => 'Mixed evidence fidelity across this group.',
|
||||
self::ReferenceOnly => 'Metadata-only evidence is available.',
|
||||
self::Unsupported => 'Fallback metadata rendering is being used.',
|
||||
self::Partial => 'Mixed evidence detail is available for this group.',
|
||||
self::ReferenceOnly => 'Metadata-only evidence is available for this group.',
|
||||
self::Unsupported => 'Support is limited for this policy type. Fallback rendering is being used.',
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@ -49,7 +49,7 @@ public static function fromItemMeta(array $meta, FidelityState $fidelity, bool $
|
||||
$messages = self::uniqueMessages($messages);
|
||||
|
||||
return new self(
|
||||
count: count($messages),
|
||||
count: 0,
|
||||
messages: $messages,
|
||||
);
|
||||
}
|
||||
@ -60,17 +60,25 @@ public static function fromItemMeta(array $meta, FidelityState $fidelity, bool $
|
||||
public static function fromReasonMap(array $reasons): self
|
||||
{
|
||||
$messages = [];
|
||||
$primaryCount = 0;
|
||||
|
||||
foreach ($reasons as $reason => $count) {
|
||||
if (! is_string($reason) || ! is_numeric($count) || (int) $count <= 0) {
|
||||
foreach ($reasons as $reason => $reasonCount) {
|
||||
if (! is_string($reason) || ! is_numeric($reasonCount) || (int) $reasonCount <= 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$messages[] = sprintf('%s (%d)', self::humanizeReason($reason), (int) $count);
|
||||
if (self::isDiagnosticReason($reason)) {
|
||||
$messages[] = sprintf('%s (%d)', self::diagnosticMessageForReason($reason), (int) $reasonCount);
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$messages[] = sprintf('%s (%d)', self::humanizeReason($reason), (int) $reasonCount);
|
||||
$primaryCount += (int) $reasonCount;
|
||||
}
|
||||
|
||||
return new self(
|
||||
count: array_sum(array_map(static fn (mixed $value): int => is_numeric($value) ? (int) $value : 0, $reasons)),
|
||||
count: $primaryCount,
|
||||
messages: self::uniqueMessages($messages),
|
||||
);
|
||||
}
|
||||
@ -90,10 +98,6 @@ public static function merge(array $summaries): self
|
||||
|
||||
$messages = self::uniqueMessages($messages);
|
||||
|
||||
if ($count === 0) {
|
||||
$count = count($messages);
|
||||
}
|
||||
|
||||
return new self(
|
||||
count: $count,
|
||||
messages: $messages,
|
||||
@ -111,14 +115,14 @@ public function withMessage(string $message): self
|
||||
$messages = self::uniqueMessages([...$this->messages, $message]);
|
||||
|
||||
return new self(
|
||||
count: max($this->count, count($messages)),
|
||||
count: $this->count,
|
||||
messages: $messages,
|
||||
);
|
||||
}
|
||||
|
||||
public function hasGaps(): bool
|
||||
{
|
||||
return $this->count > 0 || $this->messages !== [];
|
||||
return $this->count > 0;
|
||||
}
|
||||
|
||||
public function badgeState(): string
|
||||
@ -158,4 +162,17 @@ private static function humanizeReason(string $reason): string
|
||||
->headline()
|
||||
->toString();
|
||||
}
|
||||
|
||||
private static function isDiagnosticReason(string $reason): bool
|
||||
{
|
||||
return in_array($reason, ['meta_fallback'], true);
|
||||
}
|
||||
|
||||
private static function diagnosticMessageForReason(string $reason): string
|
||||
{
|
||||
return match ($reason) {
|
||||
'meta_fallback' => 'Metadata-only evidence was used for some items.',
|
||||
default => self::humanizeReason($reason),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@ -23,7 +23,12 @@ public function __construct(
|
||||
) {}
|
||||
|
||||
/**
|
||||
* @return array{status:string,reason:?string,used_artifacts:bool}
|
||||
* @return array{
|
||||
* status: string,
|
||||
* reason: ?string,
|
||||
* used_artifacts: bool,
|
||||
* reason_translation: array<string, mixed>|null
|
||||
* }
|
||||
*/
|
||||
public function check(Tenant $tenant): array
|
||||
{
|
||||
@ -105,10 +110,19 @@ public function check(Tenant $tenant): array
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{status:string,reason:?string,used_artifacts:bool}
|
||||
* @return array{
|
||||
* status: string,
|
||||
* reason: ?string,
|
||||
* used_artifacts: bool,
|
||||
* reason_translation: array<string, mixed>|null
|
||||
* }
|
||||
*/
|
||||
private function record(Tenant $tenant, string $status, ?string $reason, bool $usedArtifacts): array
|
||||
{
|
||||
$reasonTranslation = is_string($reason) && $reason !== ''
|
||||
? RbacReason::tryFrom($reason)?->toReasonResolutionEnvelope('detail')->toArray()
|
||||
: null;
|
||||
|
||||
$tenant->update([
|
||||
'rbac_status' => $status,
|
||||
'rbac_status_reason' => $reason,
|
||||
@ -119,6 +133,7 @@ private function record(Tenant $tenant, string $status, ?string $reason, bool $u
|
||||
'status' => $status,
|
||||
'reason' => $reason,
|
||||
'used_artifacts' => $usedArtifacts,
|
||||
'reason_translation' => $reasonTranslation,
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
@ -22,6 +22,12 @@
|
||||
use App\Support\OpsUx\BulkRunContext;
|
||||
use App\Support\OpsUx\RunFailureSanitizer;
|
||||
use App\Support\OpsUx\SummaryCountsNormalizer;
|
||||
use App\Support\Providers\ProviderReasonCodes;
|
||||
use App\Support\RbacReason;
|
||||
use App\Support\ReasonTranslation\NextStepOption;
|
||||
use App\Support\ReasonTranslation\ReasonResolutionEnvelope;
|
||||
use App\Support\ReasonTranslation\ReasonTranslator;
|
||||
use App\Support\Tenants\TenantOperabilityReasonCode;
|
||||
use Illuminate\Database\QueryException;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use InvalidArgumentException;
|
||||
@ -34,6 +40,7 @@ class OperationRunService
|
||||
public function __construct(
|
||||
private readonly AuditRecorder $auditRecorder,
|
||||
private readonly OperationRunCapabilityResolver $operationRunCapabilityResolver,
|
||||
private readonly ReasonTranslator $reasonTranslator,
|
||||
) {}
|
||||
|
||||
public function isStaleQueuedRun(OperationRun $run, int $thresholdMinutes = 5): bool
|
||||
@ -487,6 +494,16 @@ public function updateRun(
|
||||
$updateData['failure_summary'] = $this->sanitizeFailures($failures);
|
||||
}
|
||||
|
||||
$updatedContext = $this->withReasonTranslationContext(
|
||||
run: $run,
|
||||
context: is_array($run->context) ? $run->context : [],
|
||||
failures: is_array($updateData['failure_summary'] ?? null) ? $updateData['failure_summary'] : [],
|
||||
);
|
||||
|
||||
if ($updatedContext !== null) {
|
||||
$updateData['context'] = $updatedContext;
|
||||
}
|
||||
|
||||
if ($status === OperationRunStatus::Running->value && is_null($run->started_at)) {
|
||||
$updateData['started_at'] = now();
|
||||
}
|
||||
@ -721,6 +738,13 @@ public function finalizeBlockedRun(
|
||||
$context = is_array($run->context) ? $run->context : [];
|
||||
$context['reason_code'] = $reasonCode;
|
||||
$context['next_steps'] = $nextSteps;
|
||||
$context = $this->withReasonTranslationContext(
|
||||
run: $run,
|
||||
context: $context,
|
||||
failures: [[
|
||||
'reason_code' => $reasonCode,
|
||||
]],
|
||||
) ?? $context;
|
||||
$summaryCounts = $this->sanitizeSummaryCounts(is_array($run->summary_counts ?? null) ? $run->summary_counts : []);
|
||||
|
||||
$run->update([
|
||||
@ -943,6 +967,76 @@ protected function sanitizeNextSteps(array $nextSteps): array
|
||||
return $sanitized;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $context
|
||||
* @param array<int, array{code?: string, reason_code?: string, message?: string}> $failures
|
||||
* @return array<string, mixed>|null
|
||||
*/
|
||||
private function withReasonTranslationContext(OperationRun $run, array $context, array $failures): ?array
|
||||
{
|
||||
$reasonCode = $this->resolveReasonCode($context, $failures);
|
||||
|
||||
if ($reasonCode === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$hasExplicitContextReason = is_string(data_get($context, 'execution_legitimacy.reason_code'))
|
||||
|| is_string(data_get($context, 'reason_code'));
|
||||
|
||||
if (! $hasExplicitContextReason && ! $this->isDirectlyTranslatableReason($reasonCode)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$translation = $this->reasonTranslator->translate($reasonCode, surface: 'notification', context: $context);
|
||||
|
||||
if (! $translation instanceof ReasonResolutionEnvelope) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$legacyNextSteps = is_array($context['next_steps'] ?? null) ? NextStepOption::collect($context['next_steps']) : [];
|
||||
|
||||
if ($translation->nextSteps === [] && $legacyNextSteps !== []) {
|
||||
$translation = $translation->withNextSteps($legacyNextSteps);
|
||||
}
|
||||
|
||||
$context['reason_translation'] = $translation->toArray();
|
||||
|
||||
if ($translation->toLegacyNextSteps() !== [] && empty($context['next_steps'])) {
|
||||
$context['next_steps'] = $translation->toLegacyNextSteps();
|
||||
}
|
||||
|
||||
return $context;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $context
|
||||
* @param array<int, array{code?: string, reason_code?: string, message?: string}> $failures
|
||||
*/
|
||||
private function resolveReasonCode(array $context, array $failures): ?string
|
||||
{
|
||||
$reasonCode = data_get($context, 'execution_legitimacy.reason_code')
|
||||
?? data_get($context, 'reason_code')
|
||||
?? data_get($failures, '0.reason_code');
|
||||
|
||||
if (! is_string($reasonCode) || trim($reasonCode) === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
return trim($reasonCode);
|
||||
}
|
||||
|
||||
private function isDirectlyTranslatableReason(string $reasonCode): bool
|
||||
{
|
||||
if ($reasonCode === ProviderReasonCodes::UnknownError) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return ProviderReasonCodes::isKnown($reasonCode)
|
||||
|| ExecutionDenialReasonCode::tryFrom($reasonCode) instanceof ExecutionDenialReasonCode
|
||||
|| TenantOperabilityReasonCode::tryFrom($reasonCode) instanceof TenantOperabilityReasonCode
|
||||
|| RbacReason::tryFrom($reasonCode) instanceof RbacReason;
|
||||
}
|
||||
|
||||
private function writeTerminalAudit(OperationRun $run): void
|
||||
{
|
||||
$tenant = $run->tenant;
|
||||
|
||||
@ -8,6 +8,7 @@
|
||||
use App\Models\TenantOnboardingSession;
|
||||
use App\Models\User;
|
||||
use App\Services\Auth\CapabilityResolver;
|
||||
use App\Support\ReasonTranslation\ReasonResolutionEnvelope;
|
||||
use App\Support\Tenants\TenantInteractionLane;
|
||||
use App\Support\Tenants\TenantLifecycle;
|
||||
use App\Support\Tenants\TenantOperabilityContext;
|
||||
@ -217,6 +218,11 @@ public function canReferenceInWorkspaceMonitoring(Tenant $tenant): bool
|
||||
)->allowed;
|
||||
}
|
||||
|
||||
public function presentReason(TenantOperabilityOutcome $outcome): ?ReasonResolutionEnvelope
|
||||
{
|
||||
return $outcome->reasonCode?->toReasonResolutionEnvelope('detail');
|
||||
}
|
||||
|
||||
/**
|
||||
* @param Collection<int, Tenant> $tenants
|
||||
* @return Collection<int, Tenant>
|
||||
|
||||
@ -15,6 +15,11 @@ final class BadgeCatalog
|
||||
private const DOMAIN_MAPPERS = [
|
||||
BadgeDomain::AuditOutcome->value => Domains\AuditOutcomeBadge::class,
|
||||
BadgeDomain::AuditActorType->value => Domains\AuditActorTypeBadge::class,
|
||||
BadgeDomain::GovernanceArtifactExistence->value => Domains\GovernanceArtifactExistenceBadge::class,
|
||||
BadgeDomain::GovernanceArtifactContent->value => Domains\GovernanceArtifactContentBadge::class,
|
||||
BadgeDomain::GovernanceArtifactFreshness->value => Domains\GovernanceArtifactFreshnessBadge::class,
|
||||
BadgeDomain::GovernanceArtifactPublicationReadiness->value => Domains\GovernanceArtifactPublicationReadinessBadge::class,
|
||||
BadgeDomain::GovernanceArtifactActionability->value => Domains\GovernanceArtifactActionabilityBadge::class,
|
||||
BadgeDomain::BaselineSnapshotFidelity->value => Domains\BaselineSnapshotFidelityBadge::class,
|
||||
BadgeDomain::BaselineSnapshotGapStatus->value => Domains\BaselineSnapshotGapStatusBadge::class,
|
||||
BadgeDomain::OperationRunStatus->value => Domains\OperationRunStatusBadge::class,
|
||||
@ -93,6 +98,27 @@ public static function mapper(BadgeDomain $domain): ?BadgeMapper
|
||||
return $mapper;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param iterable<mixed> $values
|
||||
* @return array<string, string>
|
||||
*/
|
||||
public static function options(BadgeDomain $domain, iterable $values): array
|
||||
{
|
||||
$options = [];
|
||||
|
||||
foreach ($values as $value) {
|
||||
$normalized = self::normalizeState($value);
|
||||
|
||||
if ($normalized === null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$options[$normalized] = self::spec($domain, $value)->label;
|
||||
}
|
||||
|
||||
return $options;
|
||||
}
|
||||
|
||||
public static function normalizeState(mixed $value): ?string
|
||||
{
|
||||
if ($value === null) {
|
||||
|
||||
@ -6,6 +6,11 @@ enum BadgeDomain: string
|
||||
{
|
||||
case AuditOutcome = 'audit_outcome';
|
||||
case AuditActorType = 'audit_actor_type';
|
||||
case GovernanceArtifactExistence = 'governance_artifact_existence';
|
||||
case GovernanceArtifactContent = 'governance_artifact_content';
|
||||
case GovernanceArtifactFreshness = 'governance_artifact_freshness';
|
||||
case GovernanceArtifactPublicationReadiness = 'governance_artifact_publication_readiness';
|
||||
case GovernanceArtifactActionability = 'governance_artifact_actionability';
|
||||
case BaselineSnapshotFidelity = 'baseline_snapshot_fidelity';
|
||||
case BaselineSnapshotGapStatus = 'baseline_snapshot_gap_status';
|
||||
case OperationRunStatus = 'operation_run_status';
|
||||
|
||||
@ -23,6 +23,15 @@ public function __construct(
|
||||
public readonly string $color,
|
||||
public readonly ?string $icon = null,
|
||||
public readonly ?string $iconColor = null,
|
||||
public readonly ?OperatorSemanticAxis $semanticAxis = null,
|
||||
public readonly ?OperatorStateClassification $classification = null,
|
||||
public readonly ?OperatorNextActionPolicy $nextActionPolicy = null,
|
||||
public readonly ?string $diagnosticLabel = null,
|
||||
/**
|
||||
* @var list<string>
|
||||
*/
|
||||
public readonly array $legacyAliases = [],
|
||||
public readonly ?string $notes = null,
|
||||
) {
|
||||
if (trim($this->label) === '') {
|
||||
throw new InvalidArgumentException('BadgeSpec label must be a non-empty string.');
|
||||
@ -39,6 +48,41 @@ public function __construct(
|
||||
if ($this->iconColor !== null && ! in_array($this->iconColor, self::ALLOWED_COLORS, true)) {
|
||||
throw new InvalidArgumentException('BadgeSpec iconColor must be null or one of: '.implode(', ', self::ALLOWED_COLORS));
|
||||
}
|
||||
|
||||
$hasTaxonomyMetadata = $this->semanticAxis !== null
|
||||
|| $this->classification !== null
|
||||
|| $this->nextActionPolicy !== null
|
||||
|| $this->diagnosticLabel !== null
|
||||
|| $this->legacyAliases !== []
|
||||
|| $this->notes !== null;
|
||||
|
||||
if ($hasTaxonomyMetadata && ($this->semanticAxis === null || $this->classification === null || $this->nextActionPolicy === null)) {
|
||||
throw new InvalidArgumentException('BadgeSpec taxonomy metadata requires semanticAxis, classification, and nextActionPolicy together.');
|
||||
}
|
||||
|
||||
if ($this->diagnosticLabel !== null && trim($this->diagnosticLabel) === '') {
|
||||
throw new InvalidArgumentException('BadgeSpec diagnosticLabel must be null or a non-empty string.');
|
||||
}
|
||||
|
||||
foreach ($this->legacyAliases as $legacyAlias) {
|
||||
if (! is_string($legacyAlias) || trim($legacyAlias) === '') {
|
||||
throw new InvalidArgumentException('BadgeSpec legacyAliases must contain only non-empty strings.');
|
||||
}
|
||||
}
|
||||
|
||||
if ($this->notes !== null && trim($this->notes) === '') {
|
||||
throw new InvalidArgumentException('BadgeSpec notes must be null or a non-empty string.');
|
||||
}
|
||||
|
||||
if ($this->classification === OperatorStateClassification::Diagnostic && in_array($this->color, ['warning', 'danger'], true)) {
|
||||
throw new InvalidArgumentException('Diagnostic badge specs cannot use warning or danger colors.');
|
||||
}
|
||||
|
||||
if ($this->classification === OperatorStateClassification::Primary
|
||||
&& in_array($this->color, ['warning', 'danger'], true)
|
||||
&& $this->nextActionPolicy === OperatorNextActionPolicy::None) {
|
||||
throw new InvalidArgumentException('Primary warning or danger badge specs must declare an operator next-action policy.');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@ -6,8 +6,10 @@
|
||||
|
||||
use App\Services\Baselines\SnapshotRendering\FidelityState;
|
||||
use App\Support\Badges\BadgeCatalog;
|
||||
use App\Support\Badges\BadgeDomain;
|
||||
use App\Support\Badges\BadgeMapper;
|
||||
use App\Support\Badges\BadgeSpec;
|
||||
use App\Support\Badges\OperatorOutcomeTaxonomy;
|
||||
|
||||
final class BaselineSnapshotFidelityBadge implements BadgeMapper
|
||||
{
|
||||
@ -16,11 +18,11 @@ public function spec(mixed $value): BadgeSpec
|
||||
$state = BadgeCatalog::normalizeState($value);
|
||||
|
||||
return match ($state) {
|
||||
FidelityState::Full->value => new BadgeSpec('Full', 'success', 'heroicon-m-check-circle'),
|
||||
FidelityState::Partial->value => new BadgeSpec('Partial', 'warning', 'heroicon-m-exclamation-triangle'),
|
||||
FidelityState::ReferenceOnly->value => new BadgeSpec('Reference only', 'info', 'heroicon-m-document-text'),
|
||||
FidelityState::Unsupported->value => new BadgeSpec('Unsupported', 'gray', 'heroicon-m-question-mark-circle'),
|
||||
FidelityState::Full->value => OperatorOutcomeTaxonomy::spec(BadgeDomain::BaselineSnapshotFidelity, $state, 'heroicon-m-check-circle'),
|
||||
FidelityState::Partial->value => OperatorOutcomeTaxonomy::spec(BadgeDomain::BaselineSnapshotFidelity, $state, 'heroicon-m-document-magnifying-glass'),
|
||||
FidelityState::ReferenceOnly->value => OperatorOutcomeTaxonomy::spec(BadgeDomain::BaselineSnapshotFidelity, $state, 'heroicon-m-document-text'),
|
||||
FidelityState::Unsupported->value => OperatorOutcomeTaxonomy::spec(BadgeDomain::BaselineSnapshotFidelity, $state, 'heroicon-m-question-mark-circle'),
|
||||
default => BadgeSpec::unknown(),
|
||||
};
|
||||
} ?? BadgeSpec::unknown();
|
||||
}
|
||||
}
|
||||
|
||||
@ -5,8 +5,10 @@
|
||||
namespace App\Support\Badges\Domains;
|
||||
|
||||
use App\Support\Badges\BadgeCatalog;
|
||||
use App\Support\Badges\BadgeDomain;
|
||||
use App\Support\Badges\BadgeMapper;
|
||||
use App\Support\Badges\BadgeSpec;
|
||||
use App\Support\Badges\OperatorOutcomeTaxonomy;
|
||||
|
||||
final class BaselineSnapshotGapStatusBadge implements BadgeMapper
|
||||
{
|
||||
@ -15,9 +17,9 @@ public function spec(mixed $value): BadgeSpec
|
||||
$state = BadgeCatalog::normalizeState($value);
|
||||
|
||||
return match ($state) {
|
||||
'clear' => new BadgeSpec('No gaps', 'success', 'heroicon-m-check-circle'),
|
||||
'gaps_present' => new BadgeSpec('Gaps present', 'warning', 'heroicon-m-exclamation-triangle'),
|
||||
'clear' => OperatorOutcomeTaxonomy::spec(BadgeDomain::BaselineSnapshotGapStatus, $state, 'heroicon-m-check-circle'),
|
||||
'gaps_present' => OperatorOutcomeTaxonomy::spec(BadgeDomain::BaselineSnapshotGapStatus, $state, 'heroicon-m-exclamation-triangle'),
|
||||
default => BadgeSpec::unknown(),
|
||||
};
|
||||
} ?? BadgeSpec::unknown();
|
||||
}
|
||||
}
|
||||
|
||||
@ -5,8 +5,10 @@
|
||||
namespace App\Support\Badges\Domains;
|
||||
|
||||
use App\Support\Badges\BadgeCatalog;
|
||||
use App\Support\Badges\BadgeDomain;
|
||||
use App\Support\Badges\BadgeMapper;
|
||||
use App\Support\Badges\BadgeSpec;
|
||||
use App\Support\Badges\OperatorOutcomeTaxonomy;
|
||||
use App\Support\Evidence\EvidenceCompletenessState;
|
||||
|
||||
final class EvidenceCompletenessBadge implements BadgeMapper
|
||||
@ -16,11 +18,11 @@ public function spec(mixed $value): BadgeSpec
|
||||
$state = BadgeCatalog::normalizeState($value);
|
||||
|
||||
return match ($state) {
|
||||
EvidenceCompletenessState::Complete->value => new BadgeSpec('Complete', 'success', 'heroicon-m-check-badge'),
|
||||
EvidenceCompletenessState::Partial->value => new BadgeSpec('Partial', 'warning', 'heroicon-m-exclamation-triangle'),
|
||||
EvidenceCompletenessState::Missing->value => new BadgeSpec('Missing', 'danger', 'heroicon-m-x-circle'),
|
||||
EvidenceCompletenessState::Stale->value => new BadgeSpec('Stale', 'gray', 'heroicon-m-clock'),
|
||||
EvidenceCompletenessState::Complete->value => OperatorOutcomeTaxonomy::spec(BadgeDomain::EvidenceCompleteness, $state, 'heroicon-m-check-badge'),
|
||||
EvidenceCompletenessState::Partial->value => OperatorOutcomeTaxonomy::spec(BadgeDomain::EvidenceCompleteness, $state, 'heroicon-m-exclamation-triangle'),
|
||||
EvidenceCompletenessState::Missing->value => OperatorOutcomeTaxonomy::spec(BadgeDomain::EvidenceCompleteness, $state, 'heroicon-m-clock'),
|
||||
EvidenceCompletenessState::Stale->value => OperatorOutcomeTaxonomy::spec(BadgeDomain::EvidenceCompleteness, $state, 'heroicon-m-arrow-path'),
|
||||
default => BadgeSpec::unknown(),
|
||||
};
|
||||
} ?? BadgeSpec::unknown();
|
||||
}
|
||||
}
|
||||
|
||||
@ -0,0 +1,26 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Support\Badges\Domains;
|
||||
|
||||
use App\Support\Badges\BadgeCatalog;
|
||||
use App\Support\Badges\BadgeDomain;
|
||||
use App\Support\Badges\BadgeMapper;
|
||||
use App\Support\Badges\BadgeSpec;
|
||||
use App\Support\Badges\OperatorOutcomeTaxonomy;
|
||||
|
||||
final class GovernanceArtifactActionabilityBadge implements BadgeMapper
|
||||
{
|
||||
public function spec(mixed $value): BadgeSpec
|
||||
{
|
||||
$state = BadgeCatalog::normalizeState($value);
|
||||
|
||||
return match ($state) {
|
||||
'none' => OperatorOutcomeTaxonomy::spec(BadgeDomain::GovernanceArtifactActionability, $state, 'heroicon-m-check'),
|
||||
'optional' => OperatorOutcomeTaxonomy::spec(BadgeDomain::GovernanceArtifactActionability, $state, 'heroicon-m-information-circle'),
|
||||
'required' => OperatorOutcomeTaxonomy::spec(BadgeDomain::GovernanceArtifactActionability, $state, 'heroicon-m-exclamation-triangle'),
|
||||
default => BadgeSpec::unknown(),
|
||||
} ?? BadgeSpec::unknown();
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,30 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Support\Badges\Domains;
|
||||
|
||||
use App\Support\Badges\BadgeCatalog;
|
||||
use App\Support\Badges\BadgeDomain;
|
||||
use App\Support\Badges\BadgeMapper;
|
||||
use App\Support\Badges\BadgeSpec;
|
||||
use App\Support\Badges\OperatorOutcomeTaxonomy;
|
||||
|
||||
final class GovernanceArtifactContentBadge implements BadgeMapper
|
||||
{
|
||||
public function spec(mixed $value): BadgeSpec
|
||||
{
|
||||
$state = BadgeCatalog::normalizeState($value);
|
||||
|
||||
return match ($state) {
|
||||
'trusted' => OperatorOutcomeTaxonomy::spec(BadgeDomain::GovernanceArtifactContent, $state, 'heroicon-m-check-badge'),
|
||||
'partial' => OperatorOutcomeTaxonomy::spec(BadgeDomain::GovernanceArtifactContent, $state, 'heroicon-m-exclamation-triangle'),
|
||||
'missing_input' => OperatorOutcomeTaxonomy::spec(BadgeDomain::GovernanceArtifactContent, $state, 'heroicon-m-exclamation-circle'),
|
||||
'metadata_only' => OperatorOutcomeTaxonomy::spec(BadgeDomain::GovernanceArtifactContent, $state, 'heroicon-m-document-text'),
|
||||
'reference_only' => OperatorOutcomeTaxonomy::spec(BadgeDomain::GovernanceArtifactContent, $state, 'heroicon-m-link'),
|
||||
'empty' => OperatorOutcomeTaxonomy::spec(BadgeDomain::GovernanceArtifactContent, $state, 'heroicon-m-no-symbol'),
|
||||
'unsupported' => OperatorOutcomeTaxonomy::spec(BadgeDomain::GovernanceArtifactContent, $state, 'heroicon-m-question-mark-circle'),
|
||||
default => BadgeSpec::unknown(),
|
||||
} ?? BadgeSpec::unknown();
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,27 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Support\Badges\Domains;
|
||||
|
||||
use App\Support\Badges\BadgeCatalog;
|
||||
use App\Support\Badges\BadgeDomain;
|
||||
use App\Support\Badges\BadgeMapper;
|
||||
use App\Support\Badges\BadgeSpec;
|
||||
use App\Support\Badges\OperatorOutcomeTaxonomy;
|
||||
|
||||
final class GovernanceArtifactExistenceBadge implements BadgeMapper
|
||||
{
|
||||
public function spec(mixed $value): BadgeSpec
|
||||
{
|
||||
$state = BadgeCatalog::normalizeState($value);
|
||||
|
||||
return match ($state) {
|
||||
'not_created' => OperatorOutcomeTaxonomy::spec(BadgeDomain::GovernanceArtifactExistence, $state, 'heroicon-m-clock'),
|
||||
'historical_only' => OperatorOutcomeTaxonomy::spec(BadgeDomain::GovernanceArtifactExistence, $state, 'heroicon-m-archive-box'),
|
||||
'created' => OperatorOutcomeTaxonomy::spec(BadgeDomain::GovernanceArtifactExistence, $state, 'heroicon-m-check-circle'),
|
||||
'created_but_not_usable' => OperatorOutcomeTaxonomy::spec(BadgeDomain::GovernanceArtifactExistence, $state, 'heroicon-m-exclamation-triangle'),
|
||||
default => BadgeSpec::unknown(),
|
||||
} ?? BadgeSpec::unknown();
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,26 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Support\Badges\Domains;
|
||||
|
||||
use App\Support\Badges\BadgeCatalog;
|
||||
use App\Support\Badges\BadgeDomain;
|
||||
use App\Support\Badges\BadgeMapper;
|
||||
use App\Support\Badges\BadgeSpec;
|
||||
use App\Support\Badges\OperatorOutcomeTaxonomy;
|
||||
|
||||
final class GovernanceArtifactFreshnessBadge implements BadgeMapper
|
||||
{
|
||||
public function spec(mixed $value): BadgeSpec
|
||||
{
|
||||
$state = BadgeCatalog::normalizeState($value);
|
||||
|
||||
return match ($state) {
|
||||
'current' => OperatorOutcomeTaxonomy::spec(BadgeDomain::GovernanceArtifactFreshness, $state, 'heroicon-m-check-circle'),
|
||||
'stale' => OperatorOutcomeTaxonomy::spec(BadgeDomain::GovernanceArtifactFreshness, $state, 'heroicon-m-arrow-path'),
|
||||
'unknown' => OperatorOutcomeTaxonomy::spec(BadgeDomain::GovernanceArtifactFreshness, $state, 'heroicon-m-question-mark-circle'),
|
||||
default => BadgeSpec::unknown(),
|
||||
} ?? BadgeSpec::unknown();
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,27 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Support\Badges\Domains;
|
||||
|
||||
use App\Support\Badges\BadgeCatalog;
|
||||
use App\Support\Badges\BadgeDomain;
|
||||
use App\Support\Badges\BadgeMapper;
|
||||
use App\Support\Badges\BadgeSpec;
|
||||
use App\Support\Badges\OperatorOutcomeTaxonomy;
|
||||
|
||||
final class GovernanceArtifactPublicationReadinessBadge implements BadgeMapper
|
||||
{
|
||||
public function spec(mixed $value): BadgeSpec
|
||||
{
|
||||
$state = BadgeCatalog::normalizeState($value);
|
||||
|
||||
return match ($state) {
|
||||
'not_applicable' => OperatorOutcomeTaxonomy::spec(BadgeDomain::GovernanceArtifactPublicationReadiness, $state, 'heroicon-m-minus-circle'),
|
||||
'internal_only' => OperatorOutcomeTaxonomy::spec(BadgeDomain::GovernanceArtifactPublicationReadiness, $state, 'heroicon-m-document-duplicate'),
|
||||
'publishable' => OperatorOutcomeTaxonomy::spec(BadgeDomain::GovernanceArtifactPublicationReadiness, $state, 'heroicon-m-check-badge'),
|
||||
'blocked' => OperatorOutcomeTaxonomy::spec(BadgeDomain::GovernanceArtifactPublicationReadiness, $state, 'heroicon-m-no-symbol'),
|
||||
default => BadgeSpec::unknown(),
|
||||
} ?? BadgeSpec::unknown();
|
||||
}
|
||||
}
|
||||
@ -3,8 +3,10 @@
|
||||
namespace App\Support\Badges\Domains;
|
||||
|
||||
use App\Support\Badges\BadgeCatalog;
|
||||
use App\Support\Badges\BadgeDomain;
|
||||
use App\Support\Badges\BadgeMapper;
|
||||
use App\Support\Badges\BadgeSpec;
|
||||
use App\Support\Badges\OperatorOutcomeTaxonomy;
|
||||
use App\Support\OperationRunOutcome;
|
||||
|
||||
final class OperationRunOutcomeBadge implements BadgeMapper
|
||||
@ -14,13 +16,13 @@ public function spec(mixed $value): BadgeSpec
|
||||
$state = BadgeCatalog::normalizeState($value);
|
||||
|
||||
return match ($state) {
|
||||
OperationRunOutcome::Pending->value => new BadgeSpec('Pending', 'gray', 'heroicon-m-clock'),
|
||||
OperationRunOutcome::Succeeded->value => new BadgeSpec('Succeeded', 'success', 'heroicon-m-check-circle'),
|
||||
OperationRunOutcome::PartiallySucceeded->value => new BadgeSpec('Partially succeeded', 'warning', 'heroicon-m-exclamation-triangle'),
|
||||
OperationRunOutcome::Blocked->value, 'operation.blocked' => new BadgeSpec('Blocked', 'warning', 'heroicon-m-no-symbol'),
|
||||
OperationRunOutcome::Failed->value => new BadgeSpec('Failed', 'danger', 'heroicon-m-x-circle'),
|
||||
OperationRunOutcome::Cancelled->value => new BadgeSpec('Cancelled', 'gray', 'heroicon-m-minus-circle'),
|
||||
OperationRunOutcome::Pending->value => OperatorOutcomeTaxonomy::spec(BadgeDomain::OperationRunOutcome, $state, 'heroicon-m-clock'),
|
||||
OperationRunOutcome::Succeeded->value => OperatorOutcomeTaxonomy::spec(BadgeDomain::OperationRunOutcome, $state, 'heroicon-m-check-circle'),
|
||||
OperationRunOutcome::PartiallySucceeded->value => OperatorOutcomeTaxonomy::spec(BadgeDomain::OperationRunOutcome, $state, 'heroicon-m-exclamation-triangle'),
|
||||
OperationRunOutcome::Blocked->value, 'operation.blocked' => OperatorOutcomeTaxonomy::spec(BadgeDomain::OperationRunOutcome, $state, 'heroicon-m-no-symbol'),
|
||||
OperationRunOutcome::Failed->value => OperatorOutcomeTaxonomy::spec(BadgeDomain::OperationRunOutcome, $state, 'heroicon-m-x-circle'),
|
||||
OperationRunOutcome::Cancelled->value => OperatorOutcomeTaxonomy::spec(BadgeDomain::OperationRunOutcome, $state, 'heroicon-m-minus-circle'),
|
||||
default => BadgeSpec::unknown(),
|
||||
};
|
||||
} ?? BadgeSpec::unknown();
|
||||
}
|
||||
}
|
||||
|
||||
@ -3,8 +3,10 @@
|
||||
namespace App\Support\Badges\Domains;
|
||||
|
||||
use App\Support\Badges\BadgeCatalog;
|
||||
use App\Support\Badges\BadgeDomain;
|
||||
use App\Support\Badges\BadgeMapper;
|
||||
use App\Support\Badges\BadgeSpec;
|
||||
use App\Support\Badges\OperatorOutcomeTaxonomy;
|
||||
use App\Support\OperationRunStatus;
|
||||
|
||||
final class OperationRunStatusBadge implements BadgeMapper
|
||||
@ -14,9 +16,9 @@ public function spec(mixed $value): BadgeSpec
|
||||
$state = BadgeCatalog::normalizeState($value);
|
||||
|
||||
return match ($state) {
|
||||
OperationRunStatus::Queued->value => new BadgeSpec('Queued', 'warning', 'heroicon-m-clock'),
|
||||
OperationRunStatus::Running->value => new BadgeSpec('Running', 'info', 'heroicon-m-arrow-path'),
|
||||
OperationRunStatus::Completed->value => new BadgeSpec('Completed', 'gray', 'heroicon-m-check-circle'),
|
||||
OperationRunStatus::Queued->value => OperatorOutcomeTaxonomy::spec(BadgeDomain::OperationRunStatus, $state, 'heroicon-m-clock'),
|
||||
OperationRunStatus::Running->value => OperatorOutcomeTaxonomy::spec(BadgeDomain::OperationRunStatus, $state, 'heroicon-m-arrow-path'),
|
||||
OperationRunStatus::Completed->value => OperatorOutcomeTaxonomy::spec(BadgeDomain::OperationRunStatus, $state, 'heroicon-m-check-circle'),
|
||||
default => BadgeSpec::unknown(),
|
||||
};
|
||||
}
|
||||
|
||||
@ -3,8 +3,10 @@
|
||||
namespace App\Support\Badges\Domains;
|
||||
|
||||
use App\Support\Badges\BadgeCatalog;
|
||||
use App\Support\Badges\BadgeDomain;
|
||||
use App\Support\Badges\BadgeMapper;
|
||||
use App\Support\Badges\BadgeSpec;
|
||||
use App\Support\Badges\OperatorOutcomeTaxonomy;
|
||||
|
||||
final class RestoreCheckSeverityBadge implements BadgeMapper
|
||||
{
|
||||
@ -13,10 +15,10 @@ public function spec(mixed $value): BadgeSpec
|
||||
$state = BadgeCatalog::normalizeState($value);
|
||||
|
||||
return match ($state) {
|
||||
'blocking' => new BadgeSpec('Blocking', 'danger', 'heroicon-m-x-circle'),
|
||||
'warning' => new BadgeSpec('Warning', 'warning', 'heroicon-m-exclamation-triangle'),
|
||||
'safe' => new BadgeSpec('Safe', 'success', 'heroicon-m-check-circle'),
|
||||
'blocking' => OperatorOutcomeTaxonomy::spec(BadgeDomain::RestoreCheckSeverity, $state, 'heroicon-m-x-circle'),
|
||||
'warning' => OperatorOutcomeTaxonomy::spec(BadgeDomain::RestoreCheckSeverity, $state, 'heroicon-m-exclamation-triangle'),
|
||||
'safe' => OperatorOutcomeTaxonomy::spec(BadgeDomain::RestoreCheckSeverity, $state, 'heroicon-m-check-circle'),
|
||||
default => BadgeSpec::unknown(),
|
||||
};
|
||||
} ?? BadgeSpec::unknown();
|
||||
}
|
||||
}
|
||||
|
||||
@ -3,8 +3,10 @@
|
||||
namespace App\Support\Badges\Domains;
|
||||
|
||||
use App\Support\Badges\BadgeCatalog;
|
||||
use App\Support\Badges\BadgeDomain;
|
||||
use App\Support\Badges\BadgeMapper;
|
||||
use App\Support\Badges\BadgeSpec;
|
||||
use App\Support\Badges\OperatorOutcomeTaxonomy;
|
||||
|
||||
final class RestorePreviewDecisionBadge implements BadgeMapper
|
||||
{
|
||||
@ -13,12 +15,12 @@ public function spec(mixed $value): BadgeSpec
|
||||
$state = BadgeCatalog::normalizeState($value);
|
||||
|
||||
return match ($state) {
|
||||
'created' => new BadgeSpec('Created', 'success', 'heroicon-m-check-circle'),
|
||||
'created_copy' => new BadgeSpec('Created copy', 'warning', 'heroicon-m-exclamation-triangle'),
|
||||
'mapped_existing' => new BadgeSpec('Mapped existing', 'info', 'heroicon-m-arrow-path'),
|
||||
'skipped' => new BadgeSpec('Skipped', 'warning', 'heroicon-m-minus-circle'),
|
||||
'failed' => new BadgeSpec('Failed', 'danger', 'heroicon-m-x-circle'),
|
||||
'created' => OperatorOutcomeTaxonomy::spec(BadgeDomain::RestorePreviewDecision, $state, 'heroicon-m-plus-circle'),
|
||||
'created_copy' => OperatorOutcomeTaxonomy::spec(BadgeDomain::RestorePreviewDecision, $state, 'heroicon-m-document-duplicate'),
|
||||
'mapped_existing' => OperatorOutcomeTaxonomy::spec(BadgeDomain::RestorePreviewDecision, $state, 'heroicon-m-arrow-right-circle'),
|
||||
'skipped' => OperatorOutcomeTaxonomy::spec(BadgeDomain::RestorePreviewDecision, $state, 'heroicon-m-minus-circle'),
|
||||
'failed' => OperatorOutcomeTaxonomy::spec(BadgeDomain::RestorePreviewDecision, $state, 'heroicon-m-x-circle'),
|
||||
default => BadgeSpec::unknown(),
|
||||
};
|
||||
} ?? BadgeSpec::unknown();
|
||||
}
|
||||
}
|
||||
|
||||
@ -3,8 +3,10 @@
|
||||
namespace App\Support\Badges\Domains;
|
||||
|
||||
use App\Support\Badges\BadgeCatalog;
|
||||
use App\Support\Badges\BadgeDomain;
|
||||
use App\Support\Badges\BadgeMapper;
|
||||
use App\Support\Badges\BadgeSpec;
|
||||
use App\Support\Badges\OperatorOutcomeTaxonomy;
|
||||
|
||||
final class RestoreResultStatusBadge implements BadgeMapper
|
||||
{
|
||||
@ -13,14 +15,14 @@ public function spec(mixed $value): BadgeSpec
|
||||
$state = BadgeCatalog::normalizeState($value);
|
||||
|
||||
return match ($state) {
|
||||
'applied' => new BadgeSpec('Applied', 'success', 'heroicon-m-check-circle'),
|
||||
'dry_run' => new BadgeSpec('Dry run', 'info', 'heroicon-m-arrow-path'),
|
||||
'mapped' => new BadgeSpec('Mapped', 'success', 'heroicon-m-check-circle'),
|
||||
'skipped' => new BadgeSpec('Skipped', 'warning', 'heroicon-m-minus-circle'),
|
||||
'partial' => new BadgeSpec('Partial', 'warning', 'heroicon-m-exclamation-triangle'),
|
||||
'manual_required' => new BadgeSpec('Manual required', 'warning', 'heroicon-m-exclamation-triangle'),
|
||||
'failed' => new BadgeSpec('Failed', 'danger', 'heroicon-m-x-circle'),
|
||||
'applied' => OperatorOutcomeTaxonomy::spec(BadgeDomain::RestoreResultStatus, $state, 'heroicon-m-check-circle'),
|
||||
'dry_run' => OperatorOutcomeTaxonomy::spec(BadgeDomain::RestoreResultStatus, $state, 'heroicon-m-eye'),
|
||||
'mapped' => OperatorOutcomeTaxonomy::spec(BadgeDomain::RestoreResultStatus, $state, 'heroicon-m-arrow-right-circle'),
|
||||
'skipped' => OperatorOutcomeTaxonomy::spec(BadgeDomain::RestoreResultStatus, $state, 'heroicon-m-minus-circle'),
|
||||
'partial' => OperatorOutcomeTaxonomy::spec(BadgeDomain::RestoreResultStatus, $state, 'heroicon-m-exclamation-triangle'),
|
||||
'manual_required' => OperatorOutcomeTaxonomy::spec(BadgeDomain::RestoreResultStatus, $state, 'heroicon-m-wrench-screwdriver'),
|
||||
'failed' => OperatorOutcomeTaxonomy::spec(BadgeDomain::RestoreResultStatus, $state, 'heroicon-m-x-circle'),
|
||||
default => BadgeSpec::unknown(),
|
||||
};
|
||||
} ?? BadgeSpec::unknown();
|
||||
}
|
||||
}
|
||||
|
||||
@ -3,8 +3,10 @@
|
||||
namespace App\Support\Badges\Domains;
|
||||
|
||||
use App\Support\Badges\BadgeCatalog;
|
||||
use App\Support\Badges\BadgeDomain;
|
||||
use App\Support\Badges\BadgeMapper;
|
||||
use App\Support\Badges\BadgeSpec;
|
||||
use App\Support\Badges\OperatorOutcomeTaxonomy;
|
||||
use App\Support\RestoreRunStatus;
|
||||
|
||||
final class RestoreRunStatusBadge implements BadgeMapper
|
||||
@ -14,20 +16,20 @@ public function spec(mixed $value): BadgeSpec
|
||||
$state = BadgeCatalog::normalizeState($value);
|
||||
|
||||
return match ($state) {
|
||||
RestoreRunStatus::Draft->value => new BadgeSpec('Draft', 'gray', 'heroicon-m-minus-circle'),
|
||||
RestoreRunStatus::Scoped->value => new BadgeSpec('Scoped', 'gray', 'heroicon-m-minus-circle'),
|
||||
RestoreRunStatus::Checked->value => new BadgeSpec('Checked', 'gray', 'heroicon-m-minus-circle'),
|
||||
RestoreRunStatus::Previewed->value => new BadgeSpec('Previewed', 'gray', 'heroicon-m-minus-circle'),
|
||||
RestoreRunStatus::Pending->value => new BadgeSpec('Pending', 'warning', 'heroicon-m-clock'),
|
||||
RestoreRunStatus::Queued->value => new BadgeSpec('Queued', 'warning', 'heroicon-m-clock'),
|
||||
RestoreRunStatus::Running->value => new BadgeSpec('Running', 'info', 'heroicon-m-arrow-path'),
|
||||
RestoreRunStatus::Completed->value => new BadgeSpec('Completed', 'success', 'heroicon-m-check-circle'),
|
||||
RestoreRunStatus::Partial->value => new BadgeSpec('Partial', 'warning', 'heroicon-m-exclamation-triangle'),
|
||||
RestoreRunStatus::Failed->value => new BadgeSpec('Failed', 'danger', 'heroicon-m-x-circle'),
|
||||
RestoreRunStatus::Cancelled->value => new BadgeSpec('Cancelled', 'gray', 'heroicon-m-minus-circle'),
|
||||
RestoreRunStatus::Aborted->value => new BadgeSpec('Aborted', 'gray', 'heroicon-m-minus-circle'),
|
||||
RestoreRunStatus::CompletedWithErrors->value => new BadgeSpec('Completed with errors', 'warning', 'heroicon-m-exclamation-triangle'),
|
||||
RestoreRunStatus::Draft->value => OperatorOutcomeTaxonomy::spec(BadgeDomain::RestoreRunStatus, $state, 'heroicon-m-minus-circle'),
|
||||
RestoreRunStatus::Scoped->value => OperatorOutcomeTaxonomy::spec(BadgeDomain::RestoreRunStatus, $state, 'heroicon-m-funnel'),
|
||||
RestoreRunStatus::Checked->value => OperatorOutcomeTaxonomy::spec(BadgeDomain::RestoreRunStatus, $state, 'heroicon-m-shield-check'),
|
||||
RestoreRunStatus::Previewed->value => OperatorOutcomeTaxonomy::spec(BadgeDomain::RestoreRunStatus, $state, 'heroicon-m-eye'),
|
||||
RestoreRunStatus::Pending->value => OperatorOutcomeTaxonomy::spec(BadgeDomain::RestoreRunStatus, $state, 'heroicon-m-clock'),
|
||||
RestoreRunStatus::Queued->value => OperatorOutcomeTaxonomy::spec(BadgeDomain::RestoreRunStatus, $state, 'heroicon-m-queue-list'),
|
||||
RestoreRunStatus::Running->value => OperatorOutcomeTaxonomy::spec(BadgeDomain::RestoreRunStatus, $state, 'heroicon-m-arrow-path'),
|
||||
RestoreRunStatus::Completed->value => OperatorOutcomeTaxonomy::spec(BadgeDomain::RestoreRunStatus, $state, 'heroicon-m-check-circle'),
|
||||
RestoreRunStatus::Partial->value => OperatorOutcomeTaxonomy::spec(BadgeDomain::RestoreRunStatus, $state, 'heroicon-m-exclamation-triangle'),
|
||||
RestoreRunStatus::Failed->value => OperatorOutcomeTaxonomy::spec(BadgeDomain::RestoreRunStatus, $state, 'heroicon-m-x-circle'),
|
||||
RestoreRunStatus::Cancelled->value => OperatorOutcomeTaxonomy::spec(BadgeDomain::RestoreRunStatus, $state, 'heroicon-m-minus-circle'),
|
||||
RestoreRunStatus::Aborted->value => OperatorOutcomeTaxonomy::spec(BadgeDomain::RestoreRunStatus, $state, 'heroicon-m-stop-circle'),
|
||||
RestoreRunStatus::CompletedWithErrors->value => OperatorOutcomeTaxonomy::spec(BadgeDomain::RestoreRunStatus, $state, 'heroicon-m-exclamation-triangle'),
|
||||
default => BadgeSpec::unknown(),
|
||||
};
|
||||
} ?? BadgeSpec::unknown();
|
||||
}
|
||||
}
|
||||
|
||||
@ -5,8 +5,10 @@
|
||||
namespace App\Support\Badges\Domains;
|
||||
|
||||
use App\Support\Badges\BadgeCatalog;
|
||||
use App\Support\Badges\BadgeDomain;
|
||||
use App\Support\Badges\BadgeMapper;
|
||||
use App\Support\Badges\BadgeSpec;
|
||||
use App\Support\Badges\OperatorOutcomeTaxonomy;
|
||||
use App\Support\TenantReviewCompletenessState;
|
||||
|
||||
final class TenantReviewCompletenessStateBadge implements BadgeMapper
|
||||
@ -16,11 +18,11 @@ public function spec(mixed $value): BadgeSpec
|
||||
$state = BadgeCatalog::normalizeState($value);
|
||||
|
||||
return match ($state) {
|
||||
TenantReviewCompletenessState::Complete->value => new BadgeSpec('Complete', 'success', 'heroicon-m-check-circle'),
|
||||
TenantReviewCompletenessState::Partial->value => new BadgeSpec('Partial', 'warning', 'heroicon-m-exclamation-triangle'),
|
||||
TenantReviewCompletenessState::Missing->value => new BadgeSpec('Missing', 'danger', 'heroicon-m-x-circle'),
|
||||
TenantReviewCompletenessState::Stale->value => new BadgeSpec('Stale', 'gray', 'heroicon-m-clock'),
|
||||
TenantReviewCompletenessState::Complete->value => OperatorOutcomeTaxonomy::spec(BadgeDomain::TenantReviewCompleteness, $state, 'heroicon-m-check-circle'),
|
||||
TenantReviewCompletenessState::Partial->value => OperatorOutcomeTaxonomy::spec(BadgeDomain::TenantReviewCompleteness, $state, 'heroicon-m-exclamation-triangle'),
|
||||
TenantReviewCompletenessState::Missing->value => OperatorOutcomeTaxonomy::spec(BadgeDomain::TenantReviewCompleteness, $state, 'heroicon-m-clock'),
|
||||
TenantReviewCompletenessState::Stale->value => OperatorOutcomeTaxonomy::spec(BadgeDomain::TenantReviewCompleteness, $state, 'heroicon-m-arrow-path'),
|
||||
default => BadgeSpec::unknown(),
|
||||
};
|
||||
} ?? BadgeSpec::unknown();
|
||||
}
|
||||
}
|
||||
|
||||
17
app/Support/Badges/OperatorNextActionPolicy.php
Normal file
17
app/Support/Badges/OperatorNextActionPolicy.php
Normal file
@ -0,0 +1,17 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Support\Badges;
|
||||
|
||||
enum OperatorNextActionPolicy: string
|
||||
{
|
||||
case Required = 'required';
|
||||
case Optional = 'optional';
|
||||
case None = 'none';
|
||||
|
||||
public function requiresExplanation(): bool
|
||||
{
|
||||
return $this !== self::None;
|
||||
}
|
||||
}
|
||||
850
app/Support/Badges/OperatorOutcomeTaxonomy.php
Normal file
850
app/Support/Badges/OperatorOutcomeTaxonomy.php
Normal file
@ -0,0 +1,850 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Support\Badges;
|
||||
|
||||
use InvalidArgumentException;
|
||||
|
||||
final class OperatorOutcomeTaxonomy
|
||||
{
|
||||
/**
|
||||
* @var array<string, array<string, array{
|
||||
* axis: string,
|
||||
* label: string,
|
||||
* color: string,
|
||||
* classification: string,
|
||||
* next_action_policy: string,
|
||||
* legacy_aliases: list<string>,
|
||||
* diagnostic_label?: string|null,
|
||||
* notes: string
|
||||
* }>>
|
||||
*/
|
||||
private const ENTRIES = [
|
||||
'governance_artifact_existence' => [
|
||||
'not_created' => [
|
||||
'axis' => 'artifact_existence',
|
||||
'label' => 'Not created yet',
|
||||
'color' => 'info',
|
||||
'classification' => 'primary',
|
||||
'next_action_policy' => 'optional',
|
||||
'legacy_aliases' => ['No artifact'],
|
||||
'notes' => 'The intended artifact has not been produced yet.',
|
||||
],
|
||||
'historical_only' => [
|
||||
'axis' => 'artifact_existence',
|
||||
'label' => 'Historical artifact',
|
||||
'color' => 'gray',
|
||||
'classification' => 'primary',
|
||||
'next_action_policy' => 'none',
|
||||
'legacy_aliases' => ['Historical only'],
|
||||
'notes' => 'The artifact remains readable for history but is no longer the current working artifact.',
|
||||
],
|
||||
'created' => [
|
||||
'axis' => 'artifact_existence',
|
||||
'label' => 'Artifact available',
|
||||
'color' => 'success',
|
||||
'classification' => 'primary',
|
||||
'next_action_policy' => 'none',
|
||||
'legacy_aliases' => ['Created'],
|
||||
'notes' => 'The intended artifact exists and can be inspected.',
|
||||
],
|
||||
'created_but_not_usable' => [
|
||||
'axis' => 'artifact_existence',
|
||||
'label' => 'Artifact not usable',
|
||||
'color' => 'warning',
|
||||
'classification' => 'primary',
|
||||
'next_action_policy' => 'required',
|
||||
'legacy_aliases' => ['Created but not usable'],
|
||||
'notes' => 'The artifact record exists, but the operator cannot safely rely on it for the primary task.',
|
||||
],
|
||||
],
|
||||
'governance_artifact_content' => [
|
||||
'trusted' => [
|
||||
'axis' => 'data_coverage',
|
||||
'label' => 'Trustworthy artifact',
|
||||
'color' => 'success',
|
||||
'classification' => 'primary',
|
||||
'next_action_policy' => 'none',
|
||||
'legacy_aliases' => ['Trusted'],
|
||||
'notes' => 'The artifact content is fit for the primary operator workflow.',
|
||||
],
|
||||
'partial' => [
|
||||
'axis' => 'data_coverage',
|
||||
'label' => 'Partial',
|
||||
'color' => 'warning',
|
||||
'classification' => 'primary',
|
||||
'next_action_policy' => 'required',
|
||||
'legacy_aliases' => ['Partially complete'],
|
||||
'notes' => 'The artifact exists but key content is incomplete.',
|
||||
],
|
||||
'missing_input' => [
|
||||
'axis' => 'data_coverage',
|
||||
'label' => 'Missing input',
|
||||
'color' => 'warning',
|
||||
'classification' => 'primary',
|
||||
'next_action_policy' => 'required',
|
||||
'legacy_aliases' => ['Missing'],
|
||||
'notes' => 'The artifact is blocked by missing upstream inputs.',
|
||||
],
|
||||
'metadata_only' => [
|
||||
'axis' => 'evidence_depth',
|
||||
'label' => 'Metadata only',
|
||||
'color' => 'info',
|
||||
'classification' => 'diagnostic',
|
||||
'next_action_policy' => 'none',
|
||||
'legacy_aliases' => ['Metadata-only'],
|
||||
'notes' => 'Only metadata is available. This is diagnostic context and should not replace the primary truth state.',
|
||||
],
|
||||
'reference_only' => [
|
||||
'axis' => 'evidence_depth',
|
||||
'label' => 'Reference only',
|
||||
'color' => 'info',
|
||||
'classification' => 'diagnostic',
|
||||
'next_action_policy' => 'none',
|
||||
'legacy_aliases' => ['Reference-only'],
|
||||
'notes' => 'Only reference placeholders are available. This is diagnostic context and should not replace the primary truth state.',
|
||||
],
|
||||
'empty' => [
|
||||
'axis' => 'data_coverage',
|
||||
'label' => 'Empty snapshot',
|
||||
'color' => 'warning',
|
||||
'classification' => 'primary',
|
||||
'next_action_policy' => 'required',
|
||||
'legacy_aliases' => ['Empty'],
|
||||
'notes' => 'The artifact exists but captured no usable content.',
|
||||
],
|
||||
'unsupported' => [
|
||||
'axis' => 'product_support_maturity',
|
||||
'label' => 'Support limited',
|
||||
'color' => 'gray',
|
||||
'classification' => 'diagnostic',
|
||||
'next_action_policy' => 'none',
|
||||
'legacy_aliases' => ['Unsupported'],
|
||||
'notes' => 'The product is representing the source with limited fidelity. This remains diagnostic unless a stronger truth dimension applies.',
|
||||
],
|
||||
],
|
||||
'governance_artifact_freshness' => [
|
||||
'current' => [
|
||||
'axis' => 'data_freshness',
|
||||
'label' => 'Current',
|
||||
'color' => 'success',
|
||||
'classification' => 'primary',
|
||||
'next_action_policy' => 'none',
|
||||
'legacy_aliases' => ['Fresh'],
|
||||
'notes' => 'The available artifact is current enough for the primary task.',
|
||||
],
|
||||
'stale' => [
|
||||
'axis' => 'data_freshness',
|
||||
'label' => 'Stale',
|
||||
'color' => 'warning',
|
||||
'classification' => 'primary',
|
||||
'next_action_policy' => 'optional',
|
||||
'legacy_aliases' => ['Refresh recommended'],
|
||||
'notes' => 'The artifact exists but should be refreshed before relying on it.',
|
||||
],
|
||||
'unknown' => [
|
||||
'axis' => 'data_freshness',
|
||||
'label' => 'Freshness unknown',
|
||||
'color' => 'gray',
|
||||
'classification' => 'diagnostic',
|
||||
'next_action_policy' => 'none',
|
||||
'legacy_aliases' => ['Unknown'],
|
||||
'notes' => 'The system cannot determine freshness from the available payload.',
|
||||
],
|
||||
],
|
||||
'governance_artifact_publication_readiness' => [
|
||||
'not_applicable' => [
|
||||
'axis' => 'publication_readiness',
|
||||
'label' => 'Not applicable',
|
||||
'color' => 'gray',
|
||||
'classification' => 'diagnostic',
|
||||
'next_action_policy' => 'none',
|
||||
'legacy_aliases' => ['N/A'],
|
||||
'notes' => 'Publication readiness does not apply to this artifact family.',
|
||||
],
|
||||
'internal_only' => [
|
||||
'axis' => 'publication_readiness',
|
||||
'label' => 'Internal only',
|
||||
'color' => 'info',
|
||||
'classification' => 'primary',
|
||||
'next_action_policy' => 'optional',
|
||||
'legacy_aliases' => ['Draft'],
|
||||
'notes' => 'The artifact is useful internally but not ready for stakeholder delivery.',
|
||||
],
|
||||
'publishable' => [
|
||||
'axis' => 'publication_readiness',
|
||||
'label' => 'Publishable',
|
||||
'color' => 'success',
|
||||
'classification' => 'primary',
|
||||
'next_action_policy' => 'none',
|
||||
'legacy_aliases' => ['Ready'],
|
||||
'notes' => 'The artifact is ready for stakeholder publication or export.',
|
||||
],
|
||||
'blocked' => [
|
||||
'axis' => 'publication_readiness',
|
||||
'label' => 'Blocked',
|
||||
'color' => 'warning',
|
||||
'classification' => 'primary',
|
||||
'next_action_policy' => 'required',
|
||||
'legacy_aliases' => ['Not publishable'],
|
||||
'notes' => 'The artifact exists but is blocked from publication or export.',
|
||||
],
|
||||
],
|
||||
'governance_artifact_actionability' => [
|
||||
'none' => [
|
||||
'axis' => 'operator_actionability',
|
||||
'label' => 'No action needed',
|
||||
'color' => 'gray',
|
||||
'classification' => 'primary',
|
||||
'next_action_policy' => 'none',
|
||||
'legacy_aliases' => ['No follow-up'],
|
||||
'notes' => 'The current non-green state is informational only and does not require action.',
|
||||
],
|
||||
'optional' => [
|
||||
'axis' => 'operator_actionability',
|
||||
'label' => 'Review recommended',
|
||||
'color' => 'info',
|
||||
'classification' => 'primary',
|
||||
'next_action_policy' => 'optional',
|
||||
'legacy_aliases' => ['Optional follow-up'],
|
||||
'notes' => 'The artifact can be used, but the operator should review the follow-up guidance.',
|
||||
],
|
||||
'required' => [
|
||||
'axis' => 'operator_actionability',
|
||||
'label' => 'Action required',
|
||||
'color' => 'danger',
|
||||
'classification' => 'primary',
|
||||
'next_action_policy' => 'required',
|
||||
'legacy_aliases' => ['Required follow-up'],
|
||||
'notes' => 'The artifact cannot be trusted for the primary task until an operator addresses the issue.',
|
||||
],
|
||||
],
|
||||
'operation_run_status' => [
|
||||
'queued' => [
|
||||
'axis' => 'execution_lifecycle',
|
||||
'label' => 'Queued for execution',
|
||||
'color' => 'info',
|
||||
'classification' => 'primary',
|
||||
'next_action_policy' => 'none',
|
||||
'legacy_aliases' => ['Queued'],
|
||||
'notes' => 'Execution is waiting for a worker to start the run.',
|
||||
],
|
||||
'running' => [
|
||||
'axis' => 'execution_lifecycle',
|
||||
'label' => 'In progress',
|
||||
'color' => 'info',
|
||||
'classification' => 'primary',
|
||||
'next_action_policy' => 'none',
|
||||
'legacy_aliases' => ['Running'],
|
||||
'notes' => 'Execution is currently running.',
|
||||
],
|
||||
'completed' => [
|
||||
'axis' => 'execution_lifecycle',
|
||||
'label' => 'Run finished',
|
||||
'color' => 'gray',
|
||||
'classification' => 'primary',
|
||||
'next_action_policy' => 'none',
|
||||
'legacy_aliases' => ['Completed'],
|
||||
'notes' => 'Execution has reached a terminal state and the outcome badge carries the primary meaning.',
|
||||
],
|
||||
],
|
||||
'operation_run_outcome' => [
|
||||
'pending' => [
|
||||
'axis' => 'execution_outcome',
|
||||
'label' => 'Awaiting result',
|
||||
'color' => 'gray',
|
||||
'classification' => 'primary',
|
||||
'next_action_policy' => 'none',
|
||||
'legacy_aliases' => ['Pending'],
|
||||
'notes' => 'Execution has not produced a terminal outcome yet.',
|
||||
],
|
||||
'succeeded' => [
|
||||
'axis' => 'execution_outcome',
|
||||
'label' => 'Completed successfully',
|
||||
'color' => 'success',
|
||||
'classification' => 'primary',
|
||||
'next_action_policy' => 'none',
|
||||
'legacy_aliases' => ['Succeeded'],
|
||||
'notes' => 'The run finished without operator follow-up.',
|
||||
],
|
||||
'partially_succeeded' => [
|
||||
'axis' => 'execution_outcome',
|
||||
'label' => 'Completed with follow-up',
|
||||
'color' => 'warning',
|
||||
'classification' => 'primary',
|
||||
'next_action_policy' => 'optional',
|
||||
'legacy_aliases' => ['Partially succeeded', 'Partial'],
|
||||
'notes' => 'The run finished but needs operator review or cleanup.',
|
||||
],
|
||||
'blocked' => [
|
||||
'axis' => 'execution_outcome',
|
||||
'label' => 'Blocked by prerequisite',
|
||||
'color' => 'warning',
|
||||
'classification' => 'primary',
|
||||
'next_action_policy' => 'required',
|
||||
'legacy_aliases' => ['Blocked'],
|
||||
'notes' => 'Execution could not start or continue until a prerequisite is fixed.',
|
||||
],
|
||||
'failed' => [
|
||||
'axis' => 'execution_outcome',
|
||||
'label' => 'Execution failed',
|
||||
'color' => 'danger',
|
||||
'classification' => 'primary',
|
||||
'next_action_policy' => 'required',
|
||||
'legacy_aliases' => ['Failed'],
|
||||
'notes' => 'Execution ended unsuccessfully and needs operator attention.',
|
||||
],
|
||||
'cancelled' => [
|
||||
'axis' => 'execution_outcome',
|
||||
'label' => 'Cancelled',
|
||||
'color' => 'gray',
|
||||
'classification' => 'primary',
|
||||
'next_action_policy' => 'none',
|
||||
'legacy_aliases' => ['Cancelled'],
|
||||
'notes' => 'Execution was intentionally stopped.',
|
||||
],
|
||||
],
|
||||
'evidence_completeness' => [
|
||||
'complete' => [
|
||||
'axis' => 'data_coverage',
|
||||
'label' => 'Coverage ready',
|
||||
'color' => 'success',
|
||||
'classification' => 'primary',
|
||||
'next_action_policy' => 'none',
|
||||
'legacy_aliases' => ['Complete'],
|
||||
'notes' => 'Required evidence is present.',
|
||||
],
|
||||
'partial' => [
|
||||
'axis' => 'data_coverage',
|
||||
'label' => 'Coverage incomplete',
|
||||
'color' => 'warning',
|
||||
'classification' => 'primary',
|
||||
'next_action_policy' => 'required',
|
||||
'legacy_aliases' => ['Partial'],
|
||||
'notes' => 'Some required evidence dimensions are still missing.',
|
||||
],
|
||||
'missing' => [
|
||||
'axis' => 'data_coverage',
|
||||
'label' => 'Not collected yet',
|
||||
'color' => 'info',
|
||||
'classification' => 'primary',
|
||||
'next_action_policy' => 'optional',
|
||||
'legacy_aliases' => ['Missing'],
|
||||
'notes' => 'No evidence has been captured for this slice yet. This is not a failure by itself.',
|
||||
],
|
||||
'stale' => [
|
||||
'axis' => 'data_freshness',
|
||||
'label' => 'Refresh recommended',
|
||||
'color' => 'warning',
|
||||
'classification' => 'primary',
|
||||
'next_action_policy' => 'optional',
|
||||
'legacy_aliases' => ['Stale'],
|
||||
'notes' => 'Evidence exists but is old enough that the operator should refresh it before relying on it.',
|
||||
],
|
||||
],
|
||||
'tenant_review_completeness' => [
|
||||
'complete' => [
|
||||
'axis' => 'data_coverage',
|
||||
'label' => 'Review inputs ready',
|
||||
'color' => 'success',
|
||||
'classification' => 'primary',
|
||||
'next_action_policy' => 'none',
|
||||
'legacy_aliases' => ['Complete'],
|
||||
'notes' => 'The review has the evidence inputs it needs.',
|
||||
],
|
||||
'partial' => [
|
||||
'axis' => 'data_coverage',
|
||||
'label' => 'Review inputs incomplete',
|
||||
'color' => 'warning',
|
||||
'classification' => 'primary',
|
||||
'next_action_policy' => 'required',
|
||||
'legacy_aliases' => ['Partial'],
|
||||
'notes' => 'Some review sections still need inputs.',
|
||||
],
|
||||
'missing' => [
|
||||
'axis' => 'data_coverage',
|
||||
'label' => 'Review input pending',
|
||||
'color' => 'info',
|
||||
'classification' => 'primary',
|
||||
'next_action_policy' => 'optional',
|
||||
'legacy_aliases' => ['Missing'],
|
||||
'notes' => 'The review has not been anchored to usable evidence yet.',
|
||||
],
|
||||
'stale' => [
|
||||
'axis' => 'data_freshness',
|
||||
'label' => 'Refresh review inputs',
|
||||
'color' => 'warning',
|
||||
'classification' => 'primary',
|
||||
'next_action_policy' => 'optional',
|
||||
'legacy_aliases' => ['Stale'],
|
||||
'notes' => 'The review input exists but should be refreshed before stakeholder use.',
|
||||
],
|
||||
],
|
||||
'baseline_snapshot_fidelity' => [
|
||||
'full' => [
|
||||
'axis' => 'evidence_depth',
|
||||
'label' => 'Detailed evidence',
|
||||
'color' => 'success',
|
||||
'classification' => 'diagnostic',
|
||||
'next_action_policy' => 'none',
|
||||
'legacy_aliases' => ['Full'],
|
||||
'notes' => 'Full structured evidence detail is available.',
|
||||
],
|
||||
'partial' => [
|
||||
'axis' => 'evidence_depth',
|
||||
'label' => 'Mixed evidence detail',
|
||||
'color' => 'info',
|
||||
'classification' => 'diagnostic',
|
||||
'next_action_policy' => 'none',
|
||||
'legacy_aliases' => ['Partial'],
|
||||
'notes' => 'Some items have full detail while others are metadata-only.',
|
||||
],
|
||||
'reference_only' => [
|
||||
'axis' => 'evidence_depth',
|
||||
'label' => 'Metadata only',
|
||||
'color' => 'info',
|
||||
'classification' => 'diagnostic',
|
||||
'next_action_policy' => 'none',
|
||||
'legacy_aliases' => ['Reference only'],
|
||||
'notes' => 'Only reference metadata is available for this capture.',
|
||||
],
|
||||
'unsupported' => [
|
||||
'axis' => 'product_support_maturity',
|
||||
'label' => 'Support limited',
|
||||
'color' => 'gray',
|
||||
'classification' => 'diagnostic',
|
||||
'next_action_policy' => 'none',
|
||||
'legacy_aliases' => ['Unsupported'],
|
||||
'diagnostic_label' => 'Fallback renderer',
|
||||
'notes' => 'The renderer fell back to a lower-fidelity representation. This is diagnostic context, not a governance gap.',
|
||||
],
|
||||
],
|
||||
'baseline_snapshot_gap_status' => [
|
||||
'clear' => [
|
||||
'axis' => 'data_coverage',
|
||||
'label' => 'No follow-up needed',
|
||||
'color' => 'success',
|
||||
'classification' => 'primary',
|
||||
'next_action_policy' => 'none',
|
||||
'legacy_aliases' => ['No gaps'],
|
||||
'notes' => 'The captured group does not contain unresolved coverage gaps.',
|
||||
],
|
||||
'gaps_present' => [
|
||||
'axis' => 'data_coverage',
|
||||
'label' => 'Coverage gaps need review',
|
||||
'color' => 'warning',
|
||||
'classification' => 'primary',
|
||||
'next_action_policy' => 'required',
|
||||
'legacy_aliases' => ['Gaps present'],
|
||||
'notes' => 'The captured group has unresolved gaps that should be reviewed.',
|
||||
],
|
||||
],
|
||||
'restore_run_status' => [
|
||||
'draft' => [
|
||||
'axis' => 'execution_lifecycle',
|
||||
'label' => 'Draft',
|
||||
'color' => 'gray',
|
||||
'classification' => 'primary',
|
||||
'next_action_policy' => 'none',
|
||||
'legacy_aliases' => ['Draft'],
|
||||
'notes' => 'The restore run has not been prepared yet.',
|
||||
],
|
||||
'scoped' => [
|
||||
'axis' => 'execution_lifecycle',
|
||||
'label' => 'Scope selected',
|
||||
'color' => 'gray',
|
||||
'classification' => 'primary',
|
||||
'next_action_policy' => 'none',
|
||||
'legacy_aliases' => ['Scoped'],
|
||||
'notes' => 'Items were selected for restore.',
|
||||
],
|
||||
'checked' => [
|
||||
'axis' => 'execution_lifecycle',
|
||||
'label' => 'Checks complete',
|
||||
'color' => 'gray',
|
||||
'classification' => 'primary',
|
||||
'next_action_policy' => 'none',
|
||||
'legacy_aliases' => ['Checked'],
|
||||
'notes' => 'Safety checks were completed for this run.',
|
||||
],
|
||||
'previewed' => [
|
||||
'axis' => 'execution_lifecycle',
|
||||
'label' => 'Preview ready',
|
||||
'color' => 'gray',
|
||||
'classification' => 'primary',
|
||||
'next_action_policy' => 'none',
|
||||
'legacy_aliases' => ['Previewed'],
|
||||
'notes' => 'A dry-run preview is available for review.',
|
||||
],
|
||||
'pending' => [
|
||||
'axis' => 'execution_lifecycle',
|
||||
'label' => 'Pending execution',
|
||||
'color' => 'gray',
|
||||
'classification' => 'primary',
|
||||
'next_action_policy' => 'none',
|
||||
'legacy_aliases' => ['Pending'],
|
||||
'notes' => 'Execution has not been queued yet.',
|
||||
],
|
||||
'queued' => [
|
||||
'axis' => 'execution_lifecycle',
|
||||
'label' => 'Queued for execution',
|
||||
'color' => 'info',
|
||||
'classification' => 'primary',
|
||||
'next_action_policy' => 'none',
|
||||
'legacy_aliases' => ['Queued'],
|
||||
'notes' => 'Execution is queued and waiting for a worker.',
|
||||
],
|
||||
'running' => [
|
||||
'axis' => 'execution_lifecycle',
|
||||
'label' => 'Applying restore',
|
||||
'color' => 'info',
|
||||
'classification' => 'primary',
|
||||
'next_action_policy' => 'none',
|
||||
'legacy_aliases' => ['Running'],
|
||||
'notes' => 'Execution is currently applying restore work.',
|
||||
],
|
||||
'completed' => [
|
||||
'axis' => 'execution_outcome',
|
||||
'label' => 'Applied successfully',
|
||||
'color' => 'success',
|
||||
'classification' => 'primary',
|
||||
'next_action_policy' => 'none',
|
||||
'legacy_aliases' => ['Completed'],
|
||||
'notes' => 'The restore run finished successfully.',
|
||||
],
|
||||
'partial' => [
|
||||
'axis' => 'execution_outcome',
|
||||
'label' => 'Applied with follow-up',
|
||||
'color' => 'warning',
|
||||
'classification' => 'primary',
|
||||
'next_action_policy' => 'optional',
|
||||
'legacy_aliases' => ['Partial'],
|
||||
'notes' => 'The restore run finished but needs follow-up on a subset of items.',
|
||||
],
|
||||
'failed' => [
|
||||
'axis' => 'execution_outcome',
|
||||
'label' => 'Restore failed',
|
||||
'color' => 'danger',
|
||||
'classification' => 'primary',
|
||||
'next_action_policy' => 'required',
|
||||
'legacy_aliases' => ['Failed'],
|
||||
'notes' => 'The restore run did not complete successfully.',
|
||||
],
|
||||
'cancelled' => [
|
||||
'axis' => 'execution_outcome',
|
||||
'label' => 'Cancelled',
|
||||
'color' => 'gray',
|
||||
'classification' => 'primary',
|
||||
'next_action_policy' => 'none',
|
||||
'legacy_aliases' => ['Cancelled'],
|
||||
'notes' => 'Execution was intentionally cancelled.',
|
||||
],
|
||||
'aborted' => [
|
||||
'axis' => 'execution_outcome',
|
||||
'label' => 'Stopped early',
|
||||
'color' => 'gray',
|
||||
'classification' => 'primary',
|
||||
'next_action_policy' => 'none',
|
||||
'legacy_aliases' => ['Aborted'],
|
||||
'notes' => 'Execution stopped before the normal terminal path completed.',
|
||||
],
|
||||
'completed_with_errors' => [
|
||||
'axis' => 'execution_outcome',
|
||||
'label' => 'Applied with follow-up',
|
||||
'color' => 'warning',
|
||||
'classification' => 'primary',
|
||||
'next_action_policy' => 'optional',
|
||||
'legacy_aliases' => ['Completed with errors'],
|
||||
'notes' => 'Execution completed but still needs follow-up on failed items.',
|
||||
],
|
||||
],
|
||||
'restore_result_status' => [
|
||||
'applied' => [
|
||||
'axis' => 'item_result',
|
||||
'label' => 'Applied',
|
||||
'color' => 'success',
|
||||
'classification' => 'primary',
|
||||
'next_action_policy' => 'none',
|
||||
'legacy_aliases' => ['Applied'],
|
||||
'notes' => 'The item was applied successfully.',
|
||||
],
|
||||
'dry_run' => [
|
||||
'axis' => 'item_result',
|
||||
'label' => 'Preview only',
|
||||
'color' => 'info',
|
||||
'classification' => 'primary',
|
||||
'next_action_policy' => 'none',
|
||||
'legacy_aliases' => ['Dry run'],
|
||||
'notes' => 'The item was only simulated and not applied.',
|
||||
],
|
||||
'mapped' => [
|
||||
'axis' => 'item_result',
|
||||
'label' => 'Mapped to existing item',
|
||||
'color' => 'info',
|
||||
'classification' => 'primary',
|
||||
'next_action_policy' => 'none',
|
||||
'legacy_aliases' => ['Mapped'],
|
||||
'notes' => 'The source item mapped to an existing target.',
|
||||
],
|
||||
'skipped' => [
|
||||
'axis' => 'item_result',
|
||||
'label' => 'Not applied',
|
||||
'color' => 'gray',
|
||||
'classification' => 'primary',
|
||||
'next_action_policy' => 'optional',
|
||||
'legacy_aliases' => ['Skipped'],
|
||||
'notes' => 'The item was intentionally not applied.',
|
||||
],
|
||||
'partial' => [
|
||||
'axis' => 'item_result',
|
||||
'label' => 'Partially applied',
|
||||
'color' => 'warning',
|
||||
'classification' => 'primary',
|
||||
'next_action_policy' => 'optional',
|
||||
'legacy_aliases' => ['Partial'],
|
||||
'notes' => 'The item only applied in part and needs review.',
|
||||
],
|
||||
'manual_required' => [
|
||||
'axis' => 'operator_actionability',
|
||||
'label' => 'Manual follow-up needed',
|
||||
'color' => 'warning',
|
||||
'classification' => 'primary',
|
||||
'next_action_policy' => 'required',
|
||||
'legacy_aliases' => ['Manual required'],
|
||||
'notes' => 'The operator must handle this item manually.',
|
||||
],
|
||||
'failed' => [
|
||||
'axis' => 'item_result',
|
||||
'label' => 'Apply failed',
|
||||
'color' => 'danger',
|
||||
'classification' => 'primary',
|
||||
'next_action_policy' => 'required',
|
||||
'legacy_aliases' => ['Failed'],
|
||||
'notes' => 'The item failed to apply.',
|
||||
],
|
||||
],
|
||||
'restore_preview_decision' => [
|
||||
'created' => [
|
||||
'axis' => 'item_result',
|
||||
'label' => 'Will create',
|
||||
'color' => 'success',
|
||||
'classification' => 'primary',
|
||||
'next_action_policy' => 'none',
|
||||
'legacy_aliases' => ['Created'],
|
||||
'notes' => 'The preview plans to create a new target item.',
|
||||
],
|
||||
'created_copy' => [
|
||||
'axis' => 'item_result',
|
||||
'label' => 'Will create copy',
|
||||
'color' => 'warning',
|
||||
'classification' => 'primary',
|
||||
'next_action_policy' => 'optional',
|
||||
'legacy_aliases' => ['Created copy'],
|
||||
'notes' => 'The preview plans to create a copy and should be reviewed before execution.',
|
||||
],
|
||||
'mapped_existing' => [
|
||||
'axis' => 'item_result',
|
||||
'label' => 'Will map existing',
|
||||
'color' => 'info',
|
||||
'classification' => 'primary',
|
||||
'next_action_policy' => 'none',
|
||||
'legacy_aliases' => ['Mapped existing'],
|
||||
'notes' => 'The preview plans to map this item to an existing target.',
|
||||
],
|
||||
'skipped' => [
|
||||
'axis' => 'item_result',
|
||||
'label' => 'Will skip',
|
||||
'color' => 'gray',
|
||||
'classification' => 'primary',
|
||||
'next_action_policy' => 'optional',
|
||||
'legacy_aliases' => ['Skipped'],
|
||||
'notes' => 'The preview plans to skip this item.',
|
||||
],
|
||||
'failed' => [
|
||||
'axis' => 'item_result',
|
||||
'label' => 'Cannot apply',
|
||||
'color' => 'danger',
|
||||
'classification' => 'primary',
|
||||
'next_action_policy' => 'required',
|
||||
'legacy_aliases' => ['Failed'],
|
||||
'notes' => 'The preview could not produce a viable action for this item.',
|
||||
],
|
||||
],
|
||||
'restore_check_severity' => [
|
||||
'blocking' => [
|
||||
'axis' => 'operator_actionability',
|
||||
'label' => 'Fix before running',
|
||||
'color' => 'danger',
|
||||
'classification' => 'primary',
|
||||
'next_action_policy' => 'required',
|
||||
'legacy_aliases' => ['Blocking'],
|
||||
'notes' => 'Execution should not proceed until this check is fixed.',
|
||||
],
|
||||
'warning' => [
|
||||
'axis' => 'operator_actionability',
|
||||
'label' => 'Review before running',
|
||||
'color' => 'warning',
|
||||
'classification' => 'primary',
|
||||
'next_action_policy' => 'optional',
|
||||
'legacy_aliases' => ['Warning'],
|
||||
'notes' => 'Execution may proceed, but the operator should review the warning first.',
|
||||
],
|
||||
'safe' => [
|
||||
'axis' => 'operator_actionability',
|
||||
'label' => 'Ready to continue',
|
||||
'color' => 'success',
|
||||
'classification' => 'primary',
|
||||
'next_action_policy' => 'none',
|
||||
'legacy_aliases' => ['Safe'],
|
||||
'notes' => 'No blocking issue was found for this check.',
|
||||
],
|
||||
],
|
||||
];
|
||||
|
||||
/**
|
||||
* @return array{
|
||||
* axis: OperatorSemanticAxis,
|
||||
* label: string,
|
||||
* color: string,
|
||||
* classification: OperatorStateClassification,
|
||||
* next_action_policy: OperatorNextActionPolicy,
|
||||
* legacy_aliases: list<string>,
|
||||
* diagnostic_label: ?string,
|
||||
* notes: string
|
||||
* }|null
|
||||
*/
|
||||
public static function entry(BadgeDomain $domain, mixed $value): ?array
|
||||
{
|
||||
$state = BadgeCatalog::normalizeState($value);
|
||||
|
||||
if ($state === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if ($domain === BadgeDomain::OperationRunOutcome && $state === 'operation.blocked') {
|
||||
$state = 'blocked';
|
||||
}
|
||||
|
||||
$entry = self::ENTRIES[$domain->value][$state] ?? null;
|
||||
|
||||
if (! is_array($entry)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return [
|
||||
'axis' => self::axisFrom($entry['axis']),
|
||||
'label' => $entry['label'],
|
||||
'color' => $entry['color'],
|
||||
'classification' => self::classificationFrom($entry['classification']),
|
||||
'next_action_policy' => self::nextActionPolicyFrom($entry['next_action_policy']),
|
||||
'legacy_aliases' => $entry['legacy_aliases'],
|
||||
'diagnostic_label' => $entry['diagnostic_label'] ?? null,
|
||||
'notes' => $entry['notes'],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, array<string, array{
|
||||
* axis: OperatorSemanticAxis,
|
||||
* label: string,
|
||||
* color: string,
|
||||
* classification: OperatorStateClassification,
|
||||
* next_action_policy: OperatorNextActionPolicy,
|
||||
* legacy_aliases: list<string>,
|
||||
* diagnostic_label: ?string,
|
||||
* notes: string
|
||||
* }>>
|
||||
*/
|
||||
public static function all(): array
|
||||
{
|
||||
$entries = [];
|
||||
|
||||
foreach (self::ENTRIES as $domain => $mappings) {
|
||||
foreach ($mappings as $state => $entry) {
|
||||
$entries[$domain][$state] = [
|
||||
'axis' => self::axisFrom($entry['axis']),
|
||||
'label' => $entry['label'],
|
||||
'color' => $entry['color'],
|
||||
'classification' => self::classificationFrom($entry['classification']),
|
||||
'next_action_policy' => self::nextActionPolicyFrom($entry['next_action_policy']),
|
||||
'legacy_aliases' => $entry['legacy_aliases'],
|
||||
'diagnostic_label' => $entry['diagnostic_label'] ?? null,
|
||||
'notes' => $entry['notes'],
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
return $entries;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<array{name: string, domain: BadgeDomain, raw_value: string}>
|
||||
*/
|
||||
public static function curatedExamples(): array
|
||||
{
|
||||
return [
|
||||
['name' => 'Operation blocked by missing prerequisite', 'domain' => BadgeDomain::OperationRunOutcome, 'raw_value' => 'blocked'],
|
||||
['name' => 'Artifact exists but is not usable', 'domain' => BadgeDomain::GovernanceArtifactExistence, 'raw_value' => 'created_but_not_usable'],
|
||||
['name' => 'Artifact is trustworthy', 'domain' => BadgeDomain::GovernanceArtifactContent, 'raw_value' => 'trusted'],
|
||||
['name' => 'Artifact is stale', 'domain' => BadgeDomain::GovernanceArtifactFreshness, 'raw_value' => 'stale'],
|
||||
['name' => 'Artifact is publishable', 'domain' => BadgeDomain::GovernanceArtifactPublicationReadiness, 'raw_value' => 'publishable'],
|
||||
['name' => 'Artifact requires action', 'domain' => BadgeDomain::GovernanceArtifactActionability, 'raw_value' => 'required'],
|
||||
['name' => 'Operation completed with follow-up', 'domain' => BadgeDomain::OperationRunOutcome, 'raw_value' => 'partially_succeeded'],
|
||||
['name' => 'Operation completed successfully', 'domain' => BadgeDomain::OperationRunOutcome, 'raw_value' => 'succeeded'],
|
||||
['name' => 'Evidence not collected yet', 'domain' => BadgeDomain::EvidenceCompleteness, 'raw_value' => 'missing'],
|
||||
['name' => 'Evidence refresh recommended', 'domain' => BadgeDomain::EvidenceCompleteness, 'raw_value' => 'stale'],
|
||||
['name' => 'Review input pending', 'domain' => BadgeDomain::TenantReviewCompleteness, 'raw_value' => 'missing'],
|
||||
['name' => 'Mixed evidence detail stays diagnostic', 'domain' => BadgeDomain::BaselineSnapshotFidelity, 'raw_value' => 'partial'],
|
||||
['name' => 'Support limited stays diagnostic', 'domain' => BadgeDomain::BaselineSnapshotFidelity, 'raw_value' => 'unsupported'],
|
||||
['name' => 'Coverage gaps need review', 'domain' => BadgeDomain::BaselineSnapshotGapStatus, 'raw_value' => 'gaps_present'],
|
||||
['name' => 'Restore preview blocked by a check', 'domain' => BadgeDomain::RestoreCheckSeverity, 'raw_value' => 'blocking'],
|
||||
['name' => 'Restore run applied with follow-up', 'domain' => BadgeDomain::RestoreRunStatus, 'raw_value' => 'completed_with_errors'],
|
||||
['name' => 'Restore item requires manual follow-up', 'domain' => BadgeDomain::RestoreResultStatus, 'raw_value' => 'manual_required'],
|
||||
];
|
||||
}
|
||||
|
||||
public static function spec(
|
||||
BadgeDomain $domain,
|
||||
mixed $value,
|
||||
?string $icon = null,
|
||||
?string $iconColor = null,
|
||||
): ?BadgeSpec {
|
||||
$entry = self::entry($domain, $value);
|
||||
|
||||
if ($entry === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return new BadgeSpec(
|
||||
label: $entry['label'],
|
||||
color: $entry['color'],
|
||||
icon: $icon,
|
||||
iconColor: $iconColor,
|
||||
semanticAxis: $entry['axis'],
|
||||
classification: $entry['classification'],
|
||||
nextActionPolicy: $entry['next_action_policy'],
|
||||
diagnosticLabel: $entry['diagnostic_label'],
|
||||
legacyAliases: $entry['legacy_aliases'],
|
||||
notes: $entry['notes'],
|
||||
);
|
||||
}
|
||||
|
||||
private static function axisFrom(string $value): OperatorSemanticAxis
|
||||
{
|
||||
return OperatorSemanticAxis::tryFrom($value)
|
||||
?? throw new InvalidArgumentException("Unknown operator semantic axis [{$value}].");
|
||||
}
|
||||
|
||||
private static function classificationFrom(string $value): OperatorStateClassification
|
||||
{
|
||||
return OperatorStateClassification::tryFrom($value)
|
||||
?? throw new InvalidArgumentException("Unknown operator state classification [{$value}].");
|
||||
}
|
||||
|
||||
private static function nextActionPolicyFrom(string $value): OperatorNextActionPolicy
|
||||
{
|
||||
return OperatorNextActionPolicy::tryFrom($value)
|
||||
?? throw new InvalidArgumentException("Unknown operator next-action policy [{$value}].");
|
||||
}
|
||||
}
|
||||
54
app/Support/Badges/OperatorSemanticAxis.php
Normal file
54
app/Support/Badges/OperatorSemanticAxis.php
Normal file
@ -0,0 +1,54 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Support\Badges;
|
||||
|
||||
enum OperatorSemanticAxis: string
|
||||
{
|
||||
case ArtifactExistence = 'artifact_existence';
|
||||
case ExecutionLifecycle = 'execution_lifecycle';
|
||||
case ExecutionOutcome = 'execution_outcome';
|
||||
case ItemResult = 'item_result';
|
||||
case DataCoverage = 'data_coverage';
|
||||
case EvidenceDepth = 'evidence_depth';
|
||||
case ProductSupportMaturity = 'product_support_maturity';
|
||||
case DataFreshness = 'data_freshness';
|
||||
case OperatorActionability = 'operator_actionability';
|
||||
case PublicationReadiness = 'publication_readiness';
|
||||
case GovernanceDeviation = 'governance_deviation';
|
||||
|
||||
public function label(): string
|
||||
{
|
||||
return match ($this) {
|
||||
self::ArtifactExistence => 'Artifact existence',
|
||||
self::ExecutionLifecycle => 'Execution lifecycle',
|
||||
self::ExecutionOutcome => 'Execution outcome',
|
||||
self::ItemResult => 'Item result',
|
||||
self::DataCoverage => 'Data coverage',
|
||||
self::EvidenceDepth => 'Evidence depth',
|
||||
self::ProductSupportMaturity => 'Product support maturity',
|
||||
self::DataFreshness => 'Data freshness',
|
||||
self::OperatorActionability => 'Operator actionability',
|
||||
self::PublicationReadiness => 'Publication readiness',
|
||||
self::GovernanceDeviation => 'Governance deviation',
|
||||
};
|
||||
}
|
||||
|
||||
public function definition(): string
|
||||
{
|
||||
return match ($this) {
|
||||
self::ArtifactExistence => 'Whether the intended governance artifact actually exists and can be located.',
|
||||
self::ExecutionLifecycle => 'Where a run sits in its execution flow.',
|
||||
self::ExecutionOutcome => 'What happened when execution finished or stopped.',
|
||||
self::ItemResult => 'How one restore or preview item resolved.',
|
||||
self::DataCoverage => 'Whether the expected data or sections are present.',
|
||||
self::EvidenceDepth => 'How much structured evidence detail is available.',
|
||||
self::ProductSupportMaturity => 'Whether the product can represent the source faithfully.',
|
||||
self::DataFreshness => 'Whether the available data is still current enough to trust.',
|
||||
self::OperatorActionability => 'Whether an operator needs to do anything next.',
|
||||
self::PublicationReadiness => 'Whether the current record is ready for stakeholder delivery.',
|
||||
self::GovernanceDeviation => 'Whether the record represents a real governance problem.',
|
||||
};
|
||||
}
|
||||
}
|
||||
16
app/Support/Badges/OperatorStateClassification.php
Normal file
16
app/Support/Badges/OperatorStateClassification.php
Normal file
@ -0,0 +1,16 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Support\Badges;
|
||||
|
||||
enum OperatorStateClassification: string
|
||||
{
|
||||
case Primary = 'primary';
|
||||
case Diagnostic = 'diagnostic';
|
||||
|
||||
public function isDiagnostic(): bool
|
||||
{
|
||||
return $this === self::Diagnostic;
|
||||
}
|
||||
}
|
||||
@ -230,9 +230,9 @@ public static function platforms(?iterable $platforms = null): array
|
||||
public static function restoreRunOutcomes(): array
|
||||
{
|
||||
return [
|
||||
'succeeded' => 'Succeeded',
|
||||
'partial' => 'Partial',
|
||||
'failed' => 'Failed',
|
||||
'succeeded' => BadgeCatalog::spec(BadgeDomain::RestoreRunStatus, RestoreRunStatus::Completed->value)->label,
|
||||
'partial' => BadgeCatalog::spec(BadgeDomain::RestoreRunStatus, RestoreRunStatus::Partial->value)->label,
|
||||
'failed' => BadgeCatalog::spec(BadgeDomain::RestoreRunStatus, RestoreRunStatus::Failed->value)->label,
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
@ -105,4 +105,20 @@ public static function allowedSummaryKeys(): array
|
||||
{
|
||||
return OperationSummaryKeys::all();
|
||||
}
|
||||
|
||||
public static function governanceArtifactFamily(string $operationType): ?string
|
||||
{
|
||||
return match (trim($operationType)) {
|
||||
'baseline_capture' => 'baseline_snapshot',
|
||||
'tenant.evidence.snapshot.generate' => 'evidence_snapshot',
|
||||
'tenant.review.compose' => 'tenant_review',
|
||||
'tenant.review_pack.generate' => 'review_pack',
|
||||
default => null,
|
||||
};
|
||||
}
|
||||
|
||||
public static function isGovernanceArtifactOperation(string $operationType): bool
|
||||
{
|
||||
return self::governanceArtifactFamily($operationType) !== null;
|
||||
}
|
||||
}
|
||||
|
||||
@ -5,13 +5,20 @@
|
||||
use App\Filament\Pages\BaselineCompareLanding;
|
||||
use App\Filament\Resources\BackupScheduleResource;
|
||||
use App\Filament\Resources\BackupSetResource;
|
||||
use App\Filament\Resources\BaselineSnapshotResource;
|
||||
use App\Filament\Resources\EntraGroupResource;
|
||||
use App\Filament\Resources\EvidenceSnapshotResource;
|
||||
use App\Filament\Resources\InventoryItemResource;
|
||||
use App\Filament\Resources\PolicyResource;
|
||||
use App\Filament\Resources\ProviderConnectionResource;
|
||||
use App\Filament\Resources\RestoreRunResource;
|
||||
use App\Filament\Resources\ReviewPackResource;
|
||||
use App\Filament\Resources\TenantReviewResource;
|
||||
use App\Models\EvidenceSnapshot;
|
||||
use App\Models\OperationRun;
|
||||
use App\Models\ReviewPack;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\TenantReview;
|
||||
use App\Support\Navigation\CanonicalNavigationContext;
|
||||
|
||||
final class OperationRunLinks
|
||||
@ -79,6 +86,14 @@ public static function related(OperationRun $run, ?Tenant $tenant): array
|
||||
$links['Drift'] = BaselineCompareLanding::getUrl(panel: 'tenant', tenant: $tenant);
|
||||
}
|
||||
|
||||
if ($run->type === 'baseline_capture') {
|
||||
$snapshotId = data_get($context, 'result.snapshot_id');
|
||||
|
||||
if (is_numeric($snapshotId)) {
|
||||
$links['Baseline Snapshot'] = BaselineSnapshotResource::getUrl('view', ['record' => (int) $snapshotId], panel: 'admin');
|
||||
}
|
||||
}
|
||||
|
||||
if (in_array($run->type, ['backup_set.add_policies', 'backup_set.remove_policies'], true)) {
|
||||
$links['Backup Sets'] = BackupSetResource::getUrl('index', panel: 'tenant', tenant: $tenant);
|
||||
|
||||
@ -101,6 +116,39 @@ public static function related(OperationRun $run, ?Tenant $tenant): array
|
||||
}
|
||||
}
|
||||
|
||||
if ($run->type === 'tenant.evidence.snapshot.generate') {
|
||||
$snapshot = EvidenceSnapshot::query()
|
||||
->where('operation_run_id', (int) $run->getKey())
|
||||
->latest('id')
|
||||
->first();
|
||||
|
||||
if ($snapshot instanceof EvidenceSnapshot) {
|
||||
$links['Evidence Snapshot'] = EvidenceSnapshotResource::getUrl('view', ['record' => $snapshot], tenant: $tenant);
|
||||
}
|
||||
}
|
||||
|
||||
if ($run->type === 'tenant.review.compose') {
|
||||
$review = TenantReview::query()
|
||||
->where('operation_run_id', (int) $run->getKey())
|
||||
->latest('id')
|
||||
->first();
|
||||
|
||||
if ($review instanceof TenantReview) {
|
||||
$links['Tenant Review'] = TenantReviewResource::tenantScopedUrl('view', ['record' => $review], $tenant);
|
||||
}
|
||||
}
|
||||
|
||||
if ($run->type === 'tenant.review_pack.generate') {
|
||||
$pack = ReviewPack::query()
|
||||
->where('operation_run_id', (int) $run->getKey())
|
||||
->latest('id')
|
||||
->first();
|
||||
|
||||
if ($pack instanceof ReviewPack) {
|
||||
$links['Review Pack'] = ReviewPackResource::getUrl('view', ['record' => $pack], tenant: $tenant);
|
||||
}
|
||||
}
|
||||
|
||||
return array_filter($links, static fn (?string $url): bool => is_string($url) && $url !== '');
|
||||
}
|
||||
}
|
||||
|
||||
@ -4,6 +4,9 @@
|
||||
|
||||
namespace App\Support\Operations;
|
||||
|
||||
use App\Support\ReasonTranslation\NextStepOption;
|
||||
use App\Support\ReasonTranslation\ReasonResolutionEnvelope;
|
||||
|
||||
enum ExecutionDenialReasonCode: string
|
||||
{
|
||||
case WorkspaceMismatch = 'workspace_mismatch';
|
||||
@ -43,4 +46,85 @@ public function message(): string
|
||||
self::ExecutionPrerequisiteInvalid => 'Operation blocked because the queued execution prerequisites are no longer satisfied.',
|
||||
};
|
||||
}
|
||||
|
||||
public function operatorLabel(): string
|
||||
{
|
||||
return match ($this) {
|
||||
self::WorkspaceMismatch => 'Workspace context changed',
|
||||
self::TenantNotEntitled => 'Tenant access removed',
|
||||
self::MissingCapability => 'Permission required',
|
||||
self::TenantNotOperable => 'Tenant not ready',
|
||||
self::TenantMissing => 'Tenant record unavailable',
|
||||
self::InitiatorMissing => 'Initiator no longer available',
|
||||
self::InitiatorNotEntitled => 'Initiator lost tenant access',
|
||||
self::ProviderConnectionInvalid => 'Provider connection needs review',
|
||||
self::WriteGateBlocked => 'Write protection blocked execution',
|
||||
self::ExecutionPrerequisiteInvalid => 'Execution prerequisite changed',
|
||||
};
|
||||
}
|
||||
|
||||
public function shortExplanation(): string
|
||||
{
|
||||
return match ($this) {
|
||||
self::WorkspaceMismatch => 'The queued run no longer matches the current workspace scope.',
|
||||
self::TenantNotEntitled => 'The queued tenant is no longer entitled for this run.',
|
||||
self::MissingCapability => 'The initiating actor no longer has the capability required for this queued run.',
|
||||
self::TenantNotOperable => 'The target tenant is not currently operable for this action.',
|
||||
self::TenantMissing => 'The target tenant could not be resolved when execution resumed.',
|
||||
self::InitiatorMissing => 'The initiating actor could not be resolved when execution resumed.',
|
||||
self::InitiatorNotEntitled => 'The initiating actor is no longer entitled to the target tenant.',
|
||||
self::ProviderConnectionInvalid => 'The queued provider connection is no longer valid for this scope.',
|
||||
self::WriteGateBlocked => 'Current write hardening refuses execution for this tenant until the gate is satisfied.',
|
||||
self::ExecutionPrerequisiteInvalid => 'The queued execution prerequisites are no longer satisfied.',
|
||||
};
|
||||
}
|
||||
|
||||
public function actionability(): string
|
||||
{
|
||||
return match ($this) {
|
||||
self::TenantNotOperable => 'retryable_transient',
|
||||
self::ProviderConnectionInvalid, self::WriteGateBlocked, self::ExecutionPrerequisiteInvalid => 'prerequisite_missing',
|
||||
default => 'permanent_configuration',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, NextStepOption>
|
||||
*/
|
||||
public function nextSteps(): array
|
||||
{
|
||||
return match ($this) {
|
||||
self::MissingCapability, self::TenantNotEntitled, self::InitiatorNotEntitled, self::WorkspaceMismatch => [
|
||||
NextStepOption::instruction('Review workspace or tenant access before retrying.', scope: 'workspace'),
|
||||
],
|
||||
self::TenantNotOperable, self::ExecutionPrerequisiteInvalid => [
|
||||
NextStepOption::instruction('Review tenant readiness before retrying.', scope: 'tenant'),
|
||||
],
|
||||
self::ProviderConnectionInvalid => [
|
||||
NextStepOption::instruction('Review the provider connection before retrying.', scope: 'tenant'),
|
||||
],
|
||||
self::WriteGateBlocked => [
|
||||
NextStepOption::instruction('Review the write gate state before retrying.', scope: 'tenant'),
|
||||
],
|
||||
self::TenantMissing, self::InitiatorMissing => [
|
||||
NextStepOption::instruction('Requeue the operation from a current tenant context.', scope: 'tenant'),
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $context
|
||||
*/
|
||||
public function toReasonResolutionEnvelope(string $surface = 'detail', array $context = []): ReasonResolutionEnvelope
|
||||
{
|
||||
return new ReasonResolutionEnvelope(
|
||||
internalCode: $this->value,
|
||||
operatorLabel: $this->operatorLabel(),
|
||||
shortExplanation: $this->shortExplanation(),
|
||||
actionability: $this->actionability(),
|
||||
nextSteps: $this->nextSteps(),
|
||||
showNoActionNeeded: false,
|
||||
diagnosticCodeLabel: $this->value,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -7,6 +7,7 @@
|
||||
use App\Models\OperationRun;
|
||||
use App\Models\Tenant;
|
||||
use App\Support\OperationCatalog;
|
||||
use App\Support\ReasonTranslation\ReasonPresenter;
|
||||
use App\Support\RedactionIntegrity;
|
||||
use Filament\Notifications\Notification as FilamentNotification;
|
||||
|
||||
@ -25,8 +26,8 @@ public static function queuedToast(string $operationType): FilamentNotification
|
||||
|
||||
return FilamentNotification::make()
|
||||
->title("{$operationLabel} queued")
|
||||
->body('Running in the background.')
|
||||
->warning()
|
||||
->body('Queued for execution. Open the run for progress and next steps.')
|
||||
->info()
|
||||
->duration(self::QUEUED_TOAST_DURATION_MS);
|
||||
}
|
||||
|
||||
@ -39,7 +40,7 @@ public static function alreadyQueuedToast(string $operationType): FilamentNotifi
|
||||
|
||||
return FilamentNotification::make()
|
||||
->title("{$operationLabel} already queued")
|
||||
->body('A matching run is already queued or running.')
|
||||
->body('A matching run is already queued or running. No action needed unless it stays stuck.')
|
||||
->info()
|
||||
->duration(self::QUEUED_TOAST_DURATION_MS);
|
||||
}
|
||||
@ -53,54 +54,33 @@ public static function alreadyQueuedToast(string $operationType): FilamentNotifi
|
||||
public static function terminalDatabaseNotification(OperationRun $run, ?Tenant $tenant = null): FilamentNotification
|
||||
{
|
||||
$operationLabel = OperationCatalog::label((string) $run->type);
|
||||
$presentation = self::terminalPresentation($run);
|
||||
$bodyLines = [$presentation['body']];
|
||||
|
||||
$uxStatus = OperationStatusNormalizer::toUxStatus($run->status, $run->outcome);
|
||||
$failureMessage = self::surfaceFailureDetail($run);
|
||||
if ($failureMessage !== null) {
|
||||
$bodyLines[] = $failureMessage;
|
||||
}
|
||||
|
||||
$titleSuffix = match ($uxStatus) {
|
||||
'succeeded' => 'completed',
|
||||
'partial' => 'completed with warnings',
|
||||
'blocked' => 'blocked',
|
||||
default => 'failed',
|
||||
};
|
||||
|
||||
$body = match ($uxStatus) {
|
||||
'succeeded' => 'Completed successfully.',
|
||||
'partial' => 'Completed with warnings.',
|
||||
'blocked' => 'Execution was blocked.',
|
||||
default => 'Failed.',
|
||||
};
|
||||
|
||||
if (in_array($uxStatus, ['failed', 'blocked'], true)) {
|
||||
$failureMessage = (string) (($run->failure_summary[0]['message'] ?? '') ?? '');
|
||||
|
||||
$failureMessage = self::sanitizeFailureMessage($failureMessage);
|
||||
|
||||
if ($failureMessage !== null) {
|
||||
$body = $body.' '.$failureMessage;
|
||||
}
|
||||
$guidance = self::surfaceGuidance($run);
|
||||
if ($guidance !== null) {
|
||||
$bodyLines[] = $guidance;
|
||||
}
|
||||
|
||||
$summary = SummaryCountsNormalizer::renderSummaryLine(is_array($run->summary_counts) ? $run->summary_counts : []);
|
||||
if ($summary !== null) {
|
||||
$body = $body."\n".$summary;
|
||||
$bodyLines[] = $summary;
|
||||
}
|
||||
|
||||
$integritySummary = RedactionIntegrity::noteForRun($run);
|
||||
if (is_string($integritySummary) && trim($integritySummary) !== '') {
|
||||
$body = $body."\n".trim($integritySummary);
|
||||
$bodyLines[] = trim($integritySummary);
|
||||
}
|
||||
|
||||
$status = match ($uxStatus) {
|
||||
'succeeded' => 'success',
|
||||
'partial' => 'warning',
|
||||
'blocked' => 'warning',
|
||||
default => 'danger',
|
||||
};
|
||||
|
||||
$notification = FilamentNotification::make()
|
||||
->title("{$operationLabel} {$titleSuffix}")
|
||||
->body($body)
|
||||
->status($status);
|
||||
->title("{$operationLabel} {$presentation['titleSuffix']}")
|
||||
->body(implode("\n", $bodyLines))
|
||||
->status($presentation['status']);
|
||||
|
||||
if ($tenant instanceof Tenant) {
|
||||
$notification->actions([
|
||||
@ -113,6 +93,115 @@ public static function terminalDatabaseNotification(OperationRun $run, ?Tenant $
|
||||
return $notification;
|
||||
}
|
||||
|
||||
public static function surfaceGuidance(OperationRun $run): ?string
|
||||
{
|
||||
$uxStatus = OperationStatusNormalizer::toUxStatus($run->status, $run->outcome);
|
||||
$reasonEnvelope = self::reasonEnvelope($run);
|
||||
$reasonGuidance = app(ReasonPresenter::class)->guidance($reasonEnvelope);
|
||||
$nextStepLabel = self::firstNextStepLabel($run);
|
||||
|
||||
if (in_array($uxStatus, ['blocked', 'failed', 'partial'], true) && $reasonGuidance !== null) {
|
||||
return $reasonGuidance;
|
||||
}
|
||||
|
||||
return match ($uxStatus) {
|
||||
'queued' => 'No action needed yet. The run is waiting for a worker.',
|
||||
'running' => 'No action needed yet. The run is currently in progress.',
|
||||
'succeeded' => 'No action needed.',
|
||||
'partial' => $nextStepLabel !== null
|
||||
? 'Next step: '.$nextStepLabel.'.'
|
||||
: (self::requiresFollowUp($run)
|
||||
? 'Review the affected items before rerunning.'
|
||||
: 'No action needed unless the recorded warnings were unexpected.'),
|
||||
'blocked' => $nextStepLabel !== null
|
||||
? 'Next step: '.$nextStepLabel.'.'
|
||||
: 'Review the blocked prerequisite before retrying.',
|
||||
default => $nextStepLabel !== null
|
||||
? 'Next step: '.$nextStepLabel.'.'
|
||||
: 'Review the run details before retrying.',
|
||||
};
|
||||
}
|
||||
|
||||
public static function surfaceFailureDetail(OperationRun $run): ?string
|
||||
{
|
||||
$reasonEnvelope = self::reasonEnvelope($run);
|
||||
|
||||
if ($reasonEnvelope !== null) {
|
||||
return $reasonEnvelope->shortExplanation;
|
||||
}
|
||||
|
||||
$failureMessage = (string) (($run->failure_summary[0]['message'] ?? '') ?? '');
|
||||
|
||||
return self::sanitizeFailureMessage($failureMessage);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{titleSuffix: string, body: string, status: string}
|
||||
*/
|
||||
private static function terminalPresentation(OperationRun $run): array
|
||||
{
|
||||
$uxStatus = OperationStatusNormalizer::toUxStatus($run->status, $run->outcome);
|
||||
$reasonEnvelope = self::reasonEnvelope($run);
|
||||
|
||||
return match ($uxStatus) {
|
||||
'succeeded' => [
|
||||
'titleSuffix' => 'completed successfully',
|
||||
'body' => 'Completed successfully.',
|
||||
'status' => 'success',
|
||||
],
|
||||
'partial' => [
|
||||
'titleSuffix' => self::requiresFollowUp($run) ? 'needs follow-up' : 'completed with review notes',
|
||||
'body' => 'Completed with follow-up.',
|
||||
'status' => 'warning',
|
||||
],
|
||||
'blocked' => [
|
||||
'titleSuffix' => 'blocked by prerequisite',
|
||||
'body' => $reasonEnvelope?->operatorLabel ?? 'Blocked by prerequisite.',
|
||||
'status' => 'warning',
|
||||
],
|
||||
default => [
|
||||
'titleSuffix' => 'execution failed',
|
||||
'body' => $reasonEnvelope?->operatorLabel ?? 'Execution failed.',
|
||||
'status' => 'danger',
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
private static function requiresFollowUp(OperationRun $run): bool
|
||||
{
|
||||
if (self::firstNextStepLabel($run) !== null) {
|
||||
return true;
|
||||
}
|
||||
|
||||
$counts = SummaryCountsNormalizer::normalize(is_array($run->summary_counts) ? $run->summary_counts : []);
|
||||
|
||||
return (int) ($counts['failed'] ?? 0) > 0;
|
||||
}
|
||||
|
||||
private static function firstNextStepLabel(OperationRun $run): ?string
|
||||
{
|
||||
$context = is_array($run->context) ? $run->context : [];
|
||||
$nextSteps = $context['next_steps'] ?? null;
|
||||
|
||||
if (! is_array($nextSteps)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
foreach ($nextSteps as $nextStep) {
|
||||
if (! is_array($nextStep)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$label = trim((string) ($nextStep['label'] ?? ''));
|
||||
|
||||
if ($label !== '') {
|
||||
return $label;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static function sanitizeFailureMessage(string $failureMessage): ?string
|
||||
{
|
||||
$failureMessage = trim($failureMessage);
|
||||
@ -129,4 +218,9 @@ private static function sanitizeFailureMessage(string $failureMessage): ?string
|
||||
|
||||
return $failureMessage !== '' ? $failureMessage : null;
|
||||
}
|
||||
|
||||
private static function reasonEnvelope(OperationRun $run): ?\App\Support\ReasonTranslation\ReasonResolutionEnvelope
|
||||
{
|
||||
return app(ReasonPresenter::class)->forOperationRun($run, 'notification');
|
||||
}
|
||||
}
|
||||
|
||||
@ -38,16 +38,12 @@ public static function sanitizeCode(string $code): string
|
||||
public static function normalizeReasonCode(string $candidate): string
|
||||
{
|
||||
$candidate = strtolower(trim($candidate));
|
||||
$executionDenialReasonCodes = array_map(
|
||||
static fn (ExecutionDenialReasonCode $reasonCode): string => $reasonCode->value,
|
||||
ExecutionDenialReasonCode::cases(),
|
||||
);
|
||||
|
||||
if ($candidate === '') {
|
||||
return ProviderReasonCodes::UnknownError;
|
||||
}
|
||||
|
||||
if (ProviderReasonCodes::isKnown($candidate) || in_array($candidate, ['ok', 'not_applicable'], true) || in_array($candidate, $executionDenialReasonCodes, true)) {
|
||||
if (self::isStructuredOperatorReasonCode($candidate) || in_array($candidate, ['ok', 'not_applicable'], true)) {
|
||||
return $candidate;
|
||||
}
|
||||
|
||||
@ -85,11 +81,11 @@ public static function normalizeReasonCode(string $candidate): string
|
||||
default => $candidate,
|
||||
};
|
||||
|
||||
if (ProviderReasonCodes::isKnown($candidate) || in_array($candidate, ['ok', 'not_applicable'], true) || in_array($candidate, $executionDenialReasonCodes, true)) {
|
||||
if (self::isStructuredOperatorReasonCode($candidate) || in_array($candidate, ['ok', 'not_applicable'], true)) {
|
||||
return $candidate;
|
||||
}
|
||||
|
||||
// Heuristic normalization for ad-hoc codes used across jobs/services.
|
||||
// Heuristic normalization for ad-hoc inputs is bounded fallback behavior only.
|
||||
if (str_contains($candidate, 'throttle') || str_contains($candidate, '429')) {
|
||||
return ProviderReasonCodes::RateLimited;
|
||||
}
|
||||
@ -121,6 +117,22 @@ public static function normalizeReasonCode(string $candidate): string
|
||||
return ProviderReasonCodes::UnknownError;
|
||||
}
|
||||
|
||||
public static function isStructuredOperatorReasonCode(string $candidate): bool
|
||||
{
|
||||
$candidate = strtolower(trim($candidate));
|
||||
|
||||
if ($candidate === '') {
|
||||
return false;
|
||||
}
|
||||
|
||||
$executionDenialReasonCodes = array_map(
|
||||
static fn (ExecutionDenialReasonCode $reasonCode): string => $reasonCode->value,
|
||||
ExecutionDenialReasonCode::cases(),
|
||||
);
|
||||
|
||||
return ProviderReasonCodes::isKnown($candidate) || in_array($candidate, $executionDenialReasonCodes, true);
|
||||
}
|
||||
|
||||
public static function sanitizeMessage(string $message): string
|
||||
{
|
||||
$message = trim(str_replace(["\r", "\n"], ' ', $message));
|
||||
|
||||
@ -4,6 +4,8 @@
|
||||
|
||||
namespace App\Support\OpsUx;
|
||||
|
||||
use App\Support\ReasonTranslation\ReasonTranslator;
|
||||
|
||||
final class SummaryCountsNormalizer
|
||||
{
|
||||
/**
|
||||
@ -69,7 +71,7 @@ public static function renderSummaryLine(array $summaryCounts): ?string
|
||||
continue;
|
||||
}
|
||||
|
||||
$parts[] = self::humanizeKey($key).': '.$value;
|
||||
$parts[] = self::label($key).': '.$value;
|
||||
}
|
||||
|
||||
if ($parts === []) {
|
||||
@ -82,11 +84,35 @@ public static function renderSummaryLine(array $summaryCounts): ?string
|
||||
/**
|
||||
* Convert a snake_case summary key to a human-readable label.
|
||||
*/
|
||||
private static function humanizeKey(string $key): string
|
||||
public static function label(string $key): string
|
||||
{
|
||||
$reasonCode = null;
|
||||
|
||||
if (str_starts_with($key, 'reason_')) {
|
||||
$reasonCode = substr($key, strlen('reason_'));
|
||||
} elseif (str_starts_with($key, 'blocked_reason_')) {
|
||||
$reasonCode = substr($key, strlen('blocked_reason_'));
|
||||
}
|
||||
|
||||
if (is_string($reasonCode) && $reasonCode !== '') {
|
||||
$translation = app(ReasonTranslator::class)->translate($reasonCode, surface: 'summary_line');
|
||||
|
||||
if ($translation !== null) {
|
||||
return 'Reason: '.$translation->operatorLabel;
|
||||
}
|
||||
}
|
||||
|
||||
return match ($key) {
|
||||
'total' => 'Total',
|
||||
'processed' => 'Processed',
|
||||
'succeeded' => 'Completed successfully',
|
||||
'failed' => 'Failed items',
|
||||
'skipped' => 'Skipped items',
|
||||
'items' => 'Affected items',
|
||||
'tenants' => 'Tenants',
|
||||
'created' => 'Created',
|
||||
'updated' => 'Updated',
|
||||
'deleted' => 'Deleted',
|
||||
'finding_count' => 'Findings',
|
||||
'report_count' => 'Reports',
|
||||
'operation_count' => 'Operations',
|
||||
|
||||
@ -2,91 +2,23 @@
|
||||
|
||||
namespace App\Support\Providers;
|
||||
|
||||
use App\Filament\Resources\ProviderConnectionResource;
|
||||
use App\Models\ProviderConnection;
|
||||
use App\Models\Tenant;
|
||||
use App\Support\Links\RequiredPermissionsLinks;
|
||||
use App\Support\ReasonTranslation\ReasonPresenter;
|
||||
|
||||
final class ProviderNextStepsRegistry
|
||||
{
|
||||
public function __construct(
|
||||
private readonly ReasonPresenter $reasonPresenter,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* @return array<int, array{label: string, url: string}>
|
||||
*/
|
||||
public function forReason(Tenant $tenant, string $reasonCode, ?ProviderConnection $connection = null): array
|
||||
{
|
||||
return match ($reasonCode) {
|
||||
ProviderReasonCodes::ProviderConnectionMissing,
|
||||
ProviderReasonCodes::ProviderConnectionInvalid,
|
||||
ProviderReasonCodes::TenantTargetMismatch,
|
||||
ProviderReasonCodes::PlatformIdentityMissing,
|
||||
ProviderReasonCodes::PlatformIdentityIncomplete,
|
||||
ProviderReasonCodes::ProviderConnectionReviewRequired => [
|
||||
[
|
||||
'label' => $connection instanceof ProviderConnection ? 'Review migration classification' : 'Manage Provider Connections',
|
||||
'url' => $connection instanceof ProviderConnection
|
||||
? ProviderConnectionResource::getUrl('view', ['tenant' => $tenant->external_id, 'record' => (int) $connection->getKey()], panel: 'admin')
|
||||
: ProviderConnectionResource::getUrl('index', ['tenant' => $tenant->external_id], panel: 'admin'),
|
||||
],
|
||||
[
|
||||
'label' => 'Review effective app details',
|
||||
'url' => $connection instanceof ProviderConnection
|
||||
? ProviderConnectionResource::getUrl('edit', ['tenant' => $tenant->external_id, 'record' => (int) $connection->getKey()], panel: 'admin')
|
||||
: ProviderConnectionResource::getUrl('index', ['tenant' => $tenant->external_id], panel: 'admin'),
|
||||
],
|
||||
],
|
||||
ProviderReasonCodes::DedicatedCredentialMissing,
|
||||
ProviderReasonCodes::DedicatedCredentialInvalid => [
|
||||
[
|
||||
'label' => $connection instanceof ProviderConnection ? 'Manage dedicated connection' : 'Manage Provider Connections',
|
||||
'url' => $connection instanceof ProviderConnection
|
||||
? ProviderConnectionResource::getUrl('edit', ['tenant' => $tenant->external_id, 'record' => (int) $connection->getKey()], panel: 'admin')
|
||||
: ProviderConnectionResource::getUrl('index', ['tenant' => $tenant->external_id], panel: 'admin'),
|
||||
],
|
||||
],
|
||||
ProviderReasonCodes::ProviderCredentialMissing,
|
||||
ProviderReasonCodes::ProviderCredentialInvalid,
|
||||
ProviderReasonCodes::ProviderConsentFailed,
|
||||
ProviderReasonCodes::ProviderConsentRevoked,
|
||||
ProviderReasonCodes::ProviderAuthFailed,
|
||||
ProviderReasonCodes::ProviderConsentMissing => [
|
||||
[
|
||||
'label' => 'Grant admin consent',
|
||||
'url' => RequiredPermissionsLinks::adminConsentPrimaryUrl($tenant),
|
||||
],
|
||||
[
|
||||
'label' => $connection instanceof ProviderConnection
|
||||
? ($connection->connection_type === ProviderConnectionType::Dedicated ? 'Manage dedicated connection' : 'Review platform connection')
|
||||
: 'Manage Provider Connections',
|
||||
'url' => $connection instanceof ProviderConnection
|
||||
? ProviderConnectionResource::getUrl('edit', ['tenant' => $tenant->external_id, 'record' => (int) $connection->getKey()], panel: 'admin')
|
||||
: ProviderConnectionResource::getUrl('index', ['tenant' => $tenant->external_id], panel: 'admin'),
|
||||
],
|
||||
],
|
||||
ProviderReasonCodes::ProviderPermissionMissing,
|
||||
ProviderReasonCodes::ProviderPermissionDenied,
|
||||
ProviderReasonCodes::ProviderPermissionRefreshFailed,
|
||||
ProviderReasonCodes::IntuneRbacPermissionMissing => [
|
||||
[
|
||||
'label' => 'Open Required Permissions',
|
||||
'url' => RequiredPermissionsLinks::requiredPermissions($tenant),
|
||||
],
|
||||
],
|
||||
ProviderReasonCodes::NetworkUnreachable,
|
||||
ProviderReasonCodes::RateLimited,
|
||||
ProviderReasonCodes::UnknownError => [
|
||||
[
|
||||
'label' => 'Review Provider Connection',
|
||||
'url' => $connection instanceof ProviderConnection
|
||||
? ProviderConnectionResource::getUrl('edit', ['tenant' => $tenant->external_id, 'record' => (int) $connection->getKey()], panel: 'admin')
|
||||
: ProviderConnectionResource::getUrl('index', ['tenant' => $tenant->external_id], panel: 'admin'),
|
||||
],
|
||||
],
|
||||
default => [
|
||||
[
|
||||
'label' => 'Manage Provider Connections',
|
||||
'url' => ProviderConnectionResource::getUrl('index', ['tenant' => $tenant->external_id], panel: 'admin'),
|
||||
],
|
||||
],
|
||||
};
|
||||
$envelope = $this->reasonPresenter->forProviderReason($tenant, $reasonCode, $connection, 'helper_copy');
|
||||
|
||||
return $envelope?->toLegacyNextSteps() ?? [];
|
||||
}
|
||||
}
|
||||
|
||||
364
app/Support/Providers/ProviderReasonTranslator.php
Normal file
364
app/Support/Providers/ProviderReasonTranslator.php
Normal file
@ -0,0 +1,364 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Support\Providers;
|
||||
|
||||
use App\Filament\Resources\ProviderConnectionResource;
|
||||
use App\Models\ProviderConnection;
|
||||
use App\Models\Tenant;
|
||||
use App\Support\Links\RequiredPermissionsLinks;
|
||||
use App\Support\ReasonTranslation\Contracts\TranslatesReasonCode;
|
||||
use App\Support\ReasonTranslation\NextStepOption;
|
||||
use App\Support\ReasonTranslation\ReasonResolutionEnvelope;
|
||||
|
||||
final class ProviderReasonTranslator implements TranslatesReasonCode
|
||||
{
|
||||
public const string ARTIFACT_KEY = 'provider_reason_codes';
|
||||
|
||||
public function artifactKey(): string
|
||||
{
|
||||
return self::ARTIFACT_KEY;
|
||||
}
|
||||
|
||||
public function canTranslate(string $reasonCode): bool
|
||||
{
|
||||
return ProviderReasonCodes::isKnown(trim($reasonCode));
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $context
|
||||
*/
|
||||
public function translate(string $reasonCode, string $surface = 'detail', array $context = []): ?ReasonResolutionEnvelope
|
||||
{
|
||||
$reasonCode = trim($reasonCode);
|
||||
|
||||
if ($reasonCode === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
$normalizedCode = ProviderReasonCodes::isKnown($reasonCode)
|
||||
? $reasonCode
|
||||
: ProviderReasonCodes::UnknownError;
|
||||
$tenant = $context['tenant'] ?? null;
|
||||
$connection = $context['connection'] ?? null;
|
||||
|
||||
if (! $tenant instanceof Tenant) {
|
||||
$nextSteps = $this->fallbackNextSteps($normalizedCode);
|
||||
} else {
|
||||
$nextSteps = $this->nextStepsFor($tenant, $normalizedCode, $connection instanceof ProviderConnection ? $connection : null);
|
||||
}
|
||||
|
||||
return match ($normalizedCode) {
|
||||
ProviderReasonCodes::ProviderConnectionMissing => $this->envelope(
|
||||
reasonCode: $normalizedCode,
|
||||
operatorLabel: 'Provider connection required',
|
||||
shortExplanation: 'This tenant does not have a usable provider connection for Microsoft operations.',
|
||||
actionability: 'prerequisite_missing',
|
||||
nextSteps: $nextSteps,
|
||||
),
|
||||
ProviderReasonCodes::ProviderConnectionInvalid => $this->envelope(
|
||||
reasonCode: $normalizedCode,
|
||||
operatorLabel: 'Provider connection needs review',
|
||||
shortExplanation: 'The selected provider connection is incomplete or no longer valid for this workflow.',
|
||||
actionability: 'prerequisite_missing',
|
||||
nextSteps: $nextSteps,
|
||||
),
|
||||
ProviderReasonCodes::ProviderCredentialMissing => $this->envelope(
|
||||
reasonCode: $normalizedCode,
|
||||
operatorLabel: 'Credentials missing',
|
||||
shortExplanation: 'The provider connection is missing the credentials required to authenticate.',
|
||||
actionability: 'prerequisite_missing',
|
||||
nextSteps: $nextSteps,
|
||||
),
|
||||
ProviderReasonCodes::ProviderCredentialInvalid => $this->envelope(
|
||||
reasonCode: $normalizedCode,
|
||||
operatorLabel: 'Credentials need review',
|
||||
shortExplanation: 'Stored provider credentials are no longer valid for the selected provider connection.',
|
||||
actionability: 'prerequisite_missing',
|
||||
nextSteps: $nextSteps,
|
||||
),
|
||||
ProviderReasonCodes::ProviderConnectionTypeInvalid => $this->envelope(
|
||||
reasonCode: $normalizedCode,
|
||||
operatorLabel: 'Connection type unsupported',
|
||||
shortExplanation: 'The selected provider connection type cannot be used for this workflow.',
|
||||
actionability: 'permanent_configuration',
|
||||
nextSteps: $nextSteps,
|
||||
),
|
||||
ProviderReasonCodes::PlatformIdentityMissing => $this->envelope(
|
||||
reasonCode: $normalizedCode,
|
||||
operatorLabel: 'Platform identity missing',
|
||||
shortExplanation: 'The platform provider connection is missing the app identity details required to continue.',
|
||||
actionability: 'prerequisite_missing',
|
||||
nextSteps: $nextSteps,
|
||||
),
|
||||
ProviderReasonCodes::PlatformIdentityIncomplete => $this->envelope(
|
||||
reasonCode: $normalizedCode,
|
||||
operatorLabel: 'Platform identity incomplete',
|
||||
shortExplanation: 'The platform provider connection needs more app identity details before it can continue.',
|
||||
actionability: 'prerequisite_missing',
|
||||
nextSteps: $nextSteps,
|
||||
),
|
||||
ProviderReasonCodes::DedicatedCredentialMissing => $this->envelope(
|
||||
reasonCode: $normalizedCode,
|
||||
operatorLabel: 'Dedicated credentials required',
|
||||
shortExplanation: 'This dedicated provider connection cannot continue until dedicated credentials are configured.',
|
||||
actionability: 'prerequisite_missing',
|
||||
nextSteps: $nextSteps,
|
||||
),
|
||||
ProviderReasonCodes::DedicatedCredentialInvalid => $this->envelope(
|
||||
reasonCode: $normalizedCode,
|
||||
operatorLabel: 'Dedicated credentials need review',
|
||||
shortExplanation: 'The dedicated credentials are no longer valid for this provider connection.',
|
||||
actionability: 'prerequisite_missing',
|
||||
nextSteps: $nextSteps,
|
||||
),
|
||||
ProviderReasonCodes::ProviderConsentMissing => $this->envelope(
|
||||
reasonCode: $normalizedCode,
|
||||
operatorLabel: 'Admin consent required',
|
||||
shortExplanation: 'The provider connection cannot continue until admin consent is granted.',
|
||||
actionability: 'prerequisite_missing',
|
||||
nextSteps: $nextSteps,
|
||||
),
|
||||
ProviderReasonCodes::ProviderConsentFailed => $this->envelope(
|
||||
reasonCode: $normalizedCode,
|
||||
operatorLabel: 'Admin consent check failed',
|
||||
shortExplanation: 'TenantPilot could not confirm admin consent for this provider connection.',
|
||||
actionability: 'prerequisite_missing',
|
||||
nextSteps: $nextSteps,
|
||||
),
|
||||
ProviderReasonCodes::ProviderConsentRevoked => $this->envelope(
|
||||
reasonCode: $normalizedCode,
|
||||
operatorLabel: 'Admin consent revoked',
|
||||
shortExplanation: 'Previously granted admin consent is no longer valid for this provider connection.',
|
||||
actionability: 'prerequisite_missing',
|
||||
nextSteps: $nextSteps,
|
||||
),
|
||||
ProviderReasonCodes::ProviderConnectionReviewRequired => $this->envelope(
|
||||
reasonCode: $normalizedCode,
|
||||
operatorLabel: 'Connection classification needs review',
|
||||
shortExplanation: 'TenantPilot needs you to confirm how this provider connection should be used.',
|
||||
actionability: 'prerequisite_missing',
|
||||
nextSteps: $nextSteps,
|
||||
),
|
||||
ProviderReasonCodes::ProviderAuthFailed => $this->envelope(
|
||||
reasonCode: $normalizedCode,
|
||||
operatorLabel: 'Provider authentication failed',
|
||||
shortExplanation: 'The provider connection could not authenticate with the stored credentials.',
|
||||
actionability: 'prerequisite_missing',
|
||||
nextSteps: $nextSteps,
|
||||
),
|
||||
ProviderReasonCodes::ProviderPermissionMissing => $this->envelope(
|
||||
reasonCode: $normalizedCode,
|
||||
operatorLabel: 'Permissions missing',
|
||||
shortExplanation: 'The provider app is missing required Microsoft Graph permissions.',
|
||||
actionability: 'prerequisite_missing',
|
||||
nextSteps: $nextSteps,
|
||||
),
|
||||
ProviderReasonCodes::ProviderPermissionDenied => $this->envelope(
|
||||
reasonCode: $normalizedCode,
|
||||
operatorLabel: 'Permission denied',
|
||||
shortExplanation: 'Microsoft Graph denied the requested permission for this provider connection.',
|
||||
actionability: 'permanent_configuration',
|
||||
nextSteps: $nextSteps,
|
||||
),
|
||||
ProviderReasonCodes::ProviderPermissionRefreshFailed => $this->envelope(
|
||||
reasonCode: $normalizedCode,
|
||||
operatorLabel: 'Permission refresh failed',
|
||||
shortExplanation: 'TenantPilot could not refresh the provider permission snapshot.',
|
||||
actionability: 'retryable_transient',
|
||||
nextSteps: $nextSteps,
|
||||
),
|
||||
ProviderReasonCodes::IntuneRbacPermissionMissing => $this->envelope(
|
||||
reasonCode: $normalizedCode,
|
||||
operatorLabel: 'Intune RBAC permission missing',
|
||||
shortExplanation: 'The provider app lacks the Intune RBAC permission needed for this workflow.',
|
||||
actionability: 'prerequisite_missing',
|
||||
nextSteps: $nextSteps,
|
||||
),
|
||||
ProviderReasonCodes::TenantTargetMismatch => $this->envelope(
|
||||
reasonCode: $normalizedCode,
|
||||
operatorLabel: 'Connection targets a different tenant',
|
||||
shortExplanation: 'The selected provider connection points to a different Microsoft tenant than the current scope.',
|
||||
actionability: 'permanent_configuration',
|
||||
nextSteps: $nextSteps,
|
||||
),
|
||||
ProviderReasonCodes::NetworkUnreachable => $this->envelope(
|
||||
reasonCode: $normalizedCode,
|
||||
operatorLabel: 'Microsoft Graph temporarily unreachable',
|
||||
shortExplanation: 'TenantPilot could not reach Microsoft Graph or the provider dependency.',
|
||||
actionability: 'retryable_transient',
|
||||
nextSteps: $nextSteps,
|
||||
),
|
||||
ProviderReasonCodes::RateLimited => $this->envelope(
|
||||
reasonCode: $normalizedCode,
|
||||
operatorLabel: 'Request rate limited',
|
||||
shortExplanation: 'Microsoft Graph asked TenantPilot to slow down before retrying.',
|
||||
actionability: 'retryable_transient',
|
||||
nextSteps: $nextSteps,
|
||||
),
|
||||
ProviderReasonCodes::IntuneRbacNotConfigured => $this->envelope(
|
||||
reasonCode: $normalizedCode,
|
||||
operatorLabel: 'Intune RBAC not configured',
|
||||
shortExplanation: 'Intune RBAC has not been configured for this tenant yet.',
|
||||
actionability: 'prerequisite_missing',
|
||||
nextSteps: $nextSteps,
|
||||
),
|
||||
ProviderReasonCodes::IntuneRbacUnhealthy => $this->envelope(
|
||||
reasonCode: $normalizedCode,
|
||||
operatorLabel: 'Intune RBAC health degraded',
|
||||
shortExplanation: 'The latest Intune RBAC health check found a blocking issue.',
|
||||
actionability: 'prerequisite_missing',
|
||||
nextSteps: $nextSteps,
|
||||
),
|
||||
ProviderReasonCodes::IntuneRbacStale => $this->envelope(
|
||||
reasonCode: $normalizedCode,
|
||||
operatorLabel: 'Intune RBAC check is stale',
|
||||
shortExplanation: 'The latest Intune RBAC health check is too old to trust for write operations.',
|
||||
actionability: 'prerequisite_missing',
|
||||
nextSteps: $nextSteps,
|
||||
),
|
||||
default => $this->envelope(
|
||||
reasonCode: $normalizedCode,
|
||||
operatorLabel: str_starts_with($normalizedCode, 'ext.')
|
||||
? 'Provider configuration needs review'
|
||||
: 'Provider check needs review',
|
||||
shortExplanation: 'TenantPilot recorded a provider error that does not yet have a domain-specific translation.',
|
||||
actionability: 'permanent_configuration',
|
||||
nextSteps: $nextSteps,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, NextStepOption> $nextSteps
|
||||
*/
|
||||
private function envelope(
|
||||
string $reasonCode,
|
||||
string $operatorLabel,
|
||||
string $shortExplanation,
|
||||
string $actionability,
|
||||
array $nextSteps,
|
||||
): ReasonResolutionEnvelope {
|
||||
return new ReasonResolutionEnvelope(
|
||||
internalCode: $reasonCode,
|
||||
operatorLabel: $operatorLabel,
|
||||
shortExplanation: $shortExplanation,
|
||||
actionability: $actionability,
|
||||
nextSteps: $nextSteps,
|
||||
showNoActionNeeded: false,
|
||||
diagnosticCodeLabel: $reasonCode,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, NextStepOption>
|
||||
*/
|
||||
private function fallbackNextSteps(string $reasonCode): array
|
||||
{
|
||||
return match ($reasonCode) {
|
||||
ProviderReasonCodes::NetworkUnreachable, ProviderReasonCodes::RateLimited => [
|
||||
NextStepOption::instruction('Retry after the provider dependency recovers.'),
|
||||
],
|
||||
ProviderReasonCodes::UnknownError => [
|
||||
NextStepOption::instruction('Review the provider connection and retry once the cause is understood.'),
|
||||
],
|
||||
default => [
|
||||
NextStepOption::instruction('Review the provider connection before retrying.'),
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, NextStepOption>
|
||||
*/
|
||||
private function nextStepsFor(
|
||||
Tenant $tenant,
|
||||
string $reasonCode,
|
||||
?ProviderConnection $connection = null,
|
||||
): array {
|
||||
return match ($reasonCode) {
|
||||
ProviderReasonCodes::ProviderConnectionMissing,
|
||||
ProviderReasonCodes::ProviderConnectionInvalid,
|
||||
ProviderReasonCodes::TenantTargetMismatch,
|
||||
ProviderReasonCodes::PlatformIdentityMissing,
|
||||
ProviderReasonCodes::PlatformIdentityIncomplete,
|
||||
ProviderReasonCodes::ProviderConnectionReviewRequired => [
|
||||
NextStepOption::link(
|
||||
label: $connection instanceof ProviderConnection ? 'Review migration classification' : 'Manage Provider Connections',
|
||||
destination: $connection instanceof ProviderConnection
|
||||
? ProviderConnectionResource::getUrl('view', ['tenant' => $tenant->external_id, 'record' => (int) $connection->getKey()], panel: 'admin')
|
||||
: ProviderConnectionResource::getUrl('index', ['tenant' => $tenant->external_id], panel: 'admin'),
|
||||
),
|
||||
NextStepOption::link(
|
||||
label: 'Review effective app details',
|
||||
destination: $connection instanceof ProviderConnection
|
||||
? ProviderConnectionResource::getUrl('edit', ['tenant' => $tenant->external_id, 'record' => (int) $connection->getKey()], panel: 'admin')
|
||||
: ProviderConnectionResource::getUrl('index', ['tenant' => $tenant->external_id], panel: 'admin'),
|
||||
),
|
||||
],
|
||||
ProviderReasonCodes::DedicatedCredentialMissing,
|
||||
ProviderReasonCodes::DedicatedCredentialInvalid => [
|
||||
NextStepOption::link(
|
||||
label: $connection instanceof ProviderConnection ? 'Manage dedicated connection' : 'Manage Provider Connections',
|
||||
destination: $connection instanceof ProviderConnection
|
||||
? ProviderConnectionResource::getUrl('edit', ['tenant' => $tenant->external_id, 'record' => (int) $connection->getKey()], panel: 'admin')
|
||||
: ProviderConnectionResource::getUrl('index', ['tenant' => $tenant->external_id], panel: 'admin'),
|
||||
),
|
||||
],
|
||||
ProviderReasonCodes::ProviderCredentialMissing,
|
||||
ProviderReasonCodes::ProviderCredentialInvalid,
|
||||
ProviderReasonCodes::ProviderConsentFailed,
|
||||
ProviderReasonCodes::ProviderConsentRevoked,
|
||||
ProviderReasonCodes::ProviderAuthFailed,
|
||||
ProviderReasonCodes::ProviderConsentMissing => [
|
||||
NextStepOption::link(
|
||||
label: 'Grant admin consent',
|
||||
destination: RequiredPermissionsLinks::adminConsentPrimaryUrl($tenant),
|
||||
),
|
||||
NextStepOption::link(
|
||||
label: $connection instanceof ProviderConnection
|
||||
? ($connection->connection_type === ProviderConnectionType::Dedicated ? 'Manage dedicated connection' : 'Review platform connection')
|
||||
: 'Manage Provider Connections',
|
||||
destination: $connection instanceof ProviderConnection
|
||||
? ProviderConnectionResource::getUrl('edit', ['tenant' => $tenant->external_id, 'record' => (int) $connection->getKey()], panel: 'admin')
|
||||
: ProviderConnectionResource::getUrl('index', ['tenant' => $tenant->external_id], panel: 'admin'),
|
||||
),
|
||||
],
|
||||
ProviderReasonCodes::ProviderPermissionMissing,
|
||||
ProviderReasonCodes::ProviderPermissionDenied,
|
||||
ProviderReasonCodes::ProviderPermissionRefreshFailed,
|
||||
ProviderReasonCodes::IntuneRbacPermissionMissing => [
|
||||
NextStepOption::link(
|
||||
label: 'Open Required Permissions',
|
||||
destination: RequiredPermissionsLinks::requiredPermissions($tenant),
|
||||
),
|
||||
],
|
||||
ProviderReasonCodes::IntuneRbacNotConfigured,
|
||||
ProviderReasonCodes::IntuneRbacUnhealthy,
|
||||
ProviderReasonCodes::IntuneRbacStale => [
|
||||
NextStepOption::link(
|
||||
label: 'Review provider connections',
|
||||
destination: ProviderConnectionResource::getUrl('index', ['tenant' => $tenant->external_id], panel: 'admin'),
|
||||
),
|
||||
NextStepOption::instruction('Refresh the tenant RBAC health check before retrying.', scope: 'tenant'),
|
||||
],
|
||||
ProviderReasonCodes::NetworkUnreachable,
|
||||
ProviderReasonCodes::RateLimited => [
|
||||
NextStepOption::instruction('Retry after the provider dependency recovers.', scope: 'tenant'),
|
||||
NextStepOption::link(
|
||||
label: 'Review provider connection',
|
||||
destination: $connection instanceof ProviderConnection
|
||||
? ProviderConnectionResource::getUrl('edit', ['tenant' => $tenant->external_id, 'record' => (int) $connection->getKey()], panel: 'admin')
|
||||
: ProviderConnectionResource::getUrl('index', ['tenant' => $tenant->external_id], panel: 'admin'),
|
||||
),
|
||||
],
|
||||
default => [
|
||||
NextStepOption::link(
|
||||
label: 'Manage Provider Connections',
|
||||
destination: ProviderConnectionResource::getUrl('index', ['tenant' => $tenant->external_id], panel: 'admin'),
|
||||
),
|
||||
],
|
||||
};
|
||||
}
|
||||
}
|
||||
@ -2,6 +2,9 @@
|
||||
|
||||
namespace App\Support;
|
||||
|
||||
use App\Support\ReasonTranslation\NextStepOption;
|
||||
use App\Support\ReasonTranslation\ReasonResolutionEnvelope;
|
||||
|
||||
enum RbacReason: string
|
||||
{
|
||||
case MissingArtifacts = 'missing_artifacts';
|
||||
@ -14,4 +17,81 @@ enum RbacReason: string
|
||||
case CanaryFailed = 'canary_failed';
|
||||
case ManualAssignmentRequired = 'manual_assignment_required';
|
||||
case UnsupportedApi = 'unsupported_api';
|
||||
|
||||
public function operatorLabel(): string
|
||||
{
|
||||
return match ($this) {
|
||||
self::MissingArtifacts => 'RBAC setup incomplete',
|
||||
self::ServicePrincipalMissing => 'Service principal missing',
|
||||
self::GroupMissing => 'RBAC group missing',
|
||||
self::ServicePrincipalNotMember => 'Service principal not in RBAC group',
|
||||
self::AssignmentMissing => 'RBAC assignment missing',
|
||||
self::RoleMismatch => 'RBAC role mismatch',
|
||||
self::ScopeMismatch => 'RBAC scope mismatch',
|
||||
self::CanaryFailed => 'RBAC validation needs review',
|
||||
self::ManualAssignmentRequired => 'Manual role assignment required',
|
||||
self::UnsupportedApi => 'RBAC API unsupported',
|
||||
};
|
||||
}
|
||||
|
||||
public function shortExplanation(): string
|
||||
{
|
||||
return match ($this) {
|
||||
self::MissingArtifacts => 'TenantPilot could not find the RBAC artifacts required for this tenant.',
|
||||
self::ServicePrincipalMissing => 'The provider app service principal could not be resolved in Microsoft Graph.',
|
||||
self::GroupMissing => 'The configured Intune RBAC group could not be found.',
|
||||
self::ServicePrincipalNotMember => 'The provider app service principal is not currently a member of the configured RBAC group.',
|
||||
self::AssignmentMissing => 'No matching Intune RBAC assignment could be confirmed for this tenant.',
|
||||
self::RoleMismatch => 'The existing Intune RBAC assignment uses a different role than expected.',
|
||||
self::ScopeMismatch => 'The existing Intune RBAC assignment targets a different scope than expected.',
|
||||
self::CanaryFailed => 'The RBAC canary checks reported a mismatch after setup completed.',
|
||||
self::ManualAssignmentRequired => 'This tenant requires a manual Intune RBAC role assignment outside the automated API path.',
|
||||
self::UnsupportedApi => 'This account type does not support the required Intune RBAC API path.',
|
||||
};
|
||||
}
|
||||
|
||||
public function actionability(): string
|
||||
{
|
||||
return match ($this) {
|
||||
self::CanaryFailed => 'retryable_transient',
|
||||
self::ManualAssignmentRequired => 'prerequisite_missing',
|
||||
self::UnsupportedApi => 'non_actionable',
|
||||
default => 'prerequisite_missing',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, NextStepOption>
|
||||
*/
|
||||
public function nextSteps(): array
|
||||
{
|
||||
return match ($this) {
|
||||
self::UnsupportedApi => [],
|
||||
self::ManualAssignmentRequired => [
|
||||
NextStepOption::instruction('Complete the Intune role assignment manually, then refresh RBAC status.', scope: 'tenant'),
|
||||
],
|
||||
self::CanaryFailed => [
|
||||
NextStepOption::instruction('Review the RBAC canary checks and rerun the health check.', scope: 'tenant'),
|
||||
],
|
||||
default => [
|
||||
NextStepOption::instruction('Review the RBAC setup and refresh the tenant RBAC status.', scope: 'tenant'),
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $context
|
||||
*/
|
||||
public function toReasonResolutionEnvelope(string $surface = 'detail', array $context = []): ReasonResolutionEnvelope
|
||||
{
|
||||
return new ReasonResolutionEnvelope(
|
||||
internalCode: $this->value,
|
||||
operatorLabel: $this->operatorLabel(),
|
||||
shortExplanation: $this->shortExplanation(),
|
||||
actionability: $this->actionability(),
|
||||
nextSteps: $this->nextSteps(),
|
||||
showNoActionNeeded: $this->actionability() === 'non_actionable',
|
||||
diagnosticCodeLabel: $this->value,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -0,0 +1,19 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Support\ReasonTranslation\Contracts;
|
||||
|
||||
use App\Support\ReasonTranslation\ReasonResolutionEnvelope;
|
||||
|
||||
interface TranslatesReasonCode
|
||||
{
|
||||
public function artifactKey(): string;
|
||||
|
||||
public function canTranslate(string $reasonCode): bool;
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $context
|
||||
*/
|
||||
public function translate(string $reasonCode, string $surface = 'detail', array $context = []): ?ReasonResolutionEnvelope;
|
||||
}
|
||||
112
app/Support/ReasonTranslation/FallbackReasonTranslator.php
Normal file
112
app/Support/ReasonTranslation/FallbackReasonTranslator.php
Normal file
@ -0,0 +1,112 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Support\ReasonTranslation;
|
||||
|
||||
use App\Support\ReasonTranslation\Contracts\TranslatesReasonCode;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
final class FallbackReasonTranslator implements TranslatesReasonCode
|
||||
{
|
||||
public const string ARTIFACT_KEY = 'fallback_reason_code';
|
||||
|
||||
public function artifactKey(): string
|
||||
{
|
||||
return self::ARTIFACT_KEY;
|
||||
}
|
||||
|
||||
public function canTranslate(string $reasonCode): bool
|
||||
{
|
||||
return trim($reasonCode) !== '';
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $context
|
||||
*/
|
||||
public function translate(string $reasonCode, string $surface = 'detail', array $context = []): ?ReasonResolutionEnvelope
|
||||
{
|
||||
$normalizedCode = trim($reasonCode);
|
||||
|
||||
if ($normalizedCode === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
$actionability = $this->actionabilityFor($normalizedCode);
|
||||
$nextSteps = $this->fallbackNextStepsFor($actionability);
|
||||
|
||||
return new ReasonResolutionEnvelope(
|
||||
internalCode: $normalizedCode,
|
||||
operatorLabel: $this->operatorLabelFor($normalizedCode),
|
||||
shortExplanation: $this->shortExplanationFor($actionability),
|
||||
actionability: $actionability,
|
||||
nextSteps: $nextSteps,
|
||||
showNoActionNeeded: $actionability === 'non_actionable',
|
||||
diagnosticCodeLabel: $normalizedCode,
|
||||
);
|
||||
}
|
||||
|
||||
private function operatorLabelFor(string $reasonCode): string
|
||||
{
|
||||
return Str::headline(str_replace(['.', '-'], '_', $reasonCode));
|
||||
}
|
||||
|
||||
private function actionabilityFor(string $reasonCode): string
|
||||
{
|
||||
$reasonCode = strtolower($reasonCode);
|
||||
|
||||
if (str_contains($reasonCode, 'timeout')
|
||||
|| str_contains($reasonCode, 'throttle')
|
||||
|| str_contains($reasonCode, 'rate')
|
||||
|| str_contains($reasonCode, 'network')
|
||||
|| str_contains($reasonCode, 'unreachable')
|
||||
|| str_contains($reasonCode, 'transient')
|
||||
|| str_contains($reasonCode, 'retry')
|
||||
) {
|
||||
return 'retryable_transient';
|
||||
}
|
||||
|
||||
if (str_contains($reasonCode, 'missing')
|
||||
|| str_contains($reasonCode, 'required')
|
||||
|| str_contains($reasonCode, 'consent')
|
||||
|| str_contains($reasonCode, 'stale')
|
||||
|| str_contains($reasonCode, 'prerequisite')
|
||||
|| str_contains($reasonCode, 'invalid')
|
||||
) {
|
||||
return 'prerequisite_missing';
|
||||
}
|
||||
|
||||
if (str_contains($reasonCode, 'already_')
|
||||
|| str_contains($reasonCode, 'not_applicable')
|
||||
|| str_contains($reasonCode, 'no_action')
|
||||
|| str_contains($reasonCode, 'info')
|
||||
) {
|
||||
return 'non_actionable';
|
||||
}
|
||||
|
||||
return 'permanent_configuration';
|
||||
}
|
||||
|
||||
private function shortExplanationFor(string $actionability): string
|
||||
{
|
||||
return match ($actionability) {
|
||||
'retryable_transient' => 'TenantPilot recorded a transient dependency issue. Retry after the dependency recovers.',
|
||||
'prerequisite_missing' => 'TenantPilot recorded a missing or invalid prerequisite for this workflow.',
|
||||
'non_actionable' => 'TenantPilot recorded this state for visibility only. No operator action is required.',
|
||||
default => 'TenantPilot recorded an access, scope, or configuration issue that needs review before retrying.',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, NextStepOption>
|
||||
*/
|
||||
private function fallbackNextStepsFor(string $actionability): array
|
||||
{
|
||||
return match ($actionability) {
|
||||
'retryable_transient' => [NextStepOption::instruction('Retry after the dependency recovers.')],
|
||||
'prerequisite_missing' => [NextStepOption::instruction('Review the recorded prerequisite before retrying.')],
|
||||
'non_actionable' => [],
|
||||
default => [NextStepOption::instruction('Review access and configuration before retrying.')],
|
||||
};
|
||||
}
|
||||
}
|
||||
153
app/Support/ReasonTranslation/NextStepOption.php
Normal file
153
app/Support/ReasonTranslation/NextStepOption.php
Normal file
@ -0,0 +1,153 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Support\ReasonTranslation;
|
||||
|
||||
use InvalidArgumentException;
|
||||
|
||||
final readonly class NextStepOption
|
||||
{
|
||||
public function __construct(
|
||||
public string $label,
|
||||
public string $kind,
|
||||
public ?string $destination = null,
|
||||
public bool $authorizationRequired = false,
|
||||
public string $scope = 'none',
|
||||
) {
|
||||
$label = trim($this->label);
|
||||
$kind = trim($this->kind);
|
||||
$scope = trim($this->scope);
|
||||
|
||||
if ($label === '') {
|
||||
throw new InvalidArgumentException('Next-step labels must not be empty.');
|
||||
}
|
||||
|
||||
if (! in_array($kind, ['link', 'instruction', 'diagnostic_only'], true)) {
|
||||
throw new InvalidArgumentException('Unsupported next-step kind: '.$kind);
|
||||
}
|
||||
|
||||
if (! in_array($scope, ['tenant', 'workspace', 'system', 'none'], true)) {
|
||||
throw new InvalidArgumentException('Unsupported next-step scope: '.$scope);
|
||||
}
|
||||
|
||||
if ($kind === 'link' && trim((string) $this->destination) === '') {
|
||||
throw new InvalidArgumentException('Link next steps require a destination.');
|
||||
}
|
||||
}
|
||||
|
||||
public static function link(
|
||||
string $label,
|
||||
string $destination,
|
||||
bool $authorizationRequired = true,
|
||||
string $scope = 'tenant',
|
||||
): self {
|
||||
return new self(
|
||||
label: $label,
|
||||
kind: 'link',
|
||||
destination: $destination,
|
||||
authorizationRequired: $authorizationRequired,
|
||||
scope: $scope,
|
||||
);
|
||||
}
|
||||
|
||||
public static function instruction(string $label, string $scope = 'none'): self
|
||||
{
|
||||
return new self(
|
||||
label: $label,
|
||||
kind: 'instruction',
|
||||
scope: $scope,
|
||||
);
|
||||
}
|
||||
|
||||
public static function diagnosticOnly(string $label): self
|
||||
{
|
||||
return new self(
|
||||
label: $label,
|
||||
kind: 'diagnostic_only',
|
||||
scope: 'none',
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $data
|
||||
*/
|
||||
public static function fromArray(array $data): ?self
|
||||
{
|
||||
$label = is_string($data['label'] ?? null) ? trim((string) $data['label']) : '';
|
||||
$kind = is_string($data['kind'] ?? null)
|
||||
? trim((string) $data['kind'])
|
||||
: ((is_string($data['url'] ?? null) || is_string($data['destination'] ?? null)) ? 'link' : 'instruction');
|
||||
$destination = is_string($data['destination'] ?? null)
|
||||
? trim((string) $data['destination'])
|
||||
: (is_string($data['url'] ?? null) ? trim((string) $data['url']) : null);
|
||||
$authorizationRequired = (bool) ($data['authorization_required'] ?? $data['authorizationRequired'] ?? false);
|
||||
$scope = is_string($data['scope'] ?? null) ? trim((string) $data['scope']) : 'none';
|
||||
|
||||
if ($label === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
return new self(
|
||||
label: $label,
|
||||
kind: $kind,
|
||||
destination: $destination !== '' ? $destination : null,
|
||||
authorizationRequired: $authorizationRequired,
|
||||
scope: $scope,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, array<string, mixed>> $items
|
||||
* @return array<int, self>
|
||||
*/
|
||||
public static function collect(array $items): array
|
||||
{
|
||||
$options = [];
|
||||
|
||||
foreach ($items as $item) {
|
||||
if (! is_array($item)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$option = self::fromArray($item);
|
||||
|
||||
if ($option instanceof self) {
|
||||
$options[] = $option;
|
||||
}
|
||||
}
|
||||
|
||||
return $options;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{
|
||||
* label: string,
|
||||
* kind: string,
|
||||
* destination: ?string,
|
||||
* authorization_required: bool,
|
||||
* scope: string
|
||||
* }
|
||||
*/
|
||||
public function toArray(): array
|
||||
{
|
||||
return [
|
||||
'label' => $this->label,
|
||||
'kind' => $this->kind,
|
||||
'destination' => $this->destination,
|
||||
'authorization_required' => $this->authorizationRequired,
|
||||
'scope' => $this->scope,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{label: string, url: string}
|
||||
*/
|
||||
public function toLegacyArray(): array
|
||||
{
|
||||
return [
|
||||
'label' => $this->label,
|
||||
'url' => (string) $this->destination,
|
||||
];
|
||||
}
|
||||
}
|
||||
182
app/Support/ReasonTranslation/ReasonPresenter.php
Normal file
182
app/Support/ReasonTranslation/ReasonPresenter.php
Normal file
@ -0,0 +1,182 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Support\ReasonTranslation;
|
||||
|
||||
use App\Models\OperationRun;
|
||||
use App\Models\ProviderConnection;
|
||||
use App\Models\Tenant;
|
||||
use App\Support\Operations\ExecutionDenialReasonCode;
|
||||
use App\Support\Providers\ProviderReasonCodes;
|
||||
use App\Support\Providers\ProviderReasonTranslator;
|
||||
use App\Support\RbacReason;
|
||||
use App\Support\Tenants\TenantOperabilityReasonCode;
|
||||
|
||||
final class ReasonPresenter
|
||||
{
|
||||
public const string GOVERNANCE_ARTIFACT_TRUTH_ARTIFACT = ReasonTranslator::GOVERNANCE_ARTIFACT_TRUTH_ARTIFACT;
|
||||
|
||||
public function __construct(
|
||||
private readonly ReasonTranslator $reasonTranslator,
|
||||
) {}
|
||||
|
||||
public function forOperationRun(OperationRun $run, string $surface = 'detail'): ?ReasonResolutionEnvelope
|
||||
{
|
||||
$context = is_array($run->context) ? $run->context : [];
|
||||
$storedTranslation = is_array($context['reason_translation'] ?? null) ? $context['reason_translation'] : null;
|
||||
|
||||
if ($storedTranslation !== null) {
|
||||
$storedEnvelope = ReasonResolutionEnvelope::fromArray($storedTranslation);
|
||||
|
||||
if ($storedEnvelope instanceof ReasonResolutionEnvelope) {
|
||||
if ($storedEnvelope->nextSteps === [] && is_array($context['next_steps'] ?? null)) {
|
||||
return $storedEnvelope->withNextSteps(NextStepOption::collect($context['next_steps']));
|
||||
}
|
||||
|
||||
return $storedEnvelope;
|
||||
}
|
||||
}
|
||||
|
||||
$contextReasonCode = data_get($context, 'execution_legitimacy.reason_code')
|
||||
?? data_get($context, 'reason_code');
|
||||
|
||||
if (is_string($contextReasonCode) && trim($contextReasonCode) !== '') {
|
||||
return $this->translateOperationRunReason(trim($contextReasonCode), $surface, $context);
|
||||
}
|
||||
|
||||
$failureReasonCode = data_get($run->failure_summary, '0.reason_code');
|
||||
|
||||
if (! is_string($failureReasonCode) || trim($failureReasonCode) === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
$failureReasonCode = trim($failureReasonCode);
|
||||
|
||||
if (! $this->isDirectlyTranslatableOperationReason($failureReasonCode)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$envelope = $this->translateOperationRunReason($failureReasonCode, $surface, $context);
|
||||
|
||||
if (! $envelope instanceof ReasonResolutionEnvelope) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if ($envelope->nextSteps !== []) {
|
||||
return $envelope;
|
||||
}
|
||||
|
||||
$legacyNextSteps = is_array($context['next_steps'] ?? null) ? NextStepOption::collect($context['next_steps']) : [];
|
||||
|
||||
return $legacyNextSteps !== [] ? $envelope->withNextSteps($legacyNextSteps) : $envelope;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $context
|
||||
*/
|
||||
private function translateOperationRunReason(
|
||||
string $reasonCode,
|
||||
string $surface,
|
||||
array $context,
|
||||
): ?ReasonResolutionEnvelope {
|
||||
return $this->reasonTranslator->translate($reasonCode, surface: $surface, context: $context);
|
||||
}
|
||||
|
||||
private function isDirectlyTranslatableOperationReason(string $reasonCode): bool
|
||||
{
|
||||
if ($reasonCode === ProviderReasonCodes::UnknownError) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return ProviderReasonCodes::isKnown($reasonCode)
|
||||
|| ExecutionDenialReasonCode::tryFrom($reasonCode) instanceof ExecutionDenialReasonCode
|
||||
|| TenantOperabilityReasonCode::tryFrom($reasonCode) instanceof TenantOperabilityReasonCode
|
||||
|| RbacReason::tryFrom($reasonCode) instanceof RbacReason;
|
||||
}
|
||||
|
||||
public function forProviderReason(
|
||||
Tenant $tenant,
|
||||
string $reasonCode,
|
||||
?ProviderConnection $connection = null,
|
||||
string $surface = 'detail',
|
||||
): ?ReasonResolutionEnvelope {
|
||||
return $this->reasonTranslator->translate(
|
||||
reasonCode: $reasonCode,
|
||||
artifactKey: ProviderReasonTranslator::ARTIFACT_KEY,
|
||||
surface: $surface,
|
||||
context: [
|
||||
'tenant' => $tenant,
|
||||
'connection' => $connection,
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
public function forTenantOperabilityReason(
|
||||
TenantOperabilityReasonCode|string|null $reasonCode,
|
||||
string $surface = 'detail',
|
||||
): ?ReasonResolutionEnvelope {
|
||||
$normalizedCode = $reasonCode instanceof TenantOperabilityReasonCode ? $reasonCode->value : $reasonCode;
|
||||
|
||||
return $this->reasonTranslator->translate(
|
||||
reasonCode: $normalizedCode,
|
||||
artifactKey: ReasonTranslator::TENANT_OPERABILITY_ARTIFACT,
|
||||
surface: $surface,
|
||||
);
|
||||
}
|
||||
|
||||
public function forRbacReason(RbacReason|string|null $reasonCode, string $surface = 'detail'): ?ReasonResolutionEnvelope
|
||||
{
|
||||
$normalizedCode = $reasonCode instanceof RbacReason ? $reasonCode->value : $reasonCode;
|
||||
|
||||
return $this->reasonTranslator->translate(
|
||||
reasonCode: $normalizedCode,
|
||||
artifactKey: ReasonTranslator::RBAC_ARTIFACT,
|
||||
surface: $surface,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $context
|
||||
*/
|
||||
public function forArtifactTruth(
|
||||
?string $reasonCode,
|
||||
string $surface = 'detail',
|
||||
array $context = [],
|
||||
): ?ReasonResolutionEnvelope {
|
||||
return $this->reasonTranslator->translate(
|
||||
reasonCode: $reasonCode,
|
||||
artifactKey: self::GOVERNANCE_ARTIFACT_TRUTH_ARTIFACT,
|
||||
surface: $surface,
|
||||
context: $context,
|
||||
);
|
||||
}
|
||||
|
||||
public function diagnosticCode(?ReasonResolutionEnvelope $envelope): ?string
|
||||
{
|
||||
return $envelope?->diagnosticCode();
|
||||
}
|
||||
|
||||
public function primaryLabel(?ReasonResolutionEnvelope $envelope): ?string
|
||||
{
|
||||
return $envelope?->operatorLabel;
|
||||
}
|
||||
|
||||
public function shortExplanation(?ReasonResolutionEnvelope $envelope): ?string
|
||||
{
|
||||
return $envelope?->shortExplanation;
|
||||
}
|
||||
|
||||
public function guidance(?ReasonResolutionEnvelope $envelope): ?string
|
||||
{
|
||||
return $envelope?->guidanceText();
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, string>
|
||||
*/
|
||||
public function bodyLines(?ReasonResolutionEnvelope $envelope, bool $includeGuidance = true): array
|
||||
{
|
||||
return $envelope?->toBodyLines($includeGuidance) ?? [];
|
||||
}
|
||||
}
|
||||
199
app/Support/ReasonTranslation/ReasonResolutionEnvelope.php
Normal file
199
app/Support/ReasonTranslation/ReasonResolutionEnvelope.php
Normal file
@ -0,0 +1,199 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Support\ReasonTranslation;
|
||||
|
||||
use InvalidArgumentException;
|
||||
|
||||
final readonly class ReasonResolutionEnvelope
|
||||
{
|
||||
/**
|
||||
* @param array<int, NextStepOption> $nextSteps
|
||||
*/
|
||||
public function __construct(
|
||||
public string $internalCode,
|
||||
public string $operatorLabel,
|
||||
public string $shortExplanation,
|
||||
public string $actionability,
|
||||
public array $nextSteps = [],
|
||||
public bool $showNoActionNeeded = false,
|
||||
public ?string $diagnosticCodeLabel = null,
|
||||
) {
|
||||
if (trim($this->internalCode) === '') {
|
||||
throw new InvalidArgumentException('Reason envelopes must preserve an internal code.');
|
||||
}
|
||||
|
||||
if (trim($this->operatorLabel) === '') {
|
||||
throw new InvalidArgumentException('Reason envelopes require an operator label.');
|
||||
}
|
||||
|
||||
if (trim($this->shortExplanation) === '') {
|
||||
throw new InvalidArgumentException('Reason envelopes require a short explanation.');
|
||||
}
|
||||
|
||||
if (! in_array($this->actionability, [
|
||||
'retryable_transient',
|
||||
'permanent_configuration',
|
||||
'prerequisite_missing',
|
||||
'non_actionable',
|
||||
], true)) {
|
||||
throw new InvalidArgumentException('Unsupported reason actionability: '.$this->actionability);
|
||||
}
|
||||
|
||||
foreach ($this->nextSteps as $nextStep) {
|
||||
if (! $nextStep instanceof NextStepOption) {
|
||||
throw new InvalidArgumentException('Reason envelopes only support NextStepOption instances.');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $data
|
||||
*/
|
||||
public static function fromArray(array $data): ?self
|
||||
{
|
||||
$internalCode = is_string($data['internal_code'] ?? null)
|
||||
? trim((string) $data['internal_code'])
|
||||
: (is_string($data['internalCode'] ?? null) ? trim((string) $data['internalCode']) : '');
|
||||
$operatorLabel = is_string($data['operator_label'] ?? null)
|
||||
? trim((string) $data['operator_label'])
|
||||
: (is_string($data['operatorLabel'] ?? null) ? trim((string) $data['operatorLabel']) : '');
|
||||
$shortExplanation = is_string($data['short_explanation'] ?? null)
|
||||
? trim((string) $data['short_explanation'])
|
||||
: (is_string($data['shortExplanation'] ?? null) ? trim((string) $data['shortExplanation']) : '');
|
||||
$actionability = is_string($data['actionability'] ?? null) ? trim((string) $data['actionability']) : '';
|
||||
$nextSteps = is_array($data['next_steps'] ?? null)
|
||||
? NextStepOption::collect($data['next_steps'])
|
||||
: (is_array($data['nextSteps'] ?? null) ? NextStepOption::collect($data['nextSteps']) : []);
|
||||
$showNoActionNeeded = (bool) ($data['show_no_action_needed'] ?? $data['showNoActionNeeded'] ?? false);
|
||||
$diagnosticCodeLabel = is_string($data['diagnostic_code_label'] ?? null)
|
||||
? trim((string) $data['diagnostic_code_label'])
|
||||
: (is_string($data['diagnosticCodeLabel'] ?? null) ? trim((string) $data['diagnosticCodeLabel']) : null);
|
||||
|
||||
if ($internalCode === '' || $operatorLabel === '' || $shortExplanation === '' || $actionability === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
return new self(
|
||||
internalCode: $internalCode,
|
||||
operatorLabel: $operatorLabel,
|
||||
shortExplanation: $shortExplanation,
|
||||
actionability: $actionability,
|
||||
nextSteps: $nextSteps,
|
||||
showNoActionNeeded: $showNoActionNeeded,
|
||||
diagnosticCodeLabel: $diagnosticCodeLabel !== '' ? $diagnosticCodeLabel : null,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, NextStepOption> $nextSteps
|
||||
*/
|
||||
public function withNextSteps(array $nextSteps): self
|
||||
{
|
||||
return new self(
|
||||
internalCode: $this->internalCode,
|
||||
operatorLabel: $this->operatorLabel,
|
||||
shortExplanation: $this->shortExplanation,
|
||||
actionability: $this->actionability,
|
||||
nextSteps: $nextSteps,
|
||||
showNoActionNeeded: $this->showNoActionNeeded,
|
||||
diagnosticCodeLabel: $this->diagnosticCodeLabel,
|
||||
);
|
||||
}
|
||||
|
||||
public function firstNextStep(): ?NextStepOption
|
||||
{
|
||||
return $this->nextSteps[0] ?? null;
|
||||
}
|
||||
|
||||
public function guidanceText(): ?string
|
||||
{
|
||||
$nextStep = $this->firstNextStep();
|
||||
|
||||
if ($nextStep instanceof NextStepOption) {
|
||||
return 'Next step: '.rtrim($nextStep->label, ". \t\n\r\0\x0B").'.';
|
||||
}
|
||||
|
||||
if ($this->showNoActionNeeded) {
|
||||
return 'No action needed.';
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, string>
|
||||
*/
|
||||
public function toBodyLines(bool $includeGuidance = true): array
|
||||
{
|
||||
$lines = [
|
||||
$this->operatorLabel,
|
||||
$this->shortExplanation,
|
||||
];
|
||||
|
||||
if ($includeGuidance) {
|
||||
$guidance = $this->guidanceText();
|
||||
|
||||
if (is_string($guidance) && $guidance !== '') {
|
||||
$lines[] = $guidance;
|
||||
}
|
||||
}
|
||||
|
||||
return array_values(array_filter($lines, static fn (?string $line): bool => is_string($line) && trim($line) !== ''));
|
||||
}
|
||||
|
||||
public function diagnosticCode(): string
|
||||
{
|
||||
return $this->diagnosticCodeLabel !== null && trim($this->diagnosticCodeLabel) !== ''
|
||||
? $this->diagnosticCodeLabel
|
||||
: $this->internalCode;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, array{label: string, url: string}>
|
||||
*/
|
||||
public function toLegacyNextSteps(): array
|
||||
{
|
||||
return array_values(array_map(
|
||||
static fn (NextStepOption $nextStep): array => $nextStep->toLegacyArray(),
|
||||
array_filter(
|
||||
$this->nextSteps,
|
||||
static fn (NextStepOption $nextStep): bool => $nextStep->kind === 'link' && $nextStep->destination !== null,
|
||||
),
|
||||
));
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{
|
||||
* internal_code: string,
|
||||
* operator_label: string,
|
||||
* short_explanation: string,
|
||||
* actionability: string,
|
||||
* next_steps: array<int, array{
|
||||
* label: string,
|
||||
* kind: string,
|
||||
* destination: ?string,
|
||||
* authorization_required: bool,
|
||||
* scope: string
|
||||
* }>,
|
||||
* show_no_action_needed: bool,
|
||||
* diagnostic_code_label: string
|
||||
* }
|
||||
*/
|
||||
public function toArray(): array
|
||||
{
|
||||
return [
|
||||
'internal_code' => $this->internalCode,
|
||||
'operator_label' => $this->operatorLabel,
|
||||
'short_explanation' => $this->shortExplanation,
|
||||
'actionability' => $this->actionability,
|
||||
'next_steps' => array_map(
|
||||
static fn (NextStepOption $nextStep): array => $nextStep->toArray(),
|
||||
$this->nextSteps,
|
||||
),
|
||||
'show_no_action_needed' => $this->showNoActionNeeded,
|
||||
'diagnostic_code_label' => $this->diagnosticCode(),
|
||||
];
|
||||
}
|
||||
}
|
||||
77
app/Support/ReasonTranslation/ReasonTranslator.php
Normal file
77
app/Support/ReasonTranslation/ReasonTranslator.php
Normal file
@ -0,0 +1,77 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Support\ReasonTranslation;
|
||||
|
||||
use App\Support\Operations\ExecutionDenialReasonCode;
|
||||
use App\Support\Providers\ProviderReasonCodes;
|
||||
use App\Support\Providers\ProviderReasonTranslator;
|
||||
use App\Support\RbacReason;
|
||||
use App\Support\Tenants\TenantOperabilityReasonCode;
|
||||
|
||||
final class ReasonTranslator
|
||||
{
|
||||
public const string EXECUTION_DENIAL_ARTIFACT = 'execution_denial_reason_code';
|
||||
|
||||
public const string TENANT_OPERABILITY_ARTIFACT = 'tenant_operability_reason_code';
|
||||
|
||||
public const string RBAC_ARTIFACT = 'rbac_reason';
|
||||
|
||||
public const string GOVERNANCE_ARTIFACT_TRUTH_ARTIFACT = 'governance_artifact_truth_reason';
|
||||
|
||||
public function __construct(
|
||||
private readonly ProviderReasonTranslator $providerReasonTranslator,
|
||||
private readonly FallbackReasonTranslator $fallbackReasonTranslator,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $context
|
||||
*/
|
||||
public function translate(
|
||||
?string $reasonCode,
|
||||
?string $artifactKey = null,
|
||||
string $surface = 'detail',
|
||||
array $context = [],
|
||||
): ?ReasonResolutionEnvelope {
|
||||
$reasonCode = is_string($reasonCode) ? trim($reasonCode) : '';
|
||||
|
||||
if ($reasonCode === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
return match (true) {
|
||||
$artifactKey === ProviderReasonTranslator::ARTIFACT_KEY,
|
||||
$artifactKey === null && $this->providerReasonTranslator->canTranslate($reasonCode) => $this->providerReasonTranslator->translate($reasonCode, $surface, $context),
|
||||
$artifactKey === self::EXECUTION_DENIAL_ARTIFACT,
|
||||
$artifactKey === null && ExecutionDenialReasonCode::tryFrom($reasonCode) instanceof ExecutionDenialReasonCode => ExecutionDenialReasonCode::tryFrom($reasonCode)?->toReasonResolutionEnvelope($surface, $context),
|
||||
$artifactKey === self::TENANT_OPERABILITY_ARTIFACT,
|
||||
$artifactKey === null && TenantOperabilityReasonCode::tryFrom($reasonCode) instanceof TenantOperabilityReasonCode => TenantOperabilityReasonCode::tryFrom($reasonCode)?->toReasonResolutionEnvelope($surface, $context),
|
||||
$artifactKey === self::RBAC_ARTIFACT,
|
||||
$artifactKey === null && RbacReason::tryFrom($reasonCode) instanceof RbacReason => RbacReason::tryFrom($reasonCode)?->toReasonResolutionEnvelope($surface, $context),
|
||||
$artifactKey === self::GOVERNANCE_ARTIFACT_TRUTH_ARTIFACT => $this->fallbackReasonTranslator->translate($reasonCode, $surface, $context),
|
||||
$artifactKey === null && ProviderReasonCodes::isKnown($reasonCode) => $this->providerReasonTranslator->translate($reasonCode, $surface, $context),
|
||||
default => $this->fallbackTranslate($reasonCode, $artifactKey, $surface, $context),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $context
|
||||
*/
|
||||
private function fallbackTranslate(
|
||||
string $reasonCode,
|
||||
?string $artifactKey,
|
||||
string $surface,
|
||||
array $context,
|
||||
): ?ReasonResolutionEnvelope {
|
||||
if ($artifactKey === null) {
|
||||
$normalizedCode = \App\Support\OpsUx\RunFailureSanitizer::normalizeReasonCode($reasonCode);
|
||||
|
||||
if ($normalizedCode !== $reasonCode) {
|
||||
return $this->translate($normalizedCode, null, $surface, $context + ['source_reason_code' => $reasonCode]);
|
||||
}
|
||||
}
|
||||
|
||||
return $this->fallbackReasonTranslator->translate($reasonCode, $surface, $context);
|
||||
}
|
||||
}
|
||||
@ -4,6 +4,9 @@
|
||||
|
||||
namespace App\Support\Tenants;
|
||||
|
||||
use App\Support\ReasonTranslation\NextStepOption;
|
||||
use App\Support\ReasonTranslation\ReasonResolutionEnvelope;
|
||||
|
||||
enum TenantOperabilityReasonCode: string
|
||||
{
|
||||
case WorkspaceMismatch = 'workspace_mismatch';
|
||||
@ -16,4 +19,89 @@ enum TenantOperabilityReasonCode: string
|
||||
case OnboardingNotResumable = 'onboarding_not_resumable';
|
||||
case CanonicalViewFollowupOnly = 'canonical_view_followup_only';
|
||||
case RememberedContextStale = 'remembered_context_stale';
|
||||
|
||||
public function operatorLabel(): string
|
||||
{
|
||||
return match ($this) {
|
||||
self::WorkspaceMismatch => 'Workspace context changed',
|
||||
self::TenantNotEntitled => 'Tenant access removed',
|
||||
self::MissingCapability => 'Permission required',
|
||||
self::WrongLane => 'Available from a different surface',
|
||||
self::SelectorIneligibleLifecycle => 'Tenant unavailable in the current lifecycle',
|
||||
self::TenantNotArchived => 'Tenant is not archived',
|
||||
self::TenantAlreadyArchived => 'Tenant already archived',
|
||||
self::OnboardingNotResumable => 'Onboarding cannot be resumed',
|
||||
self::CanonicalViewFollowupOnly => 'Follow-up requires tenant context',
|
||||
self::RememberedContextStale => 'Saved tenant context is stale',
|
||||
};
|
||||
}
|
||||
|
||||
public function shortExplanation(): string
|
||||
{
|
||||
return match ($this) {
|
||||
self::WorkspaceMismatch => 'The current workspace scope no longer matches this tenant interaction.',
|
||||
self::TenantNotEntitled => 'The current actor is no longer entitled to this tenant.',
|
||||
self::MissingCapability => 'The current actor is missing the capability required for this tenant action.',
|
||||
self::WrongLane => 'This question can only be completed from a different tenant interaction lane.',
|
||||
self::SelectorIneligibleLifecycle => 'This tenant lifecycle is not selectable from the current surface.',
|
||||
self::TenantNotArchived => 'This action requires an archived tenant, but the tenant is still active or onboarding.',
|
||||
self::TenantAlreadyArchived => 'The tenant is already archived, so there is nothing else to do for this action.',
|
||||
self::OnboardingNotResumable => 'This onboarding session can no longer be resumed from the current lifecycle state.',
|
||||
self::CanonicalViewFollowupOnly => 'This canonical workspace view is informational only and cannot complete tenant follow-up directly.',
|
||||
self::RememberedContextStale => 'The remembered tenant context is no longer valid for the current tenant selector state.',
|
||||
};
|
||||
}
|
||||
|
||||
public function actionability(): string
|
||||
{
|
||||
return match ($this) {
|
||||
self::TenantAlreadyArchived => 'non_actionable',
|
||||
self::SelectorIneligibleLifecycle, self::TenantNotArchived, self::OnboardingNotResumable, self::CanonicalViewFollowupOnly, self::RememberedContextStale => 'prerequisite_missing',
|
||||
default => 'permanent_configuration',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, NextStepOption>
|
||||
*/
|
||||
public function nextSteps(): array
|
||||
{
|
||||
return match ($this) {
|
||||
self::TenantAlreadyArchived => [],
|
||||
self::MissingCapability => [
|
||||
NextStepOption::instruction('Ask a tenant Owner to grant the required capability.', scope: 'tenant'),
|
||||
],
|
||||
self::TenantNotEntitled, self::WorkspaceMismatch => [
|
||||
NextStepOption::instruction('Return to an entitled tenant context before retrying.', scope: 'workspace'),
|
||||
],
|
||||
self::WrongLane, self::CanonicalViewFollowupOnly => [
|
||||
NextStepOption::instruction('Open the tenant-specific management surface for follow-up.', scope: 'tenant'),
|
||||
],
|
||||
self::SelectorIneligibleLifecycle, self::RememberedContextStale => [
|
||||
NextStepOption::instruction('Refresh the tenant selector and choose an eligible tenant context.', scope: 'tenant'),
|
||||
],
|
||||
self::TenantNotArchived => [
|
||||
NextStepOption::instruction('Archive the tenant before retrying this action.', scope: 'tenant'),
|
||||
],
|
||||
self::OnboardingNotResumable => [
|
||||
NextStepOption::instruction('Review the onboarding record and start a new onboarding flow if needed.', scope: 'tenant'),
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $context
|
||||
*/
|
||||
public function toReasonResolutionEnvelope(string $surface = 'detail', array $context = []): ReasonResolutionEnvelope
|
||||
{
|
||||
return new ReasonResolutionEnvelope(
|
||||
internalCode: $this->value,
|
||||
operatorLabel: $this->operatorLabel(),
|
||||
shortExplanation: $this->shortExplanation(),
|
||||
actionability: $this->actionability(),
|
||||
nextSteps: $this->nextSteps(),
|
||||
showNoActionNeeded: $this->actionability() === 'non_actionable',
|
||||
diagnosticCodeLabel: $this->value,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -0,0 +1,66 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Support\Ui\GovernanceArtifactTruth;
|
||||
|
||||
use App\Support\ReasonTranslation\NextStepOption;
|
||||
use App\Support\ReasonTranslation\ReasonResolutionEnvelope;
|
||||
|
||||
final readonly class ArtifactTruthCause
|
||||
{
|
||||
/**
|
||||
* @param array<int, string> $nextSteps
|
||||
*/
|
||||
public function __construct(
|
||||
public ?string $reasonCode,
|
||||
public ?string $translationArtifact,
|
||||
public ?string $operatorLabel,
|
||||
public ?string $shortExplanation,
|
||||
public ?string $diagnosticCode,
|
||||
public array $nextSteps = [],
|
||||
) {}
|
||||
|
||||
public static function fromReasonResolutionEnvelope(
|
||||
?ReasonResolutionEnvelope $reason,
|
||||
?string $translationArtifact = null,
|
||||
): ?self {
|
||||
if (! $reason instanceof ReasonResolutionEnvelope) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return new self(
|
||||
reasonCode: $reason->internalCode,
|
||||
translationArtifact: $translationArtifact,
|
||||
operatorLabel: $reason->operatorLabel,
|
||||
shortExplanation: $reason->shortExplanation,
|
||||
diagnosticCode: $reason->diagnosticCode(),
|
||||
nextSteps: array_values(array_map(
|
||||
static fn (NextStepOption $nextStep): string => $nextStep->label,
|
||||
$reason->nextSteps,
|
||||
)),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{
|
||||
* reasonCode: ?string,
|
||||
* translationArtifact: ?string,
|
||||
* operatorLabel: ?string,
|
||||
* shortExplanation: ?string,
|
||||
* diagnosticCode: ?string,
|
||||
* nextSteps: array<int, string>
|
||||
* }
|
||||
*/
|
||||
public function toArray(): array
|
||||
{
|
||||
return [
|
||||
'reasonCode' => $this->reasonCode,
|
||||
'translationArtifact' => $this->translationArtifact,
|
||||
'operatorLabel' => $this->operatorLabel,
|
||||
'shortExplanation' => $this->shortExplanation,
|
||||
'diagnosticCode' => $this->diagnosticCode,
|
||||
'nextSteps' => $this->nextSteps,
|
||||
];
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,61 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Support\Ui\GovernanceArtifactTruth;
|
||||
|
||||
use App\Support\Badges\BadgeDomain;
|
||||
use App\Support\Badges\BadgeSpec;
|
||||
|
||||
final readonly class ArtifactTruthDimension
|
||||
{
|
||||
public function __construct(
|
||||
public string $axis,
|
||||
public string $state,
|
||||
public string $label,
|
||||
public string $classification,
|
||||
public ?BadgeDomain $badgeDomain = null,
|
||||
public ?string $badgeState = null,
|
||||
) {}
|
||||
|
||||
public function isPrimary(): bool
|
||||
{
|
||||
return $this->classification === 'primary';
|
||||
}
|
||||
|
||||
public function isDiagnostic(): bool
|
||||
{
|
||||
return $this->classification === 'diagnostic';
|
||||
}
|
||||
|
||||
public function badgeSpec(): ?BadgeSpec
|
||||
{
|
||||
if (! $this->badgeDomain instanceof BadgeDomain || ! is_string($this->badgeState) || trim($this->badgeState) === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
return \App\Support\Badges\BadgeCatalog::spec($this->badgeDomain, $this->badgeState);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{
|
||||
* axis: string,
|
||||
* state: string,
|
||||
* label: string,
|
||||
* classification: string,
|
||||
* badgeDomain: ?string,
|
||||
* badgeState: ?string
|
||||
* }
|
||||
*/
|
||||
public function toArray(): array
|
||||
{
|
||||
return [
|
||||
'axis' => $this->axis,
|
||||
'state' => $this->state,
|
||||
'label' => $this->label,
|
||||
'classification' => $this->classification,
|
||||
'badgeDomain' => $this->badgeDomain?->value,
|
||||
'badgeState' => $this->badgeState,
|
||||
];
|
||||
}
|
||||
}
|
||||
137
app/Support/Ui/GovernanceArtifactTruth/ArtifactTruthEnvelope.php
Normal file
137
app/Support/Ui/GovernanceArtifactTruth/ArtifactTruthEnvelope.php
Normal file
@ -0,0 +1,137 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Support\Ui\GovernanceArtifactTruth;
|
||||
|
||||
use App\Support\Badges\BadgeSpec;
|
||||
|
||||
final readonly class ArtifactTruthEnvelope
|
||||
{
|
||||
/**
|
||||
* @param array<int, ArtifactTruthDimension> $dimensions
|
||||
*/
|
||||
public function __construct(
|
||||
public string $artifactFamily,
|
||||
public string $artifactKey,
|
||||
public int $workspaceId,
|
||||
public ?int $tenantId,
|
||||
public ?string $executionOutcome,
|
||||
public string $artifactExistence,
|
||||
public string $contentState,
|
||||
public string $freshnessState,
|
||||
public ?string $publicationReadiness,
|
||||
public string $supportState,
|
||||
public string $actionability,
|
||||
public string $primaryLabel,
|
||||
public ?string $primaryExplanation,
|
||||
public ?string $diagnosticLabel,
|
||||
public ?string $nextActionLabel,
|
||||
public ?string $nextActionUrl,
|
||||
public ?int $relatedRunId,
|
||||
public ?string $relatedArtifactUrl,
|
||||
public array $dimensions = [],
|
||||
public ?ArtifactTruthCause $reason = null,
|
||||
) {}
|
||||
|
||||
public function primaryDimension(): ?ArtifactTruthDimension
|
||||
{
|
||||
foreach ($this->dimensions as $dimension) {
|
||||
if ($dimension instanceof ArtifactTruthDimension && $dimension->isPrimary()) {
|
||||
return $dimension;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
public function primaryBadgeSpec(): BadgeSpec
|
||||
{
|
||||
$dimension = $this->primaryDimension();
|
||||
|
||||
return $dimension?->badgeSpec() ?? \App\Support\Badges\BadgeSpec::unknown();
|
||||
}
|
||||
|
||||
public function nextStepText(): string
|
||||
{
|
||||
if (is_string($this->nextActionLabel) && trim($this->nextActionLabel) !== '') {
|
||||
return $this->nextActionLabel;
|
||||
}
|
||||
|
||||
return match ($this->actionability) {
|
||||
'none' => 'No action needed',
|
||||
'optional' => 'Review recommended',
|
||||
default => 'Action required',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{
|
||||
* artifactFamily: string,
|
||||
* artifactKey: string,
|
||||
* workspaceId: int,
|
||||
* tenantId: ?int,
|
||||
* executionOutcome: ?string,
|
||||
* artifactExistence: string,
|
||||
* contentState: string,
|
||||
* freshnessState: string,
|
||||
* publicationReadiness: ?string,
|
||||
* supportState: string,
|
||||
* actionability: string,
|
||||
* primaryLabel: string,
|
||||
* primaryExplanation: ?string,
|
||||
* diagnosticLabel: ?string,
|
||||
* nextActionLabel: ?string,
|
||||
* nextActionUrl: ?string,
|
||||
* relatedRunId: ?int,
|
||||
* relatedArtifactUrl: ?string,
|
||||
* dimensions: array<int, array{
|
||||
* axis: string,
|
||||
* state: string,
|
||||
* label: string,
|
||||
* classification: string,
|
||||
* badgeDomain: ?string,
|
||||
* badgeState: ?string
|
||||
* }>,
|
||||
* reason: ?array{
|
||||
* reasonCode: ?string,
|
||||
* translationArtifact: ?string,
|
||||
* operatorLabel: ?string,
|
||||
* shortExplanation: ?string,
|
||||
* diagnosticCode: ?string,
|
||||
* nextSteps: array<int, string>
|
||||
* }
|
||||
* }
|
||||
*/
|
||||
public function toArray(): array
|
||||
{
|
||||
return [
|
||||
'artifactFamily' => $this->artifactFamily,
|
||||
'artifactKey' => $this->artifactKey,
|
||||
'workspaceId' => $this->workspaceId,
|
||||
'tenantId' => $this->tenantId,
|
||||
'executionOutcome' => $this->executionOutcome,
|
||||
'artifactExistence' => $this->artifactExistence,
|
||||
'contentState' => $this->contentState,
|
||||
'freshnessState' => $this->freshnessState,
|
||||
'publicationReadiness' => $this->publicationReadiness,
|
||||
'supportState' => $this->supportState,
|
||||
'actionability' => $this->actionability,
|
||||
'primaryLabel' => $this->primaryLabel,
|
||||
'primaryExplanation' => $this->primaryExplanation,
|
||||
'diagnosticLabel' => $this->diagnosticLabel,
|
||||
'nextActionLabel' => $this->nextActionLabel,
|
||||
'nextActionUrl' => $this->nextActionUrl,
|
||||
'relatedRunId' => $this->relatedRunId,
|
||||
'relatedArtifactUrl' => $this->relatedArtifactUrl,
|
||||
'dimensions' => array_values(array_map(
|
||||
static fn (ArtifactTruthDimension $dimension): array => $dimension->toArray(),
|
||||
array_filter(
|
||||
$this->dimensions,
|
||||
static fn (mixed $dimension): bool => $dimension instanceof ArtifactTruthDimension,
|
||||
),
|
||||
)),
|
||||
'reason' => $this->reason?->toArray(),
|
||||
];
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,773 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Support\Ui\GovernanceArtifactTruth;
|
||||
|
||||
use App\Filament\Resources\BaselineSnapshotResource;
|
||||
use App\Filament\Resources\EvidenceSnapshotResource;
|
||||
use App\Filament\Resources\ReviewPackResource;
|
||||
use App\Filament\Resources\TenantReviewResource;
|
||||
use App\Models\BaselineSnapshot;
|
||||
use App\Models\EvidenceSnapshot;
|
||||
use App\Models\OperationRun;
|
||||
use App\Models\ReviewPack;
|
||||
use App\Models\TenantReview;
|
||||
use App\Services\Baselines\SnapshotRendering\FidelityState;
|
||||
use App\Support\Badges\BadgeCatalog;
|
||||
use App\Support\Badges\BadgeDomain;
|
||||
use App\Support\OperationCatalog;
|
||||
use App\Support\OperationRunLinks;
|
||||
use App\Support\OperationRunOutcome;
|
||||
use App\Support\OperationRunStatus;
|
||||
use App\Support\ReasonTranslation\ReasonPresenter;
|
||||
use App\Support\ReasonTranslation\ReasonResolutionEnvelope;
|
||||
use App\Support\ReviewPackStatus;
|
||||
use App\Support\TenantReviewCompletenessState;
|
||||
use App\Support\TenantReviewStatus;
|
||||
use Illuminate\Support\Arr;
|
||||
|
||||
final class ArtifactTruthPresenter
|
||||
{
|
||||
public function __construct(
|
||||
private readonly ReasonPresenter $reasonPresenter,
|
||||
) {}
|
||||
|
||||
public function for(mixed $record): ?ArtifactTruthEnvelope
|
||||
{
|
||||
return match (true) {
|
||||
$record instanceof BaselineSnapshot => $this->forBaselineSnapshot($record),
|
||||
$record instanceof EvidenceSnapshot => $this->forEvidenceSnapshot($record),
|
||||
$record instanceof TenantReview => $this->forTenantReview($record),
|
||||
$record instanceof ReviewPack => $this->forReviewPack($record),
|
||||
$record instanceof OperationRun => $this->forOperationRun($record),
|
||||
default => null,
|
||||
};
|
||||
}
|
||||
|
||||
public function forBaselineSnapshot(BaselineSnapshot $snapshot): ArtifactTruthEnvelope
|
||||
{
|
||||
$snapshot->loadMissing('baselineProfile');
|
||||
|
||||
$summary = is_array($snapshot->summary_jsonb) ? $snapshot->summary_jsonb : [];
|
||||
$hasItems = (int) ($summary['total_items'] ?? 0) > 0;
|
||||
$fidelity = FidelityState::fromSummary($summary, $hasItems);
|
||||
$isHistorical = (int) ($snapshot->baselineProfile?->active_snapshot_id ?? 0) !== (int) $snapshot->getKey()
|
||||
&& $snapshot->baselineProfile !== null;
|
||||
$gapReasons = is_array(Arr::get($summary, 'gaps.by_reason')) ? Arr::get($summary, 'gaps.by_reason') : [];
|
||||
$severeGapReasons = array_filter(
|
||||
$gapReasons,
|
||||
static fn (mixed $count, string $reason): bool => is_numeric($count) && (int) $count > 0 && $reason !== 'meta_fallback',
|
||||
ARRAY_FILTER_USE_BOTH,
|
||||
);
|
||||
$reasonCode = $this->firstReasonCode($severeGapReasons);
|
||||
$reason = $this->reasonPresenter->forArtifactTruth($reasonCode, 'artifact_truth');
|
||||
|
||||
$artifactExistence = match (true) {
|
||||
$isHistorical => 'historical_only',
|
||||
! $hasItems => 'created_but_not_usable',
|
||||
default => 'created',
|
||||
};
|
||||
|
||||
$contentState = match ($fidelity) {
|
||||
FidelityState::Full => $severeGapReasons === [] ? 'trusted' : 'partial',
|
||||
FidelityState::Partial => 'partial',
|
||||
FidelityState::ReferenceOnly => 'reference_only',
|
||||
FidelityState::Unsupported => $hasItems ? 'unsupported' : 'empty',
|
||||
};
|
||||
|
||||
if (! $hasItems && $reasonCode !== null) {
|
||||
$contentState = 'missing_input';
|
||||
}
|
||||
|
||||
$freshnessState = $isHistorical ? 'stale' : 'current';
|
||||
$supportState = in_array($contentState, ['reference_only', 'unsupported'], true) ? 'limited_support' : 'normal';
|
||||
$actionability = match (true) {
|
||||
$artifactExistence === 'historical_only' => 'none',
|
||||
$contentState === 'trusted' && $freshnessState === 'current' => 'none',
|
||||
$freshnessState === 'stale' => 'optional',
|
||||
in_array($contentState, ['reference_only', 'unsupported'], true) => 'optional',
|
||||
default => 'required',
|
||||
};
|
||||
|
||||
[$primaryDomain, $primaryState, $primaryExplanation, $diagnosticLabel] = match (true) {
|
||||
$artifactExistence === 'historical_only' => [
|
||||
BadgeDomain::GovernanceArtifactExistence,
|
||||
'historical_only',
|
||||
'This snapshot remains readable for historical comparison, but it is not the current baseline artifact.',
|
||||
$supportState === 'limited_support' ? 'Support limited' : null,
|
||||
],
|
||||
$artifactExistence === 'created_but_not_usable' => [
|
||||
BadgeDomain::GovernanceArtifactExistence,
|
||||
'created_but_not_usable',
|
||||
$reason?->shortExplanation ?? 'A snapshot row exists, but it does not contain a trustworthy baseline artifact yet.',
|
||||
$supportState === 'limited_support' ? 'Support limited' : null,
|
||||
],
|
||||
$contentState !== 'trusted' => [
|
||||
BadgeDomain::GovernanceArtifactContent,
|
||||
$contentState,
|
||||
$reason?->shortExplanation ?? $this->contentExplanation($contentState),
|
||||
$supportState === 'limited_support' ? 'Support limited' : null,
|
||||
],
|
||||
default => [
|
||||
BadgeDomain::GovernanceArtifactContent,
|
||||
'trusted',
|
||||
'Structured capture content is available for this baseline snapshot.',
|
||||
null,
|
||||
],
|
||||
};
|
||||
|
||||
return $this->makeEnvelope(
|
||||
artifactFamily: 'baseline_snapshot',
|
||||
artifactKey: 'baseline_snapshot:'.$snapshot->getKey(),
|
||||
workspaceId: (int) $snapshot->workspace_id,
|
||||
tenantId: null,
|
||||
executionOutcome: null,
|
||||
artifactExistence: $artifactExistence,
|
||||
contentState: $contentState,
|
||||
freshnessState: $freshnessState,
|
||||
publicationReadiness: null,
|
||||
supportState: $supportState,
|
||||
actionability: $actionability,
|
||||
primaryDomain: $primaryDomain,
|
||||
primaryState: $primaryState,
|
||||
primaryExplanation: $primaryExplanation,
|
||||
diagnosticLabel: $diagnosticLabel,
|
||||
reason: ArtifactTruthCause::fromReasonResolutionEnvelope($reason, ReasonPresenter::GOVERNANCE_ARTIFACT_TRUTH_ARTIFACT),
|
||||
nextActionLabel: $this->nextActionLabel(
|
||||
$actionability,
|
||||
$reason,
|
||||
match ($actionability) {
|
||||
'required' => 'Inspect the related capture diagnostics before using this snapshot',
|
||||
'optional' => 'Review the capture diagnostics before comparing this snapshot',
|
||||
default => null,
|
||||
},
|
||||
),
|
||||
nextActionUrl: null,
|
||||
relatedRunId: null,
|
||||
relatedArtifactUrl: BaselineSnapshotResource::getUrl('view', ['record' => $snapshot], panel: 'admin'),
|
||||
includePublicationDimension: false,
|
||||
);
|
||||
}
|
||||
|
||||
public function forEvidenceSnapshot(EvidenceSnapshot $snapshot): ArtifactTruthEnvelope
|
||||
{
|
||||
$snapshot->loadMissing('tenant');
|
||||
|
||||
$summary = is_array($snapshot->summary) ? $snapshot->summary : [];
|
||||
$missingDimensions = (int) ($summary['missing_dimensions'] ?? 0);
|
||||
$staleDimensions = (int) ($summary['stale_dimensions'] ?? 0);
|
||||
$status = (string) $snapshot->status;
|
||||
|
||||
$artifactExistence = match ($status) {
|
||||
'queued', 'generating' => 'not_created',
|
||||
'expired', 'superseded' => 'historical_only',
|
||||
'failed' => 'created_but_not_usable',
|
||||
default => 'created',
|
||||
};
|
||||
|
||||
$contentState = match (true) {
|
||||
$artifactExistence === 'not_created' => 'missing_input',
|
||||
$artifactExistence === 'historical_only' && $snapshot->completeness_state === 'missing' => 'empty',
|
||||
$status === 'failed' => 'missing_input',
|
||||
$snapshot->completeness_state === 'missing' => 'missing_input',
|
||||
$snapshot->completeness_state === 'partial' => 'partial',
|
||||
default => 'trusted',
|
||||
};
|
||||
|
||||
if ((int) ($summary['dimension_count'] ?? 0) === 0 && $artifactExistence !== 'not_created') {
|
||||
$contentState = 'empty';
|
||||
}
|
||||
|
||||
$freshnessState = match (true) {
|
||||
$artifactExistence === 'historical_only' => 'stale',
|
||||
$snapshot->completeness_state === 'stale' || $staleDimensions > 0 => 'stale',
|
||||
in_array($status, ['queued', 'generating'], true) => 'unknown',
|
||||
default => 'current',
|
||||
};
|
||||
|
||||
$actionability = match (true) {
|
||||
$artifactExistence === 'historical_only' => 'none',
|
||||
in_array($status, ['queued', 'generating'], true) => 'optional',
|
||||
$contentState === 'trusted' && $freshnessState === 'current' => 'none',
|
||||
$freshnessState === 'stale' => 'optional',
|
||||
default => 'required',
|
||||
};
|
||||
|
||||
$reasonCode = match (true) {
|
||||
$status === 'failed' => 'evidence_generation_failed',
|
||||
$missingDimensions > 0 => 'evidence_missing_dimensions',
|
||||
$staleDimensions > 0 => 'evidence_stale_dimensions',
|
||||
default => null,
|
||||
};
|
||||
$reason = $this->reasonPresenter->forArtifactTruth($reasonCode, 'artifact_truth');
|
||||
|
||||
[$primaryDomain, $primaryState, $primaryExplanation] = match (true) {
|
||||
$artifactExistence === 'not_created' => [
|
||||
BadgeDomain::GovernanceArtifactExistence,
|
||||
'not_created',
|
||||
'The evidence generation request exists, but no tenant snapshot is available yet.',
|
||||
],
|
||||
$artifactExistence === 'historical_only' => [
|
||||
BadgeDomain::GovernanceArtifactExistence,
|
||||
'historical_only',
|
||||
'This evidence snapshot remains available for history, but it is not the current working evidence artifact.',
|
||||
],
|
||||
$contentState !== 'trusted' => [
|
||||
BadgeDomain::GovernanceArtifactContent,
|
||||
$contentState,
|
||||
$reason?->shortExplanation ?? $this->contentExplanation($contentState),
|
||||
],
|
||||
$freshnessState === 'stale' => [
|
||||
BadgeDomain::GovernanceArtifactFreshness,
|
||||
'stale',
|
||||
$reason?->shortExplanation ?? 'The snapshot exists, but one or more evidence dimensions should be refreshed before relying on it.',
|
||||
],
|
||||
default => [
|
||||
BadgeDomain::GovernanceArtifactContent,
|
||||
'trusted',
|
||||
'A current evidence snapshot is available for review work.',
|
||||
],
|
||||
};
|
||||
|
||||
$nextActionUrl = $snapshot->operation_run_id
|
||||
? OperationRunLinks::tenantlessView((int) $snapshot->operation_run_id)
|
||||
: null;
|
||||
|
||||
return $this->makeEnvelope(
|
||||
artifactFamily: 'evidence_snapshot',
|
||||
artifactKey: 'evidence_snapshot:'.$snapshot->getKey(),
|
||||
workspaceId: (int) $snapshot->workspace_id,
|
||||
tenantId: $snapshot->tenant_id !== null ? (int) $snapshot->tenant_id : null,
|
||||
executionOutcome: null,
|
||||
artifactExistence: $artifactExistence,
|
||||
contentState: $contentState,
|
||||
freshnessState: $freshnessState,
|
||||
publicationReadiness: null,
|
||||
supportState: 'normal',
|
||||
actionability: $actionability,
|
||||
primaryDomain: $primaryDomain,
|
||||
primaryState: $primaryState,
|
||||
primaryExplanation: $primaryExplanation,
|
||||
diagnosticLabel: $missingDimensions > 0 && $staleDimensions > 0
|
||||
? sprintf('%d missing, %d stale dimensions', $missingDimensions, $staleDimensions)
|
||||
: null,
|
||||
reason: ArtifactTruthCause::fromReasonResolutionEnvelope($reason, ReasonPresenter::GOVERNANCE_ARTIFACT_TRUTH_ARTIFACT),
|
||||
nextActionLabel: $this->nextActionLabel(
|
||||
$actionability,
|
||||
$reason,
|
||||
match ($actionability) {
|
||||
'required' => 'Refresh evidence before using this snapshot',
|
||||
'optional' => in_array($status, ['queued', 'generating'], true)
|
||||
? 'Wait for evidence generation to finish'
|
||||
: 'Review the evidence freshness before relying on this snapshot',
|
||||
default => null,
|
||||
},
|
||||
),
|
||||
nextActionUrl: $nextActionUrl,
|
||||
relatedRunId: $snapshot->operation_run_id !== null ? (int) $snapshot->operation_run_id : null,
|
||||
relatedArtifactUrl: $snapshot->tenant !== null
|
||||
? EvidenceSnapshotResource::getUrl('view', ['record' => $snapshot], tenant: $snapshot->tenant)
|
||||
: null,
|
||||
includePublicationDimension: false,
|
||||
);
|
||||
}
|
||||
|
||||
public function forTenantReview(TenantReview $review): ArtifactTruthEnvelope
|
||||
{
|
||||
$review->loadMissing(['tenant', 'currentExportReviewPack']);
|
||||
|
||||
$summary = is_array($review->summary) ? $review->summary : [];
|
||||
$publishBlockers = $review->publishBlockers();
|
||||
$status = $review->statusEnum();
|
||||
$completeness = $review->completenessEnum()->value;
|
||||
$sectionCounts = is_array($summary['section_state_counts'] ?? null) ? $summary['section_state_counts'] : [];
|
||||
$staleSections = (int) ($sectionCounts['stale'] ?? 0);
|
||||
|
||||
$artifactExistence = match ($status) {
|
||||
TenantReviewStatus::Archived, TenantReviewStatus::Superseded => 'historical_only',
|
||||
TenantReviewStatus::Failed => 'created_but_not_usable',
|
||||
default => 'created',
|
||||
};
|
||||
|
||||
$contentState = match ($completeness) {
|
||||
TenantReviewCompletenessState::Complete->value => 'trusted',
|
||||
TenantReviewCompletenessState::Partial->value => 'partial',
|
||||
TenantReviewCompletenessState::Missing->value => 'missing_input',
|
||||
TenantReviewCompletenessState::Stale->value => 'trusted',
|
||||
default => 'partial',
|
||||
};
|
||||
|
||||
$freshnessState = match (true) {
|
||||
$artifactExistence === 'historical_only' => 'stale',
|
||||
$completeness === TenantReviewCompletenessState::Stale->value || $staleSections > 0 => 'stale',
|
||||
default => 'current',
|
||||
};
|
||||
|
||||
$publicationReadiness = match (true) {
|
||||
$artifactExistence === 'historical_only' => 'internal_only',
|
||||
$status === TenantReviewStatus::Published => 'publishable',
|
||||
$publishBlockers !== [] => 'blocked',
|
||||
$status === TenantReviewStatus::Ready => 'publishable',
|
||||
default => 'internal_only',
|
||||
};
|
||||
|
||||
$actionability = match (true) {
|
||||
$artifactExistence === 'historical_only' => 'none',
|
||||
$publicationReadiness === 'publishable' && $freshnessState === 'current' => 'none',
|
||||
$publicationReadiness === 'internal_only' && $contentState === 'trusted' => 'optional',
|
||||
$freshnessState === 'stale' && $publishBlockers === [] => 'optional',
|
||||
default => 'required',
|
||||
};
|
||||
|
||||
$reasonCode = match (true) {
|
||||
$publishBlockers !== [] => 'review_publish_blocked',
|
||||
$status === TenantReviewStatus::Failed => 'review_generation_failed',
|
||||
$completeness === TenantReviewCompletenessState::Missing->value => 'review_missing_sections',
|
||||
$completeness === TenantReviewCompletenessState::Stale->value => 'review_stale_sections',
|
||||
default => null,
|
||||
};
|
||||
$reason = $this->reasonPresenter->forArtifactTruth($reasonCode, 'artifact_truth');
|
||||
|
||||
[$primaryDomain, $primaryState, $primaryExplanation] = match (true) {
|
||||
$artifactExistence === 'historical_only' => [
|
||||
BadgeDomain::GovernanceArtifactExistence,
|
||||
'historical_only',
|
||||
'This review remains available as historical evidence, but it is no longer the current review artifact.',
|
||||
],
|
||||
$publicationReadiness === 'blocked' => [
|
||||
BadgeDomain::GovernanceArtifactPublicationReadiness,
|
||||
'blocked',
|
||||
$publishBlockers[0] ?? $reason?->shortExplanation ?? 'This review exists, but it is blocked from publication or export.',
|
||||
],
|
||||
$publicationReadiness === 'internal_only' => [
|
||||
BadgeDomain::GovernanceArtifactPublicationReadiness,
|
||||
'internal_only',
|
||||
'This review exists and is useful internally, but it is not yet ready for stakeholder publication.',
|
||||
],
|
||||
$freshnessState === 'stale' => [
|
||||
BadgeDomain::GovernanceArtifactFreshness,
|
||||
'stale',
|
||||
$reason?->shortExplanation ?? 'The review exists, but one or more required sections should be refreshed before publication.',
|
||||
],
|
||||
default => [
|
||||
BadgeDomain::GovernanceArtifactPublicationReadiness,
|
||||
'publishable',
|
||||
'This review is ready for publication and executive-pack export.',
|
||||
],
|
||||
};
|
||||
|
||||
$nextActionUrl = $review->operation_run_id
|
||||
? OperationRunLinks::tenantlessView((int) $review->operation_run_id)
|
||||
: null;
|
||||
|
||||
if ($publishBlockers !== [] && $review->tenant !== null) {
|
||||
$nextActionUrl = TenantReviewResource::tenantScopedUrl('view', ['record' => $review], $review->tenant);
|
||||
}
|
||||
|
||||
return $this->makeEnvelope(
|
||||
artifactFamily: 'tenant_review',
|
||||
artifactKey: 'tenant_review:'.$review->getKey(),
|
||||
workspaceId: (int) $review->workspace_id,
|
||||
tenantId: $review->tenant_id !== null ? (int) $review->tenant_id : null,
|
||||
executionOutcome: null,
|
||||
artifactExistence: $artifactExistence,
|
||||
contentState: $contentState,
|
||||
freshnessState: $freshnessState,
|
||||
publicationReadiness: $publicationReadiness,
|
||||
supportState: 'normal',
|
||||
actionability: $actionability,
|
||||
primaryDomain: $primaryDomain,
|
||||
primaryState: $primaryState,
|
||||
primaryExplanation: $primaryExplanation,
|
||||
diagnosticLabel: $contentState !== 'trusted'
|
||||
? BadgeCatalog::spec(BadgeDomain::GovernanceArtifactContent, $contentState)->label
|
||||
: null,
|
||||
reason: ArtifactTruthCause::fromReasonResolutionEnvelope($reason, ReasonPresenter::GOVERNANCE_ARTIFACT_TRUTH_ARTIFACT),
|
||||
nextActionLabel: $this->nextActionLabel(
|
||||
$actionability,
|
||||
$reason,
|
||||
match ($actionability) {
|
||||
'required' => 'Resolve the review blockers before publication',
|
||||
'optional' => 'Complete the remaining review work before publication',
|
||||
default => null,
|
||||
},
|
||||
),
|
||||
nextActionUrl: $nextActionUrl,
|
||||
relatedRunId: $review->operation_run_id !== null ? (int) $review->operation_run_id : null,
|
||||
relatedArtifactUrl: $review->tenant !== null
|
||||
? TenantReviewResource::tenantScopedUrl('view', ['record' => $review], $review->tenant)
|
||||
: null,
|
||||
includePublicationDimension: true,
|
||||
);
|
||||
}
|
||||
|
||||
public function forReviewPack(ReviewPack $pack): ArtifactTruthEnvelope
|
||||
{
|
||||
$pack->loadMissing(['tenant', 'tenantReview']);
|
||||
|
||||
$summary = is_array($pack->summary) ? $pack->summary : [];
|
||||
$status = (string) $pack->status;
|
||||
$evidenceResolution = is_array($summary['evidence_resolution'] ?? null) ? $summary['evidence_resolution'] : [];
|
||||
$sourceReview = $pack->tenantReview;
|
||||
$sourceBlockers = $sourceReview instanceof TenantReview ? $sourceReview->publishBlockers() : [];
|
||||
$sourceReviewStatus = $sourceReview instanceof TenantReview ? $sourceReview->statusEnum() : null;
|
||||
|
||||
$artifactExistence = match ($status) {
|
||||
ReviewPackStatus::Queued->value, ReviewPackStatus::Generating->value => 'not_created',
|
||||
ReviewPackStatus::Expired->value => 'historical_only',
|
||||
ReviewPackStatus::Failed->value => 'created_but_not_usable',
|
||||
default => 'created',
|
||||
};
|
||||
|
||||
$contentState = match (true) {
|
||||
$artifactExistence === 'not_created' => 'missing_input',
|
||||
$status === ReviewPackStatus::Failed->value => 'missing_input',
|
||||
($evidenceResolution['outcome'] ?? null) !== 'resolved' => 'missing_input',
|
||||
$sourceReview instanceof TenantReview && $sourceBlockers !== [] => 'partial',
|
||||
default => 'trusted',
|
||||
};
|
||||
|
||||
$freshnessState = $artifactExistence === 'historical_only' ? 'stale' : 'current';
|
||||
$publicationReadiness = match (true) {
|
||||
$artifactExistence === 'historical_only' => 'internal_only',
|
||||
$artifactExistence === 'not_created' => 'blocked',
|
||||
$status === ReviewPackStatus::Failed->value => 'blocked',
|
||||
$sourceReview instanceof TenantReview && $sourceBlockers !== [] => 'blocked',
|
||||
$sourceReviewStatus === TenantReviewStatus::Draft || $sourceReviewStatus === TenantReviewStatus::Failed => 'internal_only',
|
||||
default => $status === ReviewPackStatus::Ready->value ? 'publishable' : 'blocked',
|
||||
};
|
||||
|
||||
$actionability = match (true) {
|
||||
$artifactExistence === 'historical_only' => 'none',
|
||||
$publicationReadiness === 'publishable' => 'none',
|
||||
$publicationReadiness === 'internal_only' => 'optional',
|
||||
default => 'required',
|
||||
};
|
||||
|
||||
$reasonCode = match (true) {
|
||||
$status === ReviewPackStatus::Failed->value => 'review_pack_generation_failed',
|
||||
($evidenceResolution['outcome'] ?? null) !== 'resolved' => 'review_pack_missing_snapshot',
|
||||
$sourceReview instanceof TenantReview && $sourceBlockers !== [] => 'review_pack_source_not_publishable',
|
||||
$artifactExistence === 'historical_only' => 'review_pack_expired',
|
||||
default => null,
|
||||
};
|
||||
$reason = $this->reasonPresenter->forArtifactTruth($reasonCode, 'artifact_truth');
|
||||
|
||||
[$primaryDomain, $primaryState, $primaryExplanation] = match (true) {
|
||||
$artifactExistence === 'historical_only' => [
|
||||
BadgeDomain::GovernanceArtifactExistence,
|
||||
'historical_only',
|
||||
'This pack remains available as a historical export, but it is no longer the current stakeholder artifact.',
|
||||
],
|
||||
$publicationReadiness === 'blocked' => [
|
||||
BadgeDomain::GovernanceArtifactPublicationReadiness,
|
||||
'blocked',
|
||||
$sourceBlockers[0] ?? $reason?->shortExplanation ?? 'A pack file is not yet available for trustworthy stakeholder delivery.',
|
||||
],
|
||||
$publicationReadiness === 'internal_only' => [
|
||||
BadgeDomain::GovernanceArtifactPublicationReadiness,
|
||||
'internal_only',
|
||||
'This pack can be reviewed internally, but the source review is not currently publishable.',
|
||||
],
|
||||
default => [
|
||||
BadgeDomain::GovernanceArtifactPublicationReadiness,
|
||||
'publishable',
|
||||
'This executive pack is ready for stakeholder delivery.',
|
||||
],
|
||||
};
|
||||
|
||||
$nextActionUrl = null;
|
||||
|
||||
if ($sourceReview instanceof TenantReview && $pack->tenant !== null) {
|
||||
$nextActionUrl = TenantReviewResource::tenantScopedUrl('view', ['record' => $sourceReview], $pack->tenant);
|
||||
} elseif ($pack->operation_run_id !== null) {
|
||||
$nextActionUrl = OperationRunLinks::tenantlessView((int) $pack->operation_run_id);
|
||||
}
|
||||
|
||||
return $this->makeEnvelope(
|
||||
artifactFamily: 'review_pack',
|
||||
artifactKey: 'review_pack:'.$pack->getKey(),
|
||||
workspaceId: (int) $pack->workspace_id,
|
||||
tenantId: $pack->tenant_id !== null ? (int) $pack->tenant_id : null,
|
||||
executionOutcome: null,
|
||||
artifactExistence: $artifactExistence,
|
||||
contentState: $contentState,
|
||||
freshnessState: $freshnessState,
|
||||
publicationReadiness: $publicationReadiness,
|
||||
supportState: 'normal',
|
||||
actionability: $actionability,
|
||||
primaryDomain: $primaryDomain,
|
||||
primaryState: $primaryState,
|
||||
primaryExplanation: $primaryExplanation,
|
||||
diagnosticLabel: $contentState !== 'trusted'
|
||||
? BadgeCatalog::spec(BadgeDomain::GovernanceArtifactContent, $contentState)->label
|
||||
: null,
|
||||
reason: ArtifactTruthCause::fromReasonResolutionEnvelope($reason, ReasonPresenter::GOVERNANCE_ARTIFACT_TRUTH_ARTIFACT),
|
||||
nextActionLabel: $this->nextActionLabel(
|
||||
$actionability,
|
||||
$reason,
|
||||
match ($actionability) {
|
||||
'required' => 'Open the source review before sharing this pack',
|
||||
'optional' => 'Review the source review before sharing this pack',
|
||||
default => null,
|
||||
},
|
||||
),
|
||||
nextActionUrl: $nextActionUrl,
|
||||
relatedRunId: $pack->operation_run_id !== null ? (int) $pack->operation_run_id : null,
|
||||
relatedArtifactUrl: $pack->tenant !== null
|
||||
? ReviewPackResource::getUrl('view', ['record' => $pack], tenant: $pack->tenant)
|
||||
: null,
|
||||
includePublicationDimension: true,
|
||||
);
|
||||
}
|
||||
|
||||
public function forOperationRun(OperationRun $run): ArtifactTruthEnvelope
|
||||
{
|
||||
$artifact = $this->resolveArtifactForRun($run);
|
||||
$reason = $this->reasonPresenter->forOperationRun($run, 'run_detail');
|
||||
|
||||
if ($artifact !== null) {
|
||||
$artifactEnvelope = $this->for($artifact);
|
||||
|
||||
if ($artifactEnvelope instanceof ArtifactTruthEnvelope) {
|
||||
$diagnosticParts = array_values(array_filter([
|
||||
BadgeCatalog::spec(BadgeDomain::OperationRunOutcome, $run->outcome)->label !== 'Unknown'
|
||||
? BadgeCatalog::spec(BadgeDomain::OperationRunOutcome, $run->outcome)->label
|
||||
: null,
|
||||
$artifactEnvelope->diagnosticLabel,
|
||||
]));
|
||||
|
||||
return $this->makeEnvelope(
|
||||
artifactFamily: 'artifact_run',
|
||||
artifactKey: 'artifact_run:'.$run->getKey(),
|
||||
workspaceId: (int) $run->workspace_id,
|
||||
tenantId: $run->tenant_id !== null ? (int) $run->tenant_id : null,
|
||||
executionOutcome: $run->outcome !== null ? (string) $run->outcome : null,
|
||||
artifactExistence: $artifactEnvelope->artifactExistence,
|
||||
contentState: $artifactEnvelope->contentState,
|
||||
freshnessState: $artifactEnvelope->freshnessState,
|
||||
publicationReadiness: $artifactEnvelope->publicationReadiness,
|
||||
supportState: $artifactEnvelope->supportState,
|
||||
actionability: $artifactEnvelope->actionability,
|
||||
primaryDomain: $artifactEnvelope->primaryDimension()?->badgeDomain ?? BadgeDomain::GovernanceArtifactExistence,
|
||||
primaryState: $artifactEnvelope->primaryDimension()?->badgeState ?? $artifactEnvelope->artifactExistence,
|
||||
primaryExplanation: $artifactEnvelope->primaryExplanation ?? $reason?->shortExplanation ?? 'The run finished, but the related artifact needs review.',
|
||||
diagnosticLabel: $diagnosticParts === [] ? null : implode(' · ', $diagnosticParts),
|
||||
reason: $artifactEnvelope->reason,
|
||||
nextActionLabel: $artifactEnvelope->nextActionLabel,
|
||||
nextActionUrl: $artifactEnvelope->relatedArtifactUrl ?? $artifactEnvelope->nextActionUrl,
|
||||
relatedRunId: (int) $run->getKey(),
|
||||
relatedArtifactUrl: $artifactEnvelope->relatedArtifactUrl,
|
||||
includePublicationDimension: $artifactEnvelope->publicationReadiness !== null,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
$artifactExistence = match ((string) $run->status) {
|
||||
OperationRunStatus::Queued->value, OperationRunStatus::Running->value => 'not_created',
|
||||
default => 'not_created',
|
||||
};
|
||||
$contentState = in_array((string) $run->outcome, [OperationRunOutcome::Blocked->value, OperationRunOutcome::Failed->value], true)
|
||||
? 'missing_input'
|
||||
: 'empty';
|
||||
$actionability = in_array((string) $run->outcome, [OperationRunOutcome::Blocked->value, OperationRunOutcome::Failed->value], true)
|
||||
? 'required'
|
||||
: 'optional';
|
||||
$primaryState = in_array((string) $run->outcome, [OperationRunOutcome::Blocked->value, OperationRunOutcome::Failed->value], true)
|
||||
? 'created_but_not_usable'
|
||||
: 'not_created';
|
||||
|
||||
return $this->makeEnvelope(
|
||||
artifactFamily: 'artifact_run',
|
||||
artifactKey: 'artifact_run:'.$run->getKey(),
|
||||
workspaceId: (int) $run->workspace_id,
|
||||
tenantId: $run->tenant_id !== null ? (int) $run->tenant_id : null,
|
||||
executionOutcome: $run->outcome !== null ? (string) $run->outcome : null,
|
||||
artifactExistence: $artifactExistence,
|
||||
contentState: $contentState,
|
||||
freshnessState: 'unknown',
|
||||
publicationReadiness: OperationCatalog::governanceArtifactFamily((string) $run->type) === 'review_pack'
|
||||
|| OperationCatalog::governanceArtifactFamily((string) $run->type) === 'tenant_review'
|
||||
? 'blocked'
|
||||
: null,
|
||||
supportState: 'normal',
|
||||
actionability: $actionability,
|
||||
primaryDomain: BadgeDomain::GovernanceArtifactExistence,
|
||||
primaryState: $primaryState,
|
||||
primaryExplanation: $reason?->shortExplanation ?? match ((string) $run->status) {
|
||||
OperationRunStatus::Queued->value, OperationRunStatus::Running->value => 'The artifact-producing run is still in progress, so no artifact is available yet.',
|
||||
default => 'The run finished without a usable artifact result.',
|
||||
},
|
||||
diagnosticLabel: BadgeCatalog::spec(BadgeDomain::OperationRunOutcome, $run->outcome)->label,
|
||||
reason: ArtifactTruthCause::fromReasonResolutionEnvelope($reason, ReasonPresenter::GOVERNANCE_ARTIFACT_TRUTH_ARTIFACT),
|
||||
nextActionLabel: $this->nextActionLabel(
|
||||
$actionability,
|
||||
$reason,
|
||||
$actionability === 'required'
|
||||
? 'Inspect the blocked run details before retrying'
|
||||
: 'Wait for the artifact-producing run to finish',
|
||||
),
|
||||
nextActionUrl: null,
|
||||
relatedRunId: (int) $run->getKey(),
|
||||
relatedArtifactUrl: null,
|
||||
includePublicationDimension: OperationCatalog::governanceArtifactFamily((string) $run->type) === 'review_pack'
|
||||
|| OperationCatalog::governanceArtifactFamily((string) $run->type) === 'tenant_review',
|
||||
);
|
||||
}
|
||||
|
||||
private function resolveArtifactForRun(OperationRun $run): BaselineSnapshot|EvidenceSnapshot|TenantReview|ReviewPack|null
|
||||
{
|
||||
return match (OperationCatalog::governanceArtifactFamily((string) $run->type)) {
|
||||
'baseline_snapshot' => $run->relatedArtifactId() !== null
|
||||
? BaselineSnapshot::query()->with('baselineProfile')->find($run->relatedArtifactId())
|
||||
: null,
|
||||
'evidence_snapshot' => EvidenceSnapshot::query()->with('tenant')->where('operation_run_id', (int) $run->getKey())->latest('id')->first(),
|
||||
'tenant_review' => TenantReview::query()->with(['tenant', 'currentExportReviewPack'])->where('operation_run_id', (int) $run->getKey())->latest('id')->first(),
|
||||
'review_pack' => ReviewPack::query()->with(['tenant', 'tenantReview'])->where('operation_run_id', (int) $run->getKey())->latest('id')->first(),
|
||||
default => null,
|
||||
};
|
||||
}
|
||||
|
||||
private function contentExplanation(string $contentState): string
|
||||
{
|
||||
return match ($contentState) {
|
||||
'partial' => 'The artifact exists, but the captured content is incomplete for the primary operator task.',
|
||||
'missing_input' => 'The artifact is blocked by missing upstream inputs or failed capture prerequisites.',
|
||||
'metadata_only' => 'Only metadata was captured for this artifact. Use diagnostics for context, not as the primary truth signal.',
|
||||
'reference_only' => 'Only reference-level placeholders were captured for this artifact.',
|
||||
'empty' => 'The artifact row exists, but it does not contain usable captured content.',
|
||||
'unsupported' => 'Structured support is limited for this artifact family, so the current rendering should be treated as diagnostic only.',
|
||||
default => 'The artifact content is available for the intended workflow.',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, int> $reasons
|
||||
*/
|
||||
private function firstReasonCode(array $reasons): ?string
|
||||
{
|
||||
foreach ($reasons as $reason => $count) {
|
||||
if ((int) $count > 0 && is_string($reason) && trim($reason) !== '') {
|
||||
return trim($reason);
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private function nextActionLabel(
|
||||
string $actionability,
|
||||
?ReasonResolutionEnvelope $reason,
|
||||
?string $fallback = null,
|
||||
): ?string {
|
||||
if ($actionability === 'none') {
|
||||
return 'No action needed';
|
||||
}
|
||||
|
||||
if (is_string($fallback) && trim($fallback) !== '') {
|
||||
return $fallback;
|
||||
}
|
||||
|
||||
if ($reason instanceof ReasonResolutionEnvelope && $reason->firstNextStep() !== null) {
|
||||
return $reason->firstNextStep()?->label;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private function makeEnvelope(
|
||||
string $artifactFamily,
|
||||
string $artifactKey,
|
||||
int $workspaceId,
|
||||
?int $tenantId,
|
||||
?string $executionOutcome,
|
||||
string $artifactExistence,
|
||||
string $contentState,
|
||||
string $freshnessState,
|
||||
?string $publicationReadiness,
|
||||
string $supportState,
|
||||
string $actionability,
|
||||
BadgeDomain $primaryDomain,
|
||||
string $primaryState,
|
||||
?string $primaryExplanation,
|
||||
?string $diagnosticLabel,
|
||||
?ArtifactTruthCause $reason,
|
||||
?string $nextActionLabel,
|
||||
?string $nextActionUrl,
|
||||
?int $relatedRunId,
|
||||
?string $relatedArtifactUrl,
|
||||
bool $includePublicationDimension,
|
||||
): ArtifactTruthEnvelope {
|
||||
$primarySpec = BadgeCatalog::spec($primaryDomain, $primaryState);
|
||||
$dimensions = [
|
||||
$this->dimension(BadgeDomain::GovernanceArtifactExistence, $artifactExistence, 'artifact_existence', $primaryDomain === BadgeDomain::GovernanceArtifactExistence ? 'primary' : 'diagnostic'),
|
||||
$this->dimension(BadgeDomain::GovernanceArtifactContent, $contentState, 'content_fidelity', $primaryDomain === BadgeDomain::GovernanceArtifactContent ? 'primary' : 'diagnostic'),
|
||||
$this->dimension(BadgeDomain::GovernanceArtifactFreshness, $freshnessState, 'data_freshness', $primaryDomain === BadgeDomain::GovernanceArtifactFreshness ? 'primary' : 'diagnostic'),
|
||||
$this->dimension(BadgeDomain::GovernanceArtifactActionability, $actionability, 'operator_actionability', 'diagnostic'),
|
||||
];
|
||||
|
||||
if ($includePublicationDimension && $publicationReadiness !== null) {
|
||||
$dimensions[] = $this->dimension(
|
||||
BadgeDomain::GovernanceArtifactPublicationReadiness,
|
||||
$publicationReadiness,
|
||||
'publication_readiness',
|
||||
$primaryDomain === BadgeDomain::GovernanceArtifactPublicationReadiness ? 'primary' : 'diagnostic',
|
||||
);
|
||||
}
|
||||
|
||||
if ($executionOutcome !== null && trim($executionOutcome) !== '') {
|
||||
$dimensions[] = $this->dimension(BadgeDomain::OperationRunOutcome, $executionOutcome, 'execution_outcome', 'diagnostic');
|
||||
}
|
||||
|
||||
if ($supportState === 'limited_support') {
|
||||
$dimensions[] = new ArtifactTruthDimension(
|
||||
axis: 'support_maturity',
|
||||
state: 'limited_support',
|
||||
label: 'Support limited',
|
||||
classification: 'diagnostic',
|
||||
badgeDomain: BadgeDomain::GovernanceArtifactContent,
|
||||
badgeState: 'unsupported',
|
||||
);
|
||||
}
|
||||
|
||||
return new ArtifactTruthEnvelope(
|
||||
artifactFamily: $artifactFamily,
|
||||
artifactKey: $artifactKey,
|
||||
workspaceId: $workspaceId,
|
||||
tenantId: $tenantId,
|
||||
executionOutcome: $executionOutcome,
|
||||
artifactExistence: $artifactExistence,
|
||||
contentState: $contentState,
|
||||
freshnessState: $freshnessState,
|
||||
publicationReadiness: $publicationReadiness,
|
||||
supportState: $supportState,
|
||||
actionability: $actionability,
|
||||
primaryLabel: $primarySpec->label,
|
||||
primaryExplanation: $primaryExplanation,
|
||||
diagnosticLabel: $diagnosticLabel,
|
||||
nextActionLabel: $nextActionLabel,
|
||||
nextActionUrl: $nextActionUrl,
|
||||
relatedRunId: $relatedRunId,
|
||||
relatedArtifactUrl: $relatedArtifactUrl,
|
||||
dimensions: array_values($dimensions),
|
||||
reason: $reason,
|
||||
);
|
||||
}
|
||||
|
||||
private function dimension(
|
||||
BadgeDomain $domain,
|
||||
string $state,
|
||||
string $axis,
|
||||
string $classification,
|
||||
): ArtifactTruthDimension {
|
||||
return new ArtifactTruthDimension(
|
||||
axis: $axis,
|
||||
state: $state,
|
||||
label: BadgeCatalog::spec($domain, $state)->label,
|
||||
classification: $classification,
|
||||
badgeDomain: $domain,
|
||||
badgeState: $state,
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -19,6 +19,7 @@
|
||||
use App\Support\OperationCatalog;
|
||||
use App\Support\OperationRunOutcome;
|
||||
use App\Support\OperationRunStatus;
|
||||
use App\Support\OpsUx\OperationUxPresenter;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Support\Collection;
|
||||
|
||||
@ -61,6 +62,7 @@ public function __construct(
|
||||
* status_color: string,
|
||||
* outcome_label: string,
|
||||
* outcome_color: string,
|
||||
* guidance: ?string,
|
||||
* started_at: string,
|
||||
* url: string
|
||||
* }>,
|
||||
@ -334,6 +336,7 @@ private function attentionItems(int $workspaceId, array $accessibleTenantIds, bo
|
||||
* status_color: string,
|
||||
* outcome_label: string,
|
||||
* outcome_color: string,
|
||||
* guidance: ?string,
|
||||
* started_at: string,
|
||||
* url: string
|
||||
* }>
|
||||
@ -362,6 +365,7 @@ private function recentOperations(int $workspaceId, array $accessibleTenantIds):
|
||||
'status_color' => $statusColorSpec($run->status),
|
||||
'outcome_label' => $outcomeSpec($run->outcome),
|
||||
'outcome_color' => $outcomeColorSpec($run->outcome),
|
||||
'guidance' => OperationUxPresenter::surfaceGuidance($run),
|
||||
'started_at' => $run->created_at?->diffForHumans() ?? 'just now',
|
||||
'url' => route('admin.operations.view', ['run' => (int) $run->getKey()]),
|
||||
];
|
||||
|
||||
@ -120,7 +120,7 @@ ### Missing (no code, no spec beyond brainstorming)
|
||||
|
||||
## Architecture & Principles (Non-Negotiables)
|
||||
|
||||
Source: [.specify/memory/constitution.md](.specify/memory/constitution.md) (v1.10.0)
|
||||
Source: [.specify/memory/constitution.md](.specify/memory/constitution.md) (v1.12.0)
|
||||
|
||||
### Core Principles
|
||||
|
||||
@ -130,6 +130,7 @@ ### Core Principles
|
||||
4. **Deterministic Capabilities** — Backup/restore/risk flags derived from config via `CoverageCapabilitiesResolver`.
|
||||
5. **Workspace Isolation** — Non-member → 404; workspace is primary session context. Enforced via `DenyNonMemberTenantAccess` middleware + `EnsureFilamentTenantSelected`.
|
||||
6. **Tenant Isolation** — Every read/write must be tenant-scoped; `DerivesWorkspaceIdFromTenant` concern auto-fills `workspace_id` from tenant.
|
||||
7. **Operator Surface Principles** — `/admin` defaults are operator-first, diagnostics are progressively disclosed, status dimensions stay distinct, mutation scope is explicit before execution, and every materially changed operator page carries an explicit page contract.
|
||||
|
||||
### RBAC-UX Rules
|
||||
|
||||
|
||||
155
docs/product/operator-semantic-taxonomy.md
Normal file
155
docs/product/operator-semantic-taxonomy.md
Normal file
@ -0,0 +1,155 @@
|
||||
# Operator Semantic Taxonomy
|
||||
|
||||
> Canonical operator-facing state reference for the first implementation slice.
|
||||
> Downstream specs and badge mappings must reuse this vocabulary instead of inventing local synonyms.
|
||||
|
||||
**Last reviewed**: 2026-03-21
|
||||
|
||||
---
|
||||
|
||||
## Core Rules
|
||||
|
||||
1. One primary label means one thing everywhere it appears.
|
||||
2. Semantic axes stay separate. Execution outcome, coverage, freshness, evidence depth, and actionability are not interchangeable.
|
||||
3. Diagnostic-only states never use `warning` or `danger`.
|
||||
4. Primary `warning` and `danger` states must carry an operator next-action policy.
|
||||
5. Valid-empty and support-limit states are not failures unless a separate governance or execution rule says otherwise.
|
||||
|
||||
## Semantic Axes
|
||||
|
||||
| Axis | Question it answers |
|
||||
|---|---|
|
||||
| `execution_lifecycle` | Where a run sits in its execution flow |
|
||||
| `execution_outcome` | What happened when execution finished or stopped |
|
||||
| `item_result` | How one restore or preview item resolved |
|
||||
| `data_coverage` | Whether the expected data or sections are present |
|
||||
| `evidence_depth` | How much structured evidence detail is available |
|
||||
| `product_support_maturity` | Whether TenantPilot can represent the source faithfully |
|
||||
| `data_freshness` | Whether the available data is still current enough to trust |
|
||||
| `operator_actionability` | Whether an operator needs to do anything next |
|
||||
| `publication_readiness` | Whether the record is ready for stakeholder delivery |
|
||||
| `governance_deviation` | Whether the record represents a real governance problem |
|
||||
|
||||
## Severity Rules
|
||||
|
||||
| Band | Meaning |
|
||||
|---|---|
|
||||
| `danger` | Execution failure, material risk, or a stop-before-proceed condition |
|
||||
| `warning` | Operator attention or follow-up is recommended or required |
|
||||
| `info` | In-progress or context-heavy states that are not failures by themselves |
|
||||
| `success` | Intended outcome achieved with no further operator action required |
|
||||
| `gray` | Neutral, diagnostic, archived, not applicable, or intentionally non-actionable context |
|
||||
| `primary` | Reserved for badge-system compatibility only; avoid for first-slice operator states |
|
||||
|
||||
## Presentation Levels
|
||||
|
||||
| Level | Use |
|
||||
|---|---|
|
||||
| `primary` | The first label an operator uses to decide what happened and whether action is needed |
|
||||
| `diagnostic` | Secondary detail that preserves technical truth without replacing the primary meaning |
|
||||
|
||||
## Next-Action Policy
|
||||
|
||||
| Policy | Meaning |
|
||||
|---|---|
|
||||
| `required` | Operator must act before retrying or relying on the result |
|
||||
| `optional` | Review is recommended, but the state is not a hard stop |
|
||||
| `none` | No operator action is needed |
|
||||
|
||||
## Migration Guidance
|
||||
|
||||
Do not ship bare first-slice labels such as `Blocked`, `Partial`, `Missing`, `Unsupported`, `Stale`, `Warning`, or `Safe`.
|
||||
|
||||
Use these qualified replacements instead:
|
||||
|
||||
| Legacy term | Adopted replacement |
|
||||
|---|---|
|
||||
| `Blocked` | `Blocked by prerequisite` or `Fix before running` |
|
||||
| `Partial` | `Completed with follow-up`, `Coverage incomplete`, `Partially applied`, or `Mixed evidence detail` |
|
||||
| `Missing` | `Not collected yet` or `Review input pending` |
|
||||
| `Stale` | `Refresh recommended` or `Refresh review inputs` |
|
||||
| `Unsupported` | `Support limited` |
|
||||
| `Warning` | `Review before running` |
|
||||
| `Safe` | `Ready to continue` |
|
||||
|
||||
## First-Slice Canonical Mappings
|
||||
|
||||
### Operations
|
||||
|
||||
| Raw state | Primary label | Axis | Band | Next action |
|
||||
|---|---|---|---|---|
|
||||
| `queued` | `Queued for execution` | `execution_lifecycle` | `info` | `none` |
|
||||
| `running` | `In progress` | `execution_lifecycle` | `info` | `none` |
|
||||
| `succeeded` | `Completed successfully` | `execution_outcome` | `success` | `none` |
|
||||
| `partially_succeeded` | `Completed with follow-up` | `execution_outcome` | `warning` | `optional` |
|
||||
| `blocked` | `Blocked by prerequisite` | `execution_outcome` | `warning` | `required` |
|
||||
| `failed` | `Execution failed` | `execution_outcome` | `danger` | `required` |
|
||||
|
||||
### Evidence And Review Completeness
|
||||
|
||||
| Domain | Raw state | Primary label | Axis | Band | Next action |
|
||||
|---|---|---|---|---|---|
|
||||
| Evidence | `complete` | `Coverage ready` | `data_coverage` | `success` | `none` |
|
||||
| Evidence | `partial` | `Coverage incomplete` | `data_coverage` | `warning` | `required` |
|
||||
| Evidence | `missing` | `Not collected yet` | `data_coverage` | `info` | `optional` |
|
||||
| Evidence | `stale` | `Refresh recommended` | `data_freshness` | `warning` | `optional` |
|
||||
| Review | `complete` | `Review inputs ready` | `data_coverage` | `success` | `none` |
|
||||
| Review | `partial` | `Review inputs incomplete` | `data_coverage` | `warning` | `required` |
|
||||
| Review | `missing` | `Review input pending` | `data_coverage` | `info` | `optional` |
|
||||
| Review | `stale` | `Refresh review inputs` | `data_freshness` | `warning` | `optional` |
|
||||
|
||||
### Baseline Semantics
|
||||
|
||||
| Domain | Raw state | Primary label | Level | Axis | Band |
|
||||
|---|---|---|---|---|---|
|
||||
| Fidelity | `full` | `Detailed evidence` | `diagnostic` | `evidence_depth` | `success` |
|
||||
| Fidelity | `partial` | `Mixed evidence detail` | `diagnostic` | `evidence_depth` | `info` |
|
||||
| Fidelity | `reference_only` | `Metadata only` | `diagnostic` | `evidence_depth` | `info` |
|
||||
| Fidelity | `unsupported` | `Support limited` | `diagnostic` | `product_support_maturity` | `gray` |
|
||||
| Gap status | `clear` | `No follow-up needed` | `primary` | `data_coverage` | `success` |
|
||||
| Gap status | `gaps_present` | `Coverage gaps need review` | `primary` | `data_coverage` | `warning` |
|
||||
|
||||
`meta_fallback` remains diagnostic context. It does not count as a primary coverage gap by itself.
|
||||
|
||||
### Restore Semantics
|
||||
|
||||
| Domain | Raw state | Primary label | Axis | Band | Next action |
|
||||
|---|---|---|---|---|---|
|
||||
| Run | `completed` | `Applied successfully` | `execution_outcome` | `success` | `none` |
|
||||
| Run | `partial` | `Applied with follow-up` | `execution_outcome` | `warning` | `optional` |
|
||||
| Run | `completed_with_errors` | `Applied with follow-up` | `execution_outcome` | `warning` | `optional` |
|
||||
| Run | `failed` | `Restore failed` | `execution_outcome` | `danger` | `required` |
|
||||
| Run | `aborted` | `Stopped early` | `execution_outcome` | `gray` | `none` |
|
||||
| Result | `manual_required` | `Manual follow-up needed` | `operator_actionability` | `warning` | `required` |
|
||||
| Result | `mapped` | `Mapped to existing item` | `item_result` | `info` | `none` |
|
||||
| Result | `skipped` | `Not applied` | `item_result` | `gray` | `optional` |
|
||||
| Preview | `created` | `Will create` | `item_result` | `success` | `none` |
|
||||
| Preview | `created_copy` | `Will create copy` | `item_result` | `warning` | `optional` |
|
||||
| Preview | `mapped_existing` | `Will map existing` | `item_result` | `info` | `none` |
|
||||
| Preview | `skipped` | `Will skip` | `item_result` | `gray` | `optional` |
|
||||
| Check | `blocking` | `Fix before running` | `operator_actionability` | `danger` | `required` |
|
||||
| Check | `warning` | `Review before running` | `operator_actionability` | `warning` | `optional` |
|
||||
| Check | `safe` | `Ready to continue` | `operator_actionability` | `success` | `none` |
|
||||
|
||||
## Curated Examples
|
||||
|
||||
The first-slice guard rubric scores these twelve examples:
|
||||
|
||||
1. Operation blocked by missing prerequisite
|
||||
2. Operation completed with follow-up
|
||||
3. Operation completed successfully
|
||||
4. Evidence not collected yet
|
||||
5. Evidence refresh recommended
|
||||
6. Review input pending
|
||||
7. Mixed evidence detail stays diagnostic
|
||||
8. Support limited stays diagnostic
|
||||
9. Coverage gaps need review
|
||||
10. Restore preview blocked by a check
|
||||
11. Restore run applied with follow-up
|
||||
12. Restore item requires manual follow-up
|
||||
|
||||
## Cross-View Safety Rules
|
||||
|
||||
- First-slice resources that do not expose an edit or view lifecycle through global search stay out of global search.
|
||||
- Taxonomy-backed filter labels, badge counts, and summaries must be derived from already-authorized workspace and tenant scope only.
|
||||
- Canonical views may reuse the same vocabulary as tenant-context views, but they must not reveal hidden tenant names, counts, or filter hints.
|
||||
@ -3,7 +3,7 @@ # Product Principles
|
||||
> Permanent product principles that govern every spec, every UI decision, and every architectural choice.
|
||||
> New specs must align with these. If a principle needs to change, update this file first.
|
||||
|
||||
**Last reviewed**: 2026-03-08
|
||||
**Last reviewed**: 2026-03-21
|
||||
|
||||
---
|
||||
|
||||
@ -97,6 +97,18 @@ ### Canonical navigation and terminology
|
||||
Consistent naming, consistent routing, consistent mental model.
|
||||
No competing terms for the same concept.
|
||||
|
||||
### Operator-first surfaces
|
||||
`/admin` defaults are for operators, not raw implementation visibility.
|
||||
Primary content uses operator language, explicit scope, actionable status, and progressive disclosure for diagnostics.
|
||||
|
||||
### Distinct status and mutation semantics
|
||||
Execution outcome, data completeness, governance result, and lifecycle/readiness stay separate when they all exist.
|
||||
Every state-changing action tells the operator whether it affects TenantPilot only, the Microsoft tenant, or simulation only before execution.
|
||||
|
||||
### Page contract requirement
|
||||
Every new or materially refactored operator-facing page defines its persona, surface type, primary operator question,
|
||||
default-visible information, diagnostics-only information, status dimensions, mutation scope, primary actions, and dangerous actions.
|
||||
|
||||
---
|
||||
|
||||
## Process
|
||||
|
||||
@ -3,7 +3,7 @@ # Product Roadmap
|
||||
> Strategic thematic blocks and release trajectory.
|
||||
> This is the "big picture" — not individual specs.
|
||||
|
||||
**Last updated**: 2026-03-21
|
||||
**Last updated**: 2026-03-23
|
||||
|
||||
---
|
||||
|
||||
@ -26,7 +26,7 @@ ### Governance & Architecture Hardening
|
||||
|
||||
**Active specs**: 144
|
||||
**Next wave candidates**: queued execution reauthorization and scope continuity, tenant-owned query canon and wrong-tenant guards, findings workflow enforcement and audit backstop, Livewire context locking and trusted-state reduction
|
||||
**Operator truth initiative** (sequenced): Operator Outcome Taxonomy → Reason Code Translation → Provider Dispatch Gate Unification (see spec-candidates.md — "Operator Truth Initiative" sequencing note)
|
||||
**Operator truth initiative** (sequenced): Operator Outcome Taxonomy → Reason Code Translation → Artifact Truth Semantics → Governance Operator Outcome Compression; Provider Dispatch Gate Unification continues as the adjacent hardening lane (see spec-candidates.md — "Operator Truth Initiative" sequencing note)
|
||||
**Source**: architecture audit 2026-03-15, audit constitution, semantic clarity audit 2026-03-21, product spec-candidates
|
||||
|
||||
### UI & Product Maturity Polish
|
||||
|
||||
@ -3,9 +3,9 @@ # Spec Candidates
|
||||
> Concrete future specs waiting for prioritization.
|
||||
> Each entry has enough structure to become a real spec when the time comes.
|
||||
>
|
||||
> **Flow**: Inbox → Qualified → Planned → Spec created → removed from this file
|
||||
> **Flow**: Inbox → Qualified → Planned → Spec created → moved to `Promoted to Spec`
|
||||
|
||||
**Last reviewed**: 2026-03-21 (operator semantic taxonomy, semantic-clarity domain follow-ups, and absorbed extension targets updated)
|
||||
**Last reviewed**: 2026-03-23 (added governance operator outcome compression follow-up; promoted Spec 158 into ledger)
|
||||
|
||||
---
|
||||
|
||||
@ -27,28 +27,23 @@ ## Inbox
|
||||
|
||||
---
|
||||
|
||||
## Promoted to Spec
|
||||
|
||||
> Historical ledger for candidates that are no longer open. Keep them here so prioritization stays clean without losing decision history.
|
||||
|
||||
- Queued Execution Reauthorization and Scope Continuity → Spec 149 (`queued-execution-reauthorization`)
|
||||
- Livewire Context Locking and Trusted-State Reduction → Spec 152 (`livewire-context-locking`)
|
||||
- Evidence Domain Foundation → Spec 153 (`evidence-domain-foundation`)
|
||||
- Operator Outcome Taxonomy and Cross-Domain State Separation → Spec 156 (`operator-outcome-taxonomy`)
|
||||
- Operator Reason Code Translation and Humanization Contract → Spec 157 (`reason-code-translation`)
|
||||
- Governance Artifact Truthful Outcomes & Fidelity Semantics → Spec 158 (`artifact-truth-semantics`)
|
||||
|
||||
---
|
||||
|
||||
## Qualified
|
||||
|
||||
> Problem + Nutzen klar. Scope noch offen. Braucht noch Priorisierung.
|
||||
|
||||
### Queued Execution Reauthorization and Scope Continuity
|
||||
- **Type**: hardening
|
||||
- **Source**: architecture audit 2026-03-15
|
||||
- **Problem**: Queued work still relies too heavily on dispatch-time actor and tenant state. Execution-time scope continuity and capability revalidation are not yet hardened as a canonical backend contract.
|
||||
- **Why it matters**: This is a backend trust-gap on the mutation path. It creates the class of failure where a UI action was valid at dispatch time but the queued execution is no longer legitimate when it runs.
|
||||
- **Proposed direction**: Define execution-time reauthorization, tenant operability rechecks, denial semantics, and audit visibility as a dedicated spec instead of scattering local `authorize()` patches.
|
||||
- **Dependencies**: Existing operations semantics, audit log foundation, queued job execution paths
|
||||
- **Priority**: high
|
||||
|
||||
### Livewire Context Locking and Trusted-State Reduction
|
||||
- **Type**: hardening
|
||||
- **Source**: architecture audit 2026-03-15
|
||||
- **Problem**: Complex Livewire and Filament flows still expose ownership-relevant context in public component state without one explicit repo-wide hardening standard.
|
||||
- **Why it matters**: This is a trust-boundary problem. Even without a known exploit, mutable client-visible identifiers and workflow context make future authorization and isolation mistakes more likely.
|
||||
- **Proposed direction**: Define a reusable hardening pattern for locked identifiers, server-derived workflow truth, and forged-state regression tests on tier-1 component families.
|
||||
- **Dependencies**: Managed tenant onboarding draft identity (Spec 138), onboarding lifecycle checkpoint work (Spec 140)
|
||||
- **Priority**: medium
|
||||
|
||||
### Tenant Draft Discard Lifecycle and Orphaned Draft Visibility
|
||||
- **Type**: hardening
|
||||
- **Source**: domain architecture analysis 2026-03-16 — tenant lifecycle vs onboarding workflow lifecycle review
|
||||
@ -112,77 +107,6 @@ ### Baseline Capture Truthful Outcomes and Upstream Guardrails
|
||||
- If product wants stale successful inventory fallback instead of strict "latest credible only", that needs an explicit rule rather than hidden fallback behavior.
|
||||
- **Dependencies**: Baseline drift engine stable (Specs 116–119), inventory coverage context, canonical operation-run presentation work (Specs 054, 114, 144), audit log foundation (Spec 134), tenant operability and execution legitimacy direction (Specs 148, 149)
|
||||
- **Related specs / candidates**: Spec 101 (baseline governance), Specs 116–119 (baseline drift engine), Spec 144 (canonical operation viewer context decoupling), Spec 149 (queued execution reauthorization), `discoveries.md` entry "Drift engine hard-fail when no Inventory Sync exists"
|
||||
- **Priority**: high
|
||||
|
||||
### Operator Outcome Taxonomy and Cross-Domain State Separation
|
||||
- **Type**: foundation / hardening
|
||||
- **Source**: semantic clarity & operator-language audit 2026-03-21, prerequisite-handling architecture analysis
|
||||
- **Problem**: TenantPilot uses ~12 overloaded words ("failed", "partial", "missing", "gaps", "unsupported", "stale", "blocked", "complete", "ready", "reference only", "metadata only", "needs attention") across at least 8 independent meaning axes that are systematically conflated. The product does not separate execution outcome from data completeness, evidence depth from product support maturity, governance deviation from publication readiness, data freshness from operator actionability. This produces a cross-product operator-trust problem: approximately 60% of warning-colored badges communicate something that is NOT a governance problem. Examples:
|
||||
- Baseline snapshots show "Unsupported" (gray badge) and "Gaps present" (yellow badge) for policy types where the product simply uses a standard renderer — this is a product maturity fact, not a data quality failure, but it reads as a governance concern.
|
||||
- Evidence completeness shows "Missing" (red/danger) when no findings exist yet — zero findings is a valid empty state, not missing evidence, but a new tenant permanently displays red badges.
|
||||
- Restore runs show "Partial" (yellow) at both run and item level with different meanings — operators cannot determine scope of partial success or whether the tenant is in a consistent state.
|
||||
- `OperationRunOutcome::PartiallySucceeded` provides no item-level breakdown — "99 of 100 succeeded" and "1 of 100 succeeded" are visually identical.
|
||||
- "Blocked" appears across 4+ domains (operations, verification, restore, execution) without cause-specific explanation or next-action guidance.
|
||||
- "Stale" is colored gray (passive/archived) when it actually represents data that requires operator attention (freshness issue, should be yellow/orange).
|
||||
- Product support tier (e.g. fallback renderer vs. dedicated renderer) is surfaced on operator-facing badges where it should be diagnostics-only.
|
||||
- **Why it matters now**: This is not cosmetic polish — it is a governance credibility problem. TenantPilot's core value proposition is trustworthy tenant governance and review. When 60% of warning badges are false alarms, operators are trained to ignore all warnings, which then masks the badges that represent real governance gaps. Every new domain (Entra Role Governance, Enterprise App Governance, Evidence Domain) will reproduce this conflation pattern unless a shared taxonomy is established first. The semantic-clarity audit classified three of the top five findings as P0 (actively damages operator trust). The problem is systemic and cross-domain — it cannot be solved by individual surface fixes without a shared foundation. The existing badge infrastructure (`BadgeCatalog`, `BadgeRenderer`, 43 badge domains, ~500 case values) is architecturally sound; the taxonomy feeding it is structurally wrong.
|
||||
- **Proposed direction**:
|
||||
- **Define mandatory state-axis separation**: establish at minimum 8 independent axes that must never be flattened into a single badge or enum: execution lifecycle, execution outcome, item-level result, data coverage, evidence depth, product support tier, data freshness, and operator actionability. Each axis has its own vocabulary, its own badge domain, and its own color rules.
|
||||
- **Cross-domain term dictionary**: produce a canonical vocabulary where each term has exactly one meaning across the entire product. Replace the 12 overloaded terms with axis-specific alternatives (e.g. "Partially succeeded" → item-breakdown-aware messaging; "Missing" → "Not collected" / "Not generated" / "Not granted" depending on axis; "Gaps" → categorized coverage notes with cause separation; "Unsupported" → "Standard rendering" moved to diagnostics only; "Stale" → freshness axis with correct severity color).
|
||||
- **Color-severity decision rules**: codify when red/yellow/blue/green/gray are appropriate. Red = execution failure, governance violation, data loss risk. Yellow = operator action recommended, approaching threshold, mixed outcome. Blue = in-progress, informational. Green = succeeded, complete. Gray = archived, not applicable. Never use yellow for product maturity facts. Never use gray for freshness issues. Never use red for valid-empty states.
|
||||
- **Diagnostic vs. primary classification**: every piece of state information must be classified as either primary (operator-facing badge/summary) or diagnostic (expandable/secondary technical detail). Product support tier, raw reason codes, Graph API error codes, internal IDs, and renderer metadata are diagnostic-only. Execution outcome, governance status, data freshness, and operator next-actions are primary.
|
||||
- **Mandatory next-action rule**: every non-green, non-gray state must include either an inline explanation of what happened and whether action is needed, a link to a resolution path, or an explicit "No action needed — this is expected" indicator. States that fail this rule are treated as incomplete operator surfaces.
|
||||
- **Shared reference document**: produce `docs/ui/operator-semantic-taxonomy.md` (or equivalent) that all domain specs, badge mappers, and new surface implementations reference. This becomes the cross-domain truth source for operator-facing state presentation.
|
||||
- **Key domain decisions to encode**:
|
||||
- Product support maturity (renderer tier, capture mode) is NEVER operator-alarming — it belongs in diagnostics
|
||||
- Valid empty states (zero findings, zero operations, no evidence yet) are NEVER "Missing" (red) — they are "Not yet collected" (neutral) or "Empty" (informational)
|
||||
- Freshness is a separate axis from completeness — stale data requires action (yellow/orange), not archival (gray)
|
||||
- "Partial" must always be qualified: partial execution (N of M items), partial coverage (which dimensions), partial depth (which items) — never bare "Partial" without context
|
||||
- "Blocked" must always specify cause and next action
|
||||
- **Scope boundaries**:
|
||||
- **In scope**: state-axis separation rules, term dictionary, color-severity rules, diagnostic/primary classification, next-action policy, enum/badge restructuring guidance, shared reference document, domain adoption sequence recommendations
|
||||
- **Out of scope**: individual domain adoption (baseline cleanup, evidence reclassification, restore semantic cleanup, etc. are separate domain follow-up specs that consume this foundation), badge rendering infrastructure changes (BadgeCatalog/BadgeRenderer are architecturally sound — the taxonomy they consume is the problem), visual design system or theme work, new component development, operation naming vocabulary (tracked separately as "Operations Naming Harmonization")
|
||||
- **Why this should be one coherent candidate rather than fragmented domain fixes**: The semantic-clarity audit proves the problem is structural, not local. The same 12 overloaded terms leak into every domain independently. Fixing baselines without a shared taxonomy produces a different vocabulary than fixing evidence, which produces a different vocabulary than fixing operations. Domain-by-domain cleanup without a shared foundation guarantees vocabulary drift between domains. This foundation spec defines rules; domain specs apply them. The foundation is small and decisive (it produces a reference document and restructuring guidelines); the domain adoption specs do the actual refactoring.
|
||||
- **Affected workflow families / surfaces**: Operations (all run types), Baselines (snapshots, profiles, compare), Evidence (snapshots, completeness), Findings (governance validity, diff messages), Reviews / Review Packs (completeness, freshness, publication readiness), Restore (run status, item results, preview decisions), Inventory (KPI badges, coverage, snapshot mode), Onboarding / Verification (report status, check status), Alerts / Notifications (delivery status, failure messages)
|
||||
- **Dependencies**: None — this is foundational work that other candidates consume. The badge infrastructure (`BadgeCatalog`, `BadgeRenderer`) is stable and does not need changes — only the taxonomy it serves.
|
||||
- **Related specs / candidates**: Baseline Capture Truthful Outcomes (consumes this taxonomy for baseline-specific reason codes), Operator Presentation & Lifecycle Action Hardening (complementary — rendering enforcement), Operations Naming Harmonization (complementary — operation type vocabulary), Surface Signal-to-Noise Optimization (complementary — visual weight hierarchy), Admin Visual Language Canon (broader visual convention codification), semantic-clarity-audit.md (source audit)
|
||||
- **Strategic sequencing**: This is the recommended FIRST candidate in the operator-truth initiative sequence. The Reason Code Translation candidate depends on this taxonomy to define human-readable label targets. The Provider Dispatch Gate candidate benefits from shared outcome vocabulary for preflight results. Without this foundation, both downstream candidates will invent local vocabularies.
|
||||
- **Priority**: high
|
||||
|
||||
### Operator Reason Code Translation and Humanization Contract
|
||||
- **Type**: hardening
|
||||
- **Source**: semantic clarity & operator-language audit 2026-03-21, prerequisite-handling architecture analysis, cross-domain reason code inventory
|
||||
- **Problem**: TenantPilot has 6 distinct reason-code artifacts across 4 different structural patterns (final class with string constants, backed enum with `->message()`, backed enum without `->message()`, spec-level taxonomy) spread across provider, baseline, execution, operability, RBAC, and verification domains. These reason codes are the backend's primary mechanism for explaining why an operation was blocked, denied, degraded, or failed. But the product lacks a consistent translation/humanization contract for surfacing these codes to operators:
|
||||
- `ProviderReasonCodes` (24 codes) and `BaselineReasonCodes` (7 codes) are raw string constants with **no human-readable translation** — they leak directly into run detail contexts, notifications, and banners as technical fragments like `RateLimited`, `ProviderAuthFailed`, `ConsentNotGranted`, `CredentialsInvalid`.
|
||||
- `TenantOperabilityReasonCode` (10 cases) and `RbacReason` (10 cases) are backed enums with **no `->message()` method** — they can reach operator-facing surfaces as raw enum values without translation.
|
||||
- `BaselineCompareReasonCode` (5 cases) and `ExecutionDenialReasonCode` (9 cases) have inline `->message()` methods with hardcoded English strings — better, but inconsistent with the rest.
|
||||
- `RunFailureSanitizer` already performs ad-hoc normalization across reason taxonomies using heuristic string-matching and its own `REASON_*` constants, proving the need for a systematic approach.
|
||||
- `ProviderNextStepsRegistry` maps some provider reason codes to link-only remediation steps — but this is limited to provider-domain codes and provides navigation links, not human-readable explanations.
|
||||
- Notification payloads (`OperationRunQueued`, `OperationRunCompleted`, `OperationUxPresenter`) include sanitized but still technical reason strings — operators receive "Execution was blocked. Rate limited." without retry guidance or contextual explanation.
|
||||
- Run summary counts expose raw internal keys (`errors_recorded`, `report_deduped`, `posture_score`) in operator-facing summary lines via `SummaryCountsNormalizer`.
|
||||
- **Why it matters now**: Reason codes are the backend's richest source of "why did this happen?" truth. The backend frequently already knows the cause, the prerequisite, and the next step — but this knowledge reaches operators as raw technical fragments because there is no systematic translation layer. As TenantPilot adds more provider domains, more operation types, and more governance workflows, every new reason code that lacks a human-readable translation reproduces the same operator-trust degradation. Some parts of the product already translate codes well (`BaselineCompareReasonCode::message()`, `RunbookReason::options()`), proving the pattern is viable. The gap is not the pattern — it is its inconsistent adoption across all 6+ reason-code families.
|
||||
- **Proposed direction**:
|
||||
- **Reason code humanization contract**: every reason-code artifact (whether `final class` constants or backed enums) must provide a `->label()` or equivalent method that returns a human-readable, operator-appropriate string. Raw string constants that cannot provide methods must be wrapped in or migrated to enums that can.
|
||||
- **Translation target vocabulary**: human-readable labels must use the vocabulary defined by the Operator Outcome Taxonomy. Internal codes remain stable for machines/logs/tests; operator-facing surfaces exclusively use translated labels. The translation contract is the bridge between internal precision and external clarity.
|
||||
- **Structured reason resolution**: reason code translation should return not just a label but a structured resolution envelope containing: (1) human-readable label, (2) optional short explanation, (3) optional next-action link/text, (4) severity classification (retryable transient vs. permanent configuration vs. prerequisite missing). This envelope replaces the current pattern of ad-hoc string formatting in `RunFailureSanitizer`, notification builders, and presenter classes.
|
||||
- **Cross-domain registry or convention**: either a central `ReasonCodeTranslator` service that dispatches to domain-specific translators, or a mandatory `Translatable` interface/trait that all reason-code artifacts must implement. Prefer the latter (each domain owns its translations) over a central monolith, but enforce the contract architecturally.
|
||||
- **Summary count humanization**: `SummaryCountsNormalizer` (or its successor) must map internal metric keys to operator-readable labels. `errors_recorded` → "Errors", `report_deduped` → "Reports deduplicated", etc. Raw internal keys must never reach operator-facing summary lines.
|
||||
- **Next-steps enrichment**: expand `ProviderNextStepsRegistry` pattern to all reason-code families — not just provider codes. Every operator-visible reason code that implies a prerequisite or recoverable condition should include actionable next-step guidance (link, instruction, or "contact support" fallback).
|
||||
- **Notification payload cleanup**: notification builders (`OperationUxPresenter`, terminal notifications) must consume translated labels, not raw reason strings. Failure messages must include cause + retryability + next action, not just a sanitized error string.
|
||||
- **Key decisions to encode**:
|
||||
- Internal codes remain stable and must not be renamed for cosmetic reasons — they are machine contracts used in logs, tests, and audit events
|
||||
- Operator-facing surfaces exclusively use translated labels — raw codes move to diagnostic/secondary detail areas
|
||||
- Every reason code must be classifiable as retryable-transient, permanent-configuration, or prerequisite-missing — this classification drives notification tone and next-action guidance
|
||||
- `RunFailureSanitizer` should be superseded by the structured translation contract, not extended with more heuristic string-matching
|
||||
- **Scope boundaries**:
|
||||
- **In scope**: reason code humanization contract, translation interface/trait, structured resolution envelope, migration plan for existing 6 artifacts, summary count humanization, notification payload cleanup, next-steps enrichment across all reason families
|
||||
- **Out of scope**: creating new reason codes (domain specs own that), changing the semantic meaning of existing codes, badge infrastructure changes (badges consume translated labels — the rendering infrastructure is stable), operation naming vocabulary (tracked separately), individual domain-specific notification redesign beyond label substitution
|
||||
- **Affected workflow families / surfaces**: Operations (run detail, summary, notifications), Provider (connection health, blocked notifications, next-steps banners), Baselines (compare skip reasons, capture precondition reasons), Restore (run result messages, check severity explanations), Verification (check status explanations), RBAC (health check reasons), Onboarding (lifecycle denial reasons), Findings (diff unavailable messages, governance validity labels), System Console (triage, failure details)
|
||||
- **Why this should be one coherent candidate rather than fragmented per-domain fixes**: The 6 reason-code artifacts share the same structural gap (no consistent humanization contract), and per-domain fixes would each invent a different translation pattern. The contract must be defined once so that all domains implement it consistently. A per-domain approach would produce 6 different label formats, 6 different next-step patterns, and no shared resolution envelope — exactly the inconsistency this candidate eliminates. The implementation touches each domain's reason-code artifact, but the contract that governs all of them must be a single decision.
|
||||
- **Dependencies**: Operator Outcome Taxonomy (provides the target vocabulary that reason code labels translate into). Soft dependency — translation work can begin with pragmatic labels before the full taxonomy is ratified, but labels should converge with the taxonomy once available.
|
||||
- **Related specs / candidates**: Operator Outcome Taxonomy and Cross-Domain State Separation (provides vocabulary target), Operator Presentation & Lifecycle Action Hardening (provides rendering enforcement), Baseline Capture Truthful Outcomes (consumes baseline-specific reason code translations), Provider Connection Resolution Normalization (provides backend connection plumbing that gate results reference), Operations Naming Harmonization (complementary — operation type labels vs. reason code labels)
|
||||
- **Strategic sequencing**: Recommended SECOND in the operator-truth initiative sequence, after the Outcome Taxonomy. Can begin in parallel if pragmatic interim labels are acceptable, but final label convergence depends on the taxonomy.
|
||||
- **Priority**: high
|
||||
|
||||
### Provider-Backed Action Preflight and Dispatch Gate Unification
|
||||
- **Type**: hardening
|
||||
@ -214,21 +138,106 @@ ### Provider-Backed Action Preflight and Dispatch Gate Unification
|
||||
- **Why this should be one coherent candidate rather than per-action fixes**: The Gen 1 pattern is used by ~20 services. Fixing each service independently would produce 20 local preflight implementations with inconsistent result rendering, different dedup logic, and incompatible notification patterns. The value is the unified contract: one gate, one result envelope, one presenter, one notification pattern. Per-action fixes cannot deliver this convergence.
|
||||
- **Dependencies**: Provider Connection Resolution Normalization (soft dependency — gate unification is more coherent when all services receive explicit connection IDs, but the gate itself can be extended before full backend normalization is complete since `ProviderConnectionResolver::resolveDefault()` already exists). Operator Reason Code Translation (for translated blocked-reason labels in gate results). Operator Outcome Taxonomy (for consistent outcome vocabulary in gate result presentation).
|
||||
- **Related specs / candidates**: Provider Connection Resolution Normalization (backend plumbing), Provider Connection UX Clarity (UX labels), Provider Connection Legacy Cleanup (post-normalization cleanup), Queued Execution Reauthorization (execution-time revalidation — complementary to dispatch-time gating), Baseline Capture Truthful Outcomes (consumes gate results for baseline-specific preconditions), Operator Presentation & Lifecycle Action Hardening (rendering conventions for gate results)
|
||||
- **Strategic sequencing**: Recommended THIRD in the operator-truth initiative sequence. Benefits from the vocabulary defined by the Outcome Taxonomy and the humanized labels from Reason Code Translation. However, the backend extension of `ProviderOperationStartGate` scope can proceed independently and in parallel with the other two candidates.
|
||||
- **Strategic sequencing**: Recommended as the adjacent hardening lane after the shared taxonomy and translation work are in place, while governance-surface adoption proceeds through Spec 158 and the governance compression follow-up. It benefits from the vocabulary defined by the Outcome Taxonomy and the humanized labels from Reason Code Translation, but much of the backend extension of `ProviderOperationStartGate` scope can proceed independently and in parallel with governance-surface work.
|
||||
- **Priority**: high
|
||||
|
||||
### Governance Operator Outcome Compression
|
||||
- **Type**: hardening
|
||||
- **Source**: product follow-up recommendation 2026-03-23; direct continuation of Spec 158 (`artifact-truth-semantics`)
|
||||
- **Vehicle**: new standalone candidate
|
||||
- **Problem**: Spec 158 establishes the correct internal truth model for governance artifacts, but several governance-facing list and summary surfaces still risk exposing too many internal semantic axes as first-class UI language. On baseline, evidence, review, and pack surfaces the product can still read as academically correct but operator-heavy: multiple adjacent status badges, architecture-derived labels, and equal treatment of existence, readiness, freshness, completeness, and publication semantics. Normal operators are forced to synthesize the answer to three simple workflow questions themselves: Is this artifact usable, why not, and what should I do next?
|
||||
- **Why it matters**: This is the cockpit follow-up to Spec 158's engine work. Without it, TenantPilot preserves semantic correctness internally but leaks too much of that structure directly into governance UX. The result is lower scanability, weaker operator confidence, and a real risk that baseline, evidence, review, and pack domains each evolve their own local status dialect despite sharing the same truth foundation. Shipping this follow-up before broader governance expansion stabilizes operator language where MSP admins actually work.
|
||||
- **Proposed direction**:
|
||||
- Introduce a **compressed operator outcome layer** for governance artifacts that consumes the existing `ArtifactTruthEnvelope`, outcome taxonomy, and reason translation contracts without discarding any internal truth dimensions
|
||||
- Define rendering rules that classify each truth dimension as **primary operator view**, **secondary explanatory detail**, or **diagnostics only**
|
||||
- Make list and overview rows answer three questions first: **primary state**, **short reason**, **next action**
|
||||
- Normalize visible operator language so internal architectural terms such as `artifact truth`, `missing_input`, `metadata_only`, or `publication truth` do not dominate primary workflow surfaces
|
||||
- Clarify where **publication readiness** is the primary business statement versus where it is only one secondary dimension, especially for tenant reviews and review packs
|
||||
- Keep diagnostics available on detail and run-detail pages, but demote raw reason structures, fidelity sub-axes, JSON context, and renderer/support facts behind the primary operator explanation
|
||||
- **Primary adoption surfaces**:
|
||||
- Baseline snapshot lists and detail pages
|
||||
- Evidence snapshot lists and detail pages
|
||||
- Evidence overview
|
||||
- Tenant review lists and detail pages
|
||||
- Review register
|
||||
- Review pack lists and detail pages
|
||||
- Shared governance detail templates and artifact-truth presenter surfaces
|
||||
- Artifact-oriented run-detail pages only where the run is explaining baseline, evidence, review, or review-pack truth
|
||||
- **Scope boundaries**:
|
||||
- **In scope**: visible operator labels, list-column hierarchy, detail-page information hierarchy, mapping from artifact-truth envelopes to compressed operator states, explicit separation between default operator view and diagnostic detail, review/pack publication-readiness primacy rules, governance run-detail explanation hierarchy
|
||||
- **Out of scope**: full operations-list redesign, broad visual polish, color or spacing retuning as the primary goal, new semantic foundation axes, broad findings or workspace overview rewrites, compliance/audit PDF output changes, alert routing or notification copy rewrites, domain-model refactors that change the underlying truth representation
|
||||
- **Core product principles to encode**:
|
||||
- One primary operator statement per artifact on scan surfaces
|
||||
- No truth loss: internal artifact truth, reason structures, APIs, audit context, and JSON diagnostics remain intact and available
|
||||
- Diagnostics are second-layer, not the default operator language
|
||||
- Context-specific business language beats architecture-first vocabulary on primary governance surfaces
|
||||
- Lists are scan surfaces, not diagnosis surfaces
|
||||
- **Candidate requirements**:
|
||||
- **R1 Composite operator outcome**: governance artifacts expose a compressed operator-facing outcome derived from the existing truth and reason model
|
||||
- **R2 Primary / secondary / diagnostic rendering rules**: the system defines which semantic dimensions may appear in each rendering tier
|
||||
- **R3 List-surface simplification**: governance lists stop defaulting to multi-column badge explosions for separate semantic axes
|
||||
- **R4 Detail-surface hierarchy**: details lead with outcome, explanation, and next action before diagnostics
|
||||
- **R5 Operator language normalization**: internal architecture terms are translated or removed from primary governance UI
|
||||
- **R6 Review / pack publication clarity**: review and pack surfaces explicitly state when publishability is the main business decision and when it is not
|
||||
- **R7 No truth loss**: APIs, audit, diagnostics, and raw context remain available even when the primary presentation is compressed
|
||||
- **Acceptance points**:
|
||||
- Governance lists no longer present multiple equal-weight semantic badge columns as the default mental model
|
||||
- `artifact truth` and sibling architecture-first labels stop dominating primary operator surfaces
|
||||
- Governance detail pages clearly separate primary state, explanatory reason, next action, and diagnostics
|
||||
- Review and pack surfaces clearly answer whether the artifact is ready to publish or share
|
||||
- Baseline and evidence surfaces clearly answer whether the artifact is trustworthy and usable
|
||||
- Governance run-detail pages make the dominant problem and next action understandable without reading raw JSON
|
||||
- The internal truth model remains fully usable for diagnostics, audit, and downstream APIs
|
||||
- **Dependencies**: Spec 156 (operator-outcome-taxonomy), Spec 157 (reason-code-translation), Spec 158 (artifact-truth-semantics), shared governance detail templates, review-layer and evidence-domain adoption surfaces already in flight
|
||||
- **Related specs / candidates**: Spec 153 (evidence-domain-foundation), Spec 155 (tenant-review-layer), Spec 158 (artifact-truth-semantics), Baseline Snapshot Fidelity Semantics candidate, Compliance Readiness & Executive Review Packs candidate
|
||||
- **Strategic sequencing**: Recommended immediately after Spec 158 and before any major additional governance-surface expansion. This is the adoption layer that turns the truth semantics foundation into an operator-tolerable cockpit instead of a direct dump of internal semantic richness.
|
||||
- **Priority**: high
|
||||
|
||||
### Humanized Diagnostic Summaries for Governance Operations
|
||||
- **Type**: hardening
|
||||
- **Source**: operator-facing governance run-detail gap analysis 2026-03-23; follow-up to Specs 156, 157, and 158
|
||||
- **Vehicle**: new standalone candidate
|
||||
- **Problem**: Governance run-detail pages now have the right outcome, reason, and artifact-truth semantics, but the operator explanation often still lives in raw JSON. A run can read as `Completed with follow-up`, `Partial`, `Blocked`, or `Missing input` while the important meaning stays hidden: how much was affected, which cause dominates, whether retry or resume helps, and whether the resulting artifact is actually trustworthy.
|
||||
- **Why it matters**: This is the missing middle layer between Spec 158's truth engine and the operator's actual decision. Without it, TenantPilot stays semantically correct but too technical on one of its highest-trust governance surfaces. Raw JSON remains part of normal troubleshooting when it should be optional.
|
||||
- **Proposed direction**:
|
||||
- Add a humanized diagnostic summary layer on governance run-detail pages between semantic verdicts and raw JSON
|
||||
- Lead with impact, dominant cause, artifact trustworthiness, and next action instead of forcing operators to infer those from badges plus raw context
|
||||
- Render a compact dominant-cause breakdown for multi-cause degraded runs, including counts or relative scale where useful
|
||||
- Separate processing-success counters from artifact usability so technically correct metrics do not read as false-green artifact success
|
||||
- Upgrade generic `Inspect diagnostics` guidance into cause-aware next steps such as retry later, resume capture, refresh policy inventory, verify missing policies, review ambiguous matches, or fix access or scope configuration
|
||||
- Keep raw JSON and low-level context fully available, but explicitly secondary
|
||||
- **Primary adoption surfaces**:
|
||||
- Canonical Monitoring run-detail pages for governance operation types
|
||||
- Shared tenantless canonical run viewers and run-detail templates
|
||||
- Governance run detail reached from baseline capture, baseline compare, evidence refresh or snapshot generation, tenant review generation, and review-pack generation
|
||||
- **Scope boundaries**:
|
||||
- **In scope**: run-detail explanation hierarchy, humanized impact summaries, dominant-cause breakdowns, clearer processing-versus-artifact terminology, reusable guidance pattern for governance run families
|
||||
- **Out of scope**: full Operations redesign, broad list or dashboard overhaul, new persistence models for summaries, removal of raw JSON, new truth axes beyond the existing outcome or artifact-truth model, generalized rewrite of all governance artifact detail pages
|
||||
- **Acceptance points**:
|
||||
- A normal operator can understand the dominant problem and next step on a governance run-detail page without opening raw JSON
|
||||
- Runs with technically successful processing but degraded artifacts explicitly explain why those truths diverge
|
||||
- Multi-cause degraded runs show the dominant causes and their scale instead of only one flattened abstract state
|
||||
- Guidance distinguishes retryable or resumable situations from structural prerequisite problems when the data supports that distinction
|
||||
- Raw JSON remains present for audit and debugging but is clearly secondary in the page hierarchy
|
||||
- **Dependencies**: Spec 156 (operator-outcome-taxonomy), Spec 157 (reason-code-translation), Spec 158 (artifact-truth-semantics), canonical operation viewer work, shared governance detail templates
|
||||
- **Related specs / candidates**: Spec 144 (canonical operation viewer context decoupling), Spec 153 (evidence-domain-foundation), Spec 155 (tenant-review-layer), Spec 158 (artifact-truth-semantics), Governance Operator Outcome Compression candidate
|
||||
- **Strategic sequencing**: Best treated as the run-detail explainability companion to Governance Operator Outcome Compression. Compression improves governance artifact scan surfaces; this candidate makes governance run detail self-explanatory once an operator drills in.
|
||||
- **Priority**: high
|
||||
|
||||
> **Operator Truth Initiative — Sequencing Note**
|
||||
>
|
||||
> The three candidates above (Operator Outcome Taxonomy, Reason Code Translation, Provider Dispatch Gate Unification) form a coherent cross-product initiative addressing the systemic gap between backend truth richness and operator-facing truth quality. They are sequenced as a dependency chain with parallelization opportunities:
|
||||
> The operator-truth work now has two connected lanes: a shared truth-foundation lane and a governance-surface compression lane. Together they address the systemic gap between backend truth richness and operator-facing truth quality without forcing operators to parse raw internal semantics.
|
||||
>
|
||||
> **Recommended order:**
|
||||
> 1. **Operator Outcome Taxonomy and Cross-Domain State Separation** — defines the shared vocabulary, state-axis separation rules, and color-severity conventions that all other operator-facing work references. This is the smallest deliverable (a reference document + restructuring guidelines) but the highest-leverage decision. Without it, the other two candidates will invent local vocabularies that diverge.
|
||||
> 2. **Operator Reason Code Translation and Humanization Contract** — defines the translation bridge from internal codes to operator-facing labels using the Outcome Taxonomy's vocabulary. Can begin in parallel with the taxonomy using pragmatic interim labels, but final convergence depends on the taxonomy.
|
||||
> 3. **Provider-Backed Action Preflight and Dispatch Gate Unification** — extends the proven Gen 2 gate pattern to all provider-backed operations and establishes a shared result presenter. Benefits from both upstream candidates but has significant backend scope (gate extension + scope-busy enforcement) that can proceed independently.
|
||||
> 3. **Governance Artifact Truthful Outcomes & Fidelity Semantics** — establishes the full internal truth model for governance artifacts, keeping existence, usability, freshness, completeness, publication readiness, and actionability distinct.
|
||||
> 4. **Governance Operator Outcome Compression** — applies the foundation to governance workflow surfaces so lists and details answer the operator's primary question first, while preserving diagnostics as second-layer detail.
|
||||
> 5. **Provider-Backed Action Preflight and Dispatch Gate Unification** — extends the proven Gen 2 gate pattern to all provider-backed operations and establishes a shared result presenter. Benefits from the shared vocabulary work but remains a parallel hardening lane rather than a governance-surface adoption slice.
|
||||
>
|
||||
> **Why this order rather than the inverse:** The semantic-clarity audit classified the taxonomy problem as P0 (60% of warning badges are false alarms — actively damages operator trust). Reason code translation and gate unification are both P1-level (strongly confusing, should be fixed soon). The taxonomy is also the smallest and most decisive deliverable — it produces a reference that all other candidates consume. Shipping the taxonomy first prevents the other two candidates from making locally correct but globally inconsistent vocabulary choices. The gate unification has the largest implementation surface (~20 services) but much of its backend work (extending `ProviderOperationStartGate` scope, adding connection locking, dedup enforcement) can proceed in parallel once the taxonomy establishes the shared vocabulary for gate result presentation.
|
||||
> **Why this order rather than the inverse:** The semantic-clarity audit classified the taxonomy problem as P0 (60% of warning badges are false alarms — actively damages operator trust). Reason code translation creates the shared human-facing language. Spec 158 establishes the correct internal truth engine for governance artifacts. The compression follow-up is then what turns that engine into a scanable operator cockpit before more governance features land. Gate unification remains highly valuable, but it is a neighboring hardening lane rather than the immediate follow-up needed to make governance truth semantics feel product-ready.
|
||||
>
|
||||
> **Why these are not one spec:** Each candidate has a different implementation surface, different stakeholders, and different shippability boundaries. The taxonomy is a cross-cutting decision document. Reason code translation touches 6+ reason-code artifacts and notification builders. Gate unification touches ~20 services, the gate class, Filament action handlers, and notification templates. Merging them would create an unshippable monolith. Keeping them as a sequenced initiative preserves independent delivery while ensuring vocabulary convergence.
|
||||
> **Why these are not one spec:** Each candidate has a different implementation surface, different stakeholders, and different shippability boundary. The taxonomy is a cross-cutting decision document. Reason code translation touches reason-code artifacts and notification builders. Spec 158 defines the richer artifact truth engine. Governance operator outcome compression is a UI-information-architecture adoption slice across governance surfaces. Gate unification touches provider dispatch and notification plumbing across ~20 services. Merging them would create an unshippable monolith. Keeping them sequenced preserves independent delivery while still converging on one operator language.
|
||||
|
||||
### Baseline Snapshot Fidelity Semantics
|
||||
- **Type**: hardening
|
||||
@ -269,18 +278,6 @@ ### Exception / Risk-Acceptance Workflow for Findings
|
||||
- **Dependencies**: Findings workflow (Spec 111) complete, audit log foundation (Spec 134)
|
||||
- **Priority**: high
|
||||
|
||||
### Evidence Domain Foundation
|
||||
- **Type**: feature
|
||||
- **Source**: HANDOVER gap, R2 theme completion
|
||||
- **Vehicle note**: Promoted into existing Spec 153. Do not create a second evidence-domain candidate for semantic cleanup; extend Spec 153 when evidence completeness / freshness semantics need to be corrected.
|
||||
- **Problem**: Review pack export (Spec 109) and permission posture reports (104/105) exist as separate output artifacts. There is no first-class evidence domain model that curates, bundles, and tracks these artifacts as a coherent compliance deliverable for external audit submission.
|
||||
- **Why it matters**: Enterprise customers need a single, versioned, auditor-ready package — not a collection of separate exports assembled manually. The gap is not export packaging (Spec 109 handles that); it is the absence of an evidence domain layer that owns curation, completeness tracking, and audit-trail linkage.
|
||||
- **Proposed direction**: Evidence domain model with curated artifact references (review packs, posture reports, findings summaries, baseline governance snapshots). Completeness metadata. Immutable snapshots with generation timestamp and actor. Not a re-implementation of export — a higher-order assembly layer.
|
||||
- **Explicit non-goals**: Not a presentation or reporting layer — this candidate owns data curation, completeness tracking, artifact storage, and immutable snapshots. Executive summaries, framework-oriented readiness views, management-ready outputs, and stakeholder-facing packaging belong to the Compliance Readiness & Executive Review Packs candidate, which consumes this foundation. Not a replacement for Spec 109's export packaging. Not a generic BI or data warehouse initiative.
|
||||
- **Boundary with Compliance Readiness**: Evidence Domain Foundation = lower-level data assembly (what artifacts exist, are they complete, are they immutable). Compliance Readiness = upper-level presentation (how to arrange evidence into framework-oriented, stakeholder-facing deliverables). This candidate is a prerequisite; Compliance Readiness is a downstream consumer.
|
||||
- **Dependencies**: Review pack export (109), permission posture (104/105)
|
||||
- **Priority**: high
|
||||
|
||||
### Compliance Readiness & Executive Review Packs
|
||||
- **Type**: feature
|
||||
- **Source**: roadmap-to-spec coverage audit 2026-03-18, R2 theme completion, product positioning for German midmarket / MSP governance
|
||||
@ -494,6 +491,57 @@ ### Operations Naming Harmonization Across Run Types, Catalog, UI, and Audit
|
||||
- Concrete desired outcome without overdesigning the solution
|
||||
- Easy to promote into a full spec once operations-domain work is prioritized
|
||||
|
||||
### OperationRun Humanization & Diagnostics Boundary
|
||||
- **Type**: hardening
|
||||
- **Source**: operator surface standards adoption v1, operations UX consistency review, cross-link monitoring/alerts/dashboard review
|
||||
- **Problem**: `OperationRun` is one of TenantPilot's highest-leverage operator surfaces, but its current UX risks exposing implementation semantics instead of operator-facing clarity. The default surface can blur execution outcome, blocked reason, technical diagnostics, object and scope identity, and next-action guidance into one undifferentiated detail stack. Because `OperationRun` is the destination for monitoring links, alert deep links, dashboard drill-ins, and troubleshooting flows, weak clarity here propagates system-wide. The current risk pattern includes raw reason codes, internal IDs and hashes, raw payload context, ambiguous state labels, and insufficient separation between operator summary and diagnostic truth.
|
||||
- **Why it matters**: This is the best pilot surface for the operator UX standard because it is highly visible, cross-cutting, and already carries the product's richest execution truth. If the default run detail page does not quickly answer "What happened?", "Did it succeed, fail, or get blocked?", "What scope did it affect?", "Is action required?", and "What should I do next?", then monitoring, alerting, dashboards, and support flows all inherit the same ambiguity. A low-clarity run surface reduces trust far beyond the operations page itself.
|
||||
- **Desired outcome**: The default `OperationRun` list and detail experience should be operator-first: identity, status, scope, interpreted summary, and next action lead; diagnostics remain available but clearly secondary. Senior engineers must still be able to reach raw reason codes, IDs, payload context, and technical details without those details polluting the primary operator surface.
|
||||
- **Proposed direction**:
|
||||
- **Canonical detail-page hierarchy**: make run identity, status, top-level summary, explicit scope, and primary actions the first content block on the detail page. Raw JSON, hashes, internal IDs, and technical payload fragments move below a clearly labeled diagnostics boundary.
|
||||
- **Operator-first state interpretation**: translate blocked and failed states into human-readable labels and summaries. Distinguish execution outcome from completeness/downstream effect where relevant so operators can tell the difference between "execution succeeded" and "the intended effect is incomplete or limited."
|
||||
- **Diagnostics boundary pattern**: establish a reusable convention where diagnostic truth is available through progressive disclosure, not mixed into the primary reading path. The boundary should explicitly contain raw reason code, raw identifiers, payload context, low-level exception detail, and similar engineering-facing artifacts.
|
||||
- **Next-step guidance contract**: whenever a run is blocked or failed, the primary surface must show what the operator should do next. Next-step content should be visible without requiring expansion into raw diagnostics.
|
||||
- **Explicit scope communication**: run details and list rows should make the affected object, domain, and workspace/tenant context obvious so an operator can immediately understand impact and blast radius.
|
||||
- **Action hierarchy on run details**: establish a safe, obvious action order for follow-up paths such as retry, inspect source object, view related monitoring context, or open diagnostics. Diagnostic actions must not visually compete with the primary operator path.
|
||||
- **Reference implementation role**: use `OperationRun` as the reference implementation for future surfaces that need the same pattern: operator-first summary plus secondary diagnostics.
|
||||
- **In scope**:
|
||||
- `OperationRun` list and detail surfaces
|
||||
- operator-facing labels and summaries
|
||||
- blocked / failed state translation on these surfaces
|
||||
- diagnostics boundary and progressive disclosure pattern
|
||||
- top-level summary cards / interpreted content
|
||||
- explicit scope communication and workspace / tenant context
|
||||
- action hierarchy on run details
|
||||
- mapping technical reasons into operator-facing blocked reason and next-step guidance
|
||||
- monitoring / alert / dashboard deep-link landing clarity insofar as those links land on `OperationRun`
|
||||
- **Out of scope**:
|
||||
- broad redesign of the entire admin UI
|
||||
- full monitoring information-architecture rewrite
|
||||
- full alerts redesign
|
||||
- product-wide adoption of the standard in one pass
|
||||
- backend execution model changes that are not needed for `OperationRun` operator-surface clarity
|
||||
- broad operation naming refactors beyond what this surface strictly needs to be understandable
|
||||
- **Acceptance characteristics**:
|
||||
- default-visible labels do not expose raw internal field names
|
||||
- blocked reasons are human-readable
|
||||
- next steps are visible when the run is blocked or failed
|
||||
- diagnostics are present but clearly secondary
|
||||
- page hierarchy starts with identity, status, summary, and actions
|
||||
- raw JSON, internal hashes, and internal IDs are not primary content
|
||||
- monitoring and alert deep links land on a page that is understandable to operators without diagnostic context
|
||||
- **Why now vs later**:
|
||||
- **Why now**: high leverage; immediate trust improvement for monitoring and alert flows; first concrete adoption of the operator UX standard; creates a reusable pattern for future detail surfaces
|
||||
- **Why not later**: unresolved ambiguity on run surfaces keeps leaking into dashboards, alerts, and troubleshooting entry points; later surfaces will otherwise copy the current weak pattern
|
||||
- **Cross-cutting impact**: monitoring, alerts, dashboards, diagnostics conventions, status-taxonomy adoption, future detail-page standards, and operator-language normalization
|
||||
- **Likely follow-on candidates**: `PolicyResource` operator language and metadata isolation, baseline compare / snapshot completeness clarity, restore-run language and safe-execution standard alignment, canonical degraded / prerequisite state pattern across surfaces
|
||||
- **Dependencies**: Operator Outcome Taxonomy and Cross-Domain State Separation (shared vocabulary), Operator Reason Code Translation and Humanization Contract (blocked/failure labels and next steps), canonical `OperationRun` context work already explored in Specs 054, 114, and 144
|
||||
- **Boundary with Operations Naming Harmonization**: Naming harmonization owns the cross-product vocabulary for operation types, labels, and catalog mappings. This candidate owns the operator readability and diagnostics separation of the `OperationRun` surface itself. Naming alignment may be consumed here, but this candidate should not expand into a repo-wide naming refactor.
|
||||
- **Boundary with Operator Presentation & Lifecycle Action Hardening**: Presentation hardening owns shared rendering and action-visibility conventions across many surfaces. This candidate is narrower and deeper: it defines the canonical operator-versus-diagnostics hierarchy on the highest-value run detail surface and uses it as the reference implementation for future adoption.
|
||||
- **Boundary with Operator Reason Code Translation**: Reason code translation defines how internal codes become human-readable labels and next-step envelopes. This candidate consumes that translation on the `OperationRun` surface and defines where translated content stops and raw diagnostics begin.
|
||||
- **Strategic sequencing**: Best tackled after the outcome taxonomy and in parallel with or immediately after reason-code translation. It is a strong pilot implementation candidate before broader operator-surface rollout because it validates the standards on the most cross-linked execution surface first.
|
||||
- **Priority**: high
|
||||
|
||||
### Operator Presentation & Lifecycle Action Hardening
|
||||
- **Type**: hardening
|
||||
- **Source**: Evidence Snapshot / Ops-UX review 2026-03-19
|
||||
|
||||
@ -4,7 +4,7 @@ # Product Standards
|
||||
> Specs reference these standards; they do not redefine them.
|
||||
> Guard tests enforce critical constraints automatically.
|
||||
|
||||
**Last reviewed**: 2026-03-09
|
||||
**Last reviewed**: 2026-03-21
|
||||
|
||||
---
|
||||
|
||||
@ -42,7 +42,7 @@ ## Related Docs
|
||||
|
||||
| Document | Location | Purpose |
|
||||
|---|---|---|
|
||||
| Constitution | `.specify/memory/constitution.md` | Permanent principles (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 |
|
||||
| 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) |
|
||||
|
||||
521
docs/ui/operator-ux-surface-standards.md
Normal file
521
docs/ui/operator-ux-surface-standards.md
Normal file
@ -0,0 +1,521 @@
|
||||
# Operator UX & Surface Standards
|
||||
|
||||
This document defines the binding audience-and-surface contract for TenantPilot.
|
||||
|
||||
It establishes:
|
||||
|
||||
- who each product surface is primarily built for
|
||||
- what type of language is allowed on that surface
|
||||
- how technical detail must be progressively disclosed
|
||||
- how operator-facing pages must communicate status, scope, and actions
|
||||
|
||||
This document is normative for new operator-facing UI work and for major UI refactors.
|
||||
|
||||
## 1. Purpose
|
||||
|
||||
TenantPilot is not a generic admin UI. It is an enterprise operator product for managed Microsoft tenant governance, backup, restore, monitoring, drift detection, and review workflows.
|
||||
|
||||
The product must not expose internal implementation structure as if it were product UX.
|
||||
|
||||
The default experience must be optimized for the primary working audience.
|
||||
Technical truth may remain available, but it must be progressively disclosed.
|
||||
|
||||
## 2. Product Audience Model
|
||||
|
||||
TenantPilot serves four distinct audiences. Every page must have exactly one primary audience.
|
||||
|
||||
### 2.1 Primary Persona: Operator / Engineer
|
||||
|
||||
Focus:
|
||||
|
||||
- day-to-day execution
|
||||
- monitoring
|
||||
- triage
|
||||
- safe action-taking
|
||||
|
||||
Typical tasks:
|
||||
|
||||
- onboarding tenants
|
||||
- configuring providers
|
||||
- running syncs
|
||||
- reviewing inventory state
|
||||
- running backup / restore workflows
|
||||
- baseline capture / compare
|
||||
- findings triage
|
||||
- exceptions / risk acceptance
|
||||
- operations monitoring
|
||||
|
||||
UI requirements:
|
||||
|
||||
- clear operator language
|
||||
- strong scanability
|
||||
- explicit scope
|
||||
- safe defaults
|
||||
- actionable next steps
|
||||
- minimal ambiguity
|
||||
- no unnecessary internal field leakage
|
||||
|
||||
### 2.2 Secondary Persona: Senior Engineer / Troubleshooter
|
||||
|
||||
Focus:
|
||||
|
||||
- diagnosis
|
||||
- validation
|
||||
- escalation handling
|
||||
- deep technical analysis
|
||||
|
||||
Typical needs:
|
||||
|
||||
- raw payloads
|
||||
- provider error details
|
||||
- UUIDs / external IDs
|
||||
- technical context
|
||||
- low-level diagnostic traces
|
||||
|
||||
UI requirements:
|
||||
|
||||
- access to raw truth
|
||||
- no removal of diagnostic power
|
||||
- diagnostics available on-demand, never as default-visible content
|
||||
|
||||
### 2.3 Tertiary Persona: Service Manager / CISO / Customer Reviewer
|
||||
|
||||
Focus:
|
||||
|
||||
- proof
|
||||
- reporting
|
||||
- trends
|
||||
- governance posture
|
||||
- SLA / audit communication
|
||||
|
||||
Typical needs:
|
||||
|
||||
- dashboards
|
||||
- summaries
|
||||
- evidence-backed reporting
|
||||
- accepted risks
|
||||
- review readiness
|
||||
- exported review packs
|
||||
|
||||
UI requirements:
|
||||
|
||||
- aggregated, outcome-oriented surfaces
|
||||
- low operational noise
|
||||
- no dependence on operator-level detail pages
|
||||
|
||||
### 2.4 Platform Persona: Platform Admin / System Operator
|
||||
|
||||
Focus:
|
||||
|
||||
- platform administration
|
||||
- internal platform management
|
||||
- break-glass operations
|
||||
- platform-wide controls
|
||||
|
||||
Typical surface:
|
||||
|
||||
- `/system`
|
||||
|
||||
UI requirements:
|
||||
|
||||
- technically capable
|
||||
- administratively precise
|
||||
- still intentionally designed
|
||||
- not a dumping ground for unresolved `/admin` UX
|
||||
|
||||
## 3. Surface Ownership by Persona
|
||||
|
||||
### 3.1 `/admin` operator surfaces
|
||||
|
||||
`/admin` is operator-first.
|
||||
|
||||
This includes:
|
||||
|
||||
- onboarding
|
||||
- integrations used for tenant operations
|
||||
- tenant management
|
||||
- inventory
|
||||
- policy views
|
||||
- baselines
|
||||
- findings
|
||||
- evidence
|
||||
- monitoring / operations
|
||||
- backup / restore
|
||||
- governance actions
|
||||
|
||||
Default-visible UX on these surfaces must be optimized for the Operator / Engineer persona.
|
||||
|
||||
### 3.2 `/admin` diagnostics surfaces
|
||||
|
||||
Diagnostics within `/admin` belong to the Senior Engineer / Troubleshooter persona.
|
||||
|
||||
They are:
|
||||
|
||||
- available where appropriate
|
||||
- secondary
|
||||
- collapsed, tabbed, or otherwise intentionally disclosed
|
||||
- never the primary content hierarchy
|
||||
|
||||
### 3.3 Reporting / review surfaces
|
||||
|
||||
Reports, review packs, customer read-only views, and similar proof-oriented surfaces are optimized for the Service Manager / CISO / Customer Reviewer persona.
|
||||
|
||||
These surfaces prioritize:
|
||||
|
||||
- summary
|
||||
- evidence-backed conclusions
|
||||
- trends
|
||||
- risk posture
|
||||
- review readiness
|
||||
|
||||
### 3.4 `/system` platform surfaces
|
||||
|
||||
`/system` is reserved for platform-only administration.
|
||||
|
||||
Rules:
|
||||
|
||||
- `/system` may expose more technical administrative detail than `/admin`
|
||||
- `/system` must not be used to avoid fixing weak operator UX in `/admin`
|
||||
- product operator workflows belong in `/admin`, not `/system`
|
||||
|
||||
## 4. Default vs Diagnostics Rule
|
||||
|
||||
### Gold rule
|
||||
|
||||
**Default = Operator Language**
|
||||
**Expanded = Diagnostic Truth**
|
||||
|
||||
### 4.1 Default-visible content must avoid:
|
||||
|
||||
- raw JSON blobs
|
||||
- raw internal field names
|
||||
- raw DB timestamps without product meaning
|
||||
- raw enum / reason codes
|
||||
- raw provider error bodies unless translated
|
||||
- UUIDs or IDs as primary visual content
|
||||
- implementation-specific field semantics
|
||||
|
||||
### 4.2 Diagnostics content may include:
|
||||
|
||||
- raw JSON
|
||||
- provider error details
|
||||
- raw IDs / UUIDs
|
||||
- internal field values
|
||||
- stacktrace-adjacent diagnostic summaries
|
||||
- payload sizes
|
||||
- created_at / updated_at / internal audit metadata where relevant
|
||||
|
||||
### 4.3 Diagnostics placement
|
||||
|
||||
Diagnostics must be secondary and intentionally placed, for example:
|
||||
|
||||
- collapsed diagnostics section
|
||||
- dedicated diagnostics tab
|
||||
- advanced technical panel
|
||||
- expandable system detail area
|
||||
|
||||
Diagnostics must not dominate the primary page hierarchy.
|
||||
|
||||
## 5. Operator-Language Glossary
|
||||
|
||||
Internal or technical terminology must not appear in operator-facing default surfaces without translation.
|
||||
|
||||
| Internal / Technical Term | Operator-Facing UI Term | Notes |
|
||||
|---|---|---|
|
||||
| `dry_run`, `is_dry_run` | Simulation Mode / Simulate Only | Indicates no live Microsoft mutation |
|
||||
| `metadata_only` | Incomplete Capture / Configuration Unavailable | Use based on context |
|
||||
| `reason_code` | Blocked Reason / Failure Reason | Humanized mapping required |
|
||||
| `provider_consent_missing` | Missing Admin Consent | Example translated reason |
|
||||
| `scope_jsonb.policy_types` | Included Policy Types | Never expose DB path syntax |
|
||||
| `scope_jsonb.foundation_types` | Included Foundation Types | Never expose DB path syntax |
|
||||
| `evidenceGaps` | Missing Inventory Data / Incomplete Sync Data | Use operator meaning |
|
||||
| `fidelity` | Match Confidence | Only show if actionable |
|
||||
| `role_definition_id` | Assigned Role | Prefer display name over raw ID |
|
||||
|
||||
This glossary should expand over time and be treated as a product vocabulary source of truth.
|
||||
|
||||
## 6. Status Taxonomy
|
||||
|
||||
UI surfaces must not collapse all state into one ambiguous status.
|
||||
|
||||
TenantPilot must distinguish at least four status dimensions where relevant.
|
||||
|
||||
### 6.1 Execution Outcome
|
||||
|
||||
Question:
|
||||
|
||||
- Did the operation execute successfully?
|
||||
|
||||
Examples:
|
||||
|
||||
- Queued
|
||||
- Running
|
||||
- Succeeded
|
||||
- Blocked
|
||||
- Failed
|
||||
- Cancelled
|
||||
|
||||
### 6.2 Data Completeness
|
||||
|
||||
Question:
|
||||
|
||||
- Do we have the full data required for reliable interpretation?
|
||||
|
||||
Examples:
|
||||
|
||||
- Complete
|
||||
- Incomplete
|
||||
- Stale
|
||||
- Unavailable
|
||||
|
||||
### 6.3 Governance Result
|
||||
|
||||
Question:
|
||||
|
||||
- Is the tenant or object aligned with expected governance state?
|
||||
|
||||
Examples:
|
||||
|
||||
- Aligned
|
||||
- Drift Detected
|
||||
- Exception Applied
|
||||
- Risk Accepted
|
||||
- Review Needed
|
||||
|
||||
### 6.4 Lifecycle / Readiness State
|
||||
|
||||
Question:
|
||||
|
||||
- What lifecycle or publication state is the object in?
|
||||
|
||||
Examples:
|
||||
|
||||
- Draft
|
||||
- Active
|
||||
- Archived
|
||||
- Review Ready
|
||||
- Published
|
||||
- Expired
|
||||
|
||||
These dimensions must remain semantically distinct in labels, badges, summaries, and workflows.
|
||||
|
||||
## 7. Mutation Scope Rule
|
||||
|
||||
Every action that changes state must communicate its mutation scope before execution.
|
||||
|
||||
Allowed mutation-scope categories:
|
||||
|
||||
- **TenantPilot only**
|
||||
- **Microsoft tenant**
|
||||
- **Simulation only**
|
||||
|
||||
Examples:
|
||||
|
||||
- Archive local record -> TenantPilot only
|
||||
- Restore configuration to Intune -> Microsoft tenant
|
||||
- Dry-run / simulation -> Simulation only
|
||||
|
||||
This must be understandable from the action label, preview, confirmation step, or surrounding UI copy.
|
||||
|
||||
## 8. Canonical Detail-Page Layout
|
||||
|
||||
Every operator-facing detail page should follow this hierarchy unless there is a strong documented reason not to.
|
||||
|
||||
### 8.1 Header
|
||||
|
||||
Must show:
|
||||
|
||||
- object identity
|
||||
- subtle identifier if needed
|
||||
- 1-2 primary status indicators
|
||||
|
||||
### 8.2 Action Bar
|
||||
|
||||
Rules:
|
||||
|
||||
- maximum 2 primary actions
|
||||
- destructive or uncommon actions belong in a secondary group
|
||||
- action labels must reflect real outcome and mutation scope
|
||||
|
||||
### 8.3 Summary / Health
|
||||
|
||||
Top-of-page summary should answer the primary operator question quickly.
|
||||
|
||||
Typical examples:
|
||||
|
||||
- last sync / last run
|
||||
- completeness state
|
||||
- governance state
|
||||
- current baseline / current assignment
|
||||
- current blocked reason
|
||||
- recommended next step
|
||||
|
||||
### 8.4 Primary Content
|
||||
|
||||
This is the main working surface:
|
||||
|
||||
- related records
|
||||
- findings
|
||||
- relations
|
||||
- scoped data
|
||||
- actionable tables
|
||||
- interpreted content
|
||||
|
||||
### 8.5 Diagnostics
|
||||
|
||||
Diagnostics belong at the bottom or in a secondary surface:
|
||||
|
||||
- raw JSON
|
||||
- raw IDs
|
||||
- payloads
|
||||
- internal timestamps
|
||||
- provider response detail
|
||||
|
||||
## 9. Safe Execution Standard
|
||||
|
||||
Actions with meaningful blast radius must follow a consistent pattern.
|
||||
|
||||
### Required sequence
|
||||
|
||||
1. Configuration
|
||||
2. Safety checks / simulation
|
||||
3. Preview
|
||||
4. Hard confirmation where required
|
||||
5. Execute
|
||||
|
||||
### Applies to:
|
||||
|
||||
- restore
|
||||
- baseline enforce
|
||||
- high-impact bulk actions
|
||||
- delete actions with tenant impact
|
||||
- tenant-wide mutation flows
|
||||
|
||||
### Rules
|
||||
|
||||
- no dangerous one-click actions for high-blast-radius operations
|
||||
- confirmation language must match actual action semantics
|
||||
- preview must clearly indicate mutation scope
|
||||
|
||||
## 10. Workspace vs Tenant Context Rules
|
||||
|
||||
Operators must never have to infer scope from memory.
|
||||
|
||||
### 10.1 Tenant-scoped surfaces
|
||||
|
||||
If the operator is inside a tenant surface:
|
||||
|
||||
- actions must be tenant-safe
|
||||
- workspace-wide actions must not silently appear
|
||||
- `show all tenants` or similar context escapes must not behave like normal in-flow actions
|
||||
|
||||
### 10.2 Workspace-scoped surfaces
|
||||
|
||||
Workspace surfaces may aggregate:
|
||||
|
||||
- dashboards
|
||||
- review views
|
||||
- multi-tenant monitoring
|
||||
- fleet-level rollouts
|
||||
- cross-tenant summaries
|
||||
|
||||
But they must still communicate that they are workspace-level, not tenant-level.
|
||||
|
||||
### 10.3 Context clarity requirements
|
||||
|
||||
Every page must make clear:
|
||||
|
||||
- current scope
|
||||
- object scope
|
||||
- whether actions are local or cross-scope
|
||||
- whether current data is tenant-specific or workspace-aggregated
|
||||
|
||||
## 11. Page Contract Requirement
|
||||
|
||||
Every new or materially refactored page must define the following before implementation:
|
||||
|
||||
- primary persona
|
||||
- surface type
|
||||
- primary operator question
|
||||
- default-visible information
|
||||
- diagnostics-only information
|
||||
- status dimensions used
|
||||
- mutation scope
|
||||
- primary actions
|
||||
- dangerous actions
|
||||
|
||||
A page that cannot answer these questions is not ready for implementation.
|
||||
|
||||
## 12. Surface Types
|
||||
|
||||
Recommended surface categories:
|
||||
|
||||
- operator execution surface
|
||||
- operator monitoring surface
|
||||
- diagnostic detail surface
|
||||
- reporting / review surface
|
||||
- customer read-only surface
|
||||
- platform admin surface
|
||||
|
||||
These categories help determine hierarchy, language, action model, and diagnostics visibility.
|
||||
|
||||
## 13. What Good Looks Like
|
||||
|
||||
A strong operator-facing page lets the primary persona understand within a few seconds:
|
||||
|
||||
- what this page represents
|
||||
- what scope they are in
|
||||
- whether something is healthy, blocked, incomplete, or risky
|
||||
- what they should do next
|
||||
- whether any action changes TenantPilot only, Microsoft tenant state, or nothing live
|
||||
|
||||
A strong diagnostics surface lets the troubleshooting persona access raw truth quickly without polluting the primary operator experience.
|
||||
|
||||
A strong reporting surface avoids operational noise and emphasizes proof, posture, and readiness.
|
||||
|
||||
## 14. Adoption Rules
|
||||
|
||||
This document applies to:
|
||||
|
||||
- new operator-facing resources
|
||||
- new dashboards and detail pages
|
||||
- new action-heavy flows
|
||||
- major refactors of existing surfaces
|
||||
- new specs that materially affect operator UX
|
||||
|
||||
Specs should explicitly reference this document where relevant.
|
||||
|
||||
Recommended spec fields:
|
||||
|
||||
- primary persona
|
||||
- surface type
|
||||
- diagnostics boundary
|
||||
- status taxonomy used
|
||||
- mutation scope
|
||||
- action safety model
|
||||
|
||||
## 15. Non-Goals
|
||||
|
||||
This document does not define:
|
||||
|
||||
- color palette
|
||||
- visual branding
|
||||
- CSS-level implementation details
|
||||
- pixel-level layout choices
|
||||
- complete component library design
|
||||
|
||||
This document is about audience, semantics, surface structure, diagnostics boundaries, and safe operator UX.
|
||||
|
||||
## 16. Summary
|
||||
|
||||
TenantPilot must behave like a product, not a raw schema browser.
|
||||
|
||||
That means:
|
||||
|
||||
- operator-first default surfaces
|
||||
- diagnostics available, but secondary
|
||||
- distinct status dimensions
|
||||
- explicit mutation scope
|
||||
- safe execution for dangerous actions
|
||||
- explicit workspace / tenant context
|
||||
- page-level audience and surface contracts
|
||||
@ -42,14 +42,14 @@
|
||||
:description="$ranAtLabel ? ('Last run: ' . $ranAtLabel) : 'Run checks to evaluate risk before previewing.'"
|
||||
>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<x-filament::badge :color="$blocking > 0 ? 'danger' : 'gray'">
|
||||
{{ $blocking }} blocking
|
||||
<x-filament::badge :color="$blocking > 0 ? $severitySpec('blocking')->color : 'gray'">
|
||||
{{ $blocking }} {{ \Illuminate\Support\Str::lower($severitySpec('blocking')->label) }}
|
||||
</x-filament::badge>
|
||||
<x-filament::badge :color="$warning > 0 ? 'warning' : 'gray'">
|
||||
{{ $warning }} warnings
|
||||
<x-filament::badge :color="$warning > 0 ? $severitySpec('warning')->color : 'gray'">
|
||||
{{ $warning }} {{ \Illuminate\Support\Str::lower($severitySpec('warning')->label) }}
|
||||
</x-filament::badge>
|
||||
<x-filament::badge :color="$safe > 0 ? 'success' : 'gray'">
|
||||
{{ $safe }} safe
|
||||
<x-filament::badge :color="$safe > 0 ? $severitySpec('safe')->color : 'gray'">
|
||||
{{ $safe }} {{ \Illuminate\Support\Str::lower($severitySpec('safe')->label) }}
|
||||
</x-filament::badge>
|
||||
</div>
|
||||
</x-filament::section>
|
||||
|
||||
@ -54,14 +54,14 @@
|
||||
|
||||
<div class="space-y-4">
|
||||
@if (! empty($group['renderingError']))
|
||||
<div class="rounded-lg border border-amber-200 bg-amber-50 px-4 py-3 text-sm text-amber-900 dark:border-amber-800 dark:bg-amber-950/30 dark:text-amber-200">
|
||||
<div class="rounded-lg border border-gray-200 bg-gray-50 px-4 py-3 text-sm text-gray-700 dark:border-gray-700 dark:bg-gray-900/40 dark:text-gray-200">
|
||||
{{ $group['renderingError'] }}
|
||||
</div>
|
||||
@endif
|
||||
|
||||
@if (is_array($messages) && $messages !== [])
|
||||
<div class="rounded-lg border border-gray-200 bg-gray-50 px-4 py-3 text-sm text-gray-700 dark:border-gray-700 dark:bg-gray-900/40 dark:text-gray-200">
|
||||
<div class="font-medium">Gap details</div>
|
||||
<div class="rounded-lg border px-4 py-3 text-sm {{ data_get($group, 'gapSummary.has_gaps') ? 'border-amber-200 bg-amber-50 text-amber-900 dark:border-amber-800 dark:bg-amber-950/30 dark:text-amber-200' : 'border-gray-200 bg-gray-50 text-gray-700 dark:border-gray-700 dark:bg-gray-900/40 dark:text-gray-200' }}">
|
||||
<div class="font-medium">{{ data_get($group, 'gapSummary.has_gaps') ? 'Coverage gaps' : 'Diagnostic notes' }}</div>
|
||||
<ul class="mt-2 list-disc space-y-1 pl-5">
|
||||
@foreach ($messages as $message)
|
||||
<li>{{ $message }}</li>
|
||||
@ -117,7 +117,7 @@
|
||||
</div>
|
||||
|
||||
@if (is_array(data_get($item, 'gapSummary.messages')) && data_get($item, 'gapSummary.messages') !== [])
|
||||
<div class="mt-3 rounded-lg border border-amber-200 bg-amber-50 px-3 py-2 text-xs text-amber-900 dark:border-amber-800 dark:bg-amber-950/30 dark:text-amber-200">
|
||||
<div class="mt-3 rounded-lg border px-3 py-2 text-xs {{ data_get($item, 'gapSummary.has_gaps') ? 'border-amber-200 bg-amber-50 text-amber-900 dark:border-amber-800 dark:bg-amber-950/30 dark:text-amber-200' : 'border-gray-200 bg-gray-50 text-gray-700 dark:border-gray-700 dark:bg-gray-900/40 dark:text-gray-200' }}">
|
||||
{{ implode(' ', data_get($item, 'gapSummary.messages', [])) }}
|
||||
</div>
|
||||
@endif
|
||||
|
||||
@ -32,9 +32,9 @@
|
||||
<th class="px-4 py-3">Policy type</th>
|
||||
<th class="px-4 py-3">Items</th>
|
||||
<th class="px-4 py-3">Fidelity</th>
|
||||
<th class="px-4 py-3">Gaps</th>
|
||||
<th class="px-4 py-3">Coverage state</th>
|
||||
<th class="px-4 py-3">Latest evidence</th>
|
||||
<th class="px-4 py-3">Coverage hint</th>
|
||||
<th class="px-4 py-3">Notes</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-gray-100 dark:divide-gray-800">
|
||||
|
||||
@ -77,9 +77,9 @@ class="inline-flex items-center gap-2 rounded-full border px-3 py-1.5 text-sm fo
|
||||
<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 flex-wrap items-center gap-2 text-sm font-medium text-gray-900 dark:text-white">
|
||||
<div class="mt-2 flex min-w-0 flex-wrap items-center gap-2 text-sm font-medium text-gray-900 dark:text-white">
|
||||
@if ($displayValue !== null)
|
||||
<span>{{ $displayValue }}</span>
|
||||
<span class="min-w-0 break-all whitespace-normal">{{ $displayValue }}</span>
|
||||
@endif
|
||||
|
||||
@if ($badge !== null)
|
||||
|
||||
@ -37,7 +37,7 @@
|
||||
:collapsed="(bool) ($section['collapsed'] ?? false)"
|
||||
>
|
||||
@if ($view !== null)
|
||||
@include($view, is_array($section['viewData'] ?? null) ? $section['viewData'] : [])
|
||||
{!! view($view, is_array($section['viewData'] ?? null) ? $section['viewData'] : [])->render() !!}
|
||||
@elseif ($items !== [])
|
||||
@include('filament.infolists.entries.enterprise-detail.section-items', [
|
||||
'items' => $items,
|
||||
|
||||
@ -19,9 +19,9 @@
|
||||
<div class="text-xs font-semibold uppercase tracking-[0.16em] text-gray-500 dark:text-gray-400">
|
||||
{{ $item['label'] ?? 'Detail' }}
|
||||
</div>
|
||||
<div class="mt-2 flex flex-wrap items-center gap-2 text-sm font-medium text-gray-900 dark:text-white">
|
||||
<div class="mt-2 flex min-w-0 flex-wrap items-center gap-2 text-sm font-medium text-gray-900 dark:text-white">
|
||||
@if ($displayValue !== null)
|
||||
<span>{{ $displayValue }}</span>
|
||||
<span class="min-w-0 break-all whitespace-normal">{{ $displayValue }}</span>
|
||||
@endif
|
||||
|
||||
@if ($badge !== null)
|
||||
|
||||
@ -35,7 +35,7 @@ class="text-xs font-medium {{ ($action['destructive'] ?? false) === true ? 'text
|
||||
|
||||
<div class="mt-4">
|
||||
@if ($view !== null)
|
||||
@include($view, is_array($card['viewData'] ?? null) ? $card['viewData'] : [])
|
||||
{!! view($view, is_array($card['viewData'] ?? null) ? $card['viewData'] : [])->render() !!}
|
||||
@elseif ($items !== [])
|
||||
@include('filament.infolists.entries.enterprise-detail.section-items', ['items' => $items])
|
||||
@elseif ($emptyState !== null)
|
||||
|
||||
@ -20,10 +20,10 @@
|
||||
@if ($view !== null)
|
||||
@if ($entries !== [])
|
||||
<div class="mt-4">
|
||||
@include($view, is_array($section['viewData'] ?? null) ? $section['viewData'] : [])
|
||||
{!! view($view, is_array($section['viewData'] ?? null) ? $section['viewData'] : [])->render() !!}
|
||||
</div>
|
||||
@else
|
||||
@include($view, is_array($section['viewData'] ?? null) ? $section['viewData'] : [])
|
||||
{!! view($view, is_array($section['viewData'] ?? null) ? $section['viewData'] : [])->render() !!}
|
||||
@endif
|
||||
@elseif ($emptyState !== null)
|
||||
<div @class(['mt-4' => $entries !== []])>
|
||||
|
||||
@ -0,0 +1,127 @@
|
||||
@php
|
||||
use App\Support\Badges\BadgeCatalog;
|
||||
use App\Support\Badges\BadgeDomain;
|
||||
|
||||
$resolvedState = isset($getState) ? $getState() : ($artifactTruthState ?? ($state ?? null));
|
||||
$state = is_array($resolvedState) ? $resolvedState : [];
|
||||
$dimensions = collect(is_array($state['dimensions'] ?? null) ? $state['dimensions'] : []);
|
||||
|
||||
$primary = $dimensions->first(fn (mixed $dimension): bool => is_array($dimension) && ($dimension['classification'] ?? null) === 'primary');
|
||||
$existence = $dimensions->first(fn (mixed $dimension): bool => is_array($dimension) && ($dimension['axis'] ?? null) === 'artifact_existence');
|
||||
$freshness = $dimensions->first(fn (mixed $dimension): bool => is_array($dimension) && ($dimension['axis'] ?? null) === 'data_freshness');
|
||||
$publication = $dimensions->first(fn (mixed $dimension): bool => is_array($dimension) && ($dimension['axis'] ?? null) === 'publication_readiness');
|
||||
$actionability = $dimensions->first(fn (mixed $dimension): bool => is_array($dimension) && ($dimension['axis'] ?? null) === 'operator_actionability');
|
||||
|
||||
$specFor = static function (mixed $dimension): ?\App\Support\Badges\BadgeSpec {
|
||||
if (! is_array($dimension)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (! is_string($dimension['badgeDomain'] ?? null) || ! is_string($dimension['badgeState'] ?? null)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return BadgeCatalog::spec(BadgeDomain::from($dimension['badgeDomain']), $dimension['badgeState']);
|
||||
};
|
||||
|
||||
$primarySpec = $specFor($primary);
|
||||
$existenceSpec = $specFor($existence);
|
||||
$freshnessSpec = $specFor($freshness);
|
||||
$publicationSpec = $specFor($publication);
|
||||
$actionabilitySpec = $specFor($actionability);
|
||||
$reason = is_array($state['reason'] ?? null) ? $state['reason'] : [];
|
||||
$nextSteps = is_array($reason['nextSteps'] ?? null) ? $reason['nextSteps'] : [];
|
||||
@endphp
|
||||
|
||||
<div class="space-y-4">
|
||||
<div class="rounded-xl border border-gray-200 bg-white p-4 shadow-sm dark:border-gray-800 dark:bg-gray-900">
|
||||
<div class="flex flex-wrap items-start gap-2">
|
||||
@if ($primarySpec)
|
||||
<x-filament::badge :color="$primarySpec->color" :icon="$primarySpec->icon" size="sm">
|
||||
{{ $primarySpec->label }}
|
||||
</x-filament::badge>
|
||||
@endif
|
||||
|
||||
@if ($actionabilitySpec)
|
||||
<x-filament::badge :color="$actionabilitySpec->color" :icon="$actionabilitySpec->icon" size="sm">
|
||||
{{ $actionabilitySpec->label }}
|
||||
</x-filament::badge>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
<div class="mt-3 space-y-2">
|
||||
<div class="text-sm font-medium text-gray-950 dark:text-gray-100">
|
||||
{{ $state['primaryLabel'] ?? 'Artifact truth' }}
|
||||
</div>
|
||||
|
||||
@if (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($state['diagnosticLabel'] ?? null) && trim($state['diagnosticLabel']) !== '')
|
||||
<p class="text-xs uppercase tracking-wide text-gray-500 dark:text-gray-400">
|
||||
Diagnostic: {{ $state['diagnosticLabel'] }}
|
||||
</p>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<dl class="grid gap-3 sm:grid-cols-2 xl:grid-cols-4">
|
||||
@if ($existenceSpec)
|
||||
<div class="rounded-md border border-gray-100 bg-gray-50 px-3 py-2 dark:border-gray-800 dark:bg-gray-950/60">
|
||||
<dt class="text-[11px] font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">Artifact exists</dt>
|
||||
<dd class="mt-1">
|
||||
<x-filament::badge :color="$existenceSpec->color" :icon="$existenceSpec->icon" size="sm">
|
||||
{{ $existenceSpec->label }}
|
||||
</x-filament::badge>
|
||||
</dd>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
@if ($freshnessSpec)
|
||||
<div class="rounded-md border border-gray-100 bg-gray-50 px-3 py-2 dark:border-gray-800 dark:bg-gray-950/60">
|
||||
<dt class="text-[11px] font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">Freshness</dt>
|
||||
<dd class="mt-1">
|
||||
<x-filament::badge :color="$freshnessSpec->color" :icon="$freshnessSpec->icon" size="sm">
|
||||
{{ $freshnessSpec->label }}
|
||||
</x-filament::badge>
|
||||
</dd>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
@if ($publicationSpec)
|
||||
<div class="rounded-md border border-gray-100 bg-gray-50 px-3 py-2 dark:border-gray-800 dark:bg-gray-950/60">
|
||||
<dt class="text-[11px] font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">Publication</dt>
|
||||
<dd class="mt-1">
|
||||
<x-filament::badge :color="$publicationSpec->color" :icon="$publicationSpec->icon" size="sm">
|
||||
{{ $publicationSpec->label }}
|
||||
</x-filament::badge>
|
||||
</dd>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
<div class="rounded-md border border-gray-100 bg-gray-50 px-3 py-2 dark:border-gray-800 dark:bg-gray-950/60">
|
||||
<dt class="text-[11px] font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">Next step</dt>
|
||||
<dd class="mt-1 text-sm text-gray-900 dark:text-gray-100">
|
||||
{{ $state['nextActionLabel'] ?? 'No action needed' }}
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
|
||||
@if ($nextSteps !== [])
|
||||
<div class="space-y-2">
|
||||
<div class="text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">Guidance</div>
|
||||
<ul class="space-y-1 text-sm text-gray-700 dark:text-gray-300">
|
||||
@foreach ($nextSteps as $step)
|
||||
@continue(! is_string($step) || trim($step) === '')
|
||||
|
||||
<li class="rounded-md border border-primary-100 bg-primary-50 px-3 py-2 dark:border-primary-900/40 dark:bg-primary-950/30">
|
||||
{{ $step }}
|
||||
</li>
|
||||
@endforeach
|
||||
</ul>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
@ -1,5 +1,14 @@
|
||||
@php
|
||||
$preview = $getState() ?? [];
|
||||
$actionPresentation = static function (array $item): array {
|
||||
$action = is_string($item['action'] ?? null) ? $item['action'] : null;
|
||||
|
||||
return match ($action) {
|
||||
'create' => ['label' => 'Will create', 'color' => 'success'],
|
||||
'update' => ['label' => 'Will update existing', 'color' => 'info'],
|
||||
default => ['label' => \Illuminate\Support\Str::headline((string) ($action ?? 'action')), 'color' => 'gray'],
|
||||
};
|
||||
};
|
||||
$foundationItems = collect($preview)->filter(function ($item) {
|
||||
return is_array($item) && array_key_exists('decision', $item) && array_key_exists('sourceId', $item);
|
||||
});
|
||||
@ -9,7 +18,7 @@
|
||||
@endphp
|
||||
|
||||
@if (empty($preview))
|
||||
<p class="text-sm text-gray-600">No preview available.</p>
|
||||
<p class="text-sm text-gray-600">No preview has been generated yet.</p>
|
||||
@else
|
||||
<div class="space-y-4">
|
||||
@if ($foundationItems->isNotEmpty())
|
||||
@ -51,6 +60,7 @@
|
||||
@foreach ($policyItems as $item)
|
||||
@php
|
||||
$restoreMode = $item['restore_mode'] ?? null;
|
||||
$actionState = $actionPresentation(is_array($item) ? $item : []);
|
||||
@endphp
|
||||
<div class="rounded border border-gray-200 bg-white p-3 shadow-sm">
|
||||
<div class="flex items-center justify-between text-sm text-gray-800">
|
||||
@ -64,8 +74,8 @@
|
||||
{{ $restoreModeSpec->label }}
|
||||
</x-filament::badge>
|
||||
@endif
|
||||
<span class="rounded bg-gray-100 px-2 py-0.5 text-xs uppercase tracking-wide text-gray-700">
|
||||
{{ $item['action'] ?? 'action' }}
|
||||
<span class="rounded bg-gray-100 px-2 py-0.5 text-xs font-medium text-gray-700">
|
||||
{{ $actionState['label'] }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -36,7 +36,7 @@
|
||||
@endphp
|
||||
|
||||
@if ($foundationItems->isEmpty() && $policyItems->isEmpty())
|
||||
<p class="text-sm text-gray-600">No results recorded.</p>
|
||||
<p class="text-sm text-gray-600">No restore results have been recorded yet.</p>
|
||||
@else
|
||||
@php
|
||||
$needsAttention = $policyItems->contains(function ($item) {
|
||||
@ -82,7 +82,7 @@
|
||||
|
||||
@if ($needsAttention)
|
||||
<div class="rounded border border-amber-200 bg-amber-50 px-3 py-2 text-sm text-amber-900">
|
||||
Some settings could not be applied automatically. Review the per-setting details below.
|
||||
Some items still need follow-up. Review the per-item details below.
|
||||
</div>
|
||||
@endif
|
||||
|
||||
@ -121,7 +121,7 @@
|
||||
$itemGraphMessage = $item['graph_error_message'] ?? null;
|
||||
|
||||
if ($itemReason === 'preview_only') {
|
||||
$itemReason = 'Preview-only policy type; execution skipped.';
|
||||
$itemReason = 'Preview only. This policy type is not applied during execution.';
|
||||
}
|
||||
@endphp
|
||||
|
||||
@ -141,9 +141,9 @@
|
||||
@endphp
|
||||
|
||||
<div class="mt-2 text-xs text-gray-700">
|
||||
Assignments: {{ (int) ($summary['success'] ?? 0) }} success •
|
||||
{{ (int) ($summary['failed'] ?? 0) }} failed •
|
||||
{{ (int) ($summary['skipped'] ?? 0) }} skipped
|
||||
Assignments: {{ (int) ($summary['success'] ?? 0) }} applied •
|
||||
{{ (int) ($summary['failed'] ?? 0) }} failed items •
|
||||
{{ (int) ($summary['skipped'] ?? 0) }} not applied
|
||||
</div>
|
||||
|
||||
@if ($assignmentIssues->isNotEmpty())
|
||||
@ -214,7 +214,7 @@
|
||||
|
||||
<div class="mt-2 text-xs text-gray-700">
|
||||
Compliance notifications: {{ (int) ($summary['mapped'] ?? 0) }} mapped •
|
||||
{{ (int) ($summary['skipped'] ?? 0) }} skipped
|
||||
{{ (int) ($summary['skipped'] ?? 0) }} not applied
|
||||
</div>
|
||||
|
||||
@if ($complianceEntries->isNotEmpty())
|
||||
|
||||
@ -16,10 +16,12 @@
|
||||
<thead class="bg-gray-50 text-left text-gray-600">
|
||||
<tr>
|
||||
<th class="px-4 py-3 font-medium">Tenant</th>
|
||||
<th class="px-4 py-3 font-medium">Completeness</th>
|
||||
<th class="px-4 py-3 font-medium">Artifact truth</th>
|
||||
<th class="px-4 py-3 font-medium">Freshness</th>
|
||||
<th class="px-4 py-3 font-medium">Generated</th>
|
||||
<th class="px-4 py-3 font-medium">Missing</th>
|
||||
<th class="px-4 py-3 font-medium">Stale</th>
|
||||
<th class="px-4 py-3 font-medium">Not collected yet</th>
|
||||
<th class="px-4 py-3 font-medium">Refresh recommended</th>
|
||||
<th class="px-4 py-3 font-medium">Next step</th>
|
||||
<th class="px-4 py-3 font-medium">Action</th>
|
||||
</tr>
|
||||
</thead>
|
||||
@ -27,10 +29,23 @@
|
||||
@foreach ($rows as $row)
|
||||
<tr>
|
||||
<td class="px-4 py-3">{{ $row['tenant_name'] }}</td>
|
||||
<td class="px-4 py-3">{{ ucfirst(str_replace('_', ' ', $row['completeness_state'])) }}</td>
|
||||
<td class="px-4 py-3">
|
||||
<x-filament::badge :color="data_get($row, 'artifact_truth.color', 'gray')" :icon="data_get($row, 'artifact_truth.icon')" size="sm">
|
||||
{{ data_get($row, 'artifact_truth.label', 'Unknown') }}
|
||||
</x-filament::badge>
|
||||
@if (is_string(data_get($row, 'artifact_truth.explanation')) && trim((string) data_get($row, 'artifact_truth.explanation')) !== '')
|
||||
<div class="mt-1 text-xs text-gray-500">{{ data_get($row, 'artifact_truth.explanation') }}</div>
|
||||
@endif
|
||||
</td>
|
||||
<td class="px-4 py-3">
|
||||
<x-filament::badge :color="data_get($row, 'freshness.color', 'gray')" :icon="data_get($row, 'freshness.icon')" size="sm">
|
||||
{{ data_get($row, 'freshness.label', 'Unknown') }}
|
||||
</x-filament::badge>
|
||||
</td>
|
||||
<td class="px-4 py-3">{{ $row['generated_at'] ?? '—' }}</td>
|
||||
<td class="px-4 py-3">{{ $row['missing_dimensions'] }}</td>
|
||||
<td class="px-4 py-3">{{ $row['stale_dimensions'] }}</td>
|
||||
<td class="px-4 py-3">{{ $row['next_step'] ?? 'No action needed' }}</td>
|
||||
<td class="px-4 py-3">
|
||||
<a href="{{ $row['view_url'] }}" class="text-primary-600 hover:text-primary-500">View tenant evidence</a>
|
||||
</td>
|
||||
@ -41,4 +56,4 @@
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
</x-filament-panels::page>
|
||||
</x-filament-panels::page>
|
||||
|
||||
@ -12,23 +12,29 @@
|
||||
>
|
||||
Active
|
||||
</x-filament::tabs.item>
|
||||
<x-filament::tabs.item
|
||||
:active="$this->activeTab === 'blocked'"
|
||||
wire:click="$set('activeTab', 'blocked')"
|
||||
>
|
||||
Blocked by prerequisite
|
||||
</x-filament::tabs.item>
|
||||
<x-filament::tabs.item
|
||||
:active="$this->activeTab === 'succeeded'"
|
||||
wire:click="$set('activeTab', 'succeeded')"
|
||||
>
|
||||
Succeeded
|
||||
Completed successfully
|
||||
</x-filament::tabs.item>
|
||||
<x-filament::tabs.item
|
||||
:active="$this->activeTab === 'partial'"
|
||||
wire:click="$set('activeTab', 'partial')"
|
||||
>
|
||||
Partial
|
||||
Needs follow-up
|
||||
</x-filament::tabs.item>
|
||||
<x-filament::tabs.item
|
||||
:active="$this->activeTab === 'failed'"
|
||||
wire:click="$set('activeTab', 'failed')"
|
||||
>
|
||||
Failed
|
||||
Execution failed
|
||||
</x-filament::tabs.item>
|
||||
</x-filament::tabs>
|
||||
|
||||
|
||||
@ -17,6 +17,7 @@
|
||||
$summaryCounts = is_array($run->summary_counts) ? $run->summary_counts : [];
|
||||
$hasSummary = count($summaryCounts) > 0;
|
||||
$integrityNote = \App\Support\RedactionIntegrity::noteForRun($run);
|
||||
$guidance = \App\Support\OpsUx\OperationUxPresenter::surfaceGuidance($run);
|
||||
@endphp
|
||||
|
||||
<x-filament-panels::page>
|
||||
@ -105,6 +106,18 @@
|
||||
</x-filament::section>
|
||||
@endif
|
||||
|
||||
@if ($guidance)
|
||||
<x-filament::section>
|
||||
<x-slot name="heading">
|
||||
Next step
|
||||
</x-slot>
|
||||
|
||||
<div class="rounded-lg border border-gray-200 bg-gray-50 px-4 py-3 text-sm text-gray-800 dark:border-white/10 dark:bg-white/5 dark:text-gray-100">
|
||||
{{ $guidance }}
|
||||
</div>
|
||||
</x-filament::section>
|
||||
@endif
|
||||
|
||||
@if ($hasSummary)
|
||||
<x-filament::section>
|
||||
<x-slot name="heading">
|
||||
@ -115,7 +128,7 @@
|
||||
@foreach ($summaryCounts as $key => $value)
|
||||
<div class="rounded-lg bg-gray-50 px-4 py-3 dark:bg-white/5">
|
||||
<p class="text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400">
|
||||
{{ \Illuminate\Support\Str::headline((string) $key) }}
|
||||
{{ \App\Support\OpsUx\SummaryCountsNormalizer::label((string) $key) }}
|
||||
</p>
|
||||
<p class="mt-1 text-xl font-bold text-gray-950 dark:text-white">
|
||||
{{ is_numeric($value) ? number_format((int) $value) : $value }}
|
||||
|
||||
@ -21,23 +21,44 @@ class="text-sm font-medium text-primary-600 hover:text-primary-500 dark:text-pri
|
||||
@else
|
||||
<ul class="divide-y divide-gray-100 dark:divide-gray-800">
|
||||
@foreach ($runs as $run)
|
||||
@php
|
||||
$statusSpec = \App\Support\Badges\BadgeRenderer::spec(
|
||||
\App\Support\Badges\BadgeDomain::OperationRunStatus,
|
||||
(string) $run->status,
|
||||
);
|
||||
$outcomeSpec = \App\Support\Badges\BadgeRenderer::spec(
|
||||
\App\Support\Badges\BadgeDomain::OperationRunOutcome,
|
||||
(string) $run->outcome,
|
||||
);
|
||||
$guidance = \App\Support\OpsUx\OperationUxPresenter::surfaceGuidance($run);
|
||||
@endphp
|
||||
<li class="flex items-center justify-between gap-3 py-2">
|
||||
<div class="min-w-0">
|
||||
<div class="truncate text-sm font-medium">
|
||||
{{ \App\Support\OperationCatalog::label((string) $run->type) }}
|
||||
</div>
|
||||
|
||||
<div class="text-xs text-gray-500 dark:text-gray-400">
|
||||
<div class="mt-1 flex flex-wrap items-center gap-2">
|
||||
<x-filament::badge :color="$statusSpec->color" size="sm">
|
||||
{{ $statusSpec->label }}
|
||||
</x-filament::badge>
|
||||
<x-filament::badge :color="$outcomeSpec->color" size="sm">
|
||||
{{ $outcomeSpec->label }}
|
||||
</x-filament::badge>
|
||||
</div>
|
||||
|
||||
<div class="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||
{{ $run->created_at?->diffForHumans() ?? '—' }}
|
||||
</div>
|
||||
|
||||
@if ($guidance)
|
||||
<div class="mt-1 text-xs text-gray-600 dark:text-gray-300">
|
||||
{{ $guidance }}
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
<div class="flex shrink-0 items-center gap-3">
|
||||
<div class="text-right text-xs text-gray-600 dark:text-gray-300">
|
||||
<div>{{ (string) $run->status }}</div>
|
||||
<div>{{ (string) $run->outcome }}</div>
|
||||
</div>
|
||||
|
||||
<div class="shrink-0">
|
||||
<a
|
||||
href="{{ \App\Support\OperationRunLinks::tenantlessView($run) }}"
|
||||
class="text-sm font-medium text-primary-600 hover:text-primary-500 dark:text-primary-400 dark:hover:text-primary-300"
|
||||
@ -50,4 +71,3 @@ class="text-sm font-medium text-primary-600 hover:text-primary-500 dark:text-pri
|
||||
</ul>
|
||||
@endif
|
||||
</x-filament::section>
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user