Compare commits
10 Commits
156-operat
...
dev
| Author | SHA1 | Date | |
|---|---|---|---|
| 02e75e1cda | |||
| 20b6aa6a32 | |||
| c17255f854 | |||
| 7d4d607475 | |||
| 1f0cc5de56 | |||
| 845d21db6d | |||
| 8426741068 | |||
| e7c9b4b853 | |||
| 92f39d9749 | |||
| 3c3daae405 |
20
.github/agents/copilot-instructions.md
vendored
20
.github/agents/copilot-instructions.md
vendored
@ -96,6 +96,20 @@ ## Active Technologies
|
|||||||
- PostgreSQL with new tenant-owned exception tables and JSONB-backed supporting metadata (001-finding-risk-acceptance)
|
- 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)
|
- 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 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 + Laravel 12, Filament 5, Livewire 4, Pest 4, Laravel queue workers, existing `OperationRunService`, `TrackOperationRun`, `OperationUxPresenter`, `ReasonPresenter`, `BadgeCatalog` domain badges, and current Operations Monitoring pages (160-operation-lifecycle-guarantees)
|
||||||
|
- PostgreSQL for `operation_runs`, `jobs`, and `failed_jobs`; JSONB-backed `context`, `summary_counts`, and `failure_summary`; configuration in `config/queue.php` and `config/tenantpilot.php` (160-operation-lifecycle-guarantees)
|
||||||
|
- PostgreSQL (via Sail) plus existing read models persisted in application tables (161-operator-explanation-layer)
|
||||||
|
- PHP 8.4 / Laravel 12, Blade, Alpine via Filament, Tailwind CSS v4 + Filament v5, Livewire v4, Pest v4, Laravel Sail, existing `OperationRun` and baseline compare services (162-baseline-gap-details)
|
||||||
|
- PostgreSQL with JSONB-backed `operation_runs.context`; no new tables required (162-baseline-gap-details)
|
||||||
|
- PostgreSQL via existing application tables, especially `operation_runs.context` and baseline snapshot summary JSON (163-baseline-subject-resolution)
|
||||||
|
- PHP 8.4, Laravel 12, Blade views, Alpine via Filament v5 / Livewire v4 + Filament v5, Livewire v4, Pest v4, Laravel Sail, existing `OperationRunResource`, `TenantlessOperationRunViewer`, `EnterpriseDetailBuilder`, `ArtifactTruthPresenter`, `OperationUxPresenter`, and `SummaryCountsNormalizer` (164-run-detail-hardening)
|
||||||
|
- PostgreSQL with existing `operation_runs` JSONB-backed `context`, `summary_counts`, and `failure_summary`; no schema change planned (164-run-detail-hardening)
|
||||||
|
- PHP 8.4, Laravel 12, Blade, Filament v5, Livewire v4 + Filament v5, Livewire v4, Pest v4, Laravel Sail, existing `BaselineCompareStats`, `BaselineCompareExplanationRegistry`, `ReasonPresenter`, `BadgeCatalog` or `BadgeRenderer`, `UiEnforcement`, and `OperationRunLinks` (165-baseline-summary-trust)
|
||||||
|
- PostgreSQL with existing baseline, findings, and `operation_runs` tables plus JSONB-backed compare context; no schema change planned (165-baseline-summary-trust)
|
||||||
|
|
||||||
- PHP 8.4.15 (feat/005-bulk-operations)
|
- PHP 8.4.15 (feat/005-bulk-operations)
|
||||||
|
|
||||||
@ -115,8 +129,8 @@ ## Code Style
|
|||||||
PHP 8.4.15: Follow standard conventions
|
PHP 8.4.15: Follow standard conventions
|
||||||
|
|
||||||
## Recent Changes
|
## 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`
|
- 165-baseline-summary-trust: Added PHP 8.4, Laravel 12, Blade, Filament v5, Livewire v4 + Filament v5, Livewire v4, Pest v4, Laravel Sail, existing `BaselineCompareStats`, `BaselineCompareExplanationRegistry`, `ReasonPresenter`, `BadgeCatalog` or `BadgeRenderer`, `UiEnforcement`, and `OperationRunLinks`
|
||||||
- 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
|
- 164-run-detail-hardening: Added PHP 8.4, Laravel 12, Blade views, Alpine via Filament v5 / Livewire v4 + Filament v5, Livewire v4, Pest v4, Laravel Sail, existing `OperationRunResource`, `TenantlessOperationRunViewer`, `EnterpriseDetailBuilder`, `ArtifactTruthPresenter`, `OperationUxPresenter`, and `SummaryCountsNormalizer`
|
||||||
- 153-evidence-domain-foundation: Added PHP 8.4.15 + Laravel 12, Filament v5, Livewire v4, Pest v4, existing `StoredReport`, `Finding`, `OperationRun`, and `AuditLog` infrastructure
|
- 163-baseline-subject-resolution-session-1774398153: Added PHP 8.4 + Laravel 12, Filament v5, Livewire v4
|
||||||
<!-- MANUAL ADDITIONS START -->
|
<!-- MANUAL ADDITIONS START -->
|
||||||
<!-- MANUAL ADDITIONS END -->
|
<!-- MANUAL ADDITIONS END -->
|
||||||
|
|||||||
@ -1,14 +1,22 @@
|
|||||||
<!--
|
<!--
|
||||||
Sync Impact Report
|
Sync Impact Report
|
||||||
|
|
||||||
- Version change: 1.11.0 → 1.12.0
|
- Version change: 1.12.0 → 1.13.0
|
||||||
- Modified principles:
|
- Modified principles:
|
||||||
- Scope & Ownership Clarification (SCOPE-001)
|
|
||||||
- Added sections:
|
|
||||||
- None
|
- None
|
||||||
|
- Added sections:
|
||||||
|
- Filament Native First / No Ad-hoc Styling (UI-FIL-001)
|
||||||
- Removed sections: None
|
- Removed sections: None
|
||||||
- Templates requiring updates:
|
- Templates requiring updates:
|
||||||
- None
|
- ✅ .specify/memory/constitution.md
|
||||||
|
- ✅ .specify/templates/spec-template.md
|
||||||
|
- ✅ .specify/templates/plan-template.md
|
||||||
|
- ✅ .specify/templates/tasks-template.md
|
||||||
|
- ✅ docs/product/principles.md
|
||||||
|
- ✅ docs/product/standards/README.md
|
||||||
|
- ✅ docs/HANDOVER.md
|
||||||
|
- Commands checked:
|
||||||
|
- N/A `.specify/templates/commands/*.md` directory is not present in this repo
|
||||||
- Follow-up TODOs:
|
- Follow-up TODOs:
|
||||||
- None.
|
- None.
|
||||||
-->
|
-->
|
||||||
@ -330,6 +338,65 @@ ### Operator-facing UI Naming Standards (UI-NAMING-001)
|
|||||||
- The visible run label for that action MUST be `Policy sync`.
|
- The visible run label for that action MUST be `Policy sync`.
|
||||||
- The audit prose for that action MUST be `{actor} queued 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)
|
Spec Scope Fields (SCOPE-002)
|
||||||
|
|
||||||
- Every feature spec MUST declare:
|
- Every feature spec MUST declare:
|
||||||
@ -352,6 +419,39 @@ ### Badge Semantics Are Centralized (BADGE-001)
|
|||||||
- Introducing or changing a status-like value MUST include updating the relevant badge mapper and adding/updating tests for the mapping.
|
- Introducing or changing a status-like value MUST include updating the relevant badge mapper and adding/updating tests for the mapping.
|
||||||
- Tag/category chips (e.g., type/platform/environment) are not status-like and are not governed by BADGE-001.
|
- Tag/category chips (e.g., type/platform/environment) are not status-like and are not governed by BADGE-001.
|
||||||
|
|
||||||
|
### Filament Native First / No Ad-hoc Styling (UI-FIL-001)
|
||||||
|
- Admin and operator-facing surfaces MUST use native Filament components, existing shared UI primitives, and centralized design patterns first.
|
||||||
|
- If Filament already provides the required semantic element, feature code MUST use the Filament-native component instead of a locally assembled replacement.
|
||||||
|
- Preferred native elements include `x-filament::badge`, `x-filament::button`, `x-filament::icon`, and Filament Forms, Infolists, Tables, Sections, Tabs, Grids, and Actions.
|
||||||
|
|
||||||
|
Forbidden local replacements
|
||||||
|
- Feature code MUST NOT hand-build badges, pills, status chips, alert cards, or action buttons from raw `<span>`, `<div>`, or `<button>` markup plus Tailwind classes when Filament-native or shared project primitives can express the same meaning.
|
||||||
|
- Feature code MUST NOT introduce page-local visual status languages for status, risk, outcome, drift, trust, importance, or severity.
|
||||||
|
- Feature code MUST NOT make local color, border, rounding, or emphasis decisions for semantic UI states using ad-hoc classes such as `bg-danger-100`, `text-warning-900`, `border-dashed`, or `rounded-full` when the same state can be expressed through Filament props or shared primitives.
|
||||||
|
|
||||||
|
Shared primitive before local override
|
||||||
|
- If the same UI pattern can recur, it MUST use an existing shared primitive or introduce a new central primitive instead of reassembling the pattern inside a Blade view.
|
||||||
|
- Central badge and status catalogs remain the canonical source for status semantics; local views MUST consume them rather than re-map them.
|
||||||
|
|
||||||
|
Upgrade-safe preference
|
||||||
|
- Update-safe, framework-native implementations take priority over page-local styling shortcuts.
|
||||||
|
- Filament props such as `color`, `icon`, and standard component composition are the default mechanism for semantic emphasis.
|
||||||
|
- Publishing or emulating Filament internals for cosmetic speed is not acceptable when native composition or shared primitives are sufficient.
|
||||||
|
|
||||||
|
Exception rule
|
||||||
|
- Ad-hoc markup or styling is allowed only when all of the following are true:
|
||||||
|
- native Filament components cannot express the required semantics,
|
||||||
|
- no suitable shared primitive exists,
|
||||||
|
- and the deviation is justified briefly in code and in the governing spec or PR.
|
||||||
|
- Approved exceptions MUST stay layout-neutral, use the minimum local classes necessary, and MUST NOT invent a new page-local status language.
|
||||||
|
|
||||||
|
Review and enforcement
|
||||||
|
- Every UI review MUST answer:
|
||||||
|
- which native Filament element or shared primitive was used,
|
||||||
|
- why an existing component was insufficient if an exception was taken,
|
||||||
|
- and whether any ad-hoc status or emphasis styling was introduced.
|
||||||
|
- UI work is not Done if it introduces ad-hoc status styling or framework-foreign replacement components where a native Filament or shared UI solution was viable.
|
||||||
|
|
||||||
### Incremental UI Standards Enforcement (UI-STD-001)
|
### Incremental UI Standards Enforcement (UI-STD-001)
|
||||||
- UI consistency is enforced incrementally, not by recurring cleanup passes.
|
- UI consistency is enforced incrementally, not by recurring cleanup passes.
|
||||||
- New tables, filters, and list surfaces MUST follow established Filament-native standards from the first implementation.
|
- New tables, filters, and list surfaces MUST follow established Filament-native standards from the first implementation.
|
||||||
@ -387,4 +487,4 @@ ### Versioning Policy (SemVer)
|
|||||||
- **MINOR**: new principle/section or materially expanded guidance.
|
- **MINOR**: new principle/section or materially expanded guidance.
|
||||||
- **MAJOR**: removing/redefining principles in a backward-incompatible way.
|
- **MAJOR**: removing/redefining principles in a backward-incompatible way.
|
||||||
|
|
||||||
**Version**: 1.11.0 | **Ratified**: 2026-01-03 | **Last Amended**: 2026-03-10
|
**Version**: 1.13.0 | **Ratified**: 2026-01-03 | **Last Amended**: 2026-03-26
|
||||||
|
|||||||
@ -49,7 +49,14 @@ ## Constitution Check
|
|||||||
- Automation: queued/scheduled ops use locks + idempotency; handle 429/503 with backoff+jitter
|
- Automation: queued/scheduled ops use locks + idempotency; handle 429/503 with backoff+jitter
|
||||||
- Data minimization: Inventory stores metadata + whitelisted meta; logs contain no secrets/tokens
|
- Data minimization: Inventory stores metadata + whitelisted meta; logs contain no secrets/tokens
|
||||||
- Badge semantics (BADGE-001): status-like badges use `BadgeCatalog` / `BadgeRenderer`; no ad-hoc mappings; new values include tests
|
- Badge semantics (BADGE-001): status-like badges use `BadgeCatalog` / `BadgeRenderer`; no ad-hoc mappings; new values include tests
|
||||||
|
- Filament-native UI (UI-FIL-001): admin/operator surfaces use native Filament components or shared primitives first; no ad-hoc status UI, local semantic color/border decisions, or hand-built replacements when native/shared semantics exist; any exception is explicitly justified
|
||||||
- UI naming (UI-NAMING-001): operator-facing labels use `Verb + Object`; scope (`Workspace`, `Tenant`) is never the primary action label; source/domain is secondary unless disambiguation is required; runs/toasts/audit prose use the same domain vocabulary; implementation-first terms do not appear in primary operator UI
|
- UI naming (UI-NAMING-001): operator-facing labels use `Verb + Object`; scope (`Workspace`, `Tenant`) is never the primary action label; source/domain is secondary unless disambiguation is required; runs/toasts/audit prose use the same domain vocabulary; implementation-first terms do not appear in primary operator UI
|
||||||
|
- Operator surfaces (OPSURF-001): `/admin` defaults are operator-first; default-visible content avoids raw implementation detail; diagnostics are explicitly revealed secondarily
|
||||||
|
- Operator surfaces (OPSURF-001): 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 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
|
- 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
|
## Project Structure
|
||||||
|
|||||||
@ -17,6 +17,14 @@ ## Spec Scope Fields *(mandatory)*
|
|||||||
- **Default filter behavior when tenant-context is active**: [e.g., prefilter to current tenant]
|
- **Default filter behavior when tenant-context is active**: [e.g., prefilter to current tenant]
|
||||||
- **Explicit entitlement checks preventing cross-tenant leakage**: [Describe checks]
|
- **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)*
|
## User Scenarios & Testing *(mandatory)*
|
||||||
|
|
||||||
<!--
|
<!--
|
||||||
@ -119,6 +127,12 @@ ## Requirements *(mandatory)*
|
|||||||
**Constitution alignment (BADGE-001):** If this feature changes status-like badges (status/outcome/severity/risk/availability/boolean),
|
**Constitution alignment (BADGE-001):** If this feature changes status-like badges (status/outcome/severity/risk/availability/boolean),
|
||||||
the spec MUST describe how badge semantics stay centralized (no ad-hoc mappings) and which tests cover any new/changed values.
|
the spec MUST describe how badge semantics stay centralized (no ad-hoc mappings) and which tests cover any new/changed values.
|
||||||
|
|
||||||
|
**Constitution alignment (UI-FIL-001):** If this feature adds or changes Filament or Blade UI for admin/operator surfaces, the spec MUST describe:
|
||||||
|
- which native Filament components or shared UI primitives are used,
|
||||||
|
- whether any local replacement markup was avoided for badges, alerts, buttons, or status surfaces,
|
||||||
|
- how semantic emphasis is expressed through Filament props or central primitives rather than page-local color/border classes,
|
||||||
|
- and any exception where Filament or a shared primitive was insufficient, including why the exception is necessary and how it avoids introducing a new local status language.
|
||||||
|
|
||||||
**Constitution alignment (UI-NAMING-001):** If this feature adds or changes operator-facing buttons, header actions, run titles,
|
**Constitution alignment (UI-NAMING-001):** If this feature adds or changes operator-facing buttons, header actions, run titles,
|
||||||
notifications, audit prose, or related helper copy, the spec MUST describe:
|
notifications, audit prose, or related helper copy, the spec MUST describe:
|
||||||
- the target object,
|
- the target object,
|
||||||
@ -127,9 +141,19 @@ ## Requirements *(mandatory)*
|
|||||||
- how the same domain vocabulary is preserved across button labels, modal titles, run titles, notifications, and audit prose,
|
- 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.
|
- 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,
|
**Constitution alignment (Filament Action Surfaces):** If this feature adds or modifies any Filament Resource / RelationManager / Page,
|
||||||
the spec MUST include a “UI Action Matrix” (see below) and explicitly state whether the Action Surface Contract is satisfied.
|
the spec MUST include a “UI Action Matrix” (see below) and explicitly state whether the Action Surface Contract is satisfied.
|
||||||
If the contract is not satisfied, the spec MUST include an explicit exemption with rationale.
|
If the contract is not satisfied, the spec MUST include an explicit exemption with rationale.
|
||||||
|
The same section MUST also state whether UI-FIL-001 is satisfied and identify any approved exception.
|
||||||
**Constitution alignment (UX-001 — Layout & Information Architecture):** If this feature adds or modifies any Filament screen,
|
**Constitution alignment (UX-001 — Layout & Information Architecture):** If this feature adds or modifies any Filament screen,
|
||||||
the spec MUST describe compliance with UX-001: Create/Edit uses Main/Aside layout (3-col grid), all fields inside Sections/Cards
|
the spec MUST describe compliance with UX-001: Create/Edit uses Main/Aside layout (3-col grid), all fields inside Sections/Cards
|
||||||
(no naked inputs), View pages use Infolists (not disabled edit forms), status badges use BADGE-001, empty states have a specific
|
(no naked inputs), View pages use Infolists (not disabled edit forms), status badges use BADGE-001, empty states have a specific
|
||||||
|
|||||||
@ -38,6 +38,13 @@ # Tasks: [FEATURE NAME]
|
|||||||
- using source/domain terms only where same-screen disambiguation is required,
|
- 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,
|
- 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.
|
- 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:
|
**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,
|
- 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),
|
- implementing required action surfaces (header/row/bulk/empty-state CTA for lists; header actions for view; consistent save/cancel on create/edit),
|
||||||
@ -46,6 +53,9 @@ # Tasks: [FEATURE NAME]
|
|||||||
- grouping bulk actions via BulkActionGroup,
|
- grouping bulk actions via BulkActionGroup,
|
||||||
- adding confirmations for destructive actions (and typed confirmation where required by scale),
|
- adding confirmations for destructive actions (and typed confirmation where required by scale),
|
||||||
- adding `AuditLog` entries for relevant mutations,
|
- adding `AuditLog` entries for relevant mutations,
|
||||||
|
- using native Filament components or shared UI primitives before any local Blade/Tailwind assembly for badges, alerts, buttons, and semantic status surfaces,
|
||||||
|
- avoiding page-local semantic color, border, rounding, or highlight styling when Filament props or shared primitives can express the same state,
|
||||||
|
- documenting any UI-FIL-001 exception with rationale in the spec/PR,
|
||||||
- adding/updated tests that enforce the contract and block merge on violations, OR documenting an explicit exemption with rationale.
|
- adding/updated tests that enforce the contract and block merge on violations, OR documenting an explicit exemption with rationale.
|
||||||
**Filament UI UX-001 (Layout & IA)**: If this feature adds/modifies any Filament screen, tasks MUST include:
|
**Filament UI UX-001 (Layout & IA)**: If this feature adds/modifies any Filament screen, tasks MUST include:
|
||||||
- ensuring Create/Edit pages use Main/Aside layout (3-col grid, Main=columnSpan(2), Aside=columnSpan(1)),
|
- ensuring Create/Edit pages use Main/Aside layout (3-col grid, Main=columnSpan(2), Aside=columnSpan(1)),
|
||||||
|
|||||||
153
app/Console/Commands/PurgeLegacyBaselineGapRuns.php
Normal file
153
app/Console/Commands/PurgeLegacyBaselineGapRuns.php
Normal file
@ -0,0 +1,153 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Console\Commands;
|
||||||
|
|
||||||
|
use App\Models\OperationRun;
|
||||||
|
use App\Models\Tenant;
|
||||||
|
use Illuminate\Console\Command;
|
||||||
|
|
||||||
|
class PurgeLegacyBaselineGapRuns extends Command
|
||||||
|
{
|
||||||
|
protected $signature = 'tenantpilot:baselines:purge-legacy-gap-runs
|
||||||
|
{--type=* : Limit cleanup to baseline_compare and/or baseline_capture runs}
|
||||||
|
{--tenant=* : Limit cleanup to tenant ids or tenant external ids}
|
||||||
|
{--workspace=* : Limit cleanup to workspace ids}
|
||||||
|
{--limit=500 : Maximum candidate runs to inspect}
|
||||||
|
{--force : Actually delete matched legacy runs}';
|
||||||
|
|
||||||
|
protected $description = 'Purge development-only baseline compare/capture runs that still use legacy broad gap payloads.';
|
||||||
|
|
||||||
|
public function handle(): int
|
||||||
|
{
|
||||||
|
if (! app()->environment(['local', 'testing'])) {
|
||||||
|
$this->error('This cleanup command is limited to local and testing environments.');
|
||||||
|
|
||||||
|
return self::FAILURE;
|
||||||
|
}
|
||||||
|
|
||||||
|
$types = $this->normalizedTypes();
|
||||||
|
$workspaceIds = array_values(array_filter(
|
||||||
|
array_map(
|
||||||
|
static fn (mixed $workspaceId): int => is_numeric($workspaceId) ? (int) $workspaceId : 0,
|
||||||
|
(array) $this->option('workspace'),
|
||||||
|
),
|
||||||
|
static fn (int $workspaceId): bool => $workspaceId > 0,
|
||||||
|
));
|
||||||
|
$tenantIds = $this->resolveTenantIds(array_values(array_filter((array) $this->option('tenant'))));
|
||||||
|
$limit = max(1, (int) $this->option('limit'));
|
||||||
|
$dryRun = ! (bool) $this->option('force');
|
||||||
|
|
||||||
|
$query = OperationRun::query()
|
||||||
|
->whereIn('type', $types)
|
||||||
|
->orderBy('id')
|
||||||
|
->limit($limit);
|
||||||
|
|
||||||
|
if ($workspaceIds !== []) {
|
||||||
|
$query->whereIn('workspace_id', $workspaceIds);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($tenantIds !== []) {
|
||||||
|
$query->whereIn('tenant_id', $tenantIds);
|
||||||
|
}
|
||||||
|
|
||||||
|
$candidates = $query->get();
|
||||||
|
$matched = $candidates
|
||||||
|
->filter(static fn (OperationRun $run): bool => $run->hasLegacyBaselineGapPayload())
|
||||||
|
->values();
|
||||||
|
|
||||||
|
if ($matched->isEmpty()) {
|
||||||
|
$this->info('No legacy baseline gap runs matched the current filters.');
|
||||||
|
|
||||||
|
return self::SUCCESS;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->table(
|
||||||
|
['Run', 'Type', 'Tenant', 'Workspace', 'Legacy signal'],
|
||||||
|
$matched
|
||||||
|
->map(fn (OperationRun $run): array => [
|
||||||
|
'Run' => (string) $run->getKey(),
|
||||||
|
'Type' => (string) $run->type,
|
||||||
|
'Tenant' => $run->tenant_id !== null ? (string) $run->tenant_id : '—',
|
||||||
|
'Workspace' => $run->workspace_id !== null ? (string) $run->workspace_id : '—',
|
||||||
|
'Legacy signal' => $this->legacySignal($run),
|
||||||
|
])
|
||||||
|
->all(),
|
||||||
|
);
|
||||||
|
|
||||||
|
if ($dryRun) {
|
||||||
|
$this->warn(sprintf(
|
||||||
|
'Dry run: matched %d legacy baseline run(s). Re-run with --force to delete them.',
|
||||||
|
$matched->count(),
|
||||||
|
));
|
||||||
|
|
||||||
|
return self::SUCCESS;
|
||||||
|
}
|
||||||
|
|
||||||
|
OperationRun::query()
|
||||||
|
->whereKey($matched->modelKeys())
|
||||||
|
->delete();
|
||||||
|
|
||||||
|
$this->info(sprintf('Deleted %d legacy baseline run(s).', $matched->count()));
|
||||||
|
|
||||||
|
return self::SUCCESS;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<int, string>
|
||||||
|
*/
|
||||||
|
private function normalizedTypes(): array
|
||||||
|
{
|
||||||
|
$types = array_values(array_unique(array_filter(
|
||||||
|
array_map(
|
||||||
|
static fn (mixed $type): ?string => is_string($type) && trim($type) !== '' ? trim($type) : null,
|
||||||
|
(array) $this->option('type'),
|
||||||
|
),
|
||||||
|
)));
|
||||||
|
|
||||||
|
if ($types === []) {
|
||||||
|
return ['baseline_compare', 'baseline_capture'];
|
||||||
|
}
|
||||||
|
|
||||||
|
return array_values(array_filter(
|
||||||
|
$types,
|
||||||
|
static fn (string $type): bool => in_array($type, ['baseline_compare', 'baseline_capture'], true),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<int, string> $tenantIdentifiers
|
||||||
|
* @return array<int, int>
|
||||||
|
*/
|
||||||
|
private function resolveTenantIds(array $tenantIdentifiers): array
|
||||||
|
{
|
||||||
|
if ($tenantIdentifiers === []) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
$tenantIds = [];
|
||||||
|
|
||||||
|
foreach ($tenantIdentifiers as $identifier) {
|
||||||
|
$tenant = Tenant::query()->forTenant($identifier)->first();
|
||||||
|
|
||||||
|
if ($tenant instanceof Tenant) {
|
||||||
|
$tenantIds[] = (int) $tenant->getKey();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return array_values(array_unique($tenantIds));
|
||||||
|
}
|
||||||
|
|
||||||
|
private function legacySignal(OperationRun $run): string
|
||||||
|
{
|
||||||
|
$byReason = $run->baselineGapEnvelope()['by_reason'] ?? null;
|
||||||
|
$byReason = is_array($byReason) ? $byReason : [];
|
||||||
|
|
||||||
|
if (array_key_exists('policy_not_found', $byReason)) {
|
||||||
|
return 'legacy_reason_code';
|
||||||
|
}
|
||||||
|
|
||||||
|
return 'legacy_subject_shape';
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -6,6 +6,7 @@
|
|||||||
use App\Models\OperationRun;
|
use App\Models\OperationRun;
|
||||||
use App\Models\Tenant;
|
use App\Models\Tenant;
|
||||||
use App\Services\OperationRunService;
|
use App\Services\OperationRunService;
|
||||||
|
use App\Services\Operations\OperationLifecycleReconciler;
|
||||||
use App\Support\OperationRunOutcome;
|
use App\Support\OperationRunOutcome;
|
||||||
use Illuminate\Console\Command;
|
use Illuminate\Console\Command;
|
||||||
|
|
||||||
@ -18,8 +19,10 @@ class TenantpilotReconcileBackupScheduleOperationRuns extends Command
|
|||||||
|
|
||||||
protected $description = 'Reconcile stuck backup schedule OperationRuns without legacy run-table lookups.';
|
protected $description = 'Reconcile stuck backup schedule OperationRuns without legacy run-table lookups.';
|
||||||
|
|
||||||
public function handle(OperationRunService $operationRunService): int
|
public function handle(
|
||||||
{
|
OperationRunService $operationRunService,
|
||||||
|
OperationLifecycleReconciler $operationLifecycleReconciler,
|
||||||
|
): int {
|
||||||
$tenantIdentifiers = array_values(array_filter((array) $this->option('tenant')));
|
$tenantIdentifiers = array_values(array_filter((array) $this->option('tenant')));
|
||||||
$olderThanMinutes = max(0, (int) $this->option('older-than'));
|
$olderThanMinutes = max(0, (int) $this->option('older-than'));
|
||||||
$dryRun = (bool) $this->option('dry-run');
|
$dryRun = (bool) $this->option('dry-run');
|
||||||
@ -96,31 +99,9 @@ public function handle(OperationRunService $operationRunService): int
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($operationRun->status === 'queued' && $operationRunService->isStaleQueuedRun($operationRun, max(1, $olderThanMinutes))) {
|
$change = $operationLifecycleReconciler->reconcileRun($operationRun, $dryRun);
|
||||||
if (! $dryRun) {
|
|
||||||
$operationRunService->failStaleQueuedRun($operationRun, 'Backup schedule run was queued but never started.');
|
|
||||||
}
|
|
||||||
|
|
||||||
$reconciled++;
|
|
||||||
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($operationRun->status === 'running') {
|
|
||||||
if (! $dryRun) {
|
|
||||||
$operationRunService->updateRun(
|
|
||||||
$operationRun,
|
|
||||||
status: 'completed',
|
|
||||||
outcome: OperationRunOutcome::Failed->value,
|
|
||||||
failures: [
|
|
||||||
[
|
|
||||||
'code' => 'backup_schedule.stalled',
|
|
||||||
'message' => 'Backup schedule run exceeded reconciliation timeout and was marked failed.',
|
|
||||||
],
|
|
||||||
],
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
|
if ($change !== null) {
|
||||||
$reconciled++;
|
$reconciled++;
|
||||||
|
|
||||||
continue;
|
continue;
|
||||||
|
|||||||
105
app/Console/Commands/TenantpilotReconcileOperationRuns.php
Normal file
105
app/Console/Commands/TenantpilotReconcileOperationRuns.php
Normal file
@ -0,0 +1,105 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Console\Commands;
|
||||||
|
|
||||||
|
use App\Models\Tenant;
|
||||||
|
use App\Services\Operations\OperationLifecycleReconciler;
|
||||||
|
use App\Support\Operations\OperationLifecyclePolicy;
|
||||||
|
use Illuminate\Console\Command;
|
||||||
|
|
||||||
|
class TenantpilotReconcileOperationRuns extends Command
|
||||||
|
{
|
||||||
|
protected $signature = 'tenantpilot:operation-runs:reconcile
|
||||||
|
{--type=* : Limit reconciliation to one or more covered operation types}
|
||||||
|
{--tenant=* : Limit reconciliation to tenant_id or tenant external_id}
|
||||||
|
{--workspace=* : Limit reconciliation to workspace ids}
|
||||||
|
{--limit=100 : Maximum number of active runs to inspect}
|
||||||
|
{--dry-run : Report the changes without writing them}';
|
||||||
|
|
||||||
|
protected $description = 'Reconcile stale covered operation runs back to deterministic terminal truth.';
|
||||||
|
|
||||||
|
public function handle(
|
||||||
|
OperationLifecycleReconciler $reconciler,
|
||||||
|
OperationLifecyclePolicy $policy,
|
||||||
|
): int {
|
||||||
|
$types = array_values(array_filter(
|
||||||
|
(array) $this->option('type'),
|
||||||
|
static fn (mixed $type): bool => is_string($type) && trim($type) !== '',
|
||||||
|
));
|
||||||
|
$workspaceIds = array_values(array_filter(
|
||||||
|
array_map(
|
||||||
|
static fn (mixed $workspaceId): int => is_numeric($workspaceId) ? (int) $workspaceId : 0,
|
||||||
|
(array) $this->option('workspace'),
|
||||||
|
),
|
||||||
|
static fn (int $workspaceId): bool => $workspaceId > 0,
|
||||||
|
));
|
||||||
|
$tenantIds = $this->resolveTenantIds(array_values(array_filter((array) $this->option('tenant'))));
|
||||||
|
$dryRun = (bool) $this->option('dry-run');
|
||||||
|
|
||||||
|
if ($types === []) {
|
||||||
|
$types = $policy->coveredTypeNames();
|
||||||
|
}
|
||||||
|
|
||||||
|
$result = $reconciler->reconcile([
|
||||||
|
'types' => $types,
|
||||||
|
'tenant_ids' => $tenantIds,
|
||||||
|
'workspace_ids' => $workspaceIds,
|
||||||
|
'limit' => max(1, (int) $this->option('limit')),
|
||||||
|
'dry_run' => $dryRun,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$rows = collect($result['changes'] ?? [])
|
||||||
|
->map(static function (array $change): array {
|
||||||
|
return [
|
||||||
|
'Run' => (string) ($change['operation_run_id'] ?? '—'),
|
||||||
|
'Type' => (string) ($change['type'] ?? '—'),
|
||||||
|
'Reason' => (string) ($change['reason_code'] ?? '—'),
|
||||||
|
'Applied' => (($change['applied'] ?? false) === true) ? 'yes' : 'no',
|
||||||
|
];
|
||||||
|
})
|
||||||
|
->values()
|
||||||
|
->all();
|
||||||
|
|
||||||
|
if ($rows !== []) {
|
||||||
|
$this->table(['Run', 'Type', 'Reason', 'Applied'], $rows);
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->info(sprintf(
|
||||||
|
'Inspected %d run(s); reconciled %d; skipped %d.',
|
||||||
|
(int) ($result['candidates'] ?? 0),
|
||||||
|
(int) ($result['reconciled'] ?? 0),
|
||||||
|
(int) ($result['skipped'] ?? 0),
|
||||||
|
));
|
||||||
|
|
||||||
|
if ($dryRun) {
|
||||||
|
$this->comment('Dry-run: no changes written.');
|
||||||
|
}
|
||||||
|
|
||||||
|
return self::SUCCESS;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<int, string> $tenantIdentifiers
|
||||||
|
* @return array<int, int>
|
||||||
|
*/
|
||||||
|
private function resolveTenantIds(array $tenantIdentifiers): array
|
||||||
|
{
|
||||||
|
if ($tenantIdentifiers === []) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
$tenantIds = [];
|
||||||
|
|
||||||
|
foreach ($tenantIdentifiers as $identifier) {
|
||||||
|
$tenant = Tenant::query()->forTenant($identifier)->first();
|
||||||
|
|
||||||
|
if ($tenant instanceof Tenant) {
|
||||||
|
$tenantIds[] = (int) $tenant->getKey();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return array_values(array_unique($tenantIds));
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -13,6 +13,7 @@
|
|||||||
use App\Services\Baselines\BaselineCompareService;
|
use App\Services\Baselines\BaselineCompareService;
|
||||||
use App\Support\Auth\Capabilities;
|
use App\Support\Auth\Capabilities;
|
||||||
use App\Support\Baselines\BaselineCaptureMode;
|
use App\Support\Baselines\BaselineCaptureMode;
|
||||||
|
use App\Support\Baselines\BaselineCompareEvidenceGapDetails;
|
||||||
use App\Support\Baselines\BaselineCompareStats;
|
use App\Support\Baselines\BaselineCompareStats;
|
||||||
use App\Support\OperationRunLinks;
|
use App\Support\OperationRunLinks;
|
||||||
use App\Support\OpsUx\OperationUxPresenter;
|
use App\Support\OpsUx\OperationUxPresenter;
|
||||||
@ -59,6 +60,8 @@ class BaselineCompareLanding extends Page
|
|||||||
|
|
||||||
public ?int $duplicateNamePoliciesCount = null;
|
public ?int $duplicateNamePoliciesCount = null;
|
||||||
|
|
||||||
|
public ?int $duplicateNameSubjectsCount = null;
|
||||||
|
|
||||||
public ?int $operationRunId = null;
|
public ?int $operationRunId = null;
|
||||||
|
|
||||||
public ?int $findingsCount = null;
|
public ?int $findingsCount = null;
|
||||||
@ -86,9 +89,24 @@ class BaselineCompareLanding extends Page
|
|||||||
/** @var array<string, int>|null */
|
/** @var array<string, int>|null */
|
||||||
public ?array $evidenceGapsTopReasons = null;
|
public ?array $evidenceGapsTopReasons = null;
|
||||||
|
|
||||||
|
/** @var array<string, mixed>|null */
|
||||||
|
public ?array $evidenceGapSummary = null;
|
||||||
|
|
||||||
|
/** @var list<array<string, mixed>>|null */
|
||||||
|
public ?array $evidenceGapBuckets = null;
|
||||||
|
|
||||||
|
/** @var array<string, mixed>|null */
|
||||||
|
public ?array $baselineCompareDiagnostics = null;
|
||||||
|
|
||||||
/** @var array<string, int>|null */
|
/** @var array<string, int>|null */
|
||||||
public ?array $rbacRoleDefinitionSummary = null;
|
public ?array $rbacRoleDefinitionSummary = null;
|
||||||
|
|
||||||
|
/** @var array<string, mixed>|null */
|
||||||
|
public ?array $operatorExplanation = null;
|
||||||
|
|
||||||
|
/** @var array<string, mixed>|null */
|
||||||
|
public ?array $summaryAssessment = null;
|
||||||
|
|
||||||
public static function canAccess(): bool
|
public static function canAccess(): bool
|
||||||
{
|
{
|
||||||
$user = auth()->user();
|
$user = auth()->user();
|
||||||
@ -123,6 +141,7 @@ public function refreshStats(): void
|
|||||||
$this->profileId = $stats->profileId;
|
$this->profileId = $stats->profileId;
|
||||||
$this->snapshotId = $stats->snapshotId;
|
$this->snapshotId = $stats->snapshotId;
|
||||||
$this->duplicateNamePoliciesCount = $stats->duplicateNamePoliciesCount;
|
$this->duplicateNamePoliciesCount = $stats->duplicateNamePoliciesCount;
|
||||||
|
$this->duplicateNameSubjectsCount = $stats->duplicateNameSubjectsCount;
|
||||||
$this->operationRunId = $stats->operationRunId;
|
$this->operationRunId = $stats->operationRunId;
|
||||||
$this->findingsCount = $stats->findingsCount;
|
$this->findingsCount = $stats->findingsCount;
|
||||||
$this->severityCounts = $stats->severityCounts !== [] ? $stats->severityCounts : null;
|
$this->severityCounts = $stats->severityCounts !== [] ? $stats->severityCounts : null;
|
||||||
@ -139,7 +158,18 @@ public function refreshStats(): void
|
|||||||
|
|
||||||
$this->evidenceGapsCount = $stats->evidenceGapsCount;
|
$this->evidenceGapsCount = $stats->evidenceGapsCount;
|
||||||
$this->evidenceGapsTopReasons = $stats->evidenceGapsTopReasons !== [] ? $stats->evidenceGapsTopReasons : null;
|
$this->evidenceGapsTopReasons = $stats->evidenceGapsTopReasons !== [] ? $stats->evidenceGapsTopReasons : null;
|
||||||
|
$this->evidenceGapSummary = is_array($stats->evidenceGapDetails['summary'] ?? null)
|
||||||
|
? $stats->evidenceGapDetails['summary']
|
||||||
|
: null;
|
||||||
|
$this->evidenceGapBuckets = is_array($stats->evidenceGapDetails['buckets'] ?? null) && $stats->evidenceGapDetails['buckets'] !== []
|
||||||
|
? $stats->evidenceGapDetails['buckets']
|
||||||
|
: null;
|
||||||
|
$this->baselineCompareDiagnostics = $stats->baselineCompareDiagnostics !== []
|
||||||
|
? $stats->baselineCompareDiagnostics
|
||||||
|
: null;
|
||||||
$this->rbacRoleDefinitionSummary = $stats->rbacRoleDefinitionSummary !== [] ? $stats->rbacRoleDefinitionSummary : null;
|
$this->rbacRoleDefinitionSummary = $stats->rbacRoleDefinitionSummary !== [] ? $stats->rbacRoleDefinitionSummary : null;
|
||||||
|
$this->operatorExplanation = $stats->operatorExplanation()->toArray();
|
||||||
|
$this->summaryAssessment = $stats->summaryAssessment()->toArray();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -152,26 +182,32 @@ public function refreshStats(): void
|
|||||||
*/
|
*/
|
||||||
protected function getViewData(): array
|
protected function getViewData(): array
|
||||||
{
|
{
|
||||||
|
$evidenceGapSummary = is_array($this->evidenceGapSummary) ? $this->evidenceGapSummary : [];
|
||||||
$hasCoverageWarnings = in_array($this->coverageStatus, ['warning', 'unproven'], true);
|
$hasCoverageWarnings = in_array($this->coverageStatus, ['warning', 'unproven'], true);
|
||||||
$evidenceGapsCountValue = (int) ($this->evidenceGapsCount ?? 0);
|
$evidenceGapsCountValue = is_numeric($evidenceGapSummary['count'] ?? null)
|
||||||
|
? (int) $evidenceGapSummary['count']
|
||||||
|
: (int) ($this->evidenceGapsCount ?? 0);
|
||||||
$hasEvidenceGaps = $evidenceGapsCountValue > 0;
|
$hasEvidenceGaps = $evidenceGapsCountValue > 0;
|
||||||
$hasWarnings = $hasCoverageWarnings || $hasEvidenceGaps;
|
$hasWarnings = $hasCoverageWarnings || $hasEvidenceGaps;
|
||||||
$hasRbacRoleDefinitionSummary = is_array($this->rbacRoleDefinitionSummary)
|
$hasRbacRoleDefinitionSummary = is_array($this->rbacRoleDefinitionSummary)
|
||||||
&& array_sum($this->rbacRoleDefinitionSummary) > 0;
|
&& array_sum($this->rbacRoleDefinitionSummary) > 0;
|
||||||
|
$evidenceGapDetailState = is_string($evidenceGapSummary['detail_state'] ?? null)
|
||||||
|
? (string) $evidenceGapSummary['detail_state']
|
||||||
|
: 'no_gaps';
|
||||||
|
$hasEvidenceGapDetailSection = $evidenceGapDetailState !== 'no_gaps';
|
||||||
|
$hasEvidenceGapDiagnostics = is_array($this->baselineCompareDiagnostics) && $this->baselineCompareDiagnostics !== [];
|
||||||
|
|
||||||
$evidenceGapsSummary = null;
|
$evidenceGapsSummary = null;
|
||||||
$evidenceGapsTooltip = null;
|
$evidenceGapsTooltip = null;
|
||||||
|
|
||||||
if ($hasEvidenceGaps && is_array($this->evidenceGapsTopReasons) && $this->evidenceGapsTopReasons !== []) {
|
if ($hasEvidenceGaps) {
|
||||||
$parts = [];
|
$parts = array_map(
|
||||||
|
static fn (array $reason): string => $reason['reason_label'].' ('.$reason['count'].')',
|
||||||
foreach (array_slice($this->evidenceGapsTopReasons, 0, 5, true) as $reason => $count) {
|
BaselineCompareEvidenceGapDetails::topReasons(
|
||||||
if (! is_string($reason) || $reason === '' || ! is_numeric($count)) {
|
is_array($evidenceGapSummary['by_reason'] ?? null) ? $evidenceGapSummary['by_reason'] : [],
|
||||||
continue;
|
5,
|
||||||
}
|
),
|
||||||
|
);
|
||||||
$parts[] = $reason.' ('.((int) $count).')';
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($parts !== []) {
|
if ($parts !== []) {
|
||||||
$evidenceGapsSummary = implode(', ', $parts);
|
$evidenceGapsSummary = implode(', ', $parts);
|
||||||
@ -207,12 +243,16 @@ protected function getViewData(): array
|
|||||||
'hasEvidenceGaps' => $hasEvidenceGaps,
|
'hasEvidenceGaps' => $hasEvidenceGaps,
|
||||||
'hasWarnings' => $hasWarnings,
|
'hasWarnings' => $hasWarnings,
|
||||||
'hasRbacRoleDefinitionSummary' => $hasRbacRoleDefinitionSummary,
|
'hasRbacRoleDefinitionSummary' => $hasRbacRoleDefinitionSummary,
|
||||||
|
'evidenceGapDetailState' => $evidenceGapDetailState,
|
||||||
|
'hasEvidenceGapDetailSection' => $hasEvidenceGapDetailSection,
|
||||||
|
'hasEvidenceGapDiagnostics' => $hasEvidenceGapDiagnostics,
|
||||||
'evidenceGapsSummary' => $evidenceGapsSummary,
|
'evidenceGapsSummary' => $evidenceGapsSummary,
|
||||||
'evidenceGapsTooltip' => $evidenceGapsTooltip,
|
'evidenceGapsTooltip' => $evidenceGapsTooltip,
|
||||||
'findingsColorClass' => $findingsColorClass,
|
'findingsColorClass' => $findingsColorClass,
|
||||||
'whyNoFindingsMessage' => $whyNoFindingsMessage,
|
'whyNoFindingsMessage' => $whyNoFindingsMessage,
|
||||||
'whyNoFindingsFallback' => $whyNoFindingsFallback,
|
'whyNoFindingsFallback' => $whyNoFindingsFallback,
|
||||||
'whyNoFindingsColor' => $whyNoFindingsColor,
|
'whyNoFindingsColor' => $whyNoFindingsColor,
|
||||||
|
'summaryAssessment' => is_array($this->summaryAssessment) ? $this->summaryAssessment : null,
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -307,9 +347,22 @@ private function compareNowAction(): Action
|
|||||||
$result = $service->startCompare($tenant, $user);
|
$result = $service->startCompare($tenant, $user);
|
||||||
|
|
||||||
if (! ($result['ok'] ?? false)) {
|
if (! ($result['ok'] ?? false)) {
|
||||||
|
$reasonCode = is_string($result['reason_code'] ?? null) ? (string) $result['reason_code'] : 'unknown';
|
||||||
|
|
||||||
|
$message = match ($reasonCode) {
|
||||||
|
\App\Support\Baselines\BaselineReasonCodes::COMPARE_ROLLOUT_DISABLED => 'Full-content baseline compare is currently disabled for controlled rollout.',
|
||||||
|
\App\Support\Baselines\BaselineReasonCodes::COMPARE_PROFILE_NOT_ACTIVE => 'The assigned baseline profile is not active.',
|
||||||
|
\App\Support\Baselines\BaselineReasonCodes::COMPARE_NO_ACTIVE_SNAPSHOT,
|
||||||
|
\App\Support\Baselines\BaselineReasonCodes::COMPARE_NO_CONSUMABLE_SNAPSHOT => 'No complete baseline snapshot is currently available for compare.',
|
||||||
|
\App\Support\Baselines\BaselineReasonCodes::COMPARE_SNAPSHOT_BUILDING => 'The latest baseline capture is still building. Compare will be available after it completes.',
|
||||||
|
\App\Support\Baselines\BaselineReasonCodes::COMPARE_SNAPSHOT_INCOMPLETE => 'The latest baseline capture is incomplete. Capture a new baseline before comparing.',
|
||||||
|
\App\Support\Baselines\BaselineReasonCodes::COMPARE_SNAPSHOT_SUPERSEDED => 'A newer complete baseline snapshot is current. Compare uses the latest complete baseline only.',
|
||||||
|
default => 'Reason: '.$reasonCode,
|
||||||
|
};
|
||||||
|
|
||||||
Notification::make()
|
Notification::make()
|
||||||
->title('Cannot start comparison')
|
->title('Cannot start comparison')
|
||||||
->body('Reason: '.($result['reason_code'] ?? 'unknown'))
|
->body($message)
|
||||||
->danger()
|
->danger()
|
||||||
->send();
|
->send();
|
||||||
|
|
||||||
|
|||||||
@ -34,6 +34,7 @@
|
|||||||
use Filament\Tables\Contracts\HasTable;
|
use Filament\Tables\Contracts\HasTable;
|
||||||
use Filament\Tables\Filters\SelectFilter;
|
use Filament\Tables\Filters\SelectFilter;
|
||||||
use Filament\Tables\Table;
|
use Filament\Tables\Table;
|
||||||
|
use Illuminate\Contracts\View\View;
|
||||||
use Illuminate\Database\Eloquent\Builder;
|
use Illuminate\Database\Eloquent\Builder;
|
||||||
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
|
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
|
||||||
use UnitEnum;
|
use UnitEnum;
|
||||||
@ -82,14 +83,16 @@ public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration
|
|||||||
public function mount(): void
|
public function mount(): void
|
||||||
{
|
{
|
||||||
$this->authorizePageAccess();
|
$this->authorizePageAccess();
|
||||||
$this->selectedAuditLogId = is_numeric(request()->query('event')) ? (int) request()->query('event') : null;
|
$requestedEventId = is_numeric(request()->query('event')) ? (int) request()->query('event') : null;
|
||||||
|
|
||||||
app(CanonicalAdminTenantFilterState::class)->sync($this->getTableFiltersSessionKey(), request: request());
|
app(CanonicalAdminTenantFilterState::class)->sync($this->getTableFiltersSessionKey(), request: request());
|
||||||
|
|
||||||
$this->mountInteractsWithTable();
|
$this->mountInteractsWithTable();
|
||||||
|
|
||||||
if ($this->selectedAuditLogId !== null) {
|
if ($requestedEventId !== null) {
|
||||||
$this->selectedAuditLog();
|
$this->resolveAuditLog($requestedEventId);
|
||||||
|
$this->selectedAuditLogId = $requestedEventId;
|
||||||
|
$this->mountTableAction('inspect', (string) $requestedEventId);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -98,31 +101,10 @@ public function mount(): void
|
|||||||
*/
|
*/
|
||||||
protected function getHeaderActions(): array
|
protected function getHeaderActions(): array
|
||||||
{
|
{
|
||||||
$actions = app(OperateHubShell::class)->headerActions(
|
return app(OperateHubShell::class)->headerActions(
|
||||||
scopeActionName: 'operate_hub_scope_audit_log',
|
scopeActionName: 'operate_hub_scope_audit_log',
|
||||||
returnActionName: 'operate_hub_return_audit_log',
|
returnActionName: 'operate_hub_return_audit_log',
|
||||||
);
|
);
|
||||||
|
|
||||||
if ($this->selectedAuditLog() instanceof AuditLogModel) {
|
|
||||||
$actions[] = Action::make('clear_selected_audit_event')
|
|
||||||
->label('Close details')
|
|
||||||
->color('gray')
|
|
||||||
->action(function (): void {
|
|
||||||
$this->clearSelectedAuditLog();
|
|
||||||
});
|
|
||||||
|
|
||||||
$relatedLink = $this->selectedAuditLink();
|
|
||||||
|
|
||||||
if (is_array($relatedLink)) {
|
|
||||||
$actions[] = Action::make('open_selected_audit_target')
|
|
||||||
->label($relatedLink['label'])
|
|
||||||
->icon('heroicon-o-arrow-top-right-on-square')
|
|
||||||
->color('gray')
|
|
||||||
->url($relatedLink['url']);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return $actions;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public function table(Table $table): Table
|
public function table(Table $table): Table
|
||||||
@ -195,9 +177,19 @@ public function table(Table $table): Table
|
|||||||
->label('Inspect event')
|
->label('Inspect event')
|
||||||
->icon('heroicon-o-eye')
|
->icon('heroicon-o-eye')
|
||||||
->color('gray')
|
->color('gray')
|
||||||
->action(function (AuditLogModel $record): void {
|
->before(function (AuditLogModel $record): void {
|
||||||
$this->selectedAuditLogId = (int) $record->getKey();
|
$this->selectedAuditLogId = (int) $record->getKey();
|
||||||
}),
|
})
|
||||||
|
->slideOver()
|
||||||
|
->stickyModalHeader()
|
||||||
|
->modalSubmitAction(false)
|
||||||
|
->modalCancelAction(fn (Action $action): Action => $action->label('Close details'))
|
||||||
|
->modalHeading(fn (AuditLogModel $record): string => $record->summaryText())
|
||||||
|
->modalDescription(fn (AuditLogModel $record): ?string => $record->recorded_at?->toDayDateTimeString())
|
||||||
|
->modalContent(fn (AuditLogModel $record): View => view('filament.pages.monitoring.partials.audit-log-inspect-event', [
|
||||||
|
'selectedAudit' => $record,
|
||||||
|
'selectedAuditLink' => $this->auditTargetLink($record),
|
||||||
|
])),
|
||||||
])
|
])
|
||||||
->bulkActions([])
|
->bulkActions([])
|
||||||
->emptyStateHeading('No audit events match this view')
|
->emptyStateHeading('No audit events match this view')
|
||||||
@ -209,48 +201,11 @@ public function table(Table $table): Table
|
|||||||
->icon('heroicon-o-x-mark')
|
->icon('heroicon-o-x-mark')
|
||||||
->color('gray')
|
->color('gray')
|
||||||
->action(function (): void {
|
->action(function (): void {
|
||||||
$this->selectedAuditLogId = null;
|
|
||||||
$this->resetTable();
|
$this->resetTable();
|
||||||
}),
|
}),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function clearSelectedAuditLog(): void
|
|
||||||
{
|
|
||||||
$this->selectedAuditLogId = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function selectedAuditLog(): ?AuditLogModel
|
|
||||||
{
|
|
||||||
if (! is_numeric($this->selectedAuditLogId)) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
$record = $this->auditBaseQuery()
|
|
||||||
->whereKey((int) $this->selectedAuditLogId)
|
|
||||||
->first();
|
|
||||||
|
|
||||||
if (! $record instanceof AuditLogModel) {
|
|
||||||
throw new NotFoundHttpException;
|
|
||||||
}
|
|
||||||
|
|
||||||
return $record;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return array{label: string, url: string}|null
|
|
||||||
*/
|
|
||||||
public function selectedAuditLink(): ?array
|
|
||||||
{
|
|
||||||
$record = $this->selectedAuditLog();
|
|
||||||
|
|
||||||
if (! $record instanceof AuditLogModel) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return app(RelatedNavigationResolver::class)->auditTargetLink($record);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @return array<int, Tenant>
|
* @return array<int, Tenant>
|
||||||
*/
|
*/
|
||||||
@ -323,6 +278,54 @@ private function auditBaseQuery(): Builder
|
|||||||
->latestFirst();
|
->latestFirst();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private function resolveAuditLog(int $auditLogId): AuditLogModel
|
||||||
|
{
|
||||||
|
$record = $this->auditBaseQuery()
|
||||||
|
->whereKey($auditLogId)
|
||||||
|
->first();
|
||||||
|
|
||||||
|
if (! $record instanceof AuditLogModel) {
|
||||||
|
throw new NotFoundHttpException;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $record;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function selectedAuditRecord(): ?AuditLogModel
|
||||||
|
{
|
||||||
|
if (! is_int($this->selectedAuditLogId) || $this->selectedAuditLogId <= 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
return $this->resolveAuditLog($this->selectedAuditLogId);
|
||||||
|
} catch (NotFoundHttpException) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array{label: string, url: string}|null
|
||||||
|
*/
|
||||||
|
public function selectedAuditTargetLink(): ?array
|
||||||
|
{
|
||||||
|
$record = $this->selectedAuditRecord();
|
||||||
|
|
||||||
|
if (! $record instanceof AuditLogModel) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->auditTargetLink($record);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array{label: string, url: string}|null
|
||||||
|
*/
|
||||||
|
private function auditTargetLink(AuditLogModel $record): ?array
|
||||||
|
{
|
||||||
|
return app(RelatedNavigationResolver::class)->auditTargetLink($record);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @return array<string, string>
|
* @return array<string, string>
|
||||||
*/
|
*/
|
||||||
|
|||||||
@ -8,10 +8,13 @@
|
|||||||
use App\Models\EvidenceSnapshot;
|
use App\Models\EvidenceSnapshot;
|
||||||
use App\Models\Tenant;
|
use App\Models\Tenant;
|
||||||
use App\Models\User;
|
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\ActionSurfaceDeclaration;
|
||||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceInspectAffordance;
|
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceInspectAffordance;
|
||||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceProfile;
|
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceProfile;
|
||||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceSlot;
|
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceSlot;
|
||||||
|
use App\Support\Ui\GovernanceArtifactTruth\ArtifactTruthPresenter;
|
||||||
use App\Support\Workspaces\WorkspaceContext;
|
use App\Support\Workspaces\WorkspaceContext;
|
||||||
use BackedEnum;
|
use BackedEnum;
|
||||||
use Filament\Actions\Action;
|
use Filament\Actions\Action;
|
||||||
@ -87,6 +90,9 @@ public function mount(): void
|
|||||||
$snapshots = $query->get()->unique('tenant_id')->values();
|
$snapshots = $query->get()->unique('tenant_id')->values();
|
||||||
|
|
||||||
$this->rows = $snapshots->map(function (EvidenceSnapshot $snapshot): array {
|
$this->rows = $snapshots->map(function (EvidenceSnapshot $snapshot): array {
|
||||||
|
$truth = app(ArtifactTruthPresenter::class)->forEvidenceSnapshot($snapshot);
|
||||||
|
$freshnessSpec = BadgeCatalog::spec(BadgeDomain::GovernanceArtifactFreshness, $truth->freshnessState);
|
||||||
|
|
||||||
return [
|
return [
|
||||||
'tenant_name' => $snapshot->tenant?->name ?? 'Unknown tenant',
|
'tenant_name' => $snapshot->tenant?->name ?? 'Unknown tenant',
|
||||||
'tenant_id' => (int) $snapshot->tenant_id,
|
'tenant_id' => (int) $snapshot->tenant_id,
|
||||||
@ -95,7 +101,21 @@ public function mount(): void
|
|||||||
'generated_at' => $snapshot->generated_at?->toDateTimeString(),
|
'generated_at' => $snapshot->generated_at?->toDateTimeString(),
|
||||||
'missing_dimensions' => (int) (($snapshot->summary['missing_dimensions'] ?? 0)),
|
'missing_dimensions' => (int) (($snapshot->summary['missing_dimensions'] ?? 0)),
|
||||||
'stale_dimensions' => (int) (($snapshot->summary['stale_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();
|
})->all();
|
||||||
}
|
}
|
||||||
|
|||||||
@ -13,6 +13,7 @@
|
|||||||
use App\Support\OperateHub\OperateHubShell;
|
use App\Support\OperateHub\OperateHubShell;
|
||||||
use App\Support\OperationRunOutcome;
|
use App\Support\OperationRunOutcome;
|
||||||
use App\Support\OperationRunStatus;
|
use App\Support\OperationRunStatus;
|
||||||
|
use App\Support\Operations\OperationLifecyclePolicy;
|
||||||
use App\Support\Workspaces\WorkspaceContext;
|
use App\Support\Workspaces\WorkspaceContext;
|
||||||
use BackedEnum;
|
use BackedEnum;
|
||||||
use Filament\Actions\Action;
|
use Filament\Actions\Action;
|
||||||
@ -165,6 +166,68 @@ public function table(Table $table): Table
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array{likely_stale:int,reconciled:int}
|
||||||
|
*/
|
||||||
|
public function lifecycleVisibilitySummary(): array
|
||||||
|
{
|
||||||
|
$baseQuery = $this->scopedSummaryQuery();
|
||||||
|
|
||||||
|
if (! $baseQuery instanceof Builder) {
|
||||||
|
return [
|
||||||
|
'likely_stale' => 0,
|
||||||
|
'reconciled' => 0,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
$reconciled = (clone $baseQuery)
|
||||||
|
->whereNotNull('context->reconciliation->reconciled_at')
|
||||||
|
->count();
|
||||||
|
|
||||||
|
$policy = app(OperationLifecyclePolicy::class);
|
||||||
|
$likelyStale = (clone $baseQuery)
|
||||||
|
->whereIn('status', [
|
||||||
|
OperationRunStatus::Queued->value,
|
||||||
|
OperationRunStatus::Running->value,
|
||||||
|
])
|
||||||
|
->where(function (Builder $query) use ($policy): void {
|
||||||
|
foreach ($policy->coveredTypeNames() as $type) {
|
||||||
|
$query->orWhere(function (Builder $typeQuery) use ($policy, $type): void {
|
||||||
|
$typeQuery
|
||||||
|
->where('type', $type)
|
||||||
|
->where(function (Builder $stateQuery) use ($policy, $type): void {
|
||||||
|
$stateQuery
|
||||||
|
->where(function (Builder $queuedQuery) use ($policy, $type): void {
|
||||||
|
$queuedQuery
|
||||||
|
->where('status', OperationRunStatus::Queued->value)
|
||||||
|
->whereNull('started_at')
|
||||||
|
->where('created_at', '<=', now()->subSeconds($policy->queuedStaleAfterSeconds($type)));
|
||||||
|
})
|
||||||
|
->orWhere(function (Builder $runningQuery) use ($policy, $type): void {
|
||||||
|
$runningQuery
|
||||||
|
->where('status', OperationRunStatus::Running->value)
|
||||||
|
->where(function (Builder $startedAtQuery) use ($policy, $type): void {
|
||||||
|
$startedAtQuery
|
||||||
|
->where('started_at', '<=', now()->subSeconds($policy->runningStaleAfterSeconds($type)))
|
||||||
|
->orWhere(function (Builder $fallbackQuery) use ($policy, $type): void {
|
||||||
|
$fallbackQuery
|
||||||
|
->whereNull('started_at')
|
||||||
|
->where('created_at', '<=', now()->subSeconds($policy->runningStaleAfterSeconds($type)));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
})
|
||||||
|
->count();
|
||||||
|
|
||||||
|
return [
|
||||||
|
'likely_stale' => $likelyStale,
|
||||||
|
'reconciled' => $reconciled,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
private function applyActiveTab(Builder $query): Builder
|
private function applyActiveTab(Builder $query): Builder
|
||||||
{
|
{
|
||||||
return match ($this->activeTab) {
|
return match ($this->activeTab) {
|
||||||
@ -172,6 +235,9 @@ private function applyActiveTab(Builder $query): Builder
|
|||||||
OperationRunStatus::Queued->value,
|
OperationRunStatus::Queued->value,
|
||||||
OperationRunStatus::Running->value,
|
OperationRunStatus::Running->value,
|
||||||
]),
|
]),
|
||||||
|
'blocked' => $query
|
||||||
|
->where('status', OperationRunStatus::Completed->value)
|
||||||
|
->where('outcome', OperationRunOutcome::Blocked->value),
|
||||||
'succeeded' => $query
|
'succeeded' => $query
|
||||||
->where('status', OperationRunStatus::Completed->value)
|
->where('status', OperationRunStatus::Completed->value)
|
||||||
->where('outcome', OperationRunOutcome::Succeeded->value),
|
->where('outcome', OperationRunOutcome::Succeeded->value),
|
||||||
@ -184,4 +250,26 @@ private function applyActiveTab(Builder $query): Builder
|
|||||||
default => $query,
|
default => $query,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private function scopedSummaryQuery(): ?Builder
|
||||||
|
{
|
||||||
|
$workspaceId = app(WorkspaceContext::class)->currentWorkspaceId(request());
|
||||||
|
|
||||||
|
if (! $workspaceId) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$tenantFilter = data_get($this->tableFilters, 'tenant_id.value');
|
||||||
|
|
||||||
|
if (! is_numeric($tenantFilter)) {
|
||||||
|
$tenantFilter = data_get(session()->get($this->getTableFiltersSessionKey(), []), 'tenant_id.value');
|
||||||
|
}
|
||||||
|
|
||||||
|
return OperationRun::query()
|
||||||
|
->where('workspace_id', (int) $workspaceId)
|
||||||
|
->when(
|
||||||
|
is_numeric($tenantFilter),
|
||||||
|
fn (Builder $query): Builder => $query->where('tenant_id', (int) $tenantFilter),
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -19,10 +19,13 @@
|
|||||||
use App\Support\OpsUx\OperationUxPresenter;
|
use App\Support\OpsUx\OperationUxPresenter;
|
||||||
use App\Support\OpsUx\OpsUxBrowserEvents;
|
use App\Support\OpsUx\OpsUxBrowserEvents;
|
||||||
use App\Support\OpsUx\RunDetailPolling;
|
use App\Support\OpsUx\RunDetailPolling;
|
||||||
|
use App\Support\ReasonTranslation\ReasonPresenter;
|
||||||
use App\Support\RedactionIntegrity;
|
use App\Support\RedactionIntegrity;
|
||||||
use App\Support\Tenants\ReferencedTenantLifecyclePresentation;
|
use App\Support\Tenants\ReferencedTenantLifecyclePresentation;
|
||||||
use App\Support\Tenants\TenantInteractionLane;
|
use App\Support\Tenants\TenantInteractionLane;
|
||||||
use App\Support\Tenants\TenantOperabilityQuestion;
|
use App\Support\Tenants\TenantOperabilityQuestion;
|
||||||
|
use App\Support\Ui\GovernanceArtifactTruth\ArtifactTruthPresenter;
|
||||||
|
use App\Support\Ui\OperatorExplanation\OperatorExplanationPattern;
|
||||||
use Filament\Actions\Action;
|
use Filament\Actions\Action;
|
||||||
use Filament\Actions\ActionGroup;
|
use Filament\Actions\ActionGroup;
|
||||||
use Filament\Notifications\Notification;
|
use Filament\Notifications\Notification;
|
||||||
@ -169,24 +172,56 @@ public function blockedExecutionBanner(): ?array
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
$context = is_array($this->run->context) ? $this->run->context : [];
|
$operatorExplanation = $this->governanceOperatorExplanation();
|
||||||
$reasonCode = data_get($context, 'reason_code');
|
$reasonEnvelope = app(ReasonPresenter::class)->forOperationRun($this->run, 'run_detail');
|
||||||
|
$lines = $operatorExplanation instanceof OperatorExplanationPattern
|
||||||
if (! is_string($reasonCode) || trim($reasonCode) === '') {
|
? array_values(array_filter([
|
||||||
$reasonCode = data_get($context, 'execution_legitimacy.reason_code');
|
$operatorExplanation->headline,
|
||||||
}
|
$operatorExplanation->dominantCauseExplanation,
|
||||||
|
]))
|
||||||
$reasonCode = is_string($reasonCode) && trim($reasonCode) !== '' ? trim($reasonCode) : 'unknown_error';
|
: ($reasonEnvelope?->toBodyLines(false) ?? [
|
||||||
$message = $this->run->failure_summary[0]['message'] ?? null;
|
OperationUxPresenter::surfaceFailureDetail($this->run) ?? 'The queued run was refused before side effects could begin.',
|
||||||
$message = is_string($message) && trim($message) !== '' ? trim($message) : 'The queued run was refused before side effects could begin.';
|
]);
|
||||||
|
|
||||||
return [
|
return [
|
||||||
'tone' => 'amber',
|
'tone' => 'amber',
|
||||||
'title' => 'Execution blocked',
|
'title' => 'Blocked by prerequisite',
|
||||||
'body' => sprintf('Reason code: %s. %s', $reasonCode, $message),
|
'body' => implode(' ', array_values(array_unique($lines))),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array{tone: string, title: string, body: string}|null
|
||||||
|
*/
|
||||||
|
public function lifecycleBanner(): ?array
|
||||||
|
{
|
||||||
|
if (! isset($this->run)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$attention = OperationUxPresenter::lifecycleAttentionSummary($this->run);
|
||||||
|
|
||||||
|
if ($attention === null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$detail = OperationUxPresenter::surfaceFailureDetail($this->run) ?? 'Lifecycle truth needs operator review.';
|
||||||
|
|
||||||
|
return match ($this->run->freshnessState()->value) {
|
||||||
|
'likely_stale' => [
|
||||||
|
'tone' => 'amber',
|
||||||
|
'title' => 'Likely stale run',
|
||||||
|
'body' => $detail,
|
||||||
|
],
|
||||||
|
'reconciled_failed' => [
|
||||||
|
'tone' => 'rose',
|
||||||
|
'title' => 'Automatically reconciled',
|
||||||
|
'body' => $detail,
|
||||||
|
],
|
||||||
|
default => null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @return array{tone: string, title: string, body: string}|null
|
* @return array{tone: string, title: string, body: string}|null
|
||||||
*/
|
*/
|
||||||
@ -421,4 +456,13 @@ private function relatedLinksTenant(): ?Tenant
|
|||||||
lane: TenantInteractionLane::StandardActiveOperating,
|
lane: TenantInteractionLane::StandardActiveOperating,
|
||||||
)->allowed ? $tenant : null;
|
)->allowed ? $tenant : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private function governanceOperatorExplanation(): ?OperatorExplanationPattern
|
||||||
|
{
|
||||||
|
if (! isset($this->run) || ! $this->run->supportsOperatorExplanation()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return app(ArtifactTruthPresenter::class)->forOperationRun($this->run)?->operatorExplanation;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -11,15 +11,18 @@
|
|||||||
use App\Models\Workspace;
|
use App\Models\Workspace;
|
||||||
use App\Services\TenantReviews\TenantReviewRegisterService;
|
use App\Services\TenantReviews\TenantReviewRegisterService;
|
||||||
use App\Support\Auth\Capabilities;
|
use App\Support\Auth\Capabilities;
|
||||||
|
use App\Support\Badges\BadgeCatalog;
|
||||||
use App\Support\Badges\BadgeDomain;
|
use App\Support\Badges\BadgeDomain;
|
||||||
use App\Support\Badges\BadgeRenderer;
|
use App\Support\Badges\BadgeRenderer;
|
||||||
use App\Support\Filament\CanonicalAdminTenantFilterState;
|
use App\Support\Filament\CanonicalAdminTenantFilterState;
|
||||||
use App\Support\Filament\FilterPresets;
|
use App\Support\Filament\FilterPresets;
|
||||||
use App\Support\Filament\TablePaginationProfiles;
|
use App\Support\Filament\TablePaginationProfiles;
|
||||||
|
use App\Support\TenantReviewCompletenessState;
|
||||||
use App\Support\Ui\ActionSurface\ActionSurfaceDeclaration;
|
use App\Support\Ui\ActionSurface\ActionSurfaceDeclaration;
|
||||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceInspectAffordance;
|
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceInspectAffordance;
|
||||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceProfile;
|
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceProfile;
|
||||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceSlot;
|
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceSlot;
|
||||||
|
use App\Support\Ui\GovernanceArtifactTruth\ArtifactTruthPresenter;
|
||||||
use App\Support\Workspaces\WorkspaceContext;
|
use App\Support\Workspaces\WorkspaceContext;
|
||||||
use BackedEnum;
|
use BackedEnum;
|
||||||
use Filament\Actions\Action;
|
use Filament\Actions\Action;
|
||||||
@ -112,6 +115,15 @@ public function table(Table $table): Table
|
|||||||
->color(BadgeRenderer::color(BadgeDomain::TenantReviewStatus))
|
->color(BadgeRenderer::color(BadgeDomain::TenantReviewStatus))
|
||||||
->icon(BadgeRenderer::icon(BadgeDomain::TenantReviewStatus))
|
->icon(BadgeRenderer::icon(BadgeDomain::TenantReviewStatus))
|
||||||
->iconColor(BadgeRenderer::iconColor(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)->operatorExplanation?->headline ?? app(ArtifactTruthPresenter::class)->forTenantReview($record)->primaryExplanation)
|
||||||
|
->wrap(),
|
||||||
TextColumn::make('completeness_state')
|
TextColumn::make('completeness_state')
|
||||||
->label('Completeness')
|
->label('Completeness')
|
||||||
->badge()
|
->badge()
|
||||||
@ -121,15 +133,29 @@ public function table(Table $table): Table
|
|||||||
->iconColor(BadgeRenderer::iconColor(BadgeDomain::TenantReviewCompleteness)),
|
->iconColor(BadgeRenderer::iconColor(BadgeDomain::TenantReviewCompleteness)),
|
||||||
TextColumn::make('generated_at')->dateTime()->placeholder('—')->sortable(),
|
TextColumn::make('generated_at')->dateTime()->placeholder('—')->sortable(),
|
||||||
TextColumn::make('published_at')->dateTime()->placeholder('—')->sortable(),
|
TextColumn::make('published_at')->dateTime()->placeholder('—')->sortable(),
|
||||||
TextColumn::make('summary.publish_blockers')
|
TextColumn::make('publication_truth')
|
||||||
->label('Publish blockers')
|
->label('Publication')
|
||||||
->formatStateUsing(static function (mixed $state): string {
|
->badge()
|
||||||
if (! is_array($state) || $state === []) {
|
->getStateUsing(fn (TenantReview $record): string => BadgeCatalog::spec(
|
||||||
return '0';
|
BadgeDomain::GovernanceArtifactPublicationReadiness,
|
||||||
}
|
app(ArtifactTruthPresenter::class)->forTenantReview($record)->publicationReadiness ?? 'internal_only',
|
||||||
|
)->label)
|
||||||
return (string) count($state);
|
->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)->operatorExplanation?->nextActionText ?? app(ArtifactTruthPresenter::class)->forTenantReview($record)->nextStepText())
|
||||||
|
->wrap(),
|
||||||
])
|
])
|
||||||
->filters([
|
->filters([
|
||||||
SelectFilter::make('tenant_id')
|
SelectFilter::make('tenant_id')
|
||||||
@ -148,12 +174,7 @@ public function table(Table $table): Table
|
|||||||
]),
|
]),
|
||||||
SelectFilter::make('completeness_state')
|
SelectFilter::make('completeness_state')
|
||||||
->label('Completeness')
|
->label('Completeness')
|
||||||
->options([
|
->options(BadgeCatalog::options(BadgeDomain::TenantReviewCompleteness, TenantReviewCompletenessState::values())),
|
||||||
'complete' => 'Complete',
|
|
||||||
'partial' => 'Partial',
|
|
||||||
'missing' => 'Missing',
|
|
||||||
'stale' => 'Stale',
|
|
||||||
]),
|
|
||||||
SelectFilter::make('published_state')
|
SelectFilter::make('published_state')
|
||||||
->label('Published state')
|
->label('Published state')
|
||||||
->options([
|
->options([
|
||||||
|
|||||||
@ -2882,9 +2882,12 @@ public function startVerification(): void
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$reasonEnvelope = app(\App\Support\ReasonTranslation\ReasonPresenter::class)->forOperationRun($result->run, 'notification');
|
||||||
|
$bodyLines = $reasonEnvelope?->toBodyLines() ?? ['Blocked by provider configuration.'];
|
||||||
|
|
||||||
Notification::make()
|
Notification::make()
|
||||||
->title('Verification blocked')
|
->title('Verification blocked')
|
||||||
->body("Blocked by provider configuration ({$reasonCode}).")
|
->body(implode("\n", $bodyLines))
|
||||||
->warning()
|
->warning()
|
||||||
->actions($actions)
|
->actions($actions)
|
||||||
->send();
|
->send();
|
||||||
|
|||||||
@ -6,19 +6,28 @@
|
|||||||
|
|
||||||
use App\Filament\Resources\BaselineProfileResource\Pages;
|
use App\Filament\Resources\BaselineProfileResource\Pages;
|
||||||
use App\Models\BaselineProfile;
|
use App\Models\BaselineProfile;
|
||||||
|
use App\Models\BaselineSnapshot;
|
||||||
|
use App\Models\BaselineTenantAssignment;
|
||||||
|
use App\Models\Tenant;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
use App\Models\Workspace;
|
use App\Models\Workspace;
|
||||||
use App\Services\Audit\WorkspaceAuditLogger;
|
use App\Services\Audit\WorkspaceAuditLogger;
|
||||||
|
use App\Services\Auth\CapabilityResolver;
|
||||||
|
use App\Services\Baselines\BaselineSnapshotTruthResolver;
|
||||||
use App\Support\Audit\AuditActionId;
|
use App\Support\Audit\AuditActionId;
|
||||||
use App\Support\Auth\Capabilities;
|
use App\Support\Auth\Capabilities;
|
||||||
|
use App\Support\Badges\BadgeCatalog;
|
||||||
use App\Support\Badges\BadgeDomain;
|
use App\Support\Badges\BadgeDomain;
|
||||||
use App\Support\Badges\BadgeRenderer;
|
use App\Support\Badges\BadgeRenderer;
|
||||||
use App\Support\Baselines\BaselineCaptureMode;
|
use App\Support\Baselines\BaselineCaptureMode;
|
||||||
use App\Support\Baselines\BaselineFullContentRolloutGate;
|
use App\Support\Baselines\BaselineFullContentRolloutGate;
|
||||||
use App\Support\Baselines\BaselineProfileStatus;
|
use App\Support\Baselines\BaselineProfileStatus;
|
||||||
|
use App\Support\Baselines\BaselineReasonCodes;
|
||||||
use App\Support\Filament\FilterOptionCatalog;
|
use App\Support\Filament\FilterOptionCatalog;
|
||||||
use App\Support\Inventory\InventoryPolicyTypeMeta;
|
use App\Support\Inventory\InventoryPolicyTypeMeta;
|
||||||
use App\Support\Rbac\WorkspaceUiEnforcement;
|
use App\Support\Rbac\WorkspaceUiEnforcement;
|
||||||
|
use App\Support\ReasonTranslation\ReasonPresenter;
|
||||||
|
use App\Support\ReasonTranslation\ReasonResolutionEnvelope;
|
||||||
use App\Support\Ui\ActionSurface\ActionSurfaceDeclaration;
|
use App\Support\Ui\ActionSurface\ActionSurfaceDeclaration;
|
||||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceInspectAffordance;
|
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceInspectAffordance;
|
||||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceProfile;
|
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceProfile;
|
||||||
@ -288,15 +297,32 @@ public static function infolist(Schema $schema): Schema
|
|||||||
->placeholder('None'),
|
->placeholder('None'),
|
||||||
])
|
])
|
||||||
->columnSpanFull(),
|
->columnSpanFull(),
|
||||||
|
Section::make('Baseline truth')
|
||||||
|
->schema([
|
||||||
|
TextEntry::make('current_snapshot_truth')
|
||||||
|
->label('Current snapshot')
|
||||||
|
->state(fn (BaselineProfile $record): string => self::currentSnapshotLabel($record)),
|
||||||
|
TextEntry::make('latest_attempted_snapshot_truth')
|
||||||
|
->label('Latest attempt')
|
||||||
|
->state(fn (BaselineProfile $record): string => self::latestAttemptedSnapshotLabel($record)),
|
||||||
|
TextEntry::make('compare_readiness')
|
||||||
|
->label('Compare readiness')
|
||||||
|
->badge()
|
||||||
|
->state(fn (BaselineProfile $record): string => self::compareReadinessLabel($record))
|
||||||
|
->color(fn (BaselineProfile $record): string => self::compareReadinessColor($record))
|
||||||
|
->icon(fn (BaselineProfile $record): ?string => self::compareReadinessIcon($record)),
|
||||||
|
TextEntry::make('baseline_next_step')
|
||||||
|
->label('Next step')
|
||||||
|
->state(fn (BaselineProfile $record): string => self::profileNextStep($record))
|
||||||
|
->columnSpanFull(),
|
||||||
|
])
|
||||||
|
->columns(2)
|
||||||
|
->columnSpanFull(),
|
||||||
Section::make('Metadata')
|
Section::make('Metadata')
|
||||||
->schema([
|
->schema([
|
||||||
TextEntry::make('createdByUser.name')
|
TextEntry::make('createdByUser.name')
|
||||||
->label('Created by')
|
->label('Created by')
|
||||||
->placeholder('—'),
|
->placeholder('—'),
|
||||||
TextEntry::make('activeSnapshot.captured_at')
|
|
||||||
->label('Last snapshot')
|
|
||||||
->dateTime()
|
|
||||||
->placeholder('No snapshot yet'),
|
|
||||||
TextEntry::make('created_at')
|
TextEntry::make('created_at')
|
||||||
->dateTime(),
|
->dateTime(),
|
||||||
TextEntry::make('updated_at')
|
TextEntry::make('updated_at')
|
||||||
@ -355,10 +381,27 @@ public static function table(Table $table): Table
|
|||||||
TextColumn::make('tenant_assignments_count')
|
TextColumn::make('tenant_assignments_count')
|
||||||
->label('Assigned tenants')
|
->label('Assigned tenants')
|
||||||
->counts('tenantAssignments'),
|
->counts('tenantAssignments'),
|
||||||
TextColumn::make('activeSnapshot.captured_at')
|
TextColumn::make('current_snapshot_truth')
|
||||||
->label('Last snapshot')
|
->label('Current snapshot')
|
||||||
->dateTime()
|
->getStateUsing(fn (BaselineProfile $record): string => self::currentSnapshotLabel($record))
|
||||||
->placeholder('No snapshot'),
|
->description(fn (BaselineProfile $record): ?string => self::currentSnapshotDescription($record))
|
||||||
|
->wrap(),
|
||||||
|
TextColumn::make('latest_attempted_snapshot_truth')
|
||||||
|
->label('Latest attempt')
|
||||||
|
->getStateUsing(fn (BaselineProfile $record): string => self::latestAttemptedSnapshotLabel($record))
|
||||||
|
->description(fn (BaselineProfile $record): ?string => self::latestAttemptedSnapshotDescription($record))
|
||||||
|
->wrap(),
|
||||||
|
TextColumn::make('compare_readiness')
|
||||||
|
->label('Compare readiness')
|
||||||
|
->badge()
|
||||||
|
->getStateUsing(fn (BaselineProfile $record): string => self::compareReadinessLabel($record))
|
||||||
|
->color(fn (BaselineProfile $record): string => self::compareReadinessColor($record))
|
||||||
|
->icon(fn (BaselineProfile $record): ?string => self::compareReadinessIcon($record))
|
||||||
|
->wrap(),
|
||||||
|
TextColumn::make('baseline_next_step')
|
||||||
|
->label('Next step')
|
||||||
|
->getStateUsing(fn (BaselineProfile $record): string => self::profileNextStep($record))
|
||||||
|
->wrap(),
|
||||||
TextColumn::make('created_at')
|
TextColumn::make('created_at')
|
||||||
->dateTime()
|
->dateTime()
|
||||||
->sortable()
|
->sortable()
|
||||||
@ -545,4 +588,167 @@ private static function archiveTableAction(?Workspace $workspace): Action
|
|||||||
|
|
||||||
return $action;
|
return $action;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static function currentSnapshotLabel(BaselineProfile $profile): string
|
||||||
|
{
|
||||||
|
$snapshot = self::effectiveSnapshot($profile);
|
||||||
|
|
||||||
|
if (! $snapshot instanceof BaselineSnapshot) {
|
||||||
|
return 'No complete snapshot';
|
||||||
|
}
|
||||||
|
|
||||||
|
return self::snapshotReference($snapshot);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function currentSnapshotDescription(BaselineProfile $profile): ?string
|
||||||
|
{
|
||||||
|
$snapshot = self::effectiveSnapshot($profile);
|
||||||
|
|
||||||
|
if (! $snapshot instanceof BaselineSnapshot) {
|
||||||
|
return self::compareAvailabilityEnvelope($profile)?->shortExplanation;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $snapshot->captured_at?->toDayDateTimeString();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function latestAttemptedSnapshotLabel(BaselineProfile $profile): string
|
||||||
|
{
|
||||||
|
$latestAttempt = self::latestAttemptedSnapshot($profile);
|
||||||
|
|
||||||
|
if (! $latestAttempt instanceof BaselineSnapshot) {
|
||||||
|
return 'No capture attempts yet';
|
||||||
|
}
|
||||||
|
|
||||||
|
$effectiveSnapshot = self::effectiveSnapshot($profile);
|
||||||
|
|
||||||
|
if ($effectiveSnapshot instanceof BaselineSnapshot && (int) $effectiveSnapshot->getKey() === (int) $latestAttempt->getKey()) {
|
||||||
|
return 'Matches current snapshot';
|
||||||
|
}
|
||||||
|
|
||||||
|
return self::snapshotReference($latestAttempt);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function latestAttemptedSnapshotDescription(BaselineProfile $profile): ?string
|
||||||
|
{
|
||||||
|
$latestAttempt = self::latestAttemptedSnapshot($profile);
|
||||||
|
|
||||||
|
if (! $latestAttempt instanceof BaselineSnapshot) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$effectiveSnapshot = self::effectiveSnapshot($profile);
|
||||||
|
|
||||||
|
if ($effectiveSnapshot instanceof BaselineSnapshot && (int) $effectiveSnapshot->getKey() === (int) $latestAttempt->getKey()) {
|
||||||
|
return 'No newer attempt is pending.';
|
||||||
|
}
|
||||||
|
|
||||||
|
return $latestAttempt->captured_at?->toDayDateTimeString();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function compareReadinessLabel(BaselineProfile $profile): string
|
||||||
|
{
|
||||||
|
return self::compareAvailabilityEnvelope($profile)?->operatorLabel ?? 'Ready';
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function compareReadinessColor(BaselineProfile $profile): string
|
||||||
|
{
|
||||||
|
return match (self::compareAvailabilityReason($profile)) {
|
||||||
|
null => 'success',
|
||||||
|
BaselineReasonCodes::COMPARE_PROFILE_NOT_ACTIVE => 'gray',
|
||||||
|
default => 'warning',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function compareReadinessIcon(BaselineProfile $profile): ?string
|
||||||
|
{
|
||||||
|
return match (self::compareAvailabilityReason($profile)) {
|
||||||
|
null => 'heroicon-m-check-badge',
|
||||||
|
BaselineReasonCodes::COMPARE_PROFILE_NOT_ACTIVE => 'heroicon-m-pause-circle',
|
||||||
|
default => 'heroicon-m-exclamation-triangle',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function profileNextStep(BaselineProfile $profile): string
|
||||||
|
{
|
||||||
|
return self::compareAvailabilityEnvelope($profile)?->guidanceText() ?? 'No action needed.';
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function effectiveSnapshot(BaselineProfile $profile): ?BaselineSnapshot
|
||||||
|
{
|
||||||
|
return app(BaselineSnapshotTruthResolver::class)->resolveEffectiveSnapshot($profile);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function latestAttemptedSnapshot(BaselineProfile $profile): ?BaselineSnapshot
|
||||||
|
{
|
||||||
|
return app(BaselineSnapshotTruthResolver::class)->resolveLatestAttemptedSnapshot($profile);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function compareAvailabilityReason(BaselineProfile $profile): ?string
|
||||||
|
{
|
||||||
|
$status = $profile->status instanceof BaselineProfileStatus
|
||||||
|
? $profile->status
|
||||||
|
: BaselineProfileStatus::tryFrom((string) $profile->status);
|
||||||
|
|
||||||
|
if ($status !== BaselineProfileStatus::Active) {
|
||||||
|
return BaselineReasonCodes::COMPARE_PROFILE_NOT_ACTIVE;
|
||||||
|
}
|
||||||
|
|
||||||
|
$resolution = app(BaselineSnapshotTruthResolver::class)->resolveCompareSnapshot($profile);
|
||||||
|
$reasonCode = $resolution['reason_code'] ?? null;
|
||||||
|
|
||||||
|
if (is_string($reasonCode) && trim($reasonCode) !== '') {
|
||||||
|
return trim($reasonCode);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! self::hasEligibleCompareTarget($profile)) {
|
||||||
|
return BaselineReasonCodes::COMPARE_NO_ELIGIBLE_TARGET;
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function compareAvailabilityEnvelope(BaselineProfile $profile): ?ReasonResolutionEnvelope
|
||||||
|
{
|
||||||
|
$reasonCode = self::compareAvailabilityReason($profile);
|
||||||
|
|
||||||
|
if (! is_string($reasonCode)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return app(ReasonPresenter::class)->forArtifactTruth($reasonCode, 'artifact_truth');
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function snapshotReference(BaselineSnapshot $snapshot): string
|
||||||
|
{
|
||||||
|
$lifecycleLabel = BadgeCatalog::spec(BadgeDomain::BaselineSnapshotLifecycle, $snapshot->lifecycleState()->value)->label;
|
||||||
|
|
||||||
|
return sprintf('Snapshot #%d (%s)', (int) $snapshot->getKey(), $lifecycleLabel);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function hasEligibleCompareTarget(BaselineProfile $profile): bool
|
||||||
|
{
|
||||||
|
$user = auth()->user();
|
||||||
|
|
||||||
|
if (! $user instanceof User) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$tenantIds = BaselineTenantAssignment::query()
|
||||||
|
->where('workspace_id', (int) $profile->workspace_id)
|
||||||
|
->where('baseline_profile_id', (int) $profile->getKey())
|
||||||
|
->pluck('tenant_id')
|
||||||
|
->all();
|
||||||
|
|
||||||
|
if ($tenantIds === []) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$resolver = app(CapabilityResolver::class);
|
||||||
|
|
||||||
|
return Tenant::query()
|
||||||
|
->where('workspace_id', (int) $profile->workspace_id)
|
||||||
|
->whereIn('id', $tenantIds)
|
||||||
|
->get(['id'])
|
||||||
|
->contains(fn (Tenant $tenant): bool => $resolver->can($user, $tenant, Capabilities::TENANT_SYNC));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -183,7 +183,7 @@ private function compareNowAction(): Action
|
|||||||
|
|
||||||
$modalDescription = $captureMode === BaselineCaptureMode::FullContent
|
$modalDescription = $captureMode === BaselineCaptureMode::FullContent
|
||||||
? 'Select the target tenant. This will refresh content evidence on demand (redacted) before comparing.'
|
? 'Select the target tenant. This will refresh content evidence on demand (redacted) before comparing.'
|
||||||
: 'Select the target tenant to compare its current inventory against the active baseline snapshot.';
|
: 'Select the target tenant to compare its current inventory against the effective current baseline snapshot.';
|
||||||
|
|
||||||
return Action::make('compareNow')
|
return Action::make('compareNow')
|
||||||
->label($label)
|
->label($label)
|
||||||
@ -198,7 +198,7 @@ private function compareNowAction(): Action
|
|||||||
->required()
|
->required()
|
||||||
->searchable(),
|
->searchable(),
|
||||||
])
|
])
|
||||||
->disabled(fn (): bool => $this->getEligibleCompareTenantOptions() === [])
|
->disabled(fn (): bool => $this->getEligibleCompareTenantOptions() === [] || ! $this->profileHasConsumableSnapshot())
|
||||||
->action(function (array $data): void {
|
->action(function (array $data): void {
|
||||||
$user = auth()->user();
|
$user = auth()->user();
|
||||||
|
|
||||||
@ -256,7 +256,11 @@ private function compareNowAction(): Action
|
|||||||
$message = match ($reasonCode) {
|
$message = match ($reasonCode) {
|
||||||
BaselineReasonCodes::COMPARE_ROLLOUT_DISABLED => 'Full-content baseline compare is currently disabled for controlled rollout.',
|
BaselineReasonCodes::COMPARE_ROLLOUT_DISABLED => 'Full-content baseline compare is currently disabled for controlled rollout.',
|
||||||
BaselineReasonCodes::COMPARE_PROFILE_NOT_ACTIVE => 'This baseline profile is not active.',
|
BaselineReasonCodes::COMPARE_PROFILE_NOT_ACTIVE => 'This baseline profile is not active.',
|
||||||
BaselineReasonCodes::COMPARE_NO_ACTIVE_SNAPSHOT => 'This baseline profile has no active snapshot.',
|
BaselineReasonCodes::COMPARE_NO_ACTIVE_SNAPSHOT,
|
||||||
|
BaselineReasonCodes::COMPARE_NO_CONSUMABLE_SNAPSHOT => 'This baseline profile has no complete snapshot available for compare yet.',
|
||||||
|
BaselineReasonCodes::COMPARE_SNAPSHOT_BUILDING => 'The latest baseline capture is still building. Compare will be available after it completes.',
|
||||||
|
BaselineReasonCodes::COMPARE_SNAPSHOT_INCOMPLETE => 'The latest baseline capture is incomplete. Capture a new baseline before comparing.',
|
||||||
|
BaselineReasonCodes::COMPARE_SNAPSHOT_SUPERSEDED => 'A newer complete snapshot is current. Compare uses the latest complete baseline only.',
|
||||||
default => 'Reason: '.str_replace('.', ' ', $reasonCode),
|
default => 'Reason: '.str_replace('.', ' ', $reasonCode),
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -395,4 +399,12 @@ private function hasManageCapability(): bool
|
|||||||
return $resolver->isMember($user, $workspace)
|
return $resolver->isMember($user, $workspace)
|
||||||
&& $resolver->can($user, $workspace, Capabilities::WORKSPACE_BASELINES_MANAGE);
|
&& $resolver->can($user, $workspace, Capabilities::WORKSPACE_BASELINES_MANAGE);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private function profileHasConsumableSnapshot(): bool
|
||||||
|
{
|
||||||
|
/** @var BaselineProfile $profile */
|
||||||
|
$profile = $this->getRecord();
|
||||||
|
|
||||||
|
return $profile->resolveCurrentConsumableSnapshot() !== null;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -9,7 +9,12 @@
|
|||||||
use App\Models\BaselineSnapshot;
|
use App\Models\BaselineSnapshot;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
use App\Models\Workspace;
|
use App\Models\Workspace;
|
||||||
|
use App\Services\Baselines\BaselineSnapshotTruthResolver;
|
||||||
|
use App\Services\Baselines\SnapshotRendering\FidelityState;
|
||||||
use App\Support\Auth\Capabilities;
|
use App\Support\Auth\Capabilities;
|
||||||
|
use App\Support\Badges\BadgeCatalog;
|
||||||
|
use App\Support\Badges\BadgeDomain;
|
||||||
|
use App\Support\Baselines\BaselineSnapshotLifecycleState;
|
||||||
use App\Support\Filament\FilterPresets;
|
use App\Support\Filament\FilterPresets;
|
||||||
use App\Support\Navigation\CrossResourceNavigationMatrix;
|
use App\Support\Navigation\CrossResourceNavigationMatrix;
|
||||||
use App\Support\Navigation\RelatedContextEntry;
|
use App\Support\Navigation\RelatedContextEntry;
|
||||||
@ -18,6 +23,8 @@
|
|||||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceInspectAffordance;
|
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceInspectAffordance;
|
||||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceProfile;
|
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceProfile;
|
||||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceSlot;
|
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceSlot;
|
||||||
|
use App\Support\Ui\GovernanceArtifactTruth\ArtifactTruthEnvelope;
|
||||||
|
use App\Support\Ui\GovernanceArtifactTruth\ArtifactTruthPresenter;
|
||||||
use App\Support\Workspaces\WorkspaceContext;
|
use App\Support\Workspaces\WorkspaceContext;
|
||||||
use BackedEnum;
|
use BackedEnum;
|
||||||
use Filament\Actions\Action;
|
use Filament\Actions\Action;
|
||||||
@ -165,15 +172,39 @@ public static function table(Table $table): Table
|
|||||||
->label('Captured')
|
->label('Captured')
|
||||||
->since()
|
->since()
|
||||||
->sortable(),
|
->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)->operatorExplanation?->headline ?? self::truthEnvelope($record)->primaryExplanation)
|
||||||
|
->wrap(),
|
||||||
|
TextColumn::make('lifecycle_state')
|
||||||
|
->label('Lifecycle')
|
||||||
|
->badge()
|
||||||
|
->getStateUsing(static fn (BaselineSnapshot $record): string => self::lifecycleSpec($record)->label)
|
||||||
|
->color(static fn (BaselineSnapshot $record): string => self::lifecycleSpec($record)->color)
|
||||||
|
->icon(static fn (BaselineSnapshot $record): ?string => self::lifecycleSpec($record)->icon)
|
||||||
|
->iconColor(static fn (BaselineSnapshot $record): ?string => self::lifecycleSpec($record)->iconColor)
|
||||||
|
->sortable(),
|
||||||
|
TextColumn::make('current_truth')
|
||||||
|
->label('Current truth')
|
||||||
|
->badge()
|
||||||
|
->getStateUsing(static fn (BaselineSnapshot $record): string => self::currentTruthLabel($record))
|
||||||
|
->color(static fn (BaselineSnapshot $record): string => self::currentTruthColor($record))
|
||||||
|
->icon(static fn (BaselineSnapshot $record): ?string => self::currentTruthIcon($record))
|
||||||
|
->description(static fn (BaselineSnapshot $record): ?string => self::currentTruthDescription($record))
|
||||||
|
->wrap(),
|
||||||
TextColumn::make('fidelity_summary')
|
TextColumn::make('fidelity_summary')
|
||||||
->label('Fidelity')
|
->label('Fidelity')
|
||||||
->getStateUsing(static fn (BaselineSnapshot $record): string => self::fidelitySummary($record))
|
->getStateUsing(static fn (BaselineSnapshot $record): string => self::fidelitySummary($record))
|
||||||
->wrap(),
|
->wrap(),
|
||||||
TextColumn::make('snapshot_state')
|
TextColumn::make('artifact_next_step')
|
||||||
->label('State')
|
->label('Next step')
|
||||||
->badge()
|
->getStateUsing(static fn (BaselineSnapshot $record): string => self::truthEnvelope($record)->operatorExplanation?->nextActionText ?? self::truthEnvelope($record)->nextStepText())
|
||||||
->getStateUsing(static fn (BaselineSnapshot $record): string => self::stateLabel($record))
|
->wrap(),
|
||||||
->color(static fn (BaselineSnapshot $record): string => self::hasGaps($record) ? 'warning' : 'success'),
|
|
||||||
])
|
])
|
||||||
->recordUrl(static fn (BaselineSnapshot $record): ?string => static::canView($record)
|
->recordUrl(static fn (BaselineSnapshot $record): ?string => static::canView($record)
|
||||||
? static::getUrl('view', ['record' => $record])
|
? static::getUrl('view', ['record' => $record])
|
||||||
@ -183,10 +214,10 @@ public static function table(Table $table): Table
|
|||||||
->label('Baseline')
|
->label('Baseline')
|
||||||
->options(static::baselineProfileOptions())
|
->options(static::baselineProfileOptions())
|
||||||
->searchable(),
|
->searchable(),
|
||||||
SelectFilter::make('snapshot_state')
|
SelectFilter::make('lifecycle_state')
|
||||||
->label('State')
|
->label('Lifecycle')
|
||||||
->options(static::snapshotStateOptions())
|
->options(static::lifecycleOptions())
|
||||||
->query(fn (Builder $query, array $data): Builder => static::applySnapshotStateFilter($query, $data['value'] ?? null)),
|
->query(fn (Builder $query, array $data): Builder => static::applyLifecycleFilter($query, $data['value'] ?? null)),
|
||||||
FilterPresets::dateRange('captured_at', 'Captured', 'captured_at'),
|
FilterPresets::dateRange('captured_at', 'Captured', 'captured_at'),
|
||||||
])
|
])
|
||||||
->actions([
|
->actions([
|
||||||
@ -247,12 +278,9 @@ private static function baselineProfileOptions(): array
|
|||||||
/**
|
/**
|
||||||
* @return array<string, string>
|
* @return array<string, string>
|
||||||
*/
|
*/
|
||||||
private static function snapshotStateOptions(): array
|
private static function lifecycleOptions(): array
|
||||||
{
|
{
|
||||||
return [
|
return BadgeCatalog::options(BadgeDomain::BaselineSnapshotLifecycle, BaselineSnapshotLifecycleState::values());
|
||||||
'complete' => 'Complete',
|
|
||||||
'with_gaps' => 'Captured with gaps',
|
|
||||||
];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public static function resolveWorkspace(): ?Workspace
|
public static function resolveWorkspace(): ?Workspace
|
||||||
@ -290,7 +318,13 @@ private static function fidelitySummary(BaselineSnapshot $snapshot): string
|
|||||||
{
|
{
|
||||||
$counts = self::fidelityCounts($snapshot);
|
$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
|
private static function gapsCount(BaselineSnapshot $snapshot): int
|
||||||
@ -298,6 +332,17 @@ private static function gapsCount(BaselineSnapshot $snapshot): int
|
|||||||
$summary = self::summary($snapshot);
|
$summary = self::summary($snapshot);
|
||||||
$gaps = $summary['gaps'] ?? null;
|
$gaps = $summary['gaps'] ?? null;
|
||||||
$gaps = is_array($gaps) ? $gaps : [];
|
$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;
|
$count = $gaps['count'] ?? 0;
|
||||||
|
|
||||||
@ -309,32 +354,86 @@ private static function hasGaps(BaselineSnapshot $snapshot): bool
|
|||||||
return self::gapsCount($snapshot) > 0;
|
return self::gapsCount($snapshot) > 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
private static function stateLabel(BaselineSnapshot $snapshot): string
|
private static function lifecycleSpec(BaselineSnapshot $snapshot): \App\Support\Badges\BadgeSpec
|
||||||
{
|
{
|
||||||
return self::hasGaps($snapshot) ? 'Captured with gaps' : 'Complete';
|
return BadgeCatalog::spec(BadgeDomain::BaselineSnapshotLifecycle, $snapshot->lifecycleState()->value);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static function applySnapshotStateFilter(Builder $query, mixed $value): Builder
|
private static function applyLifecycleFilter(Builder $query, mixed $value): Builder
|
||||||
{
|
{
|
||||||
if (! is_string($value) || trim($value) === '') {
|
if (! is_string($value) || trim($value) === '') {
|
||||||
return $query;
|
return $query;
|
||||||
}
|
}
|
||||||
|
|
||||||
$gapCountExpression = self::gapCountExpression($query);
|
return $query->where('lifecycle_state', trim($value));
|
||||||
|
|
||||||
return match ($value) {
|
|
||||||
'complete' => $query->whereRaw("{$gapCountExpression} = 0"),
|
|
||||||
'with_gaps' => $query->whereRaw("{$gapCountExpression} > 0"),
|
|
||||||
default => $query,
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private static function gapCountExpression(Builder $query): string
|
private static function gapCountExpression(Builder $query): string
|
||||||
{
|
{
|
||||||
return match ($query->getConnection()->getDriverName()) {
|
return match ($query->getConnection()->getDriverName()) {
|
||||||
'sqlite' => "COALESCE(CAST(json_extract(summary_jsonb, '$.gaps.count') AS INTEGER), 0)",
|
'sqlite' => "MAX(0, COALESCE(CAST(json_extract(summary_jsonb, '$.gaps.count') AS INTEGER), 0) - COALESCE(CAST(json_extract(summary_jsonb, '$.gaps.by_reason.meta_fallback') AS INTEGER), 0))",
|
||||||
'pgsql' => "COALESCE((summary_jsonb #>> '{gaps,count}')::int, 0)",
|
'pgsql' => "GREATEST(0, COALESCE((summary_jsonb #>> '{gaps,count}')::int, 0) - COALESCE((summary_jsonb #>> '{gaps,by_reason,meta_fallback}')::int, 0))",
|
||||||
default => "COALESCE(CAST(JSON_EXTRACT(summary_jsonb, '$.gaps.count') AS UNSIGNED), 0)",
|
default => "GREATEST(0, COALESCE(CAST(JSON_EXTRACT(summary_jsonb, '$.gaps.count') AS SIGNED), 0) - COALESCE(CAST(JSON_EXTRACT(summary_jsonb, '$.gaps.by_reason.meta_fallback') AS SIGNED), 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);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function currentTruthLabel(BaselineSnapshot $snapshot): string
|
||||||
|
{
|
||||||
|
return match (self::currentTruthState($snapshot)) {
|
||||||
|
'current' => 'Current baseline',
|
||||||
|
'historical' => 'Historical trace',
|
||||||
|
default => 'Not compare input',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function currentTruthDescription(BaselineSnapshot $snapshot): ?string
|
||||||
|
{
|
||||||
|
return match (self::currentTruthState($snapshot)) {
|
||||||
|
'current' => 'Compare resolves to this snapshot as the current baseline truth.',
|
||||||
|
'historical' => 'A newer complete snapshot is now the current baseline truth for this profile.',
|
||||||
|
default => self::truthEnvelope($snapshot)->primaryExplanation,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function currentTruthColor(BaselineSnapshot $snapshot): string
|
||||||
|
{
|
||||||
|
return match (self::currentTruthState($snapshot)) {
|
||||||
|
'current' => 'success',
|
||||||
|
'historical' => 'gray',
|
||||||
|
default => 'warning',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function currentTruthIcon(BaselineSnapshot $snapshot): ?string
|
||||||
|
{
|
||||||
|
return match (self::currentTruthState($snapshot)) {
|
||||||
|
'current' => 'heroicon-m-check-badge',
|
||||||
|
'historical' => 'heroicon-m-clock',
|
||||||
|
default => 'heroicon-m-exclamation-triangle',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function currentTruthState(BaselineSnapshot $snapshot): string
|
||||||
|
{
|
||||||
|
if (! $snapshot->isConsumable()) {
|
||||||
|
return 'unusable';
|
||||||
|
}
|
||||||
|
|
||||||
|
return app(BaselineSnapshotTruthResolver::class)->isHistoricallySuperseded($snapshot)
|
||||||
|
? 'historical'
|
||||||
|
: 'current';
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -35,6 +35,8 @@ public function mount(int|string $record): void
|
|||||||
$snapshot = $this->getRecord();
|
$snapshot = $this->getRecord();
|
||||||
|
|
||||||
if ($snapshot instanceof BaselineSnapshot) {
|
if ($snapshot instanceof BaselineSnapshot) {
|
||||||
|
$snapshot->loadMissing(['baselineProfile', 'items']);
|
||||||
|
|
||||||
$relatedContext = app(RelatedNavigationResolver::class)
|
$relatedContext = app(RelatedNavigationResolver::class)
|
||||||
->detailEntries(CrossResourceNavigationMatrix::SOURCE_BASELINE_SNAPSHOT, $snapshot);
|
->detailEntries(CrossResourceNavigationMatrix::SOURCE_BASELINE_SNAPSHOT, $snapshot);
|
||||||
|
|
||||||
|
|||||||
@ -13,8 +13,10 @@
|
|||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
use App\Services\Evidence\EvidenceSnapshotService;
|
use App\Services\Evidence\EvidenceSnapshotService;
|
||||||
use App\Support\Auth\Capabilities;
|
use App\Support\Auth\Capabilities;
|
||||||
|
use App\Support\Badges\BadgeCatalog;
|
||||||
use App\Support\Badges\BadgeDomain;
|
use App\Support\Badges\BadgeDomain;
|
||||||
use App\Support\Badges\BadgeRenderer;
|
use App\Support\Badges\BadgeRenderer;
|
||||||
|
use App\Support\Evidence\EvidenceCompletenessState;
|
||||||
use App\Support\Evidence\EvidenceSnapshotStatus;
|
use App\Support\Evidence\EvidenceSnapshotStatus;
|
||||||
use App\Support\OperationCatalog;
|
use App\Support\OperationCatalog;
|
||||||
use App\Support\OperationRunLinks;
|
use App\Support\OperationRunLinks;
|
||||||
@ -26,6 +28,8 @@
|
|||||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceInspectAffordance;
|
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceInspectAffordance;
|
||||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceProfile;
|
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceProfile;
|
||||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceSlot;
|
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceSlot;
|
||||||
|
use App\Support\Ui\GovernanceArtifactTruth\ArtifactTruthEnvelope;
|
||||||
|
use App\Support\Ui\GovernanceArtifactTruth\ArtifactTruthPresenter;
|
||||||
use BackedEnum;
|
use BackedEnum;
|
||||||
use Filament\Actions;
|
use Filament\Actions;
|
||||||
use Filament\Facades\Filament;
|
use Filament\Facades\Filament;
|
||||||
@ -131,6 +135,15 @@ public static function form(Schema $schema): Schema
|
|||||||
public static function infolist(Schema $schema): Schema
|
public static function infolist(Schema $schema): Schema
|
||||||
{
|
{
|
||||||
return $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')
|
Section::make('Snapshot')
|
||||||
->schema([
|
->schema([
|
||||||
TextEntry::make('status')
|
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.finding_count')->label('Findings')->placeholder('—'),
|
||||||
TextEntry::make('summary.report_count')->label('Reports')->placeholder('—'),
|
TextEntry::make('summary.report_count')->label('Reports')->placeholder('—'),
|
||||||
TextEntry::make('summary.operation_count')->label('Operations')->placeholder('—'),
|
TextEntry::make('summary.operation_count')->label('Operations')->placeholder('—'),
|
||||||
TextEntry::make('summary.missing_dimensions')->label('Missing dimensions')->placeholder('—'),
|
TextEntry::make('summary.missing_dimensions')->label(static::evidenceCompletenessCountLabel(EvidenceCompletenessState::Missing->value))->placeholder('—'),
|
||||||
TextEntry::make('summary.stale_dimensions')->label('Stale dimensions')->placeholder('—'),
|
TextEntry::make('summary.stale_dimensions')->label(static::evidenceCompletenessCountLabel(EvidenceCompletenessState::Stale->value))->placeholder('—'),
|
||||||
])
|
])
|
||||||
->columns(2),
|
->columns(2),
|
||||||
Section::make('Evidence dimensions')
|
Section::make('Evidence dimensions')
|
||||||
@ -212,6 +225,15 @@ public static function table(Table $table): Table
|
|||||||
->icon(BadgeRenderer::icon(BadgeDomain::EvidenceSnapshotStatus))
|
->icon(BadgeRenderer::icon(BadgeDomain::EvidenceSnapshotStatus))
|
||||||
->iconColor(BadgeRenderer::iconColor(BadgeDomain::EvidenceSnapshotStatus))
|
->iconColor(BadgeRenderer::iconColor(BadgeDomain::EvidenceSnapshotStatus))
|
||||||
->sortable(),
|
->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')
|
Tables\Columns\TextColumn::make('completeness_state')
|
||||||
->label('Completeness')
|
->label('Completeness')
|
||||||
->badge()
|
->badge()
|
||||||
@ -222,25 +244,17 @@ public static function table(Table $table): Table
|
|||||||
->sortable(),
|
->sortable(),
|
||||||
Tables\Columns\TextColumn::make('generated_at')->dateTime()->sortable()->placeholder('—'),
|
Tables\Columns\TextColumn::make('generated_at')->dateTime()->sortable()->placeholder('—'),
|
||||||
Tables\Columns\TextColumn::make('summary.finding_count')->label('Findings'),
|
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([
|
->filters([
|
||||||
Tables\Filters\SelectFilter::make('status')
|
Tables\Filters\SelectFilter::make('status')
|
||||||
->options([
|
->options(BadgeCatalog::options(BadgeDomain::EvidenceSnapshotStatus, EvidenceSnapshotStatus::values())),
|
||||||
'queued' => 'Queued',
|
|
||||||
'generating' => 'Generating',
|
|
||||||
'active' => 'Active',
|
|
||||||
'superseded' => 'Superseded',
|
|
||||||
'expired' => 'Expired',
|
|
||||||
'failed' => 'Failed',
|
|
||||||
]),
|
|
||||||
Tables\Filters\SelectFilter::make('completeness_state')
|
Tables\Filters\SelectFilter::make('completeness_state')
|
||||||
->options([
|
->options(BadgeCatalog::options(BadgeDomain::EvidenceCompleteness, EvidenceCompletenessState::values())),
|
||||||
'complete' => 'Complete',
|
|
||||||
'partial' => 'Partial',
|
|
||||||
'missing' => 'Missing',
|
|
||||||
'stale' => 'Stale',
|
|
||||||
]),
|
|
||||||
])
|
])
|
||||||
->actions([
|
->actions([
|
||||||
Actions\Action::make('view_snapshot')
|
Actions\Action::make('view_snapshot')
|
||||||
@ -418,13 +432,16 @@ private static function operationsSummaryPresentation(array $payload): array
|
|||||||
$failedCount = (int) ($payload['failed_count'] ?? 0);
|
$failedCount = (int) ($payload['failed_count'] ?? 0);
|
||||||
$partialCount = (int) ($payload['partial_count'] ?? 0);
|
$partialCount = (int) ($payload['partial_count'] ?? 0);
|
||||||
$entries = is_array($payload['entries'] ?? null) ? $payload['entries'] : [];
|
$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 [
|
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' => [
|
'highlights' => [
|
||||||
['label' => 'Operations', 'value' => (string) $operationCount],
|
['label' => 'Operations', 'value' => (string) $operationCount],
|
||||||
['label' => 'Failed operations', 'value' => (string) $failedCount],
|
['label' => 'Execution failures', 'value' => (string) $failedCount],
|
||||||
['label' => 'Partial operations', 'value' => (string) $partialCount],
|
['label' => 'Needs follow-up', 'value' => (string) $partialCount],
|
||||||
],
|
],
|
||||||
'items' => collect($entries)
|
'items' => collect($entries)
|
||||||
->map(fn (mixed $entry): ?string => is_array($entry) ? static::operationEntryLabel($entry) : null)
|
->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;
|
$outcome = is_string($entry['outcome'] ?? null) ? trim((string) $entry['outcome']) : null;
|
||||||
|
|
||||||
return match ($status) {
|
return match ($status) {
|
||||||
OperationRunStatus::Queued->value => 'Queued',
|
OperationRunStatus::Queued->value => static::badgeLabel(BadgeDomain::OperationRunStatus, $status),
|
||||||
OperationRunStatus::Running->value => 'Running',
|
OperationRunStatus::Running->value => static::badgeLabel(BadgeDomain::OperationRunStatus, $status),
|
||||||
OperationRunStatus::Completed->value => match ($outcome) {
|
OperationRunStatus::Completed->value => match ($outcome) {
|
||||||
OperationRunOutcome::Succeeded->value => 'Completed',
|
OperationRunOutcome::Pending->value => static::badgeLabel(BadgeDomain::OperationRunStatus, $status),
|
||||||
OperationRunOutcome::PartiallySucceeded->value => OperationRunOutcome::uiLabels(true)[OperationRunOutcome::PartiallySucceeded->value],
|
OperationRunOutcome::Succeeded->value,
|
||||||
OperationRunOutcome::Blocked->value => OperationRunOutcome::uiLabels(true)[OperationRunOutcome::Blocked->value],
|
OperationRunOutcome::PartiallySucceeded->value,
|
||||||
OperationRunOutcome::Failed->value => OperationRunOutcome::uiLabels(true)[OperationRunOutcome::Failed->value],
|
OperationRunOutcome::Blocked->value,
|
||||||
OperationRunOutcome::Cancelled->value => OperationRunOutcome::uiLabels(true)[OperationRunOutcome::Cancelled->value],
|
OperationRunOutcome::Failed->value,
|
||||||
default => 'Completed',
|
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
|
private static function stringifySummaryValue(mixed $value): string
|
||||||
{
|
{
|
||||||
return match (true) {
|
return match (true) {
|
||||||
|
|||||||
@ -8,8 +8,10 @@
|
|||||||
use App\Models\Tenant;
|
use App\Models\Tenant;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
use App\Models\VerificationCheckAcknowledgement;
|
use App\Models\VerificationCheckAcknowledgement;
|
||||||
|
use App\Support\Badges\BadgeCatalog;
|
||||||
use App\Support\Badges\BadgeDomain;
|
use App\Support\Badges\BadgeDomain;
|
||||||
use App\Support\Badges\BadgeRenderer;
|
use App\Support\Badges\BadgeRenderer;
|
||||||
|
use App\Support\Baselines\BaselineCompareEvidenceGapDetails;
|
||||||
use App\Support\Baselines\BaselineCompareReasonCode;
|
use App\Support\Baselines\BaselineCompareReasonCode;
|
||||||
use App\Support\Filament\FilterOptionCatalog;
|
use App\Support\Filament\FilterOptionCatalog;
|
||||||
use App\Support\Filament\FilterPresets;
|
use App\Support\Filament\FilterPresets;
|
||||||
@ -21,13 +23,19 @@
|
|||||||
use App\Support\OperationRunLinks;
|
use App\Support\OperationRunLinks;
|
||||||
use App\Support\OperationRunOutcome;
|
use App\Support\OperationRunOutcome;
|
||||||
use App\Support\OperationRunStatus;
|
use App\Support\OperationRunStatus;
|
||||||
|
use App\Support\OpsUx\OperationUxPresenter;
|
||||||
use App\Support\OpsUx\RunDurationInsights;
|
use App\Support\OpsUx\RunDurationInsights;
|
||||||
|
use App\Support\OpsUx\SummaryCountsNormalizer;
|
||||||
|
use App\Support\ReasonTranslation\ReasonPresenter;
|
||||||
use App\Support\Tenants\ReferencedTenantLifecyclePresentation;
|
use App\Support\Tenants\ReferencedTenantLifecyclePresentation;
|
||||||
use App\Support\Ui\ActionSurface\ActionSurfaceDeclaration;
|
use App\Support\Ui\ActionSurface\ActionSurfaceDeclaration;
|
||||||
use App\Support\Ui\ActionSurface\ActionSurfaceDefaults;
|
use App\Support\Ui\ActionSurface\ActionSurfaceDefaults;
|
||||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceInspectAffordance;
|
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceInspectAffordance;
|
||||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceProfile;
|
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceProfile;
|
||||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceSlot;
|
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceSlot;
|
||||||
|
use App\Support\Ui\GovernanceArtifactTruth\ArtifactTruthEnvelope;
|
||||||
|
use App\Support\Ui\GovernanceArtifactTruth\ArtifactTruthPresenter;
|
||||||
|
use App\Support\Ui\OperatorExplanation\OperatorExplanationPattern;
|
||||||
use App\Support\Workspaces\WorkspaceContext;
|
use App\Support\Workspaces\WorkspaceContext;
|
||||||
use BackedEnum;
|
use BackedEnum;
|
||||||
use Filament\Actions;
|
use Filament\Actions;
|
||||||
@ -123,10 +131,11 @@ public static function table(Table $table): Table
|
|||||||
->columns([
|
->columns([
|
||||||
Tables\Columns\TextColumn::make('status')
|
Tables\Columns\TextColumn::make('status')
|
||||||
->badge()
|
->badge()
|
||||||
->formatStateUsing(BadgeRenderer::label(BadgeDomain::OperationRunStatus))
|
->formatStateUsing(fn (mixed $state, OperationRun $record): string => BadgeRenderer::spec(BadgeDomain::OperationRunStatus, static::statusBadgeState($record))->label)
|
||||||
->color(BadgeRenderer::color(BadgeDomain::OperationRunStatus))
|
->color(fn (mixed $state, OperationRun $record): string => BadgeRenderer::spec(BadgeDomain::OperationRunStatus, static::statusBadgeState($record))->color)
|
||||||
->icon(BadgeRenderer::icon(BadgeDomain::OperationRunStatus))
|
->icon(fn (mixed $state, OperationRun $record): ?string => BadgeRenderer::spec(BadgeDomain::OperationRunStatus, static::statusBadgeState($record))->icon)
|
||||||
->iconColor(BadgeRenderer::iconColor(BadgeDomain::OperationRunStatus)),
|
->iconColor(fn (mixed $state, OperationRun $record): ?string => BadgeRenderer::spec(BadgeDomain::OperationRunStatus, static::statusBadgeState($record))->iconColor)
|
||||||
|
->description(fn (OperationRun $record): ?string => OperationUxPresenter::lifecycleAttentionSummary($record)),
|
||||||
Tables\Columns\TextColumn::make('type')
|
Tables\Columns\TextColumn::make('type')
|
||||||
->label('Operation')
|
->label('Operation')
|
||||||
->formatStateUsing(fn (?string $state): string => OperationCatalog::label((string) $state))
|
->formatStateUsing(fn (?string $state): string => OperationCatalog::label((string) $state))
|
||||||
@ -149,10 +158,11 @@ public static function table(Table $table): Table
|
|||||||
}),
|
}),
|
||||||
Tables\Columns\TextColumn::make('outcome')
|
Tables\Columns\TextColumn::make('outcome')
|
||||||
->badge()
|
->badge()
|
||||||
->formatStateUsing(BadgeRenderer::label(BadgeDomain::OperationRunOutcome))
|
->formatStateUsing(fn (mixed $state, OperationRun $record): string => BadgeRenderer::spec(BadgeDomain::OperationRunOutcome, static::outcomeBadgeState($record))->label)
|
||||||
->color(BadgeRenderer::color(BadgeDomain::OperationRunOutcome))
|
->color(fn (mixed $state, OperationRun $record): string => BadgeRenderer::spec(BadgeDomain::OperationRunOutcome, static::outcomeBadgeState($record))->color)
|
||||||
->icon(BadgeRenderer::icon(BadgeDomain::OperationRunOutcome))
|
->icon(fn (mixed $state, OperationRun $record): ?string => BadgeRenderer::spec(BadgeDomain::OperationRunOutcome, static::outcomeBadgeState($record))->icon)
|
||||||
->iconColor(BadgeRenderer::iconColor(BadgeDomain::OperationRunOutcome)),
|
->iconColor(fn (mixed $state, OperationRun $record): ?string => BadgeRenderer::spec(BadgeDomain::OperationRunOutcome, static::outcomeBadgeState($record))->iconColor)
|
||||||
|
->description(fn (OperationRun $record): ?string => OperationUxPresenter::surfaceGuidance($record)),
|
||||||
])
|
])
|
||||||
->filters([
|
->filters([
|
||||||
Tables\Filters\SelectFilter::make('tenant_id')
|
Tables\Filters\SelectFilter::make('tenant_id')
|
||||||
@ -205,13 +215,9 @@ public static function table(Table $table): Table
|
|||||||
return FilterOptionCatalog::operationTypes(array_keys($types));
|
return FilterOptionCatalog::operationTypes(array_keys($types));
|
||||||
}),
|
}),
|
||||||
Tables\Filters\SelectFilter::make('status')
|
Tables\Filters\SelectFilter::make('status')
|
||||||
->options([
|
->options(BadgeCatalog::options(BadgeDomain::OperationRunStatus, OperationRunStatus::values())),
|
||||||
OperationRunStatus::Queued->value => 'Queued',
|
|
||||||
OperationRunStatus::Running->value => 'Running',
|
|
||||||
OperationRunStatus::Completed->value => 'Completed',
|
|
||||||
]),
|
|
||||||
Tables\Filters\SelectFilter::make('outcome')
|
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')
|
Tables\Filters\SelectFilter::make('initiator_name')
|
||||||
->label('Initiator')
|
->label('Initiator')
|
||||||
->options(function (): array {
|
->options(function (): array {
|
||||||
@ -251,13 +257,25 @@ private static function enterpriseDetailPage(OperationRun $record): \App\Support
|
|||||||
{
|
{
|
||||||
$factory = new \App\Support\Ui\EnterpriseDetail\EnterpriseDetailSectionFactory;
|
$factory = new \App\Support\Ui\EnterpriseDetail\EnterpriseDetailSectionFactory;
|
||||||
|
|
||||||
$statusSpec = BadgeRenderer::spec(BadgeDomain::OperationRunStatus, $record->status);
|
$statusSpec = BadgeRenderer::spec(BadgeDomain::OperationRunStatus, static::statusBadgeState($record));
|
||||||
$outcomeSpec = BadgeRenderer::spec(BadgeDomain::OperationRunOutcome, $record->outcome);
|
$outcomeSpec = BadgeRenderer::spec(BadgeDomain::OperationRunOutcome, static::outcomeBadgeState($record));
|
||||||
$targetScope = static::targetScopeDisplay($record);
|
$targetScope = static::targetScopeDisplay($record) ?? 'No target scope details were recorded for this run.';
|
||||||
$summaryLine = \App\Support\OpsUx\SummaryCountsNormalizer::renderSummaryLine(is_array($record->summary_counts) ? $record->summary_counts : []);
|
$summaryLine = \App\Support\OpsUx\SummaryCountsNormalizer::renderSummaryLine(is_array($record->summary_counts) ? $record->summary_counts : []);
|
||||||
$referencedTenantLifecycle = $record->tenant instanceof Tenant
|
$referencedTenantLifecycle = $record->tenant instanceof Tenant
|
||||||
? ReferencedTenantLifecyclePresentation::forOperationRun($record->tenant)
|
? ReferencedTenantLifecyclePresentation::forOperationRun($record->tenant)
|
||||||
: null;
|
: null;
|
||||||
|
$artifactTruth = $record->supportsOperatorExplanation()
|
||||||
|
? app(ArtifactTruthPresenter::class)->forOperationRun($record)
|
||||||
|
: null;
|
||||||
|
$operatorExplanation = $artifactTruth?->operatorExplanation;
|
||||||
|
$primaryNextStep = static::resolvePrimaryNextStep($record, $artifactTruth, $operatorExplanation);
|
||||||
|
$supportingGroups = static::supportingGroups(
|
||||||
|
record: $record,
|
||||||
|
factory: $factory,
|
||||||
|
referencedTenantLifecycle: $referencedTenantLifecycle,
|
||||||
|
operatorExplanation: $operatorExplanation,
|
||||||
|
primaryNextStep: $primaryNextStep,
|
||||||
|
);
|
||||||
|
|
||||||
$builder = \App\Support\Ui\EnterpriseDetail\EnterpriseDetailBuilder::make('operation_run', 'workspace-context')
|
$builder = \App\Support\Ui\EnterpriseDetail\EnterpriseDetailBuilder::make('operation_run', 'workspace-context')
|
||||||
->header(new \App\Support\Ui\EnterpriseDetail\SummaryHeaderData(
|
->header(new \App\Support\Ui\EnterpriseDetail\SummaryHeaderData(
|
||||||
@ -268,119 +286,121 @@ private static function enterpriseDetailPage(OperationRun $record): \App\Support
|
|||||||
$factory->statusBadge($outcomeSpec->label, $outcomeSpec->color, $outcomeSpec->icon, $outcomeSpec->iconColor),
|
$factory->statusBadge($outcomeSpec->label, $outcomeSpec->color, $outcomeSpec->icon, $outcomeSpec->iconColor),
|
||||||
],
|
],
|
||||||
keyFacts: [
|
keyFacts: [
|
||||||
$factory->keyFact('Target', $targetScope ?? 'No target scope details were recorded for this run.'),
|
$factory->keyFact('Target', $targetScope),
|
||||||
$factory->keyFact('Initiator', $record->initiator_name),
|
|
||||||
$factory->keyFact('Elapsed', RunDurationInsights::elapsedHuman($record)),
|
$factory->keyFact('Elapsed', RunDurationInsights::elapsedHuman($record)),
|
||||||
$factory->keyFact('Expected', RunDurationInsights::expectedHuman($record)),
|
|
||||||
],
|
],
|
||||||
descriptionHint: 'Run identity, outcome, scope, and related next steps stay ahead of stored payloads and diagnostic fragments.',
|
descriptionHint: 'Decision guidance and high-signal context stay ahead of diagnostic payloads and raw JSON.',
|
||||||
))
|
))
|
||||||
->addSection(
|
->decisionZone($factory->decisionZone(
|
||||||
$factory->factsSection(
|
facts: array_values(array_filter([
|
||||||
id: 'run_summary',
|
$factory->keyFact(
|
||||||
kind: 'core_details',
|
'Execution state',
|
||||||
title: 'Run summary',
|
$statusSpec->label,
|
||||||
items: [
|
badge: $factory->statusBadge($statusSpec->label, $statusSpec->color, $statusSpec->icon, $statusSpec->iconColor),
|
||||||
$factory->keyFact('Operation', OperationCatalog::label((string) $record->type)),
|
),
|
||||||
$factory->keyFact('Initiator', $record->initiator_name),
|
$factory->keyFact(
|
||||||
$factory->keyFact('Target scope', $targetScope ?? 'No target scope details were recorded for this run.'),
|
'Outcome',
|
||||||
$factory->keyFact('Expected duration', RunDurationInsights::expectedHuman($record)),
|
$outcomeSpec->label,
|
||||||
],
|
badge: $factory->statusBadge($outcomeSpec->label, $outcomeSpec->color, $outcomeSpec->icon, $outcomeSpec->iconColor),
|
||||||
|
),
|
||||||
|
static::artifactTruthFact($factory, $artifactTruth),
|
||||||
|
$operatorExplanation instanceof OperatorExplanationPattern
|
||||||
|
? $factory->keyFact(
|
||||||
|
'Result meaning',
|
||||||
|
$operatorExplanation->evaluationResultLabel(),
|
||||||
|
$operatorExplanation->headline,
|
||||||
|
)
|
||||||
|
: null,
|
||||||
|
$operatorExplanation instanceof OperatorExplanationPattern
|
||||||
|
? $factory->keyFact(
|
||||||
|
'Result trust',
|
||||||
|
$operatorExplanation->trustworthinessLabel(),
|
||||||
|
static::detailHintUnlessDuplicate(
|
||||||
|
$operatorExplanation->reliabilityStatement,
|
||||||
|
$artifactTruth?->primaryExplanation,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
: null,
|
||||||
|
])),
|
||||||
|
primaryNextStep: $factory->primaryNextStep(
|
||||||
|
$primaryNextStep['text'],
|
||||||
|
$primaryNextStep['source'],
|
||||||
|
$primaryNextStep['secondaryGuidance'],
|
||||||
),
|
),
|
||||||
$factory->viewSection(
|
description: 'Start here to see how the run ended, whether the result is trustworthy enough to use, and the one primary next step.',
|
||||||
id: 'related_context',
|
compactCounts: $summaryLine !== null
|
||||||
kind: 'related_context',
|
? $factory->countPresentation(summaryLine: $summaryLine)
|
||||||
title: 'Related context',
|
: null,
|
||||||
view: 'filament.infolists.entries.related-context',
|
attentionNote: static::decisionAttentionNote($record),
|
||||||
viewData: ['entries' => app(RelatedNavigationResolver::class)
|
));
|
||||||
->detailEntries(CrossResourceNavigationMatrix::SOURCE_OPERATION_RUN, $record)],
|
|
||||||
emptyState: $factory->emptyState('No related context is available for this record.'),
|
if ($supportingGroups !== []) {
|
||||||
),
|
$builder->addSupportingGroup(...$supportingGroups);
|
||||||
)
|
}
|
||||||
->addSupportingCard(
|
|
||||||
$factory->supportingFactsCard(
|
$builder->addSection(
|
||||||
kind: 'status',
|
$factory->viewSection(
|
||||||
title: 'Current state',
|
id: 'related_context',
|
||||||
items: array_values(array_filter([
|
kind: 'related_context',
|
||||||
$factory->keyFact('Status', $statusSpec->label, badge: $factory->statusBadge($statusSpec->label, $statusSpec->color, $statusSpec->icon, $statusSpec->iconColor)),
|
title: 'Related context',
|
||||||
$factory->keyFact('Outcome', $outcomeSpec->label, badge: $factory->statusBadge($outcomeSpec->label, $outcomeSpec->color, $outcomeSpec->icon, $outcomeSpec->iconColor)),
|
view: 'filament.infolists.entries.related-context',
|
||||||
$referencedTenantLifecycle !== null
|
viewData: ['entries' => app(RelatedNavigationResolver::class)
|
||||||
? $factory->keyFact(
|
->detailEntries(CrossResourceNavigationMatrix::SOURCE_OPERATION_RUN, $record)],
|
||||||
'Tenant lifecycle',
|
emptyState: $factory->emptyState('No related context is available for this record.'),
|
||||||
$referencedTenantLifecycle->presentation->label,
|
),
|
||||||
badge: $factory->statusBadge(
|
$factory->viewSection(
|
||||||
$referencedTenantLifecycle->presentation->label,
|
id: 'artifact_truth',
|
||||||
$referencedTenantLifecycle->presentation->badgeColor,
|
kind: 'supporting_detail',
|
||||||
$referencedTenantLifecycle->presentation->badgeIcon,
|
title: 'Artifact truth details',
|
||||||
$referencedTenantLifecycle->presentation->badgeIconColor,
|
view: 'filament.infolists.entries.governance-artifact-truth',
|
||||||
),
|
viewData: [
|
||||||
)
|
'artifactTruthState' => $artifactTruth?->toArray(),
|
||||||
: null,
|
'surface' => 'expanded',
|
||||||
$referencedTenantLifecycle?->selectorAvailabilityMessage() !== null
|
],
|
||||||
? $factory->keyFact('Tenant selector context', $referencedTenantLifecycle->selectorAvailabilityMessage())
|
visible: $artifactTruth !== null,
|
||||||
: null,
|
description: 'Detailed artifact-truth context explains evidence quality and caveats without repeating the top decision summary.',
|
||||||
$referencedTenantLifecycle?->contextNote !== null
|
collapsible: true,
|
||||||
? $factory->keyFact('Viewer context', $referencedTenantLifecycle->contextNote)
|
collapsed: true,
|
||||||
: null,
|
),
|
||||||
$summaryLine !== null ? $factory->keyFact('Counts', $summaryLine) : null,
|
);
|
||||||
static::blockedExecutionReasonCode($record) !== null
|
|
||||||
? $factory->keyFact('Blocked reason', static::blockedExecutionReasonCode($record))
|
|
||||||
: null,
|
|
||||||
static::blockedExecutionDetail($record) !== null
|
|
||||||
? $factory->keyFact('Blocked detail', static::blockedExecutionDetail($record))
|
|
||||||
: null,
|
|
||||||
static::blockedExecutionSource($record) !== null
|
|
||||||
? $factory->keyFact('Blocked by', static::blockedExecutionSource($record))
|
|
||||||
: null,
|
|
||||||
RunDurationInsights::stuckGuidance($record) !== null ? $factory->keyFact('Guidance', RunDurationInsights::stuckGuidance($record)) : null,
|
|
||||||
])),
|
|
||||||
),
|
|
||||||
$factory->supportingFactsCard(
|
|
||||||
kind: 'timestamps',
|
|
||||||
title: 'Timing',
|
|
||||||
items: [
|
|
||||||
$factory->keyFact('Created', static::formatDetailTimestamp($record->created_at)),
|
|
||||||
$factory->keyFact('Started', static::formatDetailTimestamp($record->started_at)),
|
|
||||||
$factory->keyFact('Completed', static::formatDetailTimestamp($record->completed_at)),
|
|
||||||
$factory->keyFact('Elapsed', RunDurationInsights::elapsedHuman($record)),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
)
|
|
||||||
->addTechnicalSection(
|
|
||||||
$factory->technicalDetail(
|
|
||||||
title: 'Context',
|
|
||||||
entries: [
|
|
||||||
$factory->keyFact('Identity hash', $record->run_identity_hash),
|
|
||||||
$factory->keyFact('Workspace scope', $record->workspace_id),
|
|
||||||
$factory->keyFact('Tenant scope', $record->tenant_id),
|
|
||||||
],
|
|
||||||
description: 'Stored run context stays available for debugging without dominating the default reading path.',
|
|
||||||
view: 'filament.infolists.entries.snapshot-json',
|
|
||||||
viewData: ['payload' => static::contextPayload($record)],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
$counts = static::summaryCountFacts($record, $factory);
|
$counts = static::summaryCountFacts($record, $factory);
|
||||||
|
|
||||||
if ($counts !== []) {
|
if ($counts !== []) {
|
||||||
$builder->addSection(
|
$builder->addTechnicalSection(
|
||||||
$factory->factsSection(
|
$factory->technicalDetail(
|
||||||
id: 'counts',
|
title: 'Count diagnostics',
|
||||||
kind: 'current_status',
|
entries: $counts,
|
||||||
title: 'Counts',
|
description: 'Normalized run counters remain available for deeper inspection without competing with the primary decision.',
|
||||||
items: $counts,
|
collapsible: true,
|
||||||
|
collapsed: true,
|
||||||
|
variant: 'diagnostic',
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (! empty($record->failure_summary)) {
|
if (! empty($record->failure_summary)) {
|
||||||
$builder->addSection(
|
$builder->addTechnicalSection(
|
||||||
$factory->viewSection(
|
$factory->technicalDetail(
|
||||||
id: 'failures',
|
|
||||||
kind: 'operational_context',
|
|
||||||
title: (string) $record->outcome === OperationRunOutcome::Blocked->value ? 'Blocked execution details' : 'Failures',
|
title: (string) $record->outcome === OperationRunOutcome::Blocked->value ? 'Blocked execution details' : 'Failures',
|
||||||
|
description: 'Detailed failure evidence stays available for investigation after the decision and supporting context.',
|
||||||
view: 'filament.infolists.entries.snapshot-json',
|
view: 'filament.infolists.entries.snapshot-json',
|
||||||
viewData: ['payload' => $record->failure_summary ?? []],
|
viewData: ['payload' => $record->failure_summary ?? []],
|
||||||
|
collapsible: true,
|
||||||
|
collapsed: false,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (static::reconciliationPayload($record) !== []) {
|
||||||
|
$builder->addTechnicalSection(
|
||||||
|
$factory->technicalDetail(
|
||||||
|
title: 'Lifecycle reconciliation',
|
||||||
|
description: 'Lifecycle reconciliation is diagnostic evidence showing when TenantPilot force-resolved the run.',
|
||||||
|
view: 'filament.infolists.entries.snapshot-json',
|
||||||
|
viewData: ['payload' => static::reconciliationPayload($record)],
|
||||||
|
collapsible: true,
|
||||||
|
collapsed: true,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@ -388,14 +408,39 @@ private static function enterpriseDetailPage(OperationRun $record): \App\Support
|
|||||||
if ((string) $record->type === 'baseline_compare') {
|
if ((string) $record->type === 'baseline_compare') {
|
||||||
$baselineCompareFacts = static::baselineCompareFacts($record, $factory);
|
$baselineCompareFacts = static::baselineCompareFacts($record, $factory);
|
||||||
$baselineCompareEvidence = static::baselineCompareEvidencePayload($record);
|
$baselineCompareEvidence = static::baselineCompareEvidencePayload($record);
|
||||||
|
$gapDetails = BaselineCompareEvidenceGapDetails::fromOperationRun($record);
|
||||||
|
$gapSummary = is_array($gapDetails['summary'] ?? null) ? $gapDetails['summary'] : [];
|
||||||
|
$gapBuckets = is_array($gapDetails['buckets'] ?? null) ? $gapDetails['buckets'] : [];
|
||||||
|
|
||||||
if ($baselineCompareFacts !== []) {
|
if ($baselineCompareFacts !== []) {
|
||||||
$builder->addSection(
|
$builder->addSection(
|
||||||
$factory->factsSection(
|
$factory->factsSection(
|
||||||
id: 'baseline_compare',
|
id: 'baseline_compare',
|
||||||
kind: 'operational_context',
|
kind: 'type_specific_detail',
|
||||||
title: 'Baseline compare',
|
title: 'Baseline compare',
|
||||||
items: $baselineCompareFacts,
|
items: $baselineCompareFacts,
|
||||||
|
description: 'Type-specific comparison detail stays below the canonical decision and supporting layers.',
|
||||||
|
collapsible: true,
|
||||||
|
collapsed: true,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (($gapSummary['detail_state'] ?? 'no_gaps') !== 'no_gaps') {
|
||||||
|
$builder->addSection(
|
||||||
|
$factory->viewSection(
|
||||||
|
id: 'baseline_compare_gap_details',
|
||||||
|
kind: 'type_specific_detail',
|
||||||
|
title: 'Evidence gap details',
|
||||||
|
description: 'Policies affected by evidence gaps, grouped by reason and searchable by reason, policy type, or subject key.',
|
||||||
|
view: 'filament.infolists.entries.evidence-gap-subjects',
|
||||||
|
viewData: [
|
||||||
|
'summary' => $gapSummary,
|
||||||
|
'buckets' => $gapBuckets,
|
||||||
|
'searchId' => 'baseline-compare-gap-search-'.$record->getKey(),
|
||||||
|
],
|
||||||
|
collapsible: true,
|
||||||
|
collapsed: true,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@ -404,10 +449,12 @@ private static function enterpriseDetailPage(OperationRun $record): \App\Support
|
|||||||
$builder->addSection(
|
$builder->addSection(
|
||||||
$factory->viewSection(
|
$factory->viewSection(
|
||||||
id: 'baseline_compare_evidence',
|
id: 'baseline_compare_evidence',
|
||||||
kind: 'operational_context',
|
kind: 'type_specific_detail',
|
||||||
title: 'Baseline compare evidence',
|
title: 'Baseline compare evidence',
|
||||||
view: 'filament.infolists.entries.snapshot-json',
|
view: 'filament.infolists.entries.snapshot-json',
|
||||||
viewData: ['payload' => $baselineCompareEvidence],
|
viewData: ['payload' => $baselineCompareEvidence],
|
||||||
|
collapsible: true,
|
||||||
|
collapsed: true,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@ -420,10 +467,12 @@ private static function enterpriseDetailPage(OperationRun $record): \App\Support
|
|||||||
$builder->addSection(
|
$builder->addSection(
|
||||||
$factory->viewSection(
|
$factory->viewSection(
|
||||||
id: 'baseline_capture_evidence',
|
id: 'baseline_capture_evidence',
|
||||||
kind: 'operational_context',
|
kind: 'type_specific_detail',
|
||||||
title: 'Baseline capture evidence',
|
title: 'Baseline capture evidence',
|
||||||
view: 'filament.infolists.entries.snapshot-json',
|
view: 'filament.infolists.entries.snapshot-json',
|
||||||
viewData: ['payload' => $baselineCaptureEvidence],
|
viewData: ['payload' => $baselineCaptureEvidence],
|
||||||
|
collapsible: true,
|
||||||
|
collapsed: true,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@ -433,7 +482,7 @@ private static function enterpriseDetailPage(OperationRun $record): \App\Support
|
|||||||
$builder->addSection(
|
$builder->addSection(
|
||||||
$factory->viewSection(
|
$factory->viewSection(
|
||||||
id: 'verification_report',
|
id: 'verification_report',
|
||||||
kind: 'operational_context',
|
kind: 'type_specific_detail',
|
||||||
title: 'Verification report',
|
title: 'Verification report',
|
||||||
view: 'filament.components.verification-report-viewer',
|
view: 'filament.components.verification-report-viewer',
|
||||||
viewData: static::verificationReportViewData($record),
|
viewData: static::verificationReportViewData($record),
|
||||||
@ -441,9 +490,321 @@ private static function enterpriseDetailPage(OperationRun $record): \App\Support
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$builder->addTechnicalSection(
|
||||||
|
$factory->technicalDetail(
|
||||||
|
title: 'Context',
|
||||||
|
entries: [
|
||||||
|
$factory->keyFact('Identity hash', $record->run_identity_hash, mono: true),
|
||||||
|
$factory->keyFact('Workspace scope', $record->workspace_id),
|
||||||
|
$factory->keyFact('Tenant scope', $record->tenant_id),
|
||||||
|
],
|
||||||
|
description: 'Stored run context stays available for debugging without dominating the default reading path.',
|
||||||
|
view: 'filament.infolists.entries.snapshot-json',
|
||||||
|
viewData: ['payload' => static::contextPayload($record)],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
return $builder->build();
|
return $builder->build();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return list<\App\Support\Ui\EnterpriseDetail\SupportingCardData>
|
||||||
|
*/
|
||||||
|
private static function supportingGroups(
|
||||||
|
OperationRun $record,
|
||||||
|
\App\Support\Ui\EnterpriseDetail\EnterpriseDetailSectionFactory $factory,
|
||||||
|
?ReferencedTenantLifecyclePresentation $referencedTenantLifecycle,
|
||||||
|
?OperatorExplanationPattern $operatorExplanation,
|
||||||
|
array $primaryNextStep,
|
||||||
|
): array {
|
||||||
|
$groups = [];
|
||||||
|
$hasElevatedLifecycleState = OperationUxPresenter::lifecycleAttentionSummary($record) !== null;
|
||||||
|
|
||||||
|
$guidanceItems = array_values(array_filter([
|
||||||
|
$operatorExplanation instanceof OperatorExplanationPattern && $operatorExplanation->coverageStatement !== null
|
||||||
|
? $factory->keyFact('Coverage', $operatorExplanation->coverageStatement)
|
||||||
|
: null,
|
||||||
|
$operatorExplanation instanceof OperatorExplanationPattern && $operatorExplanation->diagnosticsSummary !== null
|
||||||
|
? $factory->keyFact('Diagnostics summary', $operatorExplanation->diagnosticsSummary)
|
||||||
|
: null,
|
||||||
|
...array_map(
|
||||||
|
static fn (array $guidance): array => $factory->keyFact($guidance['label'], $guidance['text']),
|
||||||
|
array_values(array_filter(
|
||||||
|
$primaryNextStep['secondaryGuidance'] ?? [],
|
||||||
|
static fn (mixed $guidance): bool => is_array($guidance),
|
||||||
|
)),
|
||||||
|
),
|
||||||
|
static::blockedExecutionReasonCode($record) !== null
|
||||||
|
? $factory->keyFact('Blocked reason', static::blockedExecutionReasonCode($record))
|
||||||
|
: null,
|
||||||
|
static::blockedExecutionDetail($record) !== null
|
||||||
|
? $factory->keyFact('Blocked detail', static::blockedExecutionDetail($record))
|
||||||
|
: null,
|
||||||
|
static::blockedExecutionSource($record) !== null
|
||||||
|
? $factory->keyFact('Blocked by', static::blockedExecutionSource($record))
|
||||||
|
: null,
|
||||||
|
RunDurationInsights::stuckGuidance($record) !== null
|
||||||
|
? $factory->keyFact('Queue guidance', RunDurationInsights::stuckGuidance($record))
|
||||||
|
: null,
|
||||||
|
]));
|
||||||
|
|
||||||
|
if ($guidanceItems !== []) {
|
||||||
|
$groups[] = $factory->supportingFactsCard(
|
||||||
|
kind: 'guidance',
|
||||||
|
title: 'Guidance',
|
||||||
|
items: $guidanceItems,
|
||||||
|
description: 'Secondary guidance explains caveats and context without competing with the primary next step.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
$lifecycleItems = array_values(array_filter([
|
||||||
|
$referencedTenantLifecycle !== null
|
||||||
|
? $factory->keyFact(
|
||||||
|
'Tenant lifecycle',
|
||||||
|
$referencedTenantLifecycle->presentation->label,
|
||||||
|
badge: $factory->statusBadge(
|
||||||
|
$referencedTenantLifecycle->presentation->label,
|
||||||
|
$referencedTenantLifecycle->presentation->badgeColor,
|
||||||
|
$referencedTenantLifecycle->presentation->badgeIcon,
|
||||||
|
$referencedTenantLifecycle->presentation->badgeIconColor,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
: null,
|
||||||
|
$referencedTenantLifecycle?->selectorAvailabilityMessage() !== null
|
||||||
|
? $factory->keyFact('Tenant selector context', $referencedTenantLifecycle->selectorAvailabilityMessage())
|
||||||
|
: null,
|
||||||
|
$referencedTenantLifecycle?->contextNote !== null
|
||||||
|
? $factory->keyFact('Viewer context', $referencedTenantLifecycle->contextNote)
|
||||||
|
: null,
|
||||||
|
! $hasElevatedLifecycleState && static::freshnessLabel($record) !== null
|
||||||
|
? $factory->keyFact('Freshness', (string) static::freshnessLabel($record))
|
||||||
|
: null,
|
||||||
|
! $hasElevatedLifecycleState && static::reconciliationHeadline($record) !== null
|
||||||
|
? $factory->keyFact('Lifecycle truth', (string) static::reconciliationHeadline($record))
|
||||||
|
: null,
|
||||||
|
static::reconciledAtLabel($record) !== null
|
||||||
|
? $factory->keyFact('Reconciled at', (string) static::reconciledAtLabel($record))
|
||||||
|
: null,
|
||||||
|
static::reconciliationSourceLabel($record) !== null
|
||||||
|
? $factory->keyFact('Reconciled by', (string) static::reconciliationSourceLabel($record))
|
||||||
|
: null,
|
||||||
|
]));
|
||||||
|
|
||||||
|
if ($lifecycleItems !== []) {
|
||||||
|
$groups[] = $factory->supportingFactsCard(
|
||||||
|
kind: 'lifecycle',
|
||||||
|
title: 'Lifecycle',
|
||||||
|
items: $lifecycleItems,
|
||||||
|
description: 'Lifecycle context explains freshness, reconciliation, and tenant-scoped caveats.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
$timingItems = [
|
||||||
|
$factory->keyFact('Created', static::formatDetailTimestamp($record->created_at)),
|
||||||
|
$factory->keyFact('Started', static::formatDetailTimestamp($record->started_at)),
|
||||||
|
$factory->keyFact('Completed', static::formatDetailTimestamp($record->completed_at)),
|
||||||
|
$factory->keyFact('Elapsed', RunDurationInsights::elapsedHuman($record)),
|
||||||
|
];
|
||||||
|
|
||||||
|
$groups[] = $factory->supportingFactsCard(
|
||||||
|
kind: 'timing',
|
||||||
|
title: 'Timing',
|
||||||
|
items: $timingItems,
|
||||||
|
);
|
||||||
|
|
||||||
|
$metadataItems = array_values(array_filter([
|
||||||
|
$factory->keyFact('Initiator', $record->initiator_name),
|
||||||
|
RunDurationInsights::expectedHuman($record) !== null
|
||||||
|
? $factory->keyFact('Expected duration', RunDurationInsights::expectedHuman($record))
|
||||||
|
: null,
|
||||||
|
]));
|
||||||
|
|
||||||
|
if ($metadataItems !== []) {
|
||||||
|
$groups[] = $factory->supportingFactsCard(
|
||||||
|
kind: 'metadata',
|
||||||
|
title: 'Metadata',
|
||||||
|
items: $metadataItems,
|
||||||
|
description: 'Secondary metadata remains visible without crowding the top decision surface.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $groups;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array{
|
||||||
|
* text: string,
|
||||||
|
* source: string,
|
||||||
|
* secondaryGuidance: list<array{label: string, text: string, source: string}>
|
||||||
|
* }
|
||||||
|
*/
|
||||||
|
private static function resolvePrimaryNextStep(
|
||||||
|
OperationRun $record,
|
||||||
|
?ArtifactTruthEnvelope $artifactTruth,
|
||||||
|
?OperatorExplanationPattern $operatorExplanation,
|
||||||
|
): array {
|
||||||
|
$candidates = [];
|
||||||
|
|
||||||
|
static::pushNextStepCandidate($candidates, $operatorExplanation?->nextActionText, 'operator_explanation');
|
||||||
|
static::pushNextStepCandidate($candidates, $artifactTruth?->nextStepText(), 'artifact_truth');
|
||||||
|
|
||||||
|
$opsUxSource = match (true) {
|
||||||
|
(string) $record->outcome === OperationRunOutcome::Blocked->value => 'blocked_reason',
|
||||||
|
OperationUxPresenter::lifecycleAttentionSummary($record) !== null => 'lifecycle_attention',
|
||||||
|
default => 'ops_ux',
|
||||||
|
};
|
||||||
|
|
||||||
|
static::pushNextStepCandidate($candidates, OperationUxPresenter::surfaceGuidance($record), $opsUxSource);
|
||||||
|
|
||||||
|
if ($candidates === []) {
|
||||||
|
return [
|
||||||
|
'text' => 'No action needed.',
|
||||||
|
'source' => 'none_required',
|
||||||
|
'secondaryGuidance' => [],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
$primary = $candidates[0];
|
||||||
|
$primarySource = static::normalizeGuidance($primary['text']) === 'no action needed'
|
||||||
|
? 'none_required'
|
||||||
|
: $primary['source'];
|
||||||
|
|
||||||
|
$secondaryGuidance = array_map(
|
||||||
|
static fn (array $candidate): array => [
|
||||||
|
'label' => static::guidanceLabel($candidate['source']),
|
||||||
|
'text' => $candidate['text'],
|
||||||
|
'source' => $candidate['source'],
|
||||||
|
],
|
||||||
|
array_slice($candidates, 1),
|
||||||
|
);
|
||||||
|
|
||||||
|
return [
|
||||||
|
'text' => $primary['text'],
|
||||||
|
'source' => $primarySource,
|
||||||
|
'secondaryGuidance' => $secondaryGuidance,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<int, array{text: string, source: string, normalized: string}> $candidates
|
||||||
|
*/
|
||||||
|
private static function pushNextStepCandidate(array &$candidates, ?string $text, string $source): void
|
||||||
|
{
|
||||||
|
$formattedText = static::formatGuidanceText($text);
|
||||||
|
|
||||||
|
if ($formattedText === null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$normalized = static::normalizeGuidance($formattedText);
|
||||||
|
|
||||||
|
foreach ($candidates as $candidate) {
|
||||||
|
if (($candidate['normalized'] ?? null) === $normalized) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$candidates[] = [
|
||||||
|
'text' => $formattedText,
|
||||||
|
'source' => $source,
|
||||||
|
'normalized' => $normalized,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function formatGuidanceText(?string $text): ?string
|
||||||
|
{
|
||||||
|
if (! is_string($text)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$text = trim($text);
|
||||||
|
|
||||||
|
if ($text === '') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (preg_match('/[.!?]$/', $text) === 1) {
|
||||||
|
return $text;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $text.'.';
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function normalizeGuidance(string $text): string
|
||||||
|
{
|
||||||
|
$normalized = mb_strtolower(trim($text));
|
||||||
|
$normalized = preg_replace('/^next step:\s*/', '', $normalized) ?? $normalized;
|
||||||
|
|
||||||
|
return trim($normalized, " \t\n\r\0\x0B.!?");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function guidanceLabel(string $source): string
|
||||||
|
{
|
||||||
|
return match ($source) {
|
||||||
|
'operator_explanation' => 'Operator guidance',
|
||||||
|
'artifact_truth' => 'Artifact guidance',
|
||||||
|
'blocked_reason' => 'Blocked prerequisite',
|
||||||
|
'lifecycle_attention' => 'Lifecycle guidance',
|
||||||
|
default => 'General guidance',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, mixed>|null
|
||||||
|
*/
|
||||||
|
private static function artifactTruthFact(
|
||||||
|
\App\Support\Ui\EnterpriseDetail\EnterpriseDetailSectionFactory $factory,
|
||||||
|
?ArtifactTruthEnvelope $artifactTruth,
|
||||||
|
): ?array {
|
||||||
|
if (! $artifactTruth instanceof ArtifactTruthEnvelope) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$badge = $artifactTruth->primaryBadgeSpec();
|
||||||
|
|
||||||
|
return $factory->keyFact(
|
||||||
|
'Artifact truth',
|
||||||
|
$artifactTruth->primaryLabel,
|
||||||
|
$artifactTruth->primaryExplanation,
|
||||||
|
$factory->statusBadge($badge->label, $badge->color, $badge->icon, $badge->iconColor),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function decisionAttentionNote(OperationRun $record): ?string
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function detailHintUnlessDuplicate(?string $hint, ?string $duplicateOf): ?string
|
||||||
|
{
|
||||||
|
$normalizedHint = static::normalizeDetailText($hint);
|
||||||
|
|
||||||
|
if ($normalizedHint === null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($normalizedHint === static::normalizeDetailText($duplicateOf)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return trim($hint ?? '');
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function normalizeDetailText(?string $value): ?string
|
||||||
|
{
|
||||||
|
if (! is_string($value)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$normalized = trim((string) preg_replace('/\s+/', ' ', $value));
|
||||||
|
|
||||||
|
if ($normalized === '') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return mb_strtolower($normalized);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @return list<array<string, mixed>>
|
* @return list<array<string, mixed>>
|
||||||
*/
|
*/
|
||||||
@ -454,20 +815,42 @@ private static function summaryCountFacts(
|
|||||||
$counts = \App\Support\OpsUx\SummaryCountsNormalizer::normalize(is_array($record->summary_counts) ? $record->summary_counts : []);
|
$counts = \App\Support\OpsUx\SummaryCountsNormalizer::normalize(is_array($record->summary_counts) ? $record->summary_counts : []);
|
||||||
|
|
||||||
return array_map(
|
return array_map(
|
||||||
static fn (string $key, int $value): array => $factory->keyFact(ucfirst(str_replace('_', ' ', $key)), $value),
|
static fn (string $key, int $value): array => $factory->keyFact(
|
||||||
|
SummaryCountsNormalizer::label($key),
|
||||||
|
$value,
|
||||||
|
tone: self::countTone($key, $value),
|
||||||
|
),
|
||||||
array_keys($counts),
|
array_keys($counts),
|
||||||
array_values($counts),
|
array_values($counts),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static function countTone(string $key, int $value): ?string
|
||||||
|
{
|
||||||
|
if (in_array($key, ['failed', 'errors_recorded', 'findings_reopened'], true)) {
|
||||||
|
return $value > 0 ? 'danger' : 'success';
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($key === 'succeeded' && $value > 0) {
|
||||||
|
return 'success';
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
private static function blockedExecutionReasonCode(OperationRun $record): ?string
|
private static function blockedExecutionReasonCode(OperationRun $record): ?string
|
||||||
{
|
{
|
||||||
if ((string) $record->outcome !== OperationRunOutcome::Blocked->value) {
|
if ((string) $record->outcome !== OperationRunOutcome::Blocked->value) {
|
||||||
return null;
|
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')
|
$reasonCode = data_get($context, 'execution_legitimacy.reason_code')
|
||||||
?? data_get($context, 'reason_code')
|
?? data_get($context, 'reason_code')
|
||||||
?? data_get($record->failure_summary, '0.reason_code');
|
?? data_get($record->failure_summary, '0.reason_code');
|
||||||
@ -481,6 +864,12 @@ private static function blockedExecutionDetail(OperationRun $record): ?string
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$reasonEnvelope = app(ReasonPresenter::class)->forOperationRun($record, 'run_detail');
|
||||||
|
|
||||||
|
if ($reasonEnvelope !== null) {
|
||||||
|
return $reasonEnvelope->shortExplanation;
|
||||||
|
}
|
||||||
|
|
||||||
$message = data_get($record->failure_summary, '0.message');
|
$message = data_get($record->failure_summary, '0.message');
|
||||||
|
|
||||||
return is_string($message) && trim($message) !== '' ? trim($message) : 'Execution was refused before work began.';
|
return is_string($message) && trim($message) !== '' ? trim($message) : 'Execution was refused before work began.';
|
||||||
@ -513,6 +902,8 @@ private static function baselineCompareFacts(
|
|||||||
\App\Support\Ui\EnterpriseDetail\EnterpriseDetailSectionFactory $factory,
|
\App\Support\Ui\EnterpriseDetail\EnterpriseDetailSectionFactory $factory,
|
||||||
): array {
|
): array {
|
||||||
$context = is_array($record->context) ? $record->context : [];
|
$context = is_array($record->context) ? $record->context : [];
|
||||||
|
$gapDetails = BaselineCompareEvidenceGapDetails::fromOperationRun($record);
|
||||||
|
$gapSummary = is_array($gapDetails['summary'] ?? null) ? $gapDetails['summary'] : [];
|
||||||
$facts = [];
|
$facts = [];
|
||||||
|
|
||||||
$fidelity = data_get($context, 'baseline_compare.fidelity');
|
$fidelity = data_get($context, 'baseline_compare.fidelity');
|
||||||
@ -544,6 +935,30 @@ private static function baselineCompareFacts(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if ((int) ($gapSummary['count'] ?? 0) > 0) {
|
||||||
|
$facts[] = $factory->keyFact(
|
||||||
|
'Evidence gap detail',
|
||||||
|
match ($gapSummary['detail_state'] ?? 'no_gaps') {
|
||||||
|
'structured_details_recorded' => 'Structured subject details available',
|
||||||
|
'details_not_recorded' => 'Detailed rows were not recorded',
|
||||||
|
'legacy_broad_reason' => 'Legacy development payload should be regenerated',
|
||||||
|
default => 'No evidence gaps recorded',
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ((int) ($gapSummary['structural_count'] ?? 0) > 0) {
|
||||||
|
$facts[] = $factory->keyFact('Structural gaps', (string) (int) $gapSummary['structural_count']);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ((int) ($gapSummary['operational_count'] ?? 0) > 0) {
|
||||||
|
$facts[] = $factory->keyFact('Operational gaps', (string) (int) $gapSummary['operational_count']);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ((int) ($gapSummary['transient_count'] ?? 0) > 0) {
|
||||||
|
$facts[] = $factory->keyFact('Transient gaps', (string) (int) $gapSummary['transient_count']);
|
||||||
|
}
|
||||||
|
|
||||||
if ($uncoveredTypes !== []) {
|
if ($uncoveredTypes !== []) {
|
||||||
sort($uncoveredTypes, SORT_STRING);
|
sort($uncoveredTypes, SORT_STRING);
|
||||||
$facts[] = $factory->keyFact('Uncovered types', implode(', ', array_slice($uncoveredTypes, 0, 12)).(count($uncoveredTypes) > 12 ? '…' : ''));
|
$facts[] = $factory->keyFact('Uncovered types', implode(', ', array_slice($uncoveredTypes, 0, 12)).(count($uncoveredTypes) > 12 ? '…' : ''));
|
||||||
@ -684,6 +1099,82 @@ private static function contextPayload(OperationRun $record): array
|
|||||||
return $context;
|
return $context;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array{status:string,freshness_state:string}
|
||||||
|
*/
|
||||||
|
private static function statusBadgeState(OperationRun $record): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'status' => (string) $record->status,
|
||||||
|
'freshness_state' => $record->freshnessState()->value,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array{outcome:string,status:string,freshness_state:string}
|
||||||
|
*/
|
||||||
|
private static function outcomeBadgeState(OperationRun $record): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'outcome' => (string) $record->outcome,
|
||||||
|
'status' => (string) $record->status,
|
||||||
|
'freshness_state' => $record->freshnessState()->value,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function freshnessLabel(OperationRun $record): ?string
|
||||||
|
{
|
||||||
|
return match ($record->freshnessState()->value) {
|
||||||
|
'fresh_active' => 'Fresh activity',
|
||||||
|
'likely_stale' => 'Likely stale',
|
||||||
|
'reconciled_failed' => 'Automatically reconciled',
|
||||||
|
'terminal_normal' => 'Terminal truth confirmed',
|
||||||
|
default => null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function reconciliationHeadline(OperationRun $record): ?string
|
||||||
|
{
|
||||||
|
if (! $record->isLifecycleReconciled()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return 'TenantPilot force-resolved this run after normal lifecycle truth was lost.';
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function reconciledAtLabel(OperationRun $record): ?string
|
||||||
|
{
|
||||||
|
$reconciledAt = data_get($record->reconciliation(), 'reconciled_at');
|
||||||
|
|
||||||
|
return is_string($reconciledAt) && trim($reconciledAt) !== '' ? trim($reconciledAt) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function reconciliationSourceLabel(OperationRun $record): ?string
|
||||||
|
{
|
||||||
|
$source = data_get($record->reconciliation(), 'source');
|
||||||
|
|
||||||
|
if (! is_string($source) || trim($source) === '') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return match (trim($source)) {
|
||||||
|
'failed_callback' => 'Direct failed() bridge',
|
||||||
|
'scheduled_reconciler' => 'Scheduled reconciler',
|
||||||
|
'adapter_reconciler' => 'Adapter reconciler',
|
||||||
|
default => ucfirst(str_replace('_', ' ', trim($source))),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
private static function reconciliationPayload(OperationRun $record): array
|
||||||
|
{
|
||||||
|
$reconciliation = $record->reconciliation();
|
||||||
|
|
||||||
|
return $reconciliation;
|
||||||
|
}
|
||||||
|
|
||||||
private static function formatDetailTimestamp(mixed $value): string
|
private static function formatDetailTimestamp(mixed $value): string
|
||||||
{
|
{
|
||||||
if (! $value instanceof \Illuminate\Support\Carbon) {
|
if (! $value instanceof \Illuminate\Support\Carbon) {
|
||||||
|
|||||||
@ -824,9 +824,12 @@ public static function table(Table $table): Table
|
|||||||
? (string) $result->run->context['reason_code']
|
? (string) $result->run->context['reason_code']
|
||||||
: 'unknown_error';
|
: 'unknown_error';
|
||||||
|
|
||||||
|
$reasonEnvelope = app(\App\Support\ReasonTranslation\ReasonPresenter::class)->forOperationRun($result->run, 'notification');
|
||||||
|
$bodyLines = $reasonEnvelope?->toBodyLines() ?? ['Blocked by provider configuration.'];
|
||||||
|
|
||||||
Notification::make()
|
Notification::make()
|
||||||
->title('Connection check blocked')
|
->title('Connection check blocked')
|
||||||
->body("Blocked by provider configuration ({$reasonCode}).")
|
->body(implode("\n", $bodyLines))
|
||||||
->warning()
|
->warning()
|
||||||
->actions([
|
->actions([
|
||||||
Actions\Action::make('view_run')
|
Actions\Action::make('view_run')
|
||||||
@ -921,9 +924,12 @@ public static function table(Table $table): Table
|
|||||||
? (string) $result->run->context['reason_code']
|
? (string) $result->run->context['reason_code']
|
||||||
: 'unknown_error';
|
: 'unknown_error';
|
||||||
|
|
||||||
|
$reasonEnvelope = app(\App\Support\ReasonTranslation\ReasonPresenter::class)->forOperationRun($result->run, 'notification');
|
||||||
|
$bodyLines = $reasonEnvelope?->toBodyLines() ?? ['Blocked by provider configuration.'];
|
||||||
|
|
||||||
Notification::make()
|
Notification::make()
|
||||||
->title('Inventory sync blocked')
|
->title('Inventory sync blocked')
|
||||||
->body("Blocked by provider configuration ({$reasonCode}).")
|
->body(implode("\n", $bodyLines))
|
||||||
->warning()
|
->warning()
|
||||||
->actions([
|
->actions([
|
||||||
Actions\Action::make('view_run')
|
Actions\Action::make('view_run')
|
||||||
@ -1015,9 +1021,12 @@ public static function table(Table $table): Table
|
|||||||
? (string) $result->run->context['reason_code']
|
? (string) $result->run->context['reason_code']
|
||||||
: 'unknown_error';
|
: 'unknown_error';
|
||||||
|
|
||||||
|
$reasonEnvelope = app(\App\Support\ReasonTranslation\ReasonPresenter::class)->forOperationRun($result->run, 'notification');
|
||||||
|
$bodyLines = $reasonEnvelope?->toBodyLines() ?? ['Blocked by provider configuration.'];
|
||||||
|
|
||||||
Notification::make()
|
Notification::make()
|
||||||
->title('Compliance snapshot blocked')
|
->title('Compliance snapshot blocked')
|
||||||
->body("Blocked by provider configuration ({$reasonCode}).")
|
->body(implode("\n", $bodyLines))
|
||||||
->warning()
|
->warning()
|
||||||
->actions([
|
->actions([
|
||||||
Actions\Action::make('view_run')
|
Actions\Action::make('view_run')
|
||||||
|
|||||||
@ -278,9 +278,12 @@ protected function getHeaderActions(): array
|
|||||||
? (string) $result->run->context['reason_code']
|
? (string) $result->run->context['reason_code']
|
||||||
: 'unknown_error';
|
: 'unknown_error';
|
||||||
|
|
||||||
|
$reasonEnvelope = app(\App\Support\ReasonTranslation\ReasonPresenter::class)->forOperationRun($result->run, 'notification');
|
||||||
|
$bodyLines = $reasonEnvelope?->toBodyLines() ?? ['Blocked by provider configuration.'];
|
||||||
|
|
||||||
Notification::make()
|
Notification::make()
|
||||||
->title('Connection check blocked')
|
->title('Connection check blocked')
|
||||||
->body("Blocked by provider configuration ({$reasonCode}).")
|
->body(implode("\n", $bodyLines))
|
||||||
->warning()
|
->warning()
|
||||||
->actions([
|
->actions([
|
||||||
Action::make('view_run')
|
Action::make('view_run')
|
||||||
@ -647,9 +650,12 @@ protected function getHeaderActions(): array
|
|||||||
? (string) $result->run->context['reason_code']
|
? (string) $result->run->context['reason_code']
|
||||||
: 'unknown_error';
|
: 'unknown_error';
|
||||||
|
|
||||||
|
$reasonEnvelope = app(\App\Support\ReasonTranslation\ReasonPresenter::class)->forOperationRun($result->run, 'notification');
|
||||||
|
$bodyLines = $reasonEnvelope?->toBodyLines() ?? ['Blocked by provider configuration.'];
|
||||||
|
|
||||||
Notification::make()
|
Notification::make()
|
||||||
->title('Inventory sync blocked')
|
->title('Inventory sync blocked')
|
||||||
->body("Blocked by provider configuration ({$reasonCode}).")
|
->body(implode("\n", $bodyLines))
|
||||||
->warning()
|
->warning()
|
||||||
->actions([
|
->actions([
|
||||||
Action::make('view_run')
|
Action::make('view_run')
|
||||||
@ -758,9 +764,12 @@ protected function getHeaderActions(): array
|
|||||||
? (string) $result->run->context['reason_code']
|
? (string) $result->run->context['reason_code']
|
||||||
: 'unknown_error';
|
: 'unknown_error';
|
||||||
|
|
||||||
|
$reasonEnvelope = app(\App\Support\ReasonTranslation\ReasonPresenter::class)->forOperationRun($result->run, 'notification');
|
||||||
|
$bodyLines = $reasonEnvelope?->toBodyLines() ?? ['Blocked by provider configuration.'];
|
||||||
|
|
||||||
Notification::make()
|
Notification::make()
|
||||||
->title('Compliance snapshot blocked')
|
->title('Compliance snapshot blocked')
|
||||||
->body("Blocked by provider configuration ({$reasonCode}).")
|
->body(implode("\n", $bodyLines))
|
||||||
->warning()
|
->warning()
|
||||||
->actions([
|
->actions([
|
||||||
Action::make('view_run')
|
Action::make('view_run')
|
||||||
|
|||||||
@ -824,10 +824,10 @@ public static function table(Table $table): Table
|
|||||||
->label('Total')
|
->label('Total')
|
||||||
->state(fn (RestoreRun $record): int => (int) (($record->metadata ?? [])['total'] ?? 0)),
|
->state(fn (RestoreRun $record): int => (int) (($record->metadata ?? [])['total'] ?? 0)),
|
||||||
Tables\Columns\TextColumn::make('summary_succeeded')
|
Tables\Columns\TextColumn::make('summary_succeeded')
|
||||||
->label('Succeeded')
|
->label('Applied')
|
||||||
->state(fn (RestoreRun $record): int => (int) (($record->metadata ?? [])['succeeded'] ?? 0)),
|
->state(fn (RestoreRun $record): int => (int) (($record->metadata ?? [])['succeeded'] ?? 0)),
|
||||||
Tables\Columns\TextColumn::make('summary_failed')
|
Tables\Columns\TextColumn::make('summary_failed')
|
||||||
->label('Failed')
|
->label('Failed items')
|
||||||
->state(fn (RestoreRun $record): int => (int) (($record->metadata ?? [])['failed'] ?? 0)),
|
->state(fn (RestoreRun $record): int => (int) (($record->metadata ?? [])['failed'] ?? 0)),
|
||||||
Tables\Columns\TextColumn::make('started_at')->dateTime()->since()->sortable(),
|
Tables\Columns\TextColumn::make('started_at')->dateTime()->since()->sortable(),
|
||||||
Tables\Columns\TextColumn::make('completed_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);
|
$succeeded = (int) ($meta['succeeded'] ?? 0);
|
||||||
$failed = (int) ($meta['failed'] ?? 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')
|
Infolists\Components\TextEntry::make('is_dry_run')
|
||||||
->label('Dry-run')
|
->label('Dry-run')
|
||||||
|
|||||||
@ -10,6 +10,7 @@
|
|||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
use App\Services\ReviewPackService;
|
use App\Services\ReviewPackService;
|
||||||
use App\Support\Auth\Capabilities;
|
use App\Support\Auth\Capabilities;
|
||||||
|
use App\Support\Badges\BadgeCatalog;
|
||||||
use App\Support\Badges\BadgeDomain;
|
use App\Support\Badges\BadgeDomain;
|
||||||
use App\Support\Badges\BadgeRenderer;
|
use App\Support\Badges\BadgeRenderer;
|
||||||
use App\Support\OpsUx\OperationUxPresenter;
|
use App\Support\OpsUx\OperationUxPresenter;
|
||||||
@ -19,11 +20,14 @@
|
|||||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceInspectAffordance;
|
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceInspectAffordance;
|
||||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceProfile;
|
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceProfile;
|
||||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceSlot;
|
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceSlot;
|
||||||
|
use App\Support\Ui\GovernanceArtifactTruth\ArtifactTruthEnvelope;
|
||||||
|
use App\Support\Ui\GovernanceArtifactTruth\ArtifactTruthPresenter;
|
||||||
use BackedEnum;
|
use BackedEnum;
|
||||||
use Filament\Actions;
|
use Filament\Actions;
|
||||||
use Filament\Facades\Filament;
|
use Filament\Facades\Filament;
|
||||||
use Filament\Forms\Components\Toggle;
|
use Filament\Forms\Components\Toggle;
|
||||||
use Filament\Infolists\Components\TextEntry;
|
use Filament\Infolists\Components\TextEntry;
|
||||||
|
use Filament\Infolists\Components\ViewEntry;
|
||||||
use Filament\Notifications\Notification;
|
use Filament\Notifications\Notification;
|
||||||
use Filament\Resources\Resource;
|
use Filament\Resources\Resource;
|
||||||
use Filament\Schemas\Components\Section;
|
use Filament\Schemas\Components\Section;
|
||||||
@ -111,6 +115,15 @@ public static function infolist(Schema $schema): Schema
|
|||||||
{
|
{
|
||||||
return $schema
|
return $schema
|
||||||
->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')
|
Section::make('Status')
|
||||||
->schema([
|
->schema([
|
||||||
TextEntry::make('status')
|
TextEntry::make('status')
|
||||||
@ -238,6 +251,15 @@ public static function table(Table $table): Table
|
|||||||
->icon(BadgeRenderer::icon(BadgeDomain::ReviewPackStatus))
|
->icon(BadgeRenderer::icon(BadgeDomain::ReviewPackStatus))
|
||||||
->iconColor(BadgeRenderer::iconColor(BadgeDomain::ReviewPackStatus))
|
->iconColor(BadgeRenderer::iconColor(BadgeDomain::ReviewPackStatus))
|
||||||
->sortable(),
|
->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')
|
Tables\Columns\TextColumn::make('tenant.name')
|
||||||
->label('Tenant')
|
->label('Tenant')
|
||||||
->searchable(),
|
->searchable(),
|
||||||
@ -257,6 +279,29 @@ public static function table(Table $table): Table
|
|||||||
->label('Size')
|
->label('Size')
|
||||||
->formatStateUsing(fn ($state): string => $state ? Number::fileSize((int) $state) : '—')
|
->formatStateUsing(fn ($state): string => $state ? Number::fileSize((int) $state) : '—')
|
||||||
->sortable(),
|
->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')
|
Tables\Columns\TextColumn::make('created_at')
|
||||||
->label('Created')
|
->label('Created')
|
||||||
->since()
|
->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
|
* @param array<string, mixed> $data
|
||||||
*/
|
*/
|
||||||
|
|||||||
@ -608,9 +608,12 @@ public static function table(Table $table): Table
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$reasonEnvelope = app(\App\Support\ReasonTranslation\ReasonPresenter::class)->forOperationRun($result->run, 'notification');
|
||||||
|
$bodyLines = $reasonEnvelope?->toBodyLines() ?? ['Blocked by provider configuration.'];
|
||||||
|
|
||||||
Notification::make()
|
Notification::make()
|
||||||
->title('Verification blocked')
|
->title('Verification blocked')
|
||||||
->body("Blocked by provider configuration ({$reasonCode}).")
|
->body(implode("\n", $bodyLines))
|
||||||
->warning()
|
->warning()
|
||||||
->actions($actions)
|
->actions($actions)
|
||||||
->send();
|
->send();
|
||||||
@ -908,8 +911,20 @@ public static function infolist(Schema $schema): Schema
|
|||||||
->visible(fn (Tenant $record): bool => filled($record->rbac_status)),
|
->visible(fn (Tenant $record): bool => filled($record->rbac_status)),
|
||||||
Section::make('RBAC Details')
|
Section::make('RBAC Details')
|
||||||
->schema([
|
->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')
|
Infolists\Components\TextEntry::make('rbac_status_reason')
|
||||||
->label('Reason'),
|
->label('Diagnostic code')
|
||||||
|
->copyable(),
|
||||||
Infolists\Components\TextEntry::make('rbac_role_definition_id')
|
Infolists\Components\TextEntry::make('rbac_role_definition_id')
|
||||||
->label('Role definition ID')
|
->label('Role definition ID')
|
||||||
->copyable(),
|
->copyable(),
|
||||||
|
|||||||
@ -178,9 +178,12 @@ protected function getHeaderActions(): array
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$reasonEnvelope = app(\App\Support\ReasonTranslation\ReasonPresenter::class)->forOperationRun($result->run, 'notification');
|
||||||
|
$bodyLines = $reasonEnvelope?->toBodyLines() ?? ['Blocked by provider configuration.'];
|
||||||
|
|
||||||
Notification::make()
|
Notification::make()
|
||||||
->title('Verification blocked')
|
->title('Verification blocked')
|
||||||
->body("Blocked by provider configuration ({$reasonCode}).")
|
->body(implode("\n", $bodyLines))
|
||||||
->warning()
|
->warning()
|
||||||
->actions($actions)
|
->actions($actions)
|
||||||
->send();
|
->send();
|
||||||
|
|||||||
@ -15,17 +15,21 @@
|
|||||||
use App\Services\ReviewPackService;
|
use App\Services\ReviewPackService;
|
||||||
use App\Services\TenantReviews\TenantReviewService;
|
use App\Services\TenantReviews\TenantReviewService;
|
||||||
use App\Support\Auth\Capabilities;
|
use App\Support\Auth\Capabilities;
|
||||||
|
use App\Support\Badges\BadgeCatalog;
|
||||||
use App\Support\Badges\BadgeDomain;
|
use App\Support\Badges\BadgeDomain;
|
||||||
use App\Support\Badges\BadgeRenderer;
|
use App\Support\Badges\BadgeRenderer;
|
||||||
use App\Support\OperationRunLinks;
|
use App\Support\OperationRunLinks;
|
||||||
use App\Support\OperationRunType;
|
use App\Support\OperationRunType;
|
||||||
use App\Support\OpsUx\OperationUxPresenter;
|
use App\Support\OpsUx\OperationUxPresenter;
|
||||||
use App\Support\Rbac\UiEnforcement;
|
use App\Support\Rbac\UiEnforcement;
|
||||||
|
use App\Support\TenantReviewCompletenessState;
|
||||||
use App\Support\TenantReviewStatus;
|
use App\Support\TenantReviewStatus;
|
||||||
use App\Support\Ui\ActionSurface\ActionSurfaceDeclaration;
|
use App\Support\Ui\ActionSurface\ActionSurfaceDeclaration;
|
||||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceInspectAffordance;
|
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceInspectAffordance;
|
||||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceProfile;
|
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceProfile;
|
||||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceSlot;
|
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceSlot;
|
||||||
|
use App\Support\Ui\GovernanceArtifactTruth\ArtifactTruthEnvelope;
|
||||||
|
use App\Support\Ui\GovernanceArtifactTruth\ArtifactTruthPresenter;
|
||||||
use BackedEnum;
|
use BackedEnum;
|
||||||
use Filament\Actions;
|
use Filament\Actions;
|
||||||
use Filament\Facades\Filament;
|
use Filament\Facades\Filament;
|
||||||
@ -141,6 +145,15 @@ public static function form(Schema $schema): Schema
|
|||||||
public static function infolist(Schema $schema): Schema
|
public static function infolist(Schema $schema): Schema
|
||||||
{
|
{
|
||||||
return $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')
|
Section::make('Review')
|
||||||
->schema([
|
->schema([
|
||||||
TextEntry::make('status')
|
TextEntry::make('status')
|
||||||
@ -237,6 +250,15 @@ public static function table(Table $table): Table
|
|||||||
->icon(BadgeRenderer::icon(BadgeDomain::TenantReviewStatus))
|
->icon(BadgeRenderer::icon(BadgeDomain::TenantReviewStatus))
|
||||||
->iconColor(BadgeRenderer::iconColor(BadgeDomain::TenantReviewStatus))
|
->iconColor(BadgeRenderer::iconColor(BadgeDomain::TenantReviewStatus))
|
||||||
->sortable(),
|
->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)->operatorExplanation?->headline ?? static::truthEnvelope($record)->primaryExplanation)
|
||||||
|
->wrap(),
|
||||||
Tables\Columns\TextColumn::make('completeness_state')
|
Tables\Columns\TextColumn::make('completeness_state')
|
||||||
->label('Completeness')
|
->label('Completeness')
|
||||||
->badge()
|
->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('generated_at')->dateTime()->placeholder('—')->sortable(),
|
||||||
Tables\Columns\TextColumn::make('published_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.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')
|
Tables\Columns\IconColumn::make('summary.has_ready_export')
|
||||||
->label('Export')
|
->label('Export')
|
||||||
->boolean(),
|
->boolean(),
|
||||||
|
Tables\Columns\TextColumn::make('artifact_next_step')
|
||||||
|
->label('Next step')
|
||||||
|
->getStateUsing(fn (TenantReview $record): string => static::truthEnvelope($record)->operatorExplanation?->nextActionText ?? static::truthEnvelope($record)->nextStepText())
|
||||||
|
->wrap(),
|
||||||
Tables\Columns\TextColumn::make('fingerprint')
|
Tables\Columns\TextColumn::make('fingerprint')
|
||||||
->toggleable(isToggledHiddenByDefault: true)
|
->toggleable(isToggledHiddenByDefault: true)
|
||||||
->searchable(),
|
->searchable(),
|
||||||
@ -262,12 +307,7 @@ public static function table(Table $table): Table
|
|||||||
->mapWithKeys(fn (TenantReviewStatus $status): array => [$status->value => Str::headline($status->value)])
|
->mapWithKeys(fn (TenantReviewStatus $status): array => [$status->value => Str::headline($status->value)])
|
||||||
->all()),
|
->all()),
|
||||||
Tables\Filters\SelectFilter::make('completeness_state')
|
Tables\Filters\SelectFilter::make('completeness_state')
|
||||||
->options([
|
->options(BadgeCatalog::options(BadgeDomain::TenantReviewCompleteness, TenantReviewCompletenessState::values())),
|
||||||
'complete' => 'Complete',
|
|
||||||
'partial' => 'Partial',
|
|
||||||
'missing' => 'Missing',
|
|
||||||
'stale' => 'Stale',
|
|
||||||
]),
|
|
||||||
\App\Support\Filament\FilterPresets::dateRange('review_date', 'Review date', 'generated_at'),
|
\App\Support\Filament\FilterPresets::dateRange('review_date', 'Review date', 'generated_at'),
|
||||||
])
|
])
|
||||||
->actions([
|
->actions([
|
||||||
@ -503,13 +543,18 @@ private static function evidenceSnapshotOptions(): array
|
|||||||
(string) $snapshot->getKey() => sprintf(
|
(string) $snapshot->getKey() => sprintf(
|
||||||
'#%d · %s · %s',
|
'#%d · %s · %s',
|
||||||
(int) $snapshot->getKey(),
|
(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'
|
$snapshot->generated_at?->format('Y-m-d H:i') ?? 'Pending'
|
||||||
),
|
),
|
||||||
])
|
])
|
||||||
->all();
|
->all();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static function reviewCompletenessCountLabel(string $state): string
|
||||||
|
{
|
||||||
|
return BadgeCatalog::spec(BadgeDomain::TenantReviewCompleteness, $state)->label;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @return array<string, mixed>
|
* @return array<string, mixed>
|
||||||
*/
|
*/
|
||||||
@ -518,6 +563,7 @@ private static function summaryPresentation(TenantReview $record): array
|
|||||||
$summary = is_array($record->summary) ? $record->summary : [];
|
$summary = is_array($record->summary) ? $record->summary : [];
|
||||||
|
|
||||||
return [
|
return [
|
||||||
|
'operator_explanation' => static::truthEnvelope($record)->operatorExplanation?->toArray(),
|
||||||
'highlights' => is_array($summary['highlights'] ?? null) ? $summary['highlights'] : [],
|
'highlights' => is_array($summary['highlights'] ?? null) ? $summary['highlights'] : [],
|
||||||
'next_actions' => is_array($summary['recommended_next_actions'] ?? null) ? $summary['recommended_next_actions'] : [],
|
'next_actions' => is_array($summary['recommended_next_actions'] ?? null) ? $summary['recommended_next_actions'] : [],
|
||||||
'publish_blockers' => is_array($summary['publish_blockers'] ?? null) ? $summary['publish_blockers'] : [],
|
'publish_blockers' => is_array($summary['publish_blockers'] ?? null) ? $summary['publish_blockers'] : [],
|
||||||
@ -559,4 +605,9 @@ private static function sectionPresentation(TenantReviewSection $section): array
|
|||||||
'links' => [],
|
'links' => [],
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static function truthEnvelope(TenantReview $record): ArtifactTruthEnvelope
|
||||||
|
{
|
||||||
|
return app(ArtifactTruthPresenter::class)->forTenantReview($record);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -4,8 +4,11 @@
|
|||||||
|
|
||||||
namespace App\Filament\Widgets\Dashboard;
|
namespace App\Filament\Widgets\Dashboard;
|
||||||
|
|
||||||
|
use App\Filament\Pages\BaselineCompareLanding;
|
||||||
|
use App\Filament\Resources\FindingResource;
|
||||||
use App\Models\Tenant;
|
use App\Models\Tenant;
|
||||||
use App\Support\Baselines\BaselineCompareStats;
|
use App\Support\Baselines\BaselineCompareStats;
|
||||||
|
use App\Support\OperationRunLinks;
|
||||||
use Filament\Facades\Filament;
|
use Filament\Facades\Filament;
|
||||||
use Filament\Widgets\Widget;
|
use Filament\Widgets\Widget;
|
||||||
|
|
||||||
@ -23,33 +26,46 @@ protected function getViewData(): array
|
|||||||
$empty = [
|
$empty = [
|
||||||
'hasAssignment' => false,
|
'hasAssignment' => false,
|
||||||
'profileName' => null,
|
'profileName' => null,
|
||||||
'findingsCount' => 0,
|
|
||||||
'highCount' => 0,
|
|
||||||
'mediumCount' => 0,
|
|
||||||
'lowCount' => 0,
|
|
||||||
'lastComparedAt' => null,
|
'lastComparedAt' => null,
|
||||||
'landingUrl' => null,
|
'landingUrl' => null,
|
||||||
|
'runUrl' => null,
|
||||||
|
'findingsUrl' => null,
|
||||||
|
'nextActionUrl' => null,
|
||||||
|
'summaryAssessment' => null,
|
||||||
];
|
];
|
||||||
|
|
||||||
if (! $tenant instanceof Tenant) {
|
if (! $tenant instanceof Tenant) {
|
||||||
return $empty;
|
return $empty;
|
||||||
}
|
}
|
||||||
|
|
||||||
$stats = BaselineCompareStats::forWidget($tenant);
|
$stats = BaselineCompareStats::forTenant($tenant);
|
||||||
|
|
||||||
if (in_array($stats->state, ['no_tenant', 'no_assignment'], true)) {
|
if (in_array($stats->state, ['no_tenant', 'no_assignment'], true)) {
|
||||||
return $empty;
|
return $empty;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$landingUrl = BaselineCompareLanding::getUrl(tenant: $tenant);
|
||||||
|
$runUrl = $stats->operationRunId !== null
|
||||||
|
? OperationRunLinks::view($stats->operationRunId, $tenant)
|
||||||
|
: null;
|
||||||
|
$findingsUrl = FindingResource::getUrl('index', tenant: $tenant);
|
||||||
|
$summaryAssessment = $stats->summaryAssessment();
|
||||||
|
$nextActionUrl = match ($summaryAssessment->nextActionTarget()) {
|
||||||
|
'run' => $runUrl,
|
||||||
|
'findings' => $findingsUrl,
|
||||||
|
'landing' => $landingUrl,
|
||||||
|
default => null,
|
||||||
|
};
|
||||||
|
|
||||||
return [
|
return [
|
||||||
'hasAssignment' => true,
|
'hasAssignment' => true,
|
||||||
'profileName' => $stats->profileName,
|
'profileName' => $stats->profileName,
|
||||||
'findingsCount' => $stats->findingsCount ?? 0,
|
|
||||||
'highCount' => $stats->severityCounts['high'] ?? 0,
|
|
||||||
'mediumCount' => $stats->severityCounts['medium'] ?? 0,
|
|
||||||
'lowCount' => $stats->severityCounts['low'] ?? 0,
|
|
||||||
'lastComparedAt' => $stats->lastComparedHuman,
|
'lastComparedAt' => $stats->lastComparedHuman,
|
||||||
'landingUrl' => \App\Filament\Pages\BaselineCompareLanding::getUrl(tenant: $tenant),
|
'landingUrl' => $landingUrl,
|
||||||
|
'runUrl' => $runUrl,
|
||||||
|
'findingsUrl' => $findingsUrl,
|
||||||
|
'nextActionUrl' => $nextActionUrl,
|
||||||
|
'summaryAssessment' => $summaryAssessment->toArray(),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -4,12 +4,9 @@
|
|||||||
|
|
||||||
namespace App\Filament\Widgets\Dashboard;
|
namespace App\Filament\Widgets\Dashboard;
|
||||||
|
|
||||||
use App\Filament\Pages\BaselineCompareLanding;
|
|
||||||
use App\Filament\Resources\FindingResource;
|
|
||||||
use App\Models\Finding;
|
use App\Models\Finding;
|
||||||
use App\Models\OperationRun;
|
|
||||||
use App\Models\Tenant;
|
use App\Models\Tenant;
|
||||||
use App\Support\OperationRunLinks;
|
use App\Support\Baselines\BaselineCompareStats;
|
||||||
use App\Support\OpsUx\ActiveRuns;
|
use App\Support\OpsUx\ActiveRuns;
|
||||||
use Filament\Facades\Filament;
|
use Filament\Facades\Filament;
|
||||||
use Filament\Widgets\Widget;
|
use Filament\Widgets\Widget;
|
||||||
@ -34,6 +31,8 @@ protected function getViewData(): array
|
|||||||
}
|
}
|
||||||
|
|
||||||
$tenantId = (int) $tenant->getKey();
|
$tenantId = (int) $tenant->getKey();
|
||||||
|
$compareStats = BaselineCompareStats::forTenant($tenant);
|
||||||
|
$compareAssessment = $compareStats->summaryAssessment();
|
||||||
|
|
||||||
$items = [];
|
$items = [];
|
||||||
|
|
||||||
@ -48,71 +47,30 @@ protected function getViewData(): array
|
|||||||
$items[] = [
|
$items[] = [
|
||||||
'title' => 'High severity drift findings',
|
'title' => 'High severity drift findings',
|
||||||
'body' => "{$highSeverityCount} finding(s) need review.",
|
'body' => "{$highSeverityCount} finding(s) need review.",
|
||||||
'url' => FindingResource::getUrl('index', tenant: $tenant),
|
|
||||||
'badge' => 'Drift',
|
'badge' => 'Drift',
|
||||||
'badgeColor' => 'danger',
|
'badgeColor' => 'danger',
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
$latestBaselineCompareSuccess = OperationRun::query()
|
if ($compareAssessment->stateFamily !== 'positive') {
|
||||||
->where('tenant_id', $tenantId)
|
|
||||||
->where('type', 'baseline_compare')
|
|
||||||
->where('status', 'completed')
|
|
||||||
->where('outcome', 'succeeded')
|
|
||||||
->whereNotNull('completed_at')
|
|
||||||
->latest('completed_at')
|
|
||||||
->first();
|
|
||||||
|
|
||||||
if (! $latestBaselineCompareSuccess) {
|
|
||||||
$items[] = [
|
$items[] = [
|
||||||
'title' => 'No baseline compare yet',
|
'title' => 'Baseline compare posture',
|
||||||
'body' => 'Run a baseline compare after your tenant has an assigned baseline snapshot.',
|
'body' => $compareAssessment->headline,
|
||||||
'url' => BaselineCompareLanding::getUrl(tenant: $tenant),
|
'supportingMessage' => $compareAssessment->supportingMessage,
|
||||||
'badge' => 'Drift',
|
'badge' => 'Baseline',
|
||||||
'badgeColor' => 'warning',
|
'badgeColor' => $compareAssessment->tone,
|
||||||
];
|
'nextStep' => $compareAssessment->nextActionLabel(),
|
||||||
} else {
|
|
||||||
$isStale = $latestBaselineCompareSuccess->completed_at?->lt(now()->subDays(7)) ?? true;
|
|
||||||
|
|
||||||
if ($isStale) {
|
|
||||||
$items[] = [
|
|
||||||
'title' => 'Baseline compare stale',
|
|
||||||
'body' => 'Last baseline compare is older than 7 days.',
|
|
||||||
'url' => BaselineCompareLanding::getUrl(tenant: $tenant),
|
|
||||||
'badge' => 'Drift',
|
|
||||||
'badgeColor' => 'warning',
|
|
||||||
];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
$latestBaselineCompareFailure = OperationRun::query()
|
|
||||||
->where('tenant_id', $tenantId)
|
|
||||||
->where('type', 'baseline_compare')
|
|
||||||
->where('status', 'completed')
|
|
||||||
->where('outcome', 'failed')
|
|
||||||
->latest('id')
|
|
||||||
->first();
|
|
||||||
|
|
||||||
if ($latestBaselineCompareFailure instanceof OperationRun) {
|
|
||||||
$items[] = [
|
|
||||||
'title' => 'Baseline compare failed',
|
|
||||||
'body' => 'Investigate the latest failed run.',
|
|
||||||
'url' => OperationRunLinks::view($latestBaselineCompareFailure, $tenant),
|
|
||||||
'badge' => 'Operations',
|
|
||||||
'badgeColor' => 'danger',
|
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
$activeRuns = (int) OperationRun::query()
|
$activeRuns = ActiveRuns::existForTenant($tenant)
|
||||||
->where('tenant_id', $tenantId)
|
? (int) \App\Models\OperationRun::query()->where('tenant_id', $tenantId)->active()->count()
|
||||||
->active()
|
: 0;
|
||||||
->count();
|
|
||||||
|
|
||||||
if ($activeRuns > 0) {
|
if ($activeRuns > 0) {
|
||||||
$items[] = [
|
$items[] = [
|
||||||
'title' => 'Operations in progress',
|
'title' => 'Operations in progress',
|
||||||
'body' => "{$activeRuns} run(s) are active.",
|
'body' => "{$activeRuns} run(s) are active.",
|
||||||
'url' => OperationRunLinks::index($tenant),
|
|
||||||
'badge' => 'Operations',
|
'badge' => 'Operations',
|
||||||
'badgeColor' => 'warning',
|
'badgeColor' => 'warning',
|
||||||
];
|
];
|
||||||
@ -125,24 +83,16 @@ protected function getViewData(): array
|
|||||||
if ($items === []) {
|
if ($items === []) {
|
||||||
$healthyChecks = [
|
$healthyChecks = [
|
||||||
[
|
[
|
||||||
'title' => 'Drift findings look healthy',
|
'title' => 'Baseline compare looks trustworthy',
|
||||||
'body' => 'No high severity drift findings are open.',
|
'body' => $compareAssessment->headline,
|
||||||
'url' => FindingResource::getUrl('index', tenant: $tenant),
|
|
||||||
'linkLabel' => 'View findings',
|
|
||||||
],
|
],
|
||||||
[
|
[
|
||||||
'title' => 'Baseline compares are up to date',
|
'title' => 'No high severity drift is open',
|
||||||
'body' => $latestBaselineCompareSuccess?->completed_at
|
'body' => 'No high severity drift findings are currently open for this tenant.',
|
||||||
? 'Last baseline compare: '.$latestBaselineCompareSuccess->completed_at->diffForHumans(['short' => true]).'.'
|
|
||||||
: 'Baseline compare history is available in Baseline Compare.',
|
|
||||||
'url' => BaselineCompareLanding::getUrl(tenant: $tenant),
|
|
||||||
'linkLabel' => 'Open Baseline Compare',
|
|
||||||
],
|
],
|
||||||
[
|
[
|
||||||
'title' => 'No active operations',
|
'title' => 'No active operations',
|
||||||
'body' => 'Nothing is currently running for this tenant.',
|
'body' => 'Nothing is currently running for this tenant.',
|
||||||
'url' => OperationRunLinks::index($tenant),
|
|
||||||
'linkLabel' => 'View operations',
|
|
||||||
],
|
],
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|||||||
@ -11,6 +11,7 @@
|
|||||||
use App\Support\OperationCatalog;
|
use App\Support\OperationCatalog;
|
||||||
use App\Support\OperationRunLinks;
|
use App\Support\OperationRunLinks;
|
||||||
use App\Support\OpsUx\ActiveRuns;
|
use App\Support\OpsUx\ActiveRuns;
|
||||||
|
use App\Support\OpsUx\OperationUxPresenter;
|
||||||
use Filament\Facades\Filament;
|
use Filament\Facades\Filament;
|
||||||
use Filament\Tables\Columns\TextColumn;
|
use Filament\Tables\Columns\TextColumn;
|
||||||
use Filament\Tables\Table;
|
use Filament\Tables\Table;
|
||||||
@ -57,7 +58,8 @@ public function table(Table $table): Table
|
|||||||
->formatStateUsing(BadgeRenderer::label(BadgeDomain::OperationRunOutcome))
|
->formatStateUsing(BadgeRenderer::label(BadgeDomain::OperationRunOutcome))
|
||||||
->color(BadgeRenderer::color(BadgeDomain::OperationRunOutcome))
|
->color(BadgeRenderer::color(BadgeDomain::OperationRunOutcome))
|
||||||
->icon(BadgeRenderer::icon(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')
|
TextColumn::make('created_at')
|
||||||
->label('Started')
|
->label('Started')
|
||||||
->sortable()
|
->sortable()
|
||||||
|
|||||||
@ -4,6 +4,7 @@
|
|||||||
|
|
||||||
namespace App\Filament\Widgets\Tenant;
|
namespace App\Filament\Widgets\Tenant;
|
||||||
|
|
||||||
|
use App\Filament\Pages\BaselineCompareLanding;
|
||||||
use App\Models\Tenant;
|
use App\Models\Tenant;
|
||||||
use App\Support\Baselines\BaselineCompareStats;
|
use App\Support\Baselines\BaselineCompareStats;
|
||||||
use App\Support\OperationRunLinks;
|
use App\Support\OperationRunLinks;
|
||||||
@ -30,26 +31,29 @@ protected function getViewData(): array
|
|||||||
}
|
}
|
||||||
|
|
||||||
$stats = BaselineCompareStats::forTenant($tenant);
|
$stats = BaselineCompareStats::forTenant($tenant);
|
||||||
|
$summaryAssessment = $stats->summaryAssessment();
|
||||||
$uncoveredTypes = $stats->uncoveredTypes ?? [];
|
|
||||||
$uncoveredTypes = is_array($uncoveredTypes) ? $uncoveredTypes : [];
|
|
||||||
|
|
||||||
$coverageStatus = $stats->coverageStatus;
|
|
||||||
$hasWarnings = in_array($coverageStatus, ['warning', 'unproven'], true) && $uncoveredTypes !== [];
|
|
||||||
|
|
||||||
$runUrl = null;
|
$runUrl = null;
|
||||||
|
|
||||||
if ($stats->operationRunId !== null) {
|
if ($stats->operationRunId !== null) {
|
||||||
$runUrl = OperationRunLinks::view($stats->operationRunId, $tenant);
|
$runUrl = OperationRunLinks::view($stats->operationRunId, $tenant);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$landingUrl = BaselineCompareLanding::getUrl(tenant: $tenant);
|
||||||
|
$nextActionUrl = match ($summaryAssessment->nextActionTarget()) {
|
||||||
|
'run' => $runUrl,
|
||||||
|
'landing' => $landingUrl,
|
||||||
|
default => null,
|
||||||
|
};
|
||||||
|
$shouldShow = in_array($summaryAssessment->stateFamily, ['caution', 'stale', 'unavailable', 'in_progress'], true)
|
||||||
|
|| ($summaryAssessment->stateFamily === 'action_required' && $summaryAssessment->evaluationResult === 'failed_result');
|
||||||
|
|
||||||
return [
|
return [
|
||||||
'shouldShow' => $hasWarnings && $runUrl !== null,
|
'shouldShow' => $shouldShow,
|
||||||
|
'landingUrl' => $landingUrl,
|
||||||
'runUrl' => $runUrl,
|
'runUrl' => $runUrl,
|
||||||
'coverageStatus' => $coverageStatus,
|
'nextActionUrl' => $nextActionUrl,
|
||||||
'fidelity' => $stats->fidelity,
|
'summaryAssessment' => $summaryAssessment->toArray(),
|
||||||
'uncoveredTypesCount' => $stats->uncoveredTypesCount ?? count($uncoveredTypes),
|
'state' => $stats->state,
|
||||||
'uncoveredTypes' => $uncoveredTypes,
|
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -134,9 +134,12 @@ public function startVerification(StartVerification $verification): void
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$reasonEnvelope = app(\App\Support\ReasonTranslation\ReasonPresenter::class)->forOperationRun($result->run, 'notification');
|
||||||
|
$bodyLines = $reasonEnvelope?->toBodyLines() ?? ['Blocked by provider configuration.'];
|
||||||
|
|
||||||
Notification::make()
|
Notification::make()
|
||||||
->title('Verification blocked')
|
->title('Verification blocked')
|
||||||
->body("Blocked by provider configuration ({$reasonCode}).")
|
->body(implode("\n", $bodyLines))
|
||||||
->warning()
|
->warning()
|
||||||
->actions($actions)
|
->actions($actions)
|
||||||
->send();
|
->send();
|
||||||
|
|||||||
@ -23,6 +23,7 @@ class WorkspaceRecentOperations extends Widget
|
|||||||
* status_color: string,
|
* status_color: string,
|
||||||
* outcome_label: string,
|
* outcome_label: string,
|
||||||
* outcome_color: string,
|
* outcome_color: string,
|
||||||
|
* guidance: ?string,
|
||||||
* started_at: string,
|
* started_at: string,
|
||||||
* url: string
|
* url: string
|
||||||
* }>
|
* }>
|
||||||
@ -48,6 +49,7 @@ class WorkspaceRecentOperations extends Widget
|
|||||||
* status_color: string,
|
* status_color: string,
|
||||||
* outcome_label: string,
|
* outcome_label: string,
|
||||||
* outcome_color: string,
|
* outcome_color: string,
|
||||||
|
* guidance: ?string,
|
||||||
* started_at: string,
|
* started_at: string,
|
||||||
* url: string
|
* url: string
|
||||||
* }> $operations
|
* }> $operations
|
||||||
|
|||||||
@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
namespace App\Jobs;
|
namespace App\Jobs;
|
||||||
|
|
||||||
|
use App\Jobs\Concerns\BridgesFailedOperationRun;
|
||||||
use App\Jobs\Operations\BackupSetRestoreWorkerJob;
|
use App\Jobs\Operations\BackupSetRestoreWorkerJob;
|
||||||
use App\Models\OperationRun;
|
use App\Models\OperationRun;
|
||||||
use App\Services\OperationRunService;
|
use App\Services\OperationRunService;
|
||||||
@ -11,11 +12,18 @@
|
|||||||
use Illuminate\Queue\InteractsWithQueue;
|
use Illuminate\Queue\InteractsWithQueue;
|
||||||
use Illuminate\Queue\SerializesModels;
|
use Illuminate\Queue\SerializesModels;
|
||||||
use RuntimeException;
|
use RuntimeException;
|
||||||
use Throwable;
|
|
||||||
|
|
||||||
class BulkBackupSetRestoreJob implements ShouldQueue
|
class BulkBackupSetRestoreJob implements ShouldQueue
|
||||||
{
|
{
|
||||||
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
|
use BridgesFailedOperationRun;
|
||||||
|
use Dispatchable;
|
||||||
|
use InteractsWithQueue;
|
||||||
|
use Queueable;
|
||||||
|
use SerializesModels;
|
||||||
|
|
||||||
|
public int $timeout = 300;
|
||||||
|
|
||||||
|
public bool $failOnTimeout = true;
|
||||||
|
|
||||||
public int $bulkRunId = 0;
|
public int $bulkRunId = 0;
|
||||||
|
|
||||||
@ -68,32 +76,6 @@ public function handle(OperationRunService $runs): void
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public function failed(Throwable $e): void
|
|
||||||
{
|
|
||||||
$run = $this->operationRun;
|
|
||||||
|
|
||||||
if (! $run instanceof OperationRun && $this->bulkRunId > 0) {
|
|
||||||
$run = OperationRun::query()->find($this->bulkRunId);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (! $run instanceof OperationRun) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** @var OperationRunService $runs */
|
|
||||||
$runs = app(OperationRunService::class);
|
|
||||||
|
|
||||||
$runs->updateRun(
|
|
||||||
$run,
|
|
||||||
status: 'completed',
|
|
||||||
outcome: 'failed',
|
|
||||||
failures: [[
|
|
||||||
'code' => 'bulk_job.failed',
|
|
||||||
'message' => $e->getMessage(),
|
|
||||||
]],
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
private function resolveOperationRun(): OperationRun
|
private function resolveOperationRun(): OperationRun
|
||||||
{
|
{
|
||||||
if ($this->operationRun instanceof OperationRun) {
|
if ($this->operationRun instanceof OperationRun) {
|
||||||
|
|||||||
@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
namespace App\Jobs;
|
namespace App\Jobs;
|
||||||
|
|
||||||
|
use App\Jobs\Concerns\BridgesFailedOperationRun;
|
||||||
use App\Jobs\Middleware\EnsureQueuedExecutionLegitimate;
|
use App\Jobs\Middleware\EnsureQueuedExecutionLegitimate;
|
||||||
use App\Jobs\Operations\TenantSyncWorkerJob;
|
use App\Jobs\Operations\TenantSyncWorkerJob;
|
||||||
use App\Models\OperationRun;
|
use App\Models\OperationRun;
|
||||||
@ -15,7 +16,15 @@
|
|||||||
|
|
||||||
class BulkTenantSyncJob implements ShouldQueue
|
class BulkTenantSyncJob implements ShouldQueue
|
||||||
{
|
{
|
||||||
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
|
use BridgesFailedOperationRun;
|
||||||
|
use Dispatchable;
|
||||||
|
use InteractsWithQueue;
|
||||||
|
use Queueable;
|
||||||
|
use SerializesModels;
|
||||||
|
|
||||||
|
public int $timeout = 180;
|
||||||
|
|
||||||
|
public bool $failOnTimeout = true;
|
||||||
|
|
||||||
public ?OperationRun $operationRun = null;
|
public ?OperationRun $operationRun = null;
|
||||||
|
|
||||||
|
|||||||
@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
namespace App\Jobs;
|
namespace App\Jobs;
|
||||||
|
|
||||||
|
use App\Jobs\Concerns\BridgesFailedOperationRun;
|
||||||
use App\Jobs\Middleware\TrackOperationRun;
|
use App\Jobs\Middleware\TrackOperationRun;
|
||||||
use App\Models\BaselineProfile;
|
use App\Models\BaselineProfile;
|
||||||
use App\Models\BaselineSnapshot;
|
use App\Models\BaselineSnapshot;
|
||||||
@ -12,6 +13,7 @@
|
|||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
use App\Services\Baselines\BaselineContentCapturePhase;
|
use App\Services\Baselines\BaselineContentCapturePhase;
|
||||||
use App\Services\Baselines\BaselineSnapshotIdentity;
|
use App\Services\Baselines\BaselineSnapshotIdentity;
|
||||||
|
use App\Services\Baselines\BaselineSnapshotItemNormalizer;
|
||||||
use App\Services\Baselines\CurrentStateHashResolver;
|
use App\Services\Baselines\CurrentStateHashResolver;
|
||||||
use App\Services\Baselines\Evidence\ResolvedEvidence;
|
use App\Services\Baselines\Evidence\ResolvedEvidence;
|
||||||
use App\Services\Baselines\InventoryMetaContract;
|
use App\Services\Baselines\InventoryMetaContract;
|
||||||
@ -20,7 +22,9 @@
|
|||||||
use App\Support\Baselines\BaselineCaptureMode;
|
use App\Support\Baselines\BaselineCaptureMode;
|
||||||
use App\Support\Baselines\BaselineFullContentRolloutGate;
|
use App\Support\Baselines\BaselineFullContentRolloutGate;
|
||||||
use App\Support\Baselines\BaselineProfileStatus;
|
use App\Support\Baselines\BaselineProfileStatus;
|
||||||
|
use App\Support\Baselines\BaselineReasonCodes;
|
||||||
use App\Support\Baselines\BaselineScope;
|
use App\Support\Baselines\BaselineScope;
|
||||||
|
use App\Support\Baselines\BaselineSnapshotLifecycleState;
|
||||||
use App\Support\Baselines\PolicyVersionCapturePurpose;
|
use App\Support\Baselines\PolicyVersionCapturePurpose;
|
||||||
use App\Support\Inventory\InventoryPolicyTypeMeta;
|
use App\Support\Inventory\InventoryPolicyTypeMeta;
|
||||||
use App\Support\OperationRunOutcome;
|
use App\Support\OperationRunOutcome;
|
||||||
@ -32,10 +36,19 @@
|
|||||||
use Illuminate\Queue\InteractsWithQueue;
|
use Illuminate\Queue\InteractsWithQueue;
|
||||||
use Illuminate\Queue\SerializesModels;
|
use Illuminate\Queue\SerializesModels;
|
||||||
use RuntimeException;
|
use RuntimeException;
|
||||||
|
use Throwable;
|
||||||
|
|
||||||
class CaptureBaselineSnapshotJob implements ShouldQueue
|
class CaptureBaselineSnapshotJob implements ShouldQueue
|
||||||
{
|
{
|
||||||
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
|
use BridgesFailedOperationRun;
|
||||||
|
use Dispatchable;
|
||||||
|
use InteractsWithQueue;
|
||||||
|
use Queueable;
|
||||||
|
use SerializesModels;
|
||||||
|
|
||||||
|
public int $timeout = 300;
|
||||||
|
|
||||||
|
public bool $failOnTimeout = true;
|
||||||
|
|
||||||
public ?OperationRun $operationRun = null;
|
public ?OperationRun $operationRun = null;
|
||||||
|
|
||||||
@ -60,10 +73,12 @@ public function handle(
|
|||||||
OperationRunService $operationRunService,
|
OperationRunService $operationRunService,
|
||||||
?CurrentStateHashResolver $hashResolver = null,
|
?CurrentStateHashResolver $hashResolver = null,
|
||||||
?BaselineContentCapturePhase $contentCapturePhase = null,
|
?BaselineContentCapturePhase $contentCapturePhase = null,
|
||||||
|
?BaselineSnapshotItemNormalizer $snapshotItemNormalizer = null,
|
||||||
?BaselineFullContentRolloutGate $rolloutGate = null,
|
?BaselineFullContentRolloutGate $rolloutGate = null,
|
||||||
): void {
|
): void {
|
||||||
$hashResolver ??= app(CurrentStateHashResolver::class);
|
$hashResolver ??= app(CurrentStateHashResolver::class);
|
||||||
$contentCapturePhase ??= app(BaselineContentCapturePhase::class);
|
$contentCapturePhase ??= app(BaselineContentCapturePhase::class);
|
||||||
|
$snapshotItemNormalizer ??= app(BaselineSnapshotItemNormalizer::class);
|
||||||
$rolloutGate ??= app(BaselineFullContentRolloutGate::class);
|
$rolloutGate ??= app(BaselineFullContentRolloutGate::class);
|
||||||
|
|
||||||
if (! $this->operationRun instanceof OperationRun) {
|
if (! $this->operationRun instanceof OperationRun) {
|
||||||
@ -93,6 +108,7 @@ public function handle(
|
|||||||
: null;
|
: null;
|
||||||
|
|
||||||
$effectiveScope = BaselineScope::fromJsonb($context['effective_scope'] ?? null);
|
$effectiveScope = BaselineScope::fromJsonb($context['effective_scope'] ?? null);
|
||||||
|
$truthfulTypes = $this->truthfulTypesFromContext($context, $effectiveScope);
|
||||||
|
|
||||||
$captureMode = $profile->capture_mode instanceof BaselineCaptureMode
|
$captureMode = $profile->capture_mode instanceof BaselineCaptureMode
|
||||||
? $profile->capture_mode
|
? $profile->capture_mode
|
||||||
@ -112,6 +128,7 @@ public function handle(
|
|||||||
scope: $effectiveScope,
|
scope: $effectiveScope,
|
||||||
identity: $identity,
|
identity: $identity,
|
||||||
latestInventorySyncRunId: $latestInventorySyncRunId,
|
latestInventorySyncRunId: $latestInventorySyncRunId,
|
||||||
|
policyTypes: $truthfulTypes,
|
||||||
);
|
);
|
||||||
|
|
||||||
$subjects = $inventoryResult['subjects'];
|
$subjects = $inventoryResult['subjects'];
|
||||||
@ -183,7 +200,12 @@ public function handle(
|
|||||||
gaps: $captureGaps,
|
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);
|
$identityHash = $identity->computeIdentity($items);
|
||||||
|
|
||||||
@ -200,16 +222,17 @@ public function handle(
|
|||||||
],
|
],
|
||||||
];
|
];
|
||||||
|
|
||||||
$snapshot = $this->findOrCreateSnapshot(
|
$snapshotResult = $this->captureSnapshotArtifact(
|
||||||
$profile,
|
$profile,
|
||||||
$identityHash,
|
$identityHash,
|
||||||
$items,
|
$items,
|
||||||
$snapshotSummary,
|
$snapshotSummary,
|
||||||
);
|
);
|
||||||
|
|
||||||
$wasNewSnapshot = $snapshot->wasRecentlyCreated;
|
$snapshot = $snapshotResult['snapshot'];
|
||||||
|
$wasNewSnapshot = $snapshotResult['was_new_snapshot'];
|
||||||
|
|
||||||
if ($profile->status === BaselineProfileStatus::Active) {
|
if ($profile->status === BaselineProfileStatus::Active && $snapshot->isConsumable()) {
|
||||||
$profile->update(['active_snapshot_id' => $snapshot->getKey()]);
|
$profile->update(['active_snapshot_id' => $snapshot->getKey()]);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -241,6 +264,9 @@ public function handle(
|
|||||||
'gaps' => [
|
'gaps' => [
|
||||||
'count' => $gapsCount,
|
'count' => $gapsCount,
|
||||||
'by_reason' => $gapsByReason,
|
'by_reason' => $gapsByReason,
|
||||||
|
'subjects' => is_array($phaseResult['gap_subjects'] ?? null) && $phaseResult['gap_subjects'] !== []
|
||||||
|
? array_values($phaseResult['gap_subjects'])
|
||||||
|
: null,
|
||||||
],
|
],
|
||||||
'resume_token' => $resumeToken,
|
'resume_token' => $resumeToken,
|
||||||
],
|
],
|
||||||
@ -250,6 +276,7 @@ public function handle(
|
|||||||
'snapshot_identity_hash' => $identityHash,
|
'snapshot_identity_hash' => $identityHash,
|
||||||
'was_new_snapshot' => $wasNewSnapshot,
|
'was_new_snapshot' => $wasNewSnapshot,
|
||||||
'items_captured' => $snapshotItems['items_count'],
|
'items_captured' => $snapshotItems['items_count'],
|
||||||
|
'snapshot_lifecycle' => $snapshot->lifecycleState()->value,
|
||||||
];
|
];
|
||||||
$this->operationRun->update(['context' => $updatedContext]);
|
$this->operationRun->update(['context' => $updatedContext]);
|
||||||
|
|
||||||
@ -274,7 +301,7 @@ public function handle(
|
|||||||
/**
|
/**
|
||||||
* @return array{
|
* @return array{
|
||||||
* subjects_total: int,
|
* subjects_total: int,
|
||||||
* subjects: list<array{policy_type: string, subject_external_id: string}>,
|
* subjects: list<array{policy_type: string, subject_external_id: string, subject_key: string}>,
|
||||||
* inventory_by_key: array<string, array{
|
* inventory_by_key: array<string, array{
|
||||||
* tenant_subject_external_id: string,
|
* tenant_subject_external_id: string,
|
||||||
* workspace_subject_external_id: string,
|
* workspace_subject_external_id: string,
|
||||||
@ -295,6 +322,7 @@ private function collectInventorySubjects(
|
|||||||
BaselineScope $scope,
|
BaselineScope $scope,
|
||||||
BaselineSnapshotIdentity $identity,
|
BaselineSnapshotIdentity $identity,
|
||||||
?int $latestInventorySyncRunId = null,
|
?int $latestInventorySyncRunId = null,
|
||||||
|
?array $policyTypes = null,
|
||||||
): array {
|
): array {
|
||||||
$query = InventoryItem::query()
|
$query = InventoryItem::query()
|
||||||
->where('tenant_id', $sourceTenant->getKey());
|
->where('tenant_id', $sourceTenant->getKey());
|
||||||
@ -303,7 +331,7 @@ private function collectInventorySubjects(
|
|||||||
$query->where('last_seen_operation_run_id', $latestInventorySyncRunId);
|
$query->where('last_seen_operation_run_id', $latestInventorySyncRunId);
|
||||||
}
|
}
|
||||||
|
|
||||||
$query->whereIn('policy_type', $scope->allTypes());
|
$query->whereIn('policy_type', is_array($policyTypes) && $policyTypes !== [] ? $policyTypes : $scope->allTypes());
|
||||||
|
|
||||||
/** @var array<string, array{tenant_subject_external_id: string, workspace_subject_external_id: string, subject_key: string, policy_type: string, identity_strategy: string, display_name: ?string, category: ?string, platform: ?string, is_built_in: ?bool, role_permission_count: ?int}> $inventoryByKey */
|
/** @var array<string, array{tenant_subject_external_id: string, workspace_subject_external_id: string, subject_key: string, policy_type: string, identity_strategy: string, display_name: ?string, category: ?string, platform: ?string, is_built_in: ?bool, role_permission_count: ?int}> $inventoryByKey */
|
||||||
$inventoryByKey = [];
|
$inventoryByKey = [];
|
||||||
@ -391,6 +419,7 @@ private function collectInventorySubjects(
|
|||||||
static fn (array $item): array => [
|
static fn (array $item): array => [
|
||||||
'policy_type' => (string) $item['policy_type'],
|
'policy_type' => (string) $item['policy_type'],
|
||||||
'subject_external_id' => (string) $item['tenant_subject_external_id'],
|
'subject_external_id' => (string) $item['tenant_subject_external_id'],
|
||||||
|
'subject_key' => (string) $item['subject_key'],
|
||||||
],
|
],
|
||||||
$inventoryByKey,
|
$inventoryByKey,
|
||||||
));
|
));
|
||||||
@ -403,6 +432,27 @@ private function collectInventorySubjects(
|
|||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $context
|
||||||
|
* @return list<string>
|
||||||
|
*/
|
||||||
|
private function truthfulTypesFromContext(array $context, BaselineScope $effectiveScope): array
|
||||||
|
{
|
||||||
|
$truthfulTypes = data_get($context, 'effective_scope.truthful_types');
|
||||||
|
|
||||||
|
if (is_array($truthfulTypes)) {
|
||||||
|
$truthfulTypes = array_values(array_filter($truthfulTypes, 'is_string'));
|
||||||
|
|
||||||
|
if ($truthfulTypes !== []) {
|
||||||
|
sort($truthfulTypes, SORT_STRING);
|
||||||
|
|
||||||
|
return $truthfulTypes;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $effectiveScope->allTypes();
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param array<string, array{
|
* @param array<string, array{
|
||||||
* tenant_subject_external_id: string,
|
* tenant_subject_external_id: string,
|
||||||
@ -500,29 +550,151 @@ private function buildSnapshotItems(
|
|||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
private function findOrCreateSnapshot(
|
/**
|
||||||
|
* @param array<int, array<string, mixed>> $snapshotItems
|
||||||
|
* @param array<string, mixed> $summaryJsonb
|
||||||
|
* @return array{snapshot: BaselineSnapshot, was_new_snapshot: bool}
|
||||||
|
*/
|
||||||
|
private function captureSnapshotArtifact(
|
||||||
BaselineProfile $profile,
|
BaselineProfile $profile,
|
||||||
string $identityHash,
|
string $identityHash,
|
||||||
array $snapshotItems,
|
array $snapshotItems,
|
||||||
array $summaryJsonb,
|
array $summaryJsonb,
|
||||||
): BaselineSnapshot {
|
): array {
|
||||||
|
$existing = $this->findExistingConsumableSnapshot($profile, $identityHash);
|
||||||
|
|
||||||
|
if ($existing instanceof BaselineSnapshot) {
|
||||||
|
$this->rememberSnapshotOnRun(
|
||||||
|
snapshot: $existing,
|
||||||
|
identityHash: $identityHash,
|
||||||
|
wasNewSnapshot: false,
|
||||||
|
expectedItems: count($snapshotItems),
|
||||||
|
persistedItems: count($snapshotItems),
|
||||||
|
);
|
||||||
|
|
||||||
|
return [
|
||||||
|
'snapshot' => $existing,
|
||||||
|
'was_new_snapshot' => false,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
$expectedItems = count($snapshotItems);
|
||||||
|
$snapshot = $this->createBuildingSnapshot($profile, $identityHash, $summaryJsonb, $expectedItems);
|
||||||
|
|
||||||
|
$this->rememberSnapshotOnRun(
|
||||||
|
snapshot: $snapshot,
|
||||||
|
identityHash: $identityHash,
|
||||||
|
wasNewSnapshot: true,
|
||||||
|
expectedItems: $expectedItems,
|
||||||
|
persistedItems: 0,
|
||||||
|
);
|
||||||
|
|
||||||
|
try {
|
||||||
|
$persistedItems = $this->persistSnapshotItems($snapshot, $snapshotItems);
|
||||||
|
|
||||||
|
if ($persistedItems !== $expectedItems) {
|
||||||
|
throw new RuntimeException('Baseline snapshot completion proof failed.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$snapshot->markComplete($identityHash, [
|
||||||
|
'expected_identity_hash' => $identityHash,
|
||||||
|
'expected_items' => $expectedItems,
|
||||||
|
'persisted_items' => $persistedItems,
|
||||||
|
'producer_run_id' => (int) $this->operationRun->getKey(),
|
||||||
|
'was_empty_capture' => $expectedItems === 0,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$snapshot->refresh();
|
||||||
|
|
||||||
|
$this->rememberSnapshotOnRun(
|
||||||
|
snapshot: $snapshot,
|
||||||
|
identityHash: $identityHash,
|
||||||
|
wasNewSnapshot: true,
|
||||||
|
expectedItems: $expectedItems,
|
||||||
|
persistedItems: $persistedItems,
|
||||||
|
);
|
||||||
|
|
||||||
|
return [
|
||||||
|
'snapshot' => $snapshot,
|
||||||
|
'was_new_snapshot' => true,
|
||||||
|
];
|
||||||
|
} catch (Throwable $exception) {
|
||||||
|
$persistedItems = (int) BaselineSnapshotItem::query()
|
||||||
|
->where('baseline_snapshot_id', (int) $snapshot->getKey())
|
||||||
|
->count();
|
||||||
|
|
||||||
|
$reasonCode = $exception instanceof RuntimeException
|
||||||
|
&& $exception->getMessage() === 'Baseline snapshot completion proof failed.'
|
||||||
|
? BaselineReasonCodes::SNAPSHOT_COMPLETION_PROOF_FAILED
|
||||||
|
: BaselineReasonCodes::SNAPSHOT_CAPTURE_FAILED;
|
||||||
|
|
||||||
|
$snapshot->markIncomplete($reasonCode, [
|
||||||
|
'expected_identity_hash' => $identityHash,
|
||||||
|
'expected_items' => $expectedItems,
|
||||||
|
'persisted_items' => $persistedItems,
|
||||||
|
'producer_run_id' => (int) $this->operationRun->getKey(),
|
||||||
|
'was_empty_capture' => $expectedItems === 0,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$snapshot->refresh();
|
||||||
|
|
||||||
|
$this->rememberSnapshotOnRun(
|
||||||
|
snapshot: $snapshot,
|
||||||
|
identityHash: $identityHash,
|
||||||
|
wasNewSnapshot: true,
|
||||||
|
expectedItems: $expectedItems,
|
||||||
|
persistedItems: $persistedItems,
|
||||||
|
reasonCode: $reasonCode,
|
||||||
|
);
|
||||||
|
|
||||||
|
throw $exception;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private function findExistingConsumableSnapshot(BaselineProfile $profile, string $identityHash): ?BaselineSnapshot
|
||||||
|
{
|
||||||
$existing = BaselineSnapshot::query()
|
$existing = BaselineSnapshot::query()
|
||||||
->where('workspace_id', $profile->workspace_id)
|
->where('workspace_id', $profile->workspace_id)
|
||||||
->where('baseline_profile_id', $profile->getKey())
|
->where('baseline_profile_id', $profile->getKey())
|
||||||
->where('snapshot_identity_hash', $identityHash)
|
->where('snapshot_identity_hash', $identityHash)
|
||||||
|
->where('lifecycle_state', BaselineSnapshotLifecycleState::Complete->value)
|
||||||
->first();
|
->first();
|
||||||
|
|
||||||
if ($existing instanceof BaselineSnapshot) {
|
return $existing instanceof BaselineSnapshot ? $existing : null;
|
||||||
return $existing;
|
}
|
||||||
}
|
|
||||||
|
|
||||||
$snapshot = BaselineSnapshot::create([
|
/**
|
||||||
|
* @param array<string, mixed> $summaryJsonb
|
||||||
|
*/
|
||||||
|
private function createBuildingSnapshot(
|
||||||
|
BaselineProfile $profile,
|
||||||
|
string $identityHash,
|
||||||
|
array $summaryJsonb,
|
||||||
|
int $expectedItems,
|
||||||
|
): BaselineSnapshot {
|
||||||
|
return BaselineSnapshot::create([
|
||||||
'workspace_id' => (int) $profile->workspace_id,
|
'workspace_id' => (int) $profile->workspace_id,
|
||||||
'baseline_profile_id' => (int) $profile->getKey(),
|
'baseline_profile_id' => (int) $profile->getKey(),
|
||||||
'snapshot_identity_hash' => $identityHash,
|
'snapshot_identity_hash' => $this->temporarySnapshotIdentityHash($profile),
|
||||||
'captured_at' => now(),
|
'captured_at' => now(),
|
||||||
|
'lifecycle_state' => BaselineSnapshotLifecycleState::Building->value,
|
||||||
'summary_jsonb' => $summaryJsonb,
|
'summary_jsonb' => $summaryJsonb,
|
||||||
|
'completion_meta_jsonb' => [
|
||||||
|
'expected_identity_hash' => $identityHash,
|
||||||
|
'expected_items' => $expectedItems,
|
||||||
|
'persisted_items' => 0,
|
||||||
|
'producer_run_id' => (int) $this->operationRun->getKey(),
|
||||||
|
'was_empty_capture' => $expectedItems === 0,
|
||||||
|
],
|
||||||
]);
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<int, array<string, mixed>> $snapshotItems
|
||||||
|
*/
|
||||||
|
private function persistSnapshotItems(BaselineSnapshot $snapshot, array $snapshotItems): int
|
||||||
|
{
|
||||||
|
$persistedItems = 0;
|
||||||
|
|
||||||
foreach (array_chunk($snapshotItems, 100) as $chunk) {
|
foreach (array_chunk($snapshotItems, 100) as $chunk) {
|
||||||
$rows = array_map(
|
$rows = array_map(
|
||||||
@ -541,9 +713,56 @@ private function findOrCreateSnapshot(
|
|||||||
);
|
);
|
||||||
|
|
||||||
BaselineSnapshotItem::insert($rows);
|
BaselineSnapshotItem::insert($rows);
|
||||||
|
$persistedItems += count($rows);
|
||||||
}
|
}
|
||||||
|
|
||||||
return $snapshot;
|
return $persistedItems;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function temporarySnapshotIdentityHash(BaselineProfile $profile): string
|
||||||
|
{
|
||||||
|
return hash(
|
||||||
|
'sha256',
|
||||||
|
implode('|', [
|
||||||
|
'building',
|
||||||
|
(string) $profile->getKey(),
|
||||||
|
(string) $this->operationRun->getKey(),
|
||||||
|
(string) microtime(true),
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function rememberSnapshotOnRun(
|
||||||
|
BaselineSnapshot $snapshot,
|
||||||
|
string $identityHash,
|
||||||
|
bool $wasNewSnapshot,
|
||||||
|
int $expectedItems,
|
||||||
|
int $persistedItems,
|
||||||
|
?string $reasonCode = null,
|
||||||
|
): void {
|
||||||
|
$context = is_array($this->operationRun->context) ? $this->operationRun->context : [];
|
||||||
|
$context['baseline_snapshot_id'] = (int) $snapshot->getKey();
|
||||||
|
$context['result'] = array_merge(
|
||||||
|
is_array($context['result'] ?? null) ? $context['result'] : [],
|
||||||
|
[
|
||||||
|
'snapshot_id' => (int) $snapshot->getKey(),
|
||||||
|
'snapshot_identity_hash' => $identityHash,
|
||||||
|
'was_new_snapshot' => $wasNewSnapshot,
|
||||||
|
'items_captured' => $persistedItems,
|
||||||
|
'snapshot_lifecycle' => $snapshot->lifecycleState()->value,
|
||||||
|
'expected_items' => $expectedItems,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
if (is_string($reasonCode) && $reasonCode !== '') {
|
||||||
|
$context['reason_code'] = $reasonCode;
|
||||||
|
$context['result']['snapshot_reason_code'] = $reasonCode;
|
||||||
|
} else {
|
||||||
|
unset($context['reason_code'], $context['result']['snapshot_reason_code']);
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->operationRun->update(['context' => $context]);
|
||||||
|
$this->operationRun->refresh();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@ -4,6 +4,7 @@
|
|||||||
|
|
||||||
namespace App\Jobs;
|
namespace App\Jobs;
|
||||||
|
|
||||||
|
use App\Jobs\Concerns\BridgesFailedOperationRun;
|
||||||
use App\Jobs\Middleware\TrackOperationRun;
|
use App\Jobs\Middleware\TrackOperationRun;
|
||||||
use App\Models\BaselineProfile;
|
use App\Models\BaselineProfile;
|
||||||
use App\Models\BaselineSnapshot;
|
use App\Models\BaselineSnapshot;
|
||||||
@ -18,6 +19,7 @@
|
|||||||
use App\Services\Baselines\BaselineAutoCloseService;
|
use App\Services\Baselines\BaselineAutoCloseService;
|
||||||
use App\Services\Baselines\BaselineContentCapturePhase;
|
use App\Services\Baselines\BaselineContentCapturePhase;
|
||||||
use App\Services\Baselines\BaselineSnapshotIdentity;
|
use App\Services\Baselines\BaselineSnapshotIdentity;
|
||||||
|
use App\Services\Baselines\BaselineSnapshotTruthResolver;
|
||||||
use App\Services\Baselines\CurrentStateHashResolver;
|
use App\Services\Baselines\CurrentStateHashResolver;
|
||||||
use App\Services\Baselines\Evidence\BaselinePolicyVersionResolver;
|
use App\Services\Baselines\Evidence\BaselinePolicyVersionResolver;
|
||||||
use App\Services\Baselines\Evidence\ContentEvidenceProvider;
|
use App\Services\Baselines\Evidence\ContentEvidenceProvider;
|
||||||
@ -37,13 +39,16 @@
|
|||||||
use App\Support\Baselines\BaselineCaptureMode;
|
use App\Support\Baselines\BaselineCaptureMode;
|
||||||
use App\Support\Baselines\BaselineCompareReasonCode;
|
use App\Support\Baselines\BaselineCompareReasonCode;
|
||||||
use App\Support\Baselines\BaselineFullContentRolloutGate;
|
use App\Support\Baselines\BaselineFullContentRolloutGate;
|
||||||
|
use App\Support\Baselines\BaselineReasonCodes;
|
||||||
use App\Support\Baselines\BaselineScope;
|
use App\Support\Baselines\BaselineScope;
|
||||||
use App\Support\Baselines\BaselineSubjectKey;
|
use App\Support\Baselines\BaselineSubjectKey;
|
||||||
use App\Support\Baselines\PolicyVersionCapturePurpose;
|
use App\Support\Baselines\PolicyVersionCapturePurpose;
|
||||||
|
use App\Support\Baselines\SubjectResolver;
|
||||||
use App\Support\Inventory\InventoryCoverage;
|
use App\Support\Inventory\InventoryCoverage;
|
||||||
use App\Support\OperationRunOutcome;
|
use App\Support\OperationRunOutcome;
|
||||||
use App\Support\OperationRunStatus;
|
use App\Support\OperationRunStatus;
|
||||||
use App\Support\OperationRunType;
|
use App\Support\OperationRunType;
|
||||||
|
use App\Support\ReasonTranslation\ReasonPresenter;
|
||||||
use Carbon\CarbonImmutable;
|
use Carbon\CarbonImmutable;
|
||||||
use Illuminate\Bus\Queueable;
|
use Illuminate\Bus\Queueable;
|
||||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||||
@ -54,7 +59,15 @@
|
|||||||
|
|
||||||
class CompareBaselineToTenantJob implements ShouldQueue
|
class CompareBaselineToTenantJob implements ShouldQueue
|
||||||
{
|
{
|
||||||
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
|
use BridgesFailedOperationRun;
|
||||||
|
use Dispatchable;
|
||||||
|
use InteractsWithQueue;
|
||||||
|
use Queueable;
|
||||||
|
use SerializesModels;
|
||||||
|
|
||||||
|
public int $timeout = 300;
|
||||||
|
|
||||||
|
public bool $failOnTimeout = true;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @var array<int, string>
|
* @var array<int, string>
|
||||||
@ -84,6 +97,7 @@ public function handle(
|
|||||||
?SettingsResolver $settingsResolver = null,
|
?SettingsResolver $settingsResolver = null,
|
||||||
?BaselineAutoCloseService $baselineAutoCloseService = null,
|
?BaselineAutoCloseService $baselineAutoCloseService = null,
|
||||||
?CurrentStateHashResolver $hashResolver = null,
|
?CurrentStateHashResolver $hashResolver = null,
|
||||||
|
?BaselineSnapshotTruthResolver $snapshotTruthResolver = null,
|
||||||
?MetaEvidenceProvider $metaEvidenceProvider = null,
|
?MetaEvidenceProvider $metaEvidenceProvider = null,
|
||||||
?BaselineContentCapturePhase $contentCapturePhase = null,
|
?BaselineContentCapturePhase $contentCapturePhase = null,
|
||||||
?BaselineFullContentRolloutGate $rolloutGate = null,
|
?BaselineFullContentRolloutGate $rolloutGate = null,
|
||||||
@ -92,6 +106,7 @@ public function handle(
|
|||||||
$settingsResolver ??= app(SettingsResolver::class);
|
$settingsResolver ??= app(SettingsResolver::class);
|
||||||
$baselineAutoCloseService ??= app(BaselineAutoCloseService::class);
|
$baselineAutoCloseService ??= app(BaselineAutoCloseService::class);
|
||||||
$hashResolver ??= app(CurrentStateHashResolver::class);
|
$hashResolver ??= app(CurrentStateHashResolver::class);
|
||||||
|
$snapshotTruthResolver ??= app(BaselineSnapshotTruthResolver::class);
|
||||||
$metaEvidenceProvider ??= app(MetaEvidenceProvider::class);
|
$metaEvidenceProvider ??= app(MetaEvidenceProvider::class);
|
||||||
$contentCapturePhase ??= app(BaselineContentCapturePhase::class);
|
$contentCapturePhase ??= app(BaselineContentCapturePhase::class);
|
||||||
$rolloutGate ??= app(BaselineFullContentRolloutGate::class);
|
$rolloutGate ??= app(BaselineFullContentRolloutGate::class);
|
||||||
@ -130,7 +145,7 @@ public function handle(
|
|||||||
: null;
|
: null;
|
||||||
|
|
||||||
$effectiveScope = BaselineScope::fromJsonb($context['effective_scope'] ?? null);
|
$effectiveScope = BaselineScope::fromJsonb($context['effective_scope'] ?? null);
|
||||||
$effectiveTypes = $effectiveScope->allTypes();
|
$effectiveTypes = $this->truthfulTypesFromContext($context, $effectiveScope);
|
||||||
$scopeKey = 'baseline_profile:'.$profile->getKey();
|
$scopeKey = 'baseline_profile:'.$profile->getKey();
|
||||||
|
|
||||||
$captureMode = $profile->capture_mode instanceof BaselineCaptureMode
|
$captureMode = $profile->capture_mode instanceof BaselineCaptureMode
|
||||||
@ -278,12 +293,52 @@ public function handle(
|
|||||||
->where('workspace_id', (int) $profile->workspace_id)
|
->where('workspace_id', (int) $profile->workspace_id)
|
||||||
->where('baseline_profile_id', (int) $profile->getKey())
|
->where('baseline_profile_id', (int) $profile->getKey())
|
||||||
->whereKey($snapshotId)
|
->whereKey($snapshotId)
|
||||||
->first(['id', 'captured_at']);
|
->first();
|
||||||
|
|
||||||
if (! $snapshot instanceof BaselineSnapshot) {
|
if (! $snapshot instanceof BaselineSnapshot) {
|
||||||
throw new RuntimeException("BaselineSnapshot #{$snapshotId} not found.");
|
throw new RuntimeException("BaselineSnapshot #{$snapshotId} not found.");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$snapshotResolution = $snapshotTruthResolver->resolveCompareSnapshot($profile, $snapshot);
|
||||||
|
|
||||||
|
if (! ($snapshotResolution['ok'] ?? false)) {
|
||||||
|
$reasonCode = is_string($snapshotResolution['reason_code'] ?? null)
|
||||||
|
? (string) $snapshotResolution['reason_code']
|
||||||
|
: BaselineReasonCodes::COMPARE_NO_CONSUMABLE_SNAPSHOT;
|
||||||
|
|
||||||
|
$context = is_array($this->operationRun->context) ? $this->operationRun->context : [];
|
||||||
|
$context['baseline_compare'] = array_merge(
|
||||||
|
is_array($context['baseline_compare'] ?? null) ? $context['baseline_compare'] : [],
|
||||||
|
[
|
||||||
|
'reason_code' => $reasonCode,
|
||||||
|
'effective_snapshot_id' => $snapshotResolution['effective_snapshot']?->getKey(),
|
||||||
|
'latest_attempted_snapshot_id' => $snapshotResolution['latest_attempted_snapshot']?->getKey(),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
$context['result'] = array_merge(
|
||||||
|
is_array($context['result'] ?? null) ? $context['result'] : [],
|
||||||
|
[
|
||||||
|
'snapshot_id' => (int) $snapshot->getKey(),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
$context = $this->withCompareReasonTranslation($context, $reasonCode);
|
||||||
|
|
||||||
|
$this->operationRun->update(['context' => $context]);
|
||||||
|
$this->operationRun->refresh();
|
||||||
|
|
||||||
|
$operationRunService->finalizeBlockedRun(
|
||||||
|
run: $this->operationRun,
|
||||||
|
reasonCode: $reasonCode,
|
||||||
|
message: $this->snapshotBlockedMessage($reasonCode),
|
||||||
|
);
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @var BaselineSnapshot $snapshot */
|
||||||
|
$snapshot = $snapshotResolution['snapshot'];
|
||||||
|
$snapshotId = (int) $snapshot->getKey();
|
||||||
|
|
||||||
$since = $snapshot->captured_at instanceof \DateTimeInterface
|
$since = $snapshot->captured_at instanceof \DateTimeInterface
|
||||||
? CarbonImmutable::instance($snapshot->captured_at)
|
? CarbonImmutable::instance($snapshot->captured_at)
|
||||||
: null;
|
: null;
|
||||||
@ -309,6 +364,7 @@ public function handle(
|
|||||||
static fn (array $item): array => [
|
static fn (array $item): array => [
|
||||||
'policy_type' => (string) $item['policy_type'],
|
'policy_type' => (string) $item['policy_type'],
|
||||||
'subject_external_id' => (string) $item['subject_external_id'],
|
'subject_external_id' => (string) $item['subject_external_id'],
|
||||||
|
'subject_key' => (string) $item['subject_key'],
|
||||||
],
|
],
|
||||||
$currentItems,
|
$currentItems,
|
||||||
));
|
));
|
||||||
@ -334,6 +390,7 @@ public function handle(
|
|||||||
];
|
];
|
||||||
$phaseResult = [];
|
$phaseResult = [];
|
||||||
$phaseGaps = [];
|
$phaseGaps = [];
|
||||||
|
$phaseGapSubjects = [];
|
||||||
$resumeToken = null;
|
$resumeToken = null;
|
||||||
|
|
||||||
if ($captureMode === BaselineCaptureMode::FullContent) {
|
if ($captureMode === BaselineCaptureMode::FullContent) {
|
||||||
@ -362,6 +419,7 @@ public function handle(
|
|||||||
|
|
||||||
$phaseStats = is_array($phaseResult['stats'] ?? null) ? $phaseResult['stats'] : $phaseStats;
|
$phaseStats = is_array($phaseResult['stats'] ?? null) ? $phaseResult['stats'] : $phaseStats;
|
||||||
$phaseGaps = is_array($phaseResult['gaps'] ?? null) ? $phaseResult['gaps'] : [];
|
$phaseGaps = is_array($phaseResult['gaps'] ?? null) ? $phaseResult['gaps'] : [];
|
||||||
|
$phaseGapSubjects = is_array($phaseResult['gap_subjects'] ?? null) ? $phaseResult['gap_subjects'] : [];
|
||||||
$resumeToken = is_string($phaseResult['resume_token'] ?? null) ? $phaseResult['resume_token'] : null;
|
$resumeToken = is_string($phaseResult['resume_token'] ?? null) ? $phaseResult['resume_token'] : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -441,6 +499,12 @@ public function handle(
|
|||||||
$gapsByReason = $this->mergeGapCounts($baselineGaps, $currentGaps, $phaseGaps, $driftGaps);
|
$gapsByReason = $this->mergeGapCounts($baselineGaps, $currentGaps, $phaseGaps, $driftGaps);
|
||||||
$gapsCount = array_sum($gapsByReason);
|
$gapsCount = array_sum($gapsByReason);
|
||||||
|
|
||||||
|
$gapSubjects = $this->collectGapSubjects(
|
||||||
|
ambiguousKeys: $ambiguousKeys,
|
||||||
|
phaseGapSubjects: $phaseGapSubjects ?? [],
|
||||||
|
driftGapSubjects: $computeResult['evidence_gap_subjects'] ?? [],
|
||||||
|
);
|
||||||
|
|
||||||
$summaryCounts = [
|
$summaryCounts = [
|
||||||
'total' => count($driftResults),
|
'total' => count($driftResults),
|
||||||
'processed' => count($driftResults),
|
'processed' => count($driftResults),
|
||||||
@ -518,6 +582,7 @@ public function handle(
|
|||||||
'count' => $gapsCount,
|
'count' => $gapsCount,
|
||||||
'by_reason' => $gapsByReason,
|
'by_reason' => $gapsByReason,
|
||||||
...$gapsByReason,
|
...$gapsByReason,
|
||||||
|
'subjects' => $gapSubjects !== [] ? $gapSubjects : null,
|
||||||
],
|
],
|
||||||
'resume_token' => $resumeToken,
|
'resume_token' => $resumeToken,
|
||||||
'coverage' => [
|
'coverage' => [
|
||||||
@ -545,6 +610,10 @@ public function handle(
|
|||||||
'findings_resolved' => $resolvedCount,
|
'findings_resolved' => $resolvedCount,
|
||||||
'severity_breakdown' => $severityBreakdown,
|
'severity_breakdown' => $severityBreakdown,
|
||||||
];
|
];
|
||||||
|
$updatedContext = $this->withCompareReasonTranslation(
|
||||||
|
$updatedContext,
|
||||||
|
$reasonCode?->value,
|
||||||
|
);
|
||||||
$this->operationRun->update(['context' => $updatedContext]);
|
$this->operationRun->update(['context' => $updatedContext]);
|
||||||
|
|
||||||
$this->auditCompleted(
|
$this->auditCompleted(
|
||||||
@ -790,6 +859,7 @@ private function completeWithCoverageWarning(
|
|||||||
'findings_resolved' => 0,
|
'findings_resolved' => 0,
|
||||||
'severity_breakdown' => [],
|
'severity_breakdown' => [],
|
||||||
];
|
];
|
||||||
|
$updatedContext = $this->withCompareReasonTranslation($updatedContext, $reasonCode->value);
|
||||||
|
|
||||||
$this->operationRun->update(['context' => $updatedContext]);
|
$this->operationRun->update(['context' => $updatedContext]);
|
||||||
|
|
||||||
@ -896,6 +966,34 @@ private function loadBaselineItems(int $snapshotId, array $policyTypes): array
|
|||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $context
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
private function withCompareReasonTranslation(array $context, ?string $reasonCode): array
|
||||||
|
{
|
||||||
|
if (! is_string($reasonCode) || trim($reasonCode) === '') {
|
||||||
|
unset($context['reason_translation'], $context['next_steps']);
|
||||||
|
|
||||||
|
return $context;
|
||||||
|
}
|
||||||
|
|
||||||
|
$translation = app(ReasonPresenter::class)->forArtifactTruth($reasonCode, 'baseline_compare');
|
||||||
|
|
||||||
|
if ($translation === null) {
|
||||||
|
return $context;
|
||||||
|
}
|
||||||
|
|
||||||
|
$context['reason_translation'] = $translation->toArray();
|
||||||
|
$context['reason_code'] = $reasonCode;
|
||||||
|
|
||||||
|
if ($translation->toLegacyNextSteps() !== []) {
|
||||||
|
$context['next_steps'] = $translation->toLegacyNextSteps();
|
||||||
|
}
|
||||||
|
|
||||||
|
return $context;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Load current inventory items keyed by "policy_type|subject_key".
|
* Load current inventory items keyed by "policy_type|subject_key".
|
||||||
*
|
*
|
||||||
@ -1004,6 +1102,38 @@ private function resolveLatestInventorySyncRun(Tenant $tenant): ?OperationRun
|
|||||||
return $run instanceof OperationRun ? $run : null;
|
return $run instanceof OperationRun ? $run : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private function snapshotBlockedMessage(string $reasonCode): string
|
||||||
|
{
|
||||||
|
return match ($reasonCode) {
|
||||||
|
BaselineReasonCodes::COMPARE_SNAPSHOT_BUILDING => 'The selected baseline snapshot is still building and cannot be used for compare yet.',
|
||||||
|
BaselineReasonCodes::COMPARE_SNAPSHOT_INCOMPLETE => 'The selected baseline snapshot is incomplete and cannot be used for compare.',
|
||||||
|
BaselineReasonCodes::COMPARE_SNAPSHOT_SUPERSEDED => 'A newer complete baseline snapshot is now current, so this historical snapshot is blocked from compare.',
|
||||||
|
BaselineReasonCodes::COMPARE_INVALID_SNAPSHOT => 'The selected baseline snapshot is no longer available.',
|
||||||
|
default => 'No consumable baseline snapshot is currently available for compare.',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $context
|
||||||
|
* @return list<string>
|
||||||
|
*/
|
||||||
|
private function truthfulTypesFromContext(array $context, BaselineScope $effectiveScope): array
|
||||||
|
{
|
||||||
|
$truthfulTypes = data_get($context, 'effective_scope.truthful_types');
|
||||||
|
|
||||||
|
if (is_array($truthfulTypes)) {
|
||||||
|
$truthfulTypes = array_values(array_filter($truthfulTypes, 'is_string'));
|
||||||
|
|
||||||
|
if ($truthfulTypes !== []) {
|
||||||
|
sort($truthfulTypes, SORT_STRING);
|
||||||
|
|
||||||
|
return $truthfulTypes;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $effectiveScope->allTypes();
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Compare baseline items vs current inventory and produce drift results.
|
* Compare baseline items vs current inventory and produce drift results.
|
||||||
*
|
*
|
||||||
@ -1036,6 +1166,7 @@ private function computeDrift(
|
|||||||
): array {
|
): array {
|
||||||
$drift = [];
|
$drift = [];
|
||||||
$evidenceGaps = [];
|
$evidenceGaps = [];
|
||||||
|
$evidenceGapSubjects = [];
|
||||||
$rbacRoleDefinitionSummary = $this->emptyRbacRoleDefinitionSummary();
|
$rbacRoleDefinitionSummary = $this->emptyRbacRoleDefinitionSummary();
|
||||||
$roleDefinitionNormalizer = app(IntuneRoleDefinitionNormalizer::class);
|
$roleDefinitionNormalizer = app(IntuneRoleDefinitionNormalizer::class);
|
||||||
|
|
||||||
@ -1077,6 +1208,7 @@ private function computeDrift(
|
|||||||
if (! is_array($currentItem)) {
|
if (! is_array($currentItem)) {
|
||||||
if ($isRbacRoleDefinition && $baselinePolicyVersionId === null) {
|
if ($isRbacRoleDefinition && $baselinePolicyVersionId === null) {
|
||||||
$evidenceGaps['missing_role_definition_baseline_version_reference'] = ($evidenceGaps['missing_role_definition_baseline_version_reference'] ?? 0) + 1;
|
$evidenceGaps['missing_role_definition_baseline_version_reference'] = ($evidenceGaps['missing_role_definition_baseline_version_reference'] ?? 0) + 1;
|
||||||
|
$evidenceGapSubjects['missing_role_definition_baseline_version_reference'][] = $key;
|
||||||
|
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@ -1141,6 +1273,7 @@ private function computeDrift(
|
|||||||
|
|
||||||
if (! $currentEvidence instanceof ResolvedEvidence) {
|
if (! $currentEvidence instanceof ResolvedEvidence) {
|
||||||
$evidenceGaps['missing_current'] = ($evidenceGaps['missing_current'] ?? 0) + 1;
|
$evidenceGaps['missing_current'] = ($evidenceGaps['missing_current'] ?? 0) + 1;
|
||||||
|
$evidenceGapSubjects['missing_current'][] = $key;
|
||||||
|
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@ -1157,12 +1290,14 @@ private function computeDrift(
|
|||||||
if ($isRbacRoleDefinition) {
|
if ($isRbacRoleDefinition) {
|
||||||
if ($baselinePolicyVersionId === null) {
|
if ($baselinePolicyVersionId === null) {
|
||||||
$evidenceGaps['missing_role_definition_baseline_version_reference'] = ($evidenceGaps['missing_role_definition_baseline_version_reference'] ?? 0) + 1;
|
$evidenceGaps['missing_role_definition_baseline_version_reference'] = ($evidenceGaps['missing_role_definition_baseline_version_reference'] ?? 0) + 1;
|
||||||
|
$evidenceGapSubjects['missing_role_definition_baseline_version_reference'][] = $key;
|
||||||
|
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($currentPolicyVersionId === null) {
|
if ($currentPolicyVersionId === null) {
|
||||||
$evidenceGaps['missing_role_definition_current_version_reference'] = ($evidenceGaps['missing_role_definition_current_version_reference'] ?? 0) + 1;
|
$evidenceGaps['missing_role_definition_current_version_reference'] = ($evidenceGaps['missing_role_definition_current_version_reference'] ?? 0) + 1;
|
||||||
|
$evidenceGapSubjects['missing_role_definition_current_version_reference'][] = $key;
|
||||||
|
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@ -1176,6 +1311,7 @@ private function computeDrift(
|
|||||||
|
|
||||||
if ($roleDefinitionDiff === null) {
|
if ($roleDefinitionDiff === null) {
|
||||||
$evidenceGaps['missing_role_definition_compare_surface'] = ($evidenceGaps['missing_role_definition_compare_surface'] ?? 0) + 1;
|
$evidenceGaps['missing_role_definition_compare_surface'] = ($evidenceGaps['missing_role_definition_compare_surface'] ?? 0) + 1;
|
||||||
|
$evidenceGapSubjects['missing_role_definition_compare_surface'][] = $key;
|
||||||
|
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@ -1256,6 +1392,7 @@ private function computeDrift(
|
|||||||
|
|
||||||
if (! $currentEvidence instanceof ResolvedEvidence) {
|
if (! $currentEvidence instanceof ResolvedEvidence) {
|
||||||
$evidenceGaps['missing_current'] = ($evidenceGaps['missing_current'] ?? 0) + 1;
|
$evidenceGaps['missing_current'] = ($evidenceGaps['missing_current'] ?? 0) + 1;
|
||||||
|
$evidenceGapSubjects['missing_current'][] = $key;
|
||||||
|
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@ -1271,6 +1408,7 @@ private function computeDrift(
|
|||||||
|
|
||||||
if ($isRbacRoleDefinition && $currentPolicyVersionId === null) {
|
if ($isRbacRoleDefinition && $currentPolicyVersionId === null) {
|
||||||
$evidenceGaps['missing_role_definition_current_version_reference'] = ($evidenceGaps['missing_role_definition_current_version_reference'] ?? 0) + 1;
|
$evidenceGaps['missing_role_definition_current_version_reference'] = ($evidenceGaps['missing_role_definition_current_version_reference'] ?? 0) + 1;
|
||||||
|
$evidenceGapSubjects['missing_role_definition_current_version_reference'][] = $key;
|
||||||
|
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@ -1330,6 +1468,7 @@ private function computeDrift(
|
|||||||
return [
|
return [
|
||||||
'drift' => $drift,
|
'drift' => $drift,
|
||||||
'evidence_gaps' => $evidenceGaps,
|
'evidence_gaps' => $evidenceGaps,
|
||||||
|
'evidence_gap_subjects' => $evidenceGapSubjects,
|
||||||
'rbac_role_definitions' => $rbacRoleDefinitionSummary,
|
'rbac_role_definitions' => $rbacRoleDefinitionSummary,
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
@ -1841,6 +1980,163 @@ private function mergeGapCounts(array ...$gaps): array
|
|||||||
return $merged;
|
return $merged;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private const GAP_SUBJECTS_LIMIT = 50;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param list<string> $ambiguousKeys
|
||||||
|
* @return list<array<string, mixed>>
|
||||||
|
*/
|
||||||
|
private function collectGapSubjects(array $ambiguousKeys, mixed $phaseGapSubjects, mixed $driftGapSubjects): array
|
||||||
|
{
|
||||||
|
$subjects = [];
|
||||||
|
$seen = [];
|
||||||
|
|
||||||
|
if ($ambiguousKeys !== []) {
|
||||||
|
foreach (array_slice($ambiguousKeys, 0, self::GAP_SUBJECTS_LIMIT) as $ambiguousKey) {
|
||||||
|
if (! is_string($ambiguousKey) || $ambiguousKey === '') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
[$policyType, $subjectKey] = $this->splitGapSubjectKey($ambiguousKey);
|
||||||
|
|
||||||
|
if ($policyType === null || $subjectKey === null) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$descriptor = $this->subjectResolver()->describeForCompare($policyType, subjectKey: $subjectKey);
|
||||||
|
$record = array_merge($descriptor->toArray(), $this->subjectResolver()->ambiguousMatch($descriptor)->toArray());
|
||||||
|
$fingerprint = md5(json_encode([$record['policy_type'], $record['subject_key'], $record['reason_code']]));
|
||||||
|
|
||||||
|
if (isset($seen[$fingerprint])) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$seen[$fingerprint] = true;
|
||||||
|
$subjects[] = $record;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($this->normalizeStructuredGapSubjects($phaseGapSubjects) as $record) {
|
||||||
|
$fingerprint = md5(json_encode([$record['policy_type'] ?? null, $record['subject_key'] ?? null, $record['reason_code'] ?? null]));
|
||||||
|
|
||||||
|
if (isset($seen[$fingerprint])) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$seen[$fingerprint] = true;
|
||||||
|
$subjects[] = $record;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($this->normalizeLegacyGapSubjects($driftGapSubjects) as $record) {
|
||||||
|
$fingerprint = md5(json_encode([$record['policy_type'] ?? null, $record['subject_key'] ?? null, $record['reason_code'] ?? null]));
|
||||||
|
|
||||||
|
if (isset($seen[$fingerprint])) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$seen[$fingerprint] = true;
|
||||||
|
$subjects[] = $record;
|
||||||
|
}
|
||||||
|
|
||||||
|
return array_slice($subjects, 0, self::GAP_SUBJECTS_LIMIT);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return list<array<string, mixed>>
|
||||||
|
*/
|
||||||
|
private function normalizeStructuredGapSubjects(mixed $value): array
|
||||||
|
{
|
||||||
|
if (! is_array($value)) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
$subjects = [];
|
||||||
|
|
||||||
|
foreach ($value as $record) {
|
||||||
|
if (! is_array($record)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! is_string($record['policy_type'] ?? null) || ! is_string($record['subject_key'] ?? null) || ! is_string($record['reason_code'] ?? null)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$subjects[] = $record;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $subjects;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return list<array<string, mixed>>
|
||||||
|
*/
|
||||||
|
private function normalizeLegacyGapSubjects(mixed $value): array
|
||||||
|
{
|
||||||
|
if (! is_array($value)) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
$subjects = [];
|
||||||
|
|
||||||
|
foreach ($value as $reasonCode => $keys) {
|
||||||
|
if (! is_string($reasonCode) || ! is_array($keys)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($keys as $key) {
|
||||||
|
if (! is_string($key) || $key === '') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
[$policyType, $subjectKey] = $this->splitGapSubjectKey($key);
|
||||||
|
|
||||||
|
if ($policyType === null || $subjectKey === null) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$descriptor = $this->subjectResolver()->describeForCompare($policyType, subjectKey: $subjectKey);
|
||||||
|
$outcome = match ($reasonCode) {
|
||||||
|
'missing_current' => $this->subjectResolver()->missingExpectedRecord($descriptor),
|
||||||
|
'ambiguous_match' => $this->subjectResolver()->ambiguousMatch($descriptor),
|
||||||
|
default => $this->subjectResolver()->captureFailed($descriptor),
|
||||||
|
};
|
||||||
|
|
||||||
|
$record = array_merge($descriptor->toArray(), $outcome->toArray());
|
||||||
|
$record['reason_code'] = $reasonCode;
|
||||||
|
$subjects[] = $record;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $subjects;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array{0: ?string, 1: ?string}
|
||||||
|
*/
|
||||||
|
private function splitGapSubjectKey(string $value): array
|
||||||
|
{
|
||||||
|
$parts = explode('|', $value, 2);
|
||||||
|
|
||||||
|
if (count($parts) !== 2) {
|
||||||
|
return [null, null];
|
||||||
|
}
|
||||||
|
|
||||||
|
[$policyType, $subjectKey] = $parts;
|
||||||
|
$policyType = trim($policyType);
|
||||||
|
$subjectKey = trim($subjectKey);
|
||||||
|
|
||||||
|
if ($policyType === '' || $subjectKey === '') {
|
||||||
|
return [null, null];
|
||||||
|
}
|
||||||
|
|
||||||
|
return [$policyType, $subjectKey];
|
||||||
|
}
|
||||||
|
|
||||||
|
private function subjectResolver(): SubjectResolver
|
||||||
|
{
|
||||||
|
return app(SubjectResolver::class);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param array<string, array{subject_external_id: string, policy_type: string, meta_jsonb: array<string, mixed>}> $currentItems
|
* @param array<string, array{subject_external_id: string, policy_type: string, meta_jsonb: array<string, mixed>}> $currentItems
|
||||||
* @param array<string, ResolvedEvidence|null> $resolvedCurrentEvidence
|
* @param array<string, ResolvedEvidence|null> $resolvedCurrentEvidence
|
||||||
|
|||||||
@ -4,6 +4,7 @@
|
|||||||
|
|
||||||
namespace App\Jobs;
|
namespace App\Jobs;
|
||||||
|
|
||||||
|
use App\Jobs\Concerns\BridgesFailedOperationRun;
|
||||||
use App\Models\OperationRun;
|
use App\Models\OperationRun;
|
||||||
use App\Models\TenantReview;
|
use App\Models\TenantReview;
|
||||||
use App\Services\OperationRunService;
|
use App\Services\OperationRunService;
|
||||||
@ -17,8 +18,13 @@
|
|||||||
|
|
||||||
class ComposeTenantReviewJob implements ShouldQueue
|
class ComposeTenantReviewJob implements ShouldQueue
|
||||||
{
|
{
|
||||||
|
use BridgesFailedOperationRun;
|
||||||
use Queueable;
|
use Queueable;
|
||||||
|
|
||||||
|
public int $timeout = 240;
|
||||||
|
|
||||||
|
public bool $failOnTimeout = true;
|
||||||
|
|
||||||
public function __construct(
|
public function __construct(
|
||||||
public int $tenantReviewId,
|
public int $tenantReviewId,
|
||||||
public int $operationRunId,
|
public int $operationRunId,
|
||||||
|
|||||||
58
app/Jobs/Concerns/BridgesFailedOperationRun.php
Normal file
58
app/Jobs/Concerns/BridgesFailedOperationRun.php
Normal file
@ -0,0 +1,58 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Jobs\Concerns;
|
||||||
|
|
||||||
|
use App\Models\OperationRun;
|
||||||
|
use App\Services\OperationRunService;
|
||||||
|
use Throwable;
|
||||||
|
|
||||||
|
trait BridgesFailedOperationRun
|
||||||
|
{
|
||||||
|
public function failed(Throwable $exception): void
|
||||||
|
{
|
||||||
|
$operationRun = $this->failedBridgeOperationRun();
|
||||||
|
|
||||||
|
if (! $operationRun instanceof OperationRun) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
app(OperationRunService::class)->bridgeFailedJobFailure($operationRun, $exception);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function failedBridgeOperationRun(): ?OperationRun
|
||||||
|
{
|
||||||
|
if (property_exists($this, 'operationRun') && $this->operationRun instanceof OperationRun) {
|
||||||
|
return $this->operationRun;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (property_exists($this, 'run') && $this->run instanceof OperationRun) {
|
||||||
|
return $this->run;
|
||||||
|
}
|
||||||
|
|
||||||
|
$candidateIds = [];
|
||||||
|
|
||||||
|
foreach (['operationRunId', 'bulkRunId', 'runId'] as $property) {
|
||||||
|
if (! property_exists($this, $property)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$value = $this->{$property};
|
||||||
|
|
||||||
|
if (is_numeric($value) && (int) $value > 0) {
|
||||||
|
$candidateIds[] = (int) $value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (array_values(array_unique($candidateIds)) as $candidateId) {
|
||||||
|
$operationRun = OperationRun::query()->find($candidateId);
|
||||||
|
|
||||||
|
if ($operationRun instanceof OperationRun) {
|
||||||
|
return $operationRun;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -20,6 +20,10 @@ class EntraGroupSyncJob implements ShouldQueue
|
|||||||
{
|
{
|
||||||
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
|
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
|
||||||
|
|
||||||
|
public int $timeout = 240;
|
||||||
|
|
||||||
|
public bool $failOnTimeout = true;
|
||||||
|
|
||||||
public ?OperationRun $operationRun = null;
|
public ?OperationRun $operationRun = null;
|
||||||
|
|
||||||
public function __construct(
|
public function __construct(
|
||||||
|
|||||||
@ -25,6 +25,10 @@ class ExecuteRestoreRunJob implements ShouldQueue
|
|||||||
{
|
{
|
||||||
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
|
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
|
||||||
|
|
||||||
|
public int $timeout = 420;
|
||||||
|
|
||||||
|
public bool $failOnTimeout = true;
|
||||||
|
|
||||||
public ?OperationRun $operationRun = null;
|
public ?OperationRun $operationRun = null;
|
||||||
|
|
||||||
public function __construct(
|
public function __construct(
|
||||||
|
|||||||
@ -19,6 +19,10 @@ class GenerateEvidenceSnapshotJob implements ShouldQueue
|
|||||||
{
|
{
|
||||||
use Queueable;
|
use Queueable;
|
||||||
|
|
||||||
|
public int $timeout = 240;
|
||||||
|
|
||||||
|
public bool $failOnTimeout = true;
|
||||||
|
|
||||||
public function __construct(
|
public function __construct(
|
||||||
public int $snapshotId,
|
public int $snapshotId,
|
||||||
public int $operationRunId,
|
public int $operationRunId,
|
||||||
|
|||||||
@ -28,6 +28,10 @@ class GenerateReviewPackJob implements ShouldQueue
|
|||||||
{
|
{
|
||||||
use Queueable;
|
use Queueable;
|
||||||
|
|
||||||
|
public int $timeout = 240;
|
||||||
|
|
||||||
|
public bool $failOnTimeout = true;
|
||||||
|
|
||||||
public function __construct(
|
public function __construct(
|
||||||
public int $reviewPackId,
|
public int $reviewPackId,
|
||||||
public int $operationRunId,
|
public int $operationRunId,
|
||||||
|
|||||||
@ -40,6 +40,10 @@ class RunBackupScheduleJob implements ShouldQueue
|
|||||||
|
|
||||||
public int $tries = 3;
|
public int $tries = 3;
|
||||||
|
|
||||||
|
public int $timeout = 300;
|
||||||
|
|
||||||
|
public bool $failOnTimeout = true;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Compatibility-only legacy field.
|
* Compatibility-only legacy field.
|
||||||
*
|
*
|
||||||
|
|||||||
@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
namespace App\Jobs;
|
namespace App\Jobs;
|
||||||
|
|
||||||
|
use App\Jobs\Concerns\BridgesFailedOperationRun;
|
||||||
use App\Jobs\Middleware\EnsureQueuedExecutionLegitimate;
|
use App\Jobs\Middleware\EnsureQueuedExecutionLegitimate;
|
||||||
use App\Jobs\Middleware\TrackOperationRun;
|
use App\Jobs\Middleware\TrackOperationRun;
|
||||||
use App\Models\OperationRun;
|
use App\Models\OperationRun;
|
||||||
@ -24,7 +25,15 @@
|
|||||||
|
|
||||||
class RunInventorySyncJob implements ShouldQueue
|
class RunInventorySyncJob implements ShouldQueue
|
||||||
{
|
{
|
||||||
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
|
use BridgesFailedOperationRun;
|
||||||
|
use Dispatchable;
|
||||||
|
use InteractsWithQueue;
|
||||||
|
use Queueable;
|
||||||
|
use SerializesModels;
|
||||||
|
|
||||||
|
public int $timeout = 240;
|
||||||
|
|
||||||
|
public bool $failOnTimeout = true;
|
||||||
|
|
||||||
public ?OperationRun $operationRun = null;
|
public ?OperationRun $operationRun = null;
|
||||||
|
|
||||||
|
|||||||
@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
namespace App\Jobs;
|
namespace App\Jobs;
|
||||||
|
|
||||||
|
use App\Jobs\Concerns\BridgesFailedOperationRun;
|
||||||
use App\Jobs\Middleware\EnsureQueuedExecutionLegitimate;
|
use App\Jobs\Middleware\EnsureQueuedExecutionLegitimate;
|
||||||
use App\Jobs\Middleware\TrackOperationRun;
|
use App\Jobs\Middleware\TrackOperationRun;
|
||||||
use App\Models\OperationRun;
|
use App\Models\OperationRun;
|
||||||
@ -21,7 +22,15 @@
|
|||||||
|
|
||||||
class SyncPoliciesJob implements ShouldQueue
|
class SyncPoliciesJob implements ShouldQueue
|
||||||
{
|
{
|
||||||
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
|
use BridgesFailedOperationRun;
|
||||||
|
use Dispatchable;
|
||||||
|
use InteractsWithQueue;
|
||||||
|
use Queueable;
|
||||||
|
use SerializesModels;
|
||||||
|
|
||||||
|
public int $timeout = 180;
|
||||||
|
|
||||||
|
public bool $failOnTimeout = true;
|
||||||
|
|
||||||
public ?OperationRun $operationRun = null;
|
public ?OperationRun $operationRun = null;
|
||||||
|
|
||||||
|
|||||||
@ -20,6 +20,10 @@ class SyncRoleDefinitionsJob implements ShouldQueue
|
|||||||
{
|
{
|
||||||
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
|
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
|
||||||
|
|
||||||
|
public int $timeout = 240;
|
||||||
|
|
||||||
|
public bool $failOnTimeout = true;
|
||||||
|
|
||||||
public ?OperationRun $operationRun = null;
|
public ?OperationRun $operationRun = null;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
254
app/Livewire/BaselineCompareEvidenceGapTable.php
Normal file
254
app/Livewire/BaselineCompareEvidenceGapTable.php
Normal file
@ -0,0 +1,254 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Livewire;
|
||||||
|
|
||||||
|
use App\Support\Badges\TagBadgeDomain;
|
||||||
|
use App\Support\Badges\TagBadgeRenderer;
|
||||||
|
use App\Support\Baselines\BaselineCompareEvidenceGapDetails;
|
||||||
|
use App\Support\Filament\TablePaginationProfiles;
|
||||||
|
use Filament\Tables\Columns\TextColumn;
|
||||||
|
use Filament\Tables\Filters\SelectFilter;
|
||||||
|
use Filament\Tables\Table;
|
||||||
|
use Filament\Tables\TableComponent;
|
||||||
|
use Illuminate\Contracts\View\View;
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Pagination\LengthAwarePaginator;
|
||||||
|
use Illuminate\Support\Collection;
|
||||||
|
use Illuminate\Support\Str;
|
||||||
|
|
||||||
|
class BaselineCompareEvidenceGapTable extends TableComponent
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* @var list<array<string, mixed>>
|
||||||
|
*/
|
||||||
|
public array $gapRows = [];
|
||||||
|
|
||||||
|
public string $context = 'default';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param list<array<string, mixed>> $buckets
|
||||||
|
*/
|
||||||
|
public function mount(array $buckets = [], string $context = 'default'): void
|
||||||
|
{
|
||||||
|
$this->gapRows = BaselineCompareEvidenceGapDetails::tableRows($buckets);
|
||||||
|
$this->context = $context;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function table(Table $table): Table
|
||||||
|
{
|
||||||
|
return $table
|
||||||
|
->queryStringIdentifier('baselineCompareEvidenceGap'.Str::studly($this->context))
|
||||||
|
->defaultSort('reason_label')
|
||||||
|
->defaultPaginationPageOption(10)
|
||||||
|
->paginated(TablePaginationProfiles::picker())
|
||||||
|
->searchable()
|
||||||
|
->searchPlaceholder(__('baseline-compare.evidence_gap_search_placeholder'))
|
||||||
|
->records(function (
|
||||||
|
?string $sortColumn,
|
||||||
|
?string $sortDirection,
|
||||||
|
?string $search,
|
||||||
|
array $filters,
|
||||||
|
int $page,
|
||||||
|
int $recordsPerPage
|
||||||
|
): LengthAwarePaginator {
|
||||||
|
$rows = $this->filterRows(
|
||||||
|
rows: collect($this->gapRows),
|
||||||
|
search: $search,
|
||||||
|
filters: $filters,
|
||||||
|
);
|
||||||
|
|
||||||
|
$rows = $this->sortRows(
|
||||||
|
rows: $rows,
|
||||||
|
sortColumn: $sortColumn,
|
||||||
|
sortDirection: $sortDirection,
|
||||||
|
);
|
||||||
|
|
||||||
|
return $this->paginateRows(
|
||||||
|
rows: $rows,
|
||||||
|
page: $page,
|
||||||
|
recordsPerPage: $recordsPerPage,
|
||||||
|
);
|
||||||
|
})
|
||||||
|
->filters([
|
||||||
|
SelectFilter::make('reason_code')
|
||||||
|
->label(__('baseline-compare.evidence_gap_reason'))
|
||||||
|
->options(BaselineCompareEvidenceGapDetails::reasonFilterOptions($this->gapRows)),
|
||||||
|
SelectFilter::make('policy_type')
|
||||||
|
->label(__('baseline-compare.evidence_gap_policy_type'))
|
||||||
|
->options(BaselineCompareEvidenceGapDetails::policyTypeFilterOptions($this->gapRows)),
|
||||||
|
SelectFilter::make('subject_class')
|
||||||
|
->label(__('baseline-compare.evidence_gap_subject_class'))
|
||||||
|
->options(BaselineCompareEvidenceGapDetails::subjectClassFilterOptions($this->gapRows)),
|
||||||
|
SelectFilter::make('operator_action_category')
|
||||||
|
->label(__('baseline-compare.evidence_gap_next_action'))
|
||||||
|
->options(BaselineCompareEvidenceGapDetails::actionCategoryFilterOptions($this->gapRows)),
|
||||||
|
])
|
||||||
|
->striped()
|
||||||
|
->deferLoading(! app()->runningUnitTests())
|
||||||
|
->columns([
|
||||||
|
TextColumn::make('reason_label')
|
||||||
|
->label(__('baseline-compare.evidence_gap_reason'))
|
||||||
|
->searchable()
|
||||||
|
->sortable()
|
||||||
|
->wrap()
|
||||||
|
->toggleable(isToggledHiddenByDefault: true),
|
||||||
|
TextColumn::make('policy_type')
|
||||||
|
->label(__('baseline-compare.evidence_gap_policy_type'))
|
||||||
|
->badge()
|
||||||
|
->formatStateUsing(TagBadgeRenderer::label(TagBadgeDomain::PolicyType))
|
||||||
|
->color(TagBadgeRenderer::color(TagBadgeDomain::PolicyType))
|
||||||
|
->icon(TagBadgeRenderer::icon(TagBadgeDomain::PolicyType))
|
||||||
|
->iconColor(TagBadgeRenderer::iconColor(TagBadgeDomain::PolicyType))
|
||||||
|
->searchable()
|
||||||
|
->sortable()
|
||||||
|
->wrap(),
|
||||||
|
TextColumn::make('subject_class_label')
|
||||||
|
->label(__('baseline-compare.evidence_gap_subject_class'))
|
||||||
|
->badge()
|
||||||
|
->searchable()
|
||||||
|
->sortable()
|
||||||
|
->wrap(),
|
||||||
|
TextColumn::make('resolution_outcome_label')
|
||||||
|
->label(__('baseline-compare.evidence_gap_outcome'))
|
||||||
|
->searchable()
|
||||||
|
->sortable()
|
||||||
|
->wrap(),
|
||||||
|
TextColumn::make('operator_action_category_label')
|
||||||
|
->label(__('baseline-compare.evidence_gap_next_action'))
|
||||||
|
->searchable()
|
||||||
|
->sortable()
|
||||||
|
->wrap(),
|
||||||
|
TextColumn::make('subject_key')
|
||||||
|
->label(__('baseline-compare.evidence_gap_subject_key'))
|
||||||
|
->searchable()
|
||||||
|
->sortable()
|
||||||
|
->wrap(),
|
||||||
|
])
|
||||||
|
->actions([])
|
||||||
|
->bulkActions([])
|
||||||
|
->emptyStateHeading(__('baseline-compare.evidence_gap_table_empty_heading'))
|
||||||
|
->emptyStateDescription(__('baseline-compare.evidence_gap_table_empty_description'));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function render(): View
|
||||||
|
{
|
||||||
|
return view('livewire.baseline-compare-evidence-gap-table');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param Collection<int, array<string, mixed>> $rows
|
||||||
|
* @param array<string, mixed> $filters
|
||||||
|
* @return Collection<int, array<string, mixed>>
|
||||||
|
*/
|
||||||
|
private function filterRows(Collection $rows, ?string $search, array $filters): Collection
|
||||||
|
{
|
||||||
|
$normalizedSearch = Str::lower(trim((string) $search));
|
||||||
|
$reasonCode = $filters['reason_code']['value'] ?? null;
|
||||||
|
$policyType = $filters['policy_type']['value'] ?? null;
|
||||||
|
$subjectClass = $filters['subject_class']['value'] ?? null;
|
||||||
|
$operatorActionCategory = $filters['operator_action_category']['value'] ?? null;
|
||||||
|
|
||||||
|
return $rows
|
||||||
|
->when(
|
||||||
|
$normalizedSearch !== '',
|
||||||
|
function (Collection $rows) use ($normalizedSearch): Collection {
|
||||||
|
return $rows->filter(function (array $row) use ($normalizedSearch): bool {
|
||||||
|
return str_contains(Str::lower((string) ($row['search_text'] ?? '')), $normalizedSearch);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
)
|
||||||
|
->when(
|
||||||
|
filled($reasonCode),
|
||||||
|
fn (Collection $rows): Collection => $rows->where('reason_code', (string) $reasonCode)
|
||||||
|
)
|
||||||
|
->when(
|
||||||
|
filled($policyType),
|
||||||
|
fn (Collection $rows): Collection => $rows->where('policy_type', (string) $policyType)
|
||||||
|
)
|
||||||
|
->when(
|
||||||
|
filled($subjectClass),
|
||||||
|
fn (Collection $rows): Collection => $rows->where('subject_class', (string) $subjectClass)
|
||||||
|
)
|
||||||
|
->when(
|
||||||
|
filled($operatorActionCategory),
|
||||||
|
fn (Collection $rows): Collection => $rows->where('operator_action_category', (string) $operatorActionCategory)
|
||||||
|
)
|
||||||
|
->values();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param Collection<int, array<string, mixed>> $rows
|
||||||
|
* @return Collection<int, array<string, mixed>>
|
||||||
|
*/
|
||||||
|
private function sortRows(Collection $rows, ?string $sortColumn, ?string $sortDirection): Collection
|
||||||
|
{
|
||||||
|
if (! filled($sortColumn)) {
|
||||||
|
return $rows;
|
||||||
|
}
|
||||||
|
|
||||||
|
$direction = Str::lower((string) ($sortDirection ?? 'asc')) === 'desc' ? 'desc' : 'asc';
|
||||||
|
|
||||||
|
return $rows->sortBy(
|
||||||
|
fn (array $row): string => (string) ($row[$sortColumn] ?? ''),
|
||||||
|
SORT_NATURAL | SORT_FLAG_CASE,
|
||||||
|
$direction === 'desc'
|
||||||
|
)->values();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param Collection<int, array<string, mixed>> $rows
|
||||||
|
*/
|
||||||
|
private function paginateRows(Collection $rows, int $page, int $recordsPerPage): LengthAwarePaginator
|
||||||
|
{
|
||||||
|
$perPage = max(1, $recordsPerPage);
|
||||||
|
$currentPage = max(1, $page);
|
||||||
|
$total = $rows->count();
|
||||||
|
$items = $rows->forPage($currentPage, $perPage)
|
||||||
|
->values()
|
||||||
|
->map(fn (array $row, int $index): Model => $this->toTableRecord(
|
||||||
|
row: $row,
|
||||||
|
index: (($currentPage - 1) * $perPage) + $index,
|
||||||
|
));
|
||||||
|
|
||||||
|
return new LengthAwarePaginator(
|
||||||
|
$items,
|
||||||
|
$total,
|
||||||
|
$perPage,
|
||||||
|
$currentPage,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $row
|
||||||
|
*/
|
||||||
|
private function toTableRecord(array $row, int $index): Model
|
||||||
|
{
|
||||||
|
$record = new class extends Model
|
||||||
|
{
|
||||||
|
public $timestamps = false;
|
||||||
|
|
||||||
|
public $incrementing = false;
|
||||||
|
|
||||||
|
protected $keyType = 'string';
|
||||||
|
|
||||||
|
protected $guarded = [];
|
||||||
|
|
||||||
|
protected $table = 'baseline_compare_evidence_gap_rows';
|
||||||
|
};
|
||||||
|
|
||||||
|
$record->forceFill([
|
||||||
|
'id' => implode(':', array_filter([
|
||||||
|
(string) ($row['reason_code'] ?? 'reason'),
|
||||||
|
(string) ($row['policy_type'] ?? 'policy'),
|
||||||
|
(string) ($row['subject_key'] ?? 'subject'),
|
||||||
|
(string) $index,
|
||||||
|
])),
|
||||||
|
...$row,
|
||||||
|
]);
|
||||||
|
$record->exists = true;
|
||||||
|
|
||||||
|
return $record;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -7,6 +7,7 @@
|
|||||||
use App\Support\Baselines\BaselineCaptureMode;
|
use App\Support\Baselines\BaselineCaptureMode;
|
||||||
use App\Support\Baselines\BaselineProfileStatus;
|
use App\Support\Baselines\BaselineProfileStatus;
|
||||||
use App\Support\Baselines\BaselineScope;
|
use App\Support\Baselines\BaselineScope;
|
||||||
|
use App\Support\Baselines\BaselineSnapshotLifecycleState;
|
||||||
use Illuminate\Database\Eloquent\Casts\Attribute;
|
use Illuminate\Database\Eloquent\Casts\Attribute;
|
||||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||||
use Illuminate\Database\Eloquent\Model;
|
use Illuminate\Database\Eloquent\Model;
|
||||||
@ -121,6 +122,37 @@ public function snapshots(): HasMany
|
|||||||
return $this->hasMany(BaselineSnapshot::class);
|
return $this->hasMany(BaselineSnapshot::class);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function resolveCurrentConsumableSnapshot(): ?BaselineSnapshot
|
||||||
|
{
|
||||||
|
$activeSnapshot = $this->relationLoaded('activeSnapshot')
|
||||||
|
? $this->getRelation('activeSnapshot')
|
||||||
|
: $this->activeSnapshot()->first();
|
||||||
|
|
||||||
|
if ($activeSnapshot instanceof BaselineSnapshot && $activeSnapshot->isConsumable()) {
|
||||||
|
return $activeSnapshot;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->snapshots()
|
||||||
|
->where('lifecycle_state', BaselineSnapshotLifecycleState::Complete->value)
|
||||||
|
->orderByDesc('completed_at')
|
||||||
|
->orderByDesc('captured_at')
|
||||||
|
->orderByDesc('id')
|
||||||
|
->first();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function resolveLatestAttemptedSnapshot(): ?BaselineSnapshot
|
||||||
|
{
|
||||||
|
return $this->snapshots()
|
||||||
|
->orderByDesc('captured_at')
|
||||||
|
->orderByDesc('id')
|
||||||
|
->first();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function hasConsumableSnapshot(): bool
|
||||||
|
{
|
||||||
|
return $this->resolveCurrentConsumableSnapshot() instanceof BaselineSnapshot;
|
||||||
|
}
|
||||||
|
|
||||||
public function tenantAssignments(): HasMany
|
public function tenantAssignments(): HasMany
|
||||||
{
|
{
|
||||||
return $this->hasMany(BaselineTenantAssignment::class);
|
return $this->hasMany(BaselineTenantAssignment::class);
|
||||||
|
|||||||
@ -1,11 +1,17 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
namespace App\Models;
|
namespace App\Models;
|
||||||
|
|
||||||
|
use App\Support\Baselines\BaselineReasonCodes;
|
||||||
|
use App\Support\Baselines\BaselineSnapshotLifecycleState;
|
||||||
|
use Illuminate\Database\Eloquent\Builder;
|
||||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||||
use Illuminate\Database\Eloquent\Model;
|
use Illuminate\Database\Eloquent\Model;
|
||||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||||
|
use RuntimeException;
|
||||||
|
|
||||||
class BaselineSnapshot extends Model
|
class BaselineSnapshot extends Model
|
||||||
{
|
{
|
||||||
@ -13,10 +19,20 @@ class BaselineSnapshot extends Model
|
|||||||
|
|
||||||
protected $guarded = [];
|
protected $guarded = [];
|
||||||
|
|
||||||
protected $casts = [
|
/**
|
||||||
'summary_jsonb' => 'array',
|
* @return array<string, string>
|
||||||
'captured_at' => 'datetime',
|
*/
|
||||||
];
|
protected function casts(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'lifecycle_state' => BaselineSnapshotLifecycleState::class,
|
||||||
|
'summary_jsonb' => 'array',
|
||||||
|
'completion_meta_jsonb' => 'array',
|
||||||
|
'captured_at' => 'datetime',
|
||||||
|
'completed_at' => 'datetime',
|
||||||
|
'failed_at' => 'datetime',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
public function workspace(): BelongsTo
|
public function workspace(): BelongsTo
|
||||||
{
|
{
|
||||||
@ -32,4 +48,100 @@ public function items(): HasMany
|
|||||||
{
|
{
|
||||||
return $this->hasMany(BaselineSnapshotItem::class);
|
return $this->hasMany(BaselineSnapshotItem::class);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function scopeConsumable(Builder $query): Builder
|
||||||
|
{
|
||||||
|
return $query->where('lifecycle_state', BaselineSnapshotLifecycleState::Complete->value);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function scopeLatestConsumable(Builder $query): Builder
|
||||||
|
{
|
||||||
|
return $query
|
||||||
|
->consumable()
|
||||||
|
->orderByDesc('completed_at')
|
||||||
|
->orderByDesc('captured_at')
|
||||||
|
->orderByDesc('id');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function isConsumable(): bool
|
||||||
|
{
|
||||||
|
return $this->lifecycleState() === BaselineSnapshotLifecycleState::Complete;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function isBuilding(): bool
|
||||||
|
{
|
||||||
|
return $this->lifecycleState() === BaselineSnapshotLifecycleState::Building;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function isComplete(): bool
|
||||||
|
{
|
||||||
|
return $this->lifecycleState() === BaselineSnapshotLifecycleState::Complete;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function isIncomplete(): bool
|
||||||
|
{
|
||||||
|
return $this->lifecycleState() === BaselineSnapshotLifecycleState::Incomplete;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function markBuilding(array $completionMeta = []): void
|
||||||
|
{
|
||||||
|
$this->forceFill([
|
||||||
|
'lifecycle_state' => BaselineSnapshotLifecycleState::Building,
|
||||||
|
'completed_at' => null,
|
||||||
|
'failed_at' => null,
|
||||||
|
'completion_meta_jsonb' => $this->mergedCompletionMeta($completionMeta),
|
||||||
|
])->save();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function markComplete(string $identityHash, array $completionMeta = []): void
|
||||||
|
{
|
||||||
|
if ($this->isIncomplete()) {
|
||||||
|
throw new RuntimeException('Incomplete baseline snapshots cannot transition back to complete.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->forceFill([
|
||||||
|
'snapshot_identity_hash' => $identityHash,
|
||||||
|
'lifecycle_state' => BaselineSnapshotLifecycleState::Complete,
|
||||||
|
'completed_at' => now(),
|
||||||
|
'failed_at' => null,
|
||||||
|
'completion_meta_jsonb' => $this->mergedCompletionMeta($completionMeta),
|
||||||
|
])->save();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function markIncomplete(?string $reasonCode = null, array $completionMeta = []): void
|
||||||
|
{
|
||||||
|
$this->forceFill([
|
||||||
|
'lifecycle_state' => BaselineSnapshotLifecycleState::Incomplete,
|
||||||
|
'completed_at' => null,
|
||||||
|
'failed_at' => now(),
|
||||||
|
'completion_meta_jsonb' => $this->mergedCompletionMeta(array_filter([
|
||||||
|
'finalization_reason_code' => $reasonCode ?? BaselineReasonCodes::SNAPSHOT_INCOMPLETE,
|
||||||
|
...$completionMeta,
|
||||||
|
], static fn (mixed $value): bool => $value !== null)),
|
||||||
|
])->save();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function lifecycleState(): BaselineSnapshotLifecycleState
|
||||||
|
{
|
||||||
|
if ($this->lifecycle_state instanceof BaselineSnapshotLifecycleState) {
|
||||||
|
return $this->lifecycle_state;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (is_string($this->lifecycle_state) && BaselineSnapshotLifecycleState::tryFrom($this->lifecycle_state) instanceof BaselineSnapshotLifecycleState) {
|
||||||
|
return BaselineSnapshotLifecycleState::from($this->lifecycle_state);
|
||||||
|
}
|
||||||
|
|
||||||
|
return BaselineSnapshotLifecycleState::Incomplete;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $completionMeta
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
private function mergedCompletionMeta(array $completionMeta): array
|
||||||
|
{
|
||||||
|
$existing = is_array($this->completion_meta_jsonb) ? $this->completion_meta_jsonb : [];
|
||||||
|
|
||||||
|
return array_replace($existing, $completionMeta);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -2,6 +2,8 @@
|
|||||||
|
|
||||||
namespace App\Models;
|
namespace App\Models;
|
||||||
|
|
||||||
|
use App\Support\OperationCatalog;
|
||||||
|
use App\Support\Operations\OperationRunFreshnessState;
|
||||||
use Illuminate\Database\Eloquent\Builder;
|
use Illuminate\Database\Eloquent\Builder;
|
||||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||||
use Illuminate\Database\Eloquent\Model;
|
use Illuminate\Database\Eloquent\Model;
|
||||||
@ -127,4 +129,145 @@ public function setFinishedAtAttribute(mixed $value): void
|
|||||||
{
|
{
|
||||||
$this->completed_at = $value;
|
$this->completed_at = $value;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function isGovernanceArtifactOperation(): bool
|
||||||
|
{
|
||||||
|
return OperationCatalog::isGovernanceArtifactOperation((string) $this->type);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function supportsOperatorExplanation(): bool
|
||||||
|
{
|
||||||
|
return OperationCatalog::supportsOperatorExplanation((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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
public function reconciliation(): array
|
||||||
|
{
|
||||||
|
$context = is_array($this->context) ? $this->context : [];
|
||||||
|
$reconciliation = $context['reconciliation'] ?? null;
|
||||||
|
|
||||||
|
return is_array($reconciliation) ? $reconciliation : [];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function isLifecycleReconciled(): bool
|
||||||
|
{
|
||||||
|
return $this->reconciliation() !== [];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function lifecycleReconciliationReasonCode(): ?string
|
||||||
|
{
|
||||||
|
$reasonCode = $this->reconciliation()['reason_code'] ?? null;
|
||||||
|
|
||||||
|
return is_string($reasonCode) && trim($reasonCode) !== '' ? trim($reasonCode) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function freshnessState(): OperationRunFreshnessState
|
||||||
|
{
|
||||||
|
return OperationRunFreshnessState::forRun($this);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
public function baselineGapEnvelope(): array
|
||||||
|
{
|
||||||
|
$context = is_array($this->context) ? $this->context : [];
|
||||||
|
|
||||||
|
return match ((string) $this->type) {
|
||||||
|
'baseline_compare' => is_array(data_get($context, 'baseline_compare.evidence_gaps'))
|
||||||
|
? data_get($context, 'baseline_compare.evidence_gaps')
|
||||||
|
: [],
|
||||||
|
'baseline_capture' => is_array(data_get($context, 'baseline_capture.gaps'))
|
||||||
|
? data_get($context, 'baseline_capture.gaps')
|
||||||
|
: [],
|
||||||
|
default => [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public function hasStructuredBaselineGapPayload(): bool
|
||||||
|
{
|
||||||
|
$subjects = $this->baselineGapEnvelope()['subjects'] ?? null;
|
||||||
|
|
||||||
|
if (! is_array($subjects) || ! array_is_list($subjects) || $subjects === []) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($subjects as $subject) {
|
||||||
|
if (! is_array($subject)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ([
|
||||||
|
'policy_type',
|
||||||
|
'subject_key',
|
||||||
|
'subject_class',
|
||||||
|
'resolution_path',
|
||||||
|
'resolution_outcome',
|
||||||
|
'reason_code',
|
||||||
|
'operator_action_category',
|
||||||
|
'structural',
|
||||||
|
'retryable',
|
||||||
|
] as $key) {
|
||||||
|
if (! array_key_exists($key, $subject)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function hasLegacyBaselineGapPayload(): bool
|
||||||
|
{
|
||||||
|
$envelope = $this->baselineGapEnvelope();
|
||||||
|
$byReason = is_array($envelope['by_reason'] ?? null) ? $envelope['by_reason'] : [];
|
||||||
|
|
||||||
|
if (array_key_exists('policy_not_found', $byReason)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
$subjects = $envelope['subjects'] ?? null;
|
||||||
|
|
||||||
|
if (! is_array($subjects)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! array_is_list($subjects)) {
|
||||||
|
return $subjects !== [];
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($subjects === []) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return ! $this->hasStructuredBaselineGapPayload();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -7,6 +7,7 @@
|
|||||||
use App\Models\Tenant;
|
use App\Models\Tenant;
|
||||||
use App\Support\OperationRunLinks;
|
use App\Support\OperationRunLinks;
|
||||||
use App\Support\OpsUx\OperationUxPresenter;
|
use App\Support\OpsUx\OperationUxPresenter;
|
||||||
|
use App\Support\ReasonTranslation\ReasonPresenter;
|
||||||
use App\Support\System\SystemOperationRunLinks;
|
use App\Support\System\SystemOperationRunLinks;
|
||||||
use Illuminate\Bus\Queueable;
|
use Illuminate\Bus\Queueable;
|
||||||
use Illuminate\Notifications\Notification;
|
use Illuminate\Notifications\Notification;
|
||||||
@ -44,6 +45,14 @@ public function toDatabase(object $notifiable): array
|
|||||||
->url($runUrl),
|
->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()
|
return FilamentNotification::make()
|
||||||
->title("{$operationLabel} queued")
|
->title("{$operationLabel} queued")
|
||||||
->body('Queued. Monitor progress in Monitoring → Operations.')
|
->body('Queued for execution. Open the run for progress and next steps.')
|
||||||
->warning()
|
->info()
|
||||||
->actions([
|
->actions([
|
||||||
\Filament\Actions\Action::make('view_run')
|
\Filament\Actions\Action::make('view_run')
|
||||||
->label('View run')
|
->label('View run')
|
||||||
|
|||||||
@ -61,7 +61,7 @@ public function view(User $user, OperationRun $run): Response|bool
|
|||||||
}
|
}
|
||||||
|
|
||||||
$requiredCapability = app(OperationRunCapabilityResolver::class)
|
$requiredCapability = app(OperationRunCapabilityResolver::class)
|
||||||
->requiredCapabilityForType((string) $run->type);
|
->requiredCapabilityForRun($run);
|
||||||
|
|
||||||
if (! is_string($requiredCapability) || $requiredCapability === '') {
|
if (! is_string($requiredCapability) || $requiredCapability === '') {
|
||||||
return true;
|
return true;
|
||||||
|
|||||||
@ -28,6 +28,7 @@
|
|||||||
use App\Services\Auth\WorkspaceCapabilityResolver;
|
use App\Services\Auth\WorkspaceCapabilityResolver;
|
||||||
use App\Services\Auth\WorkspaceRoleCapabilityMap;
|
use App\Services\Auth\WorkspaceRoleCapabilityMap;
|
||||||
use App\Support\Auth\Capabilities;
|
use App\Support\Auth\Capabilities;
|
||||||
|
use App\Support\Filament\PanelThemeAsset;
|
||||||
use App\Support\Workspaces\WorkspaceContext;
|
use App\Support\Workspaces\WorkspaceContext;
|
||||||
use Filament\Http\Middleware\Authenticate;
|
use Filament\Http\Middleware\Authenticate;
|
||||||
use Filament\Http\Middleware\AuthenticateSession;
|
use Filament\Http\Middleware\AuthenticateSession;
|
||||||
@ -202,7 +203,7 @@ public function panel(Panel $panel): Panel
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
if (! app()->runningUnitTests()) {
|
if (! app()->runningUnitTests()) {
|
||||||
$panel->viteTheme('resources/css/filament/admin/theme.css');
|
$panel->theme(PanelThemeAsset::resolve('resources/css/filament/admin/theme.css'));
|
||||||
}
|
}
|
||||||
|
|
||||||
return $panel;
|
return $panel;
|
||||||
|
|||||||
@ -6,6 +6,7 @@
|
|||||||
use App\Filament\System\Pages\Dashboard;
|
use App\Filament\System\Pages\Dashboard;
|
||||||
use App\Http\Middleware\UseSystemSessionCookie;
|
use App\Http\Middleware\UseSystemSessionCookie;
|
||||||
use App\Support\Auth\PlatformCapabilities;
|
use App\Support\Auth\PlatformCapabilities;
|
||||||
|
use App\Support\Filament\PanelThemeAsset;
|
||||||
use Filament\Http\Middleware\Authenticate;
|
use Filament\Http\Middleware\Authenticate;
|
||||||
use Filament\Http\Middleware\AuthenticateSession;
|
use Filament\Http\Middleware\AuthenticateSession;
|
||||||
use Filament\Http\Middleware\DisableBladeIconComponents;
|
use Filament\Http\Middleware\DisableBladeIconComponents;
|
||||||
@ -60,6 +61,6 @@ public function panel(Panel $panel): Panel
|
|||||||
Authenticate::class,
|
Authenticate::class,
|
||||||
'ensure-platform-capability:'.PlatformCapabilities::ACCESS_SYSTEM_PANEL,
|
'ensure-platform-capability:'.PlatformCapabilities::ACCESS_SYSTEM_PANEL,
|
||||||
])
|
])
|
||||||
->viteTheme('resources/css/filament/system/theme.css');
|
->theme(PanelThemeAsset::resolve('resources/css/filament/system/theme.css'));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -6,6 +6,7 @@
|
|||||||
use App\Filament\Pages\TenantDashboard;
|
use App\Filament\Pages\TenantDashboard;
|
||||||
use App\Filament\Resources\TenantReviewResource;
|
use App\Filament\Resources\TenantReviewResource;
|
||||||
use App\Models\Tenant;
|
use App\Models\Tenant;
|
||||||
|
use App\Support\Filament\PanelThemeAsset;
|
||||||
use App\Support\Middleware\DenyNonMemberTenantAccess;
|
use App\Support\Middleware\DenyNonMemberTenantAccess;
|
||||||
use Filament\Facades\Filament;
|
use Filament\Facades\Filament;
|
||||||
use Filament\Http\Middleware\Authenticate;
|
use Filament\Http\Middleware\Authenticate;
|
||||||
@ -112,7 +113,7 @@ public function panel(Panel $panel): Panel
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
if (! app()->runningUnitTests()) {
|
if (! app()->runningUnitTests()) {
|
||||||
$panel->viteTheme('resources/css/filament/admin/theme.css');
|
$panel->theme(PanelThemeAsset::resolve('resources/css/filament/admin/theme.css'));
|
||||||
}
|
}
|
||||||
|
|
||||||
return $panel;
|
return $panel;
|
||||||
|
|||||||
@ -8,6 +8,7 @@
|
|||||||
use App\Models\RestoreRun;
|
use App\Models\RestoreRun;
|
||||||
use App\Support\OperationRunOutcome;
|
use App\Support\OperationRunOutcome;
|
||||||
use App\Support\OperationRunStatus;
|
use App\Support\OperationRunStatus;
|
||||||
|
use App\Support\Operations\LifecycleReconciliationReason;
|
||||||
use App\Support\RestoreRunStatus;
|
use App\Support\RestoreRunStatus;
|
||||||
use Carbon\CarbonImmutable;
|
use Carbon\CarbonImmutable;
|
||||||
use Illuminate\Database\Eloquent\Builder;
|
use Illuminate\Database\Eloquent\Builder;
|
||||||
@ -151,25 +152,23 @@ private function reconcileOne(OperationRun $run, bool $dryRun): ?array
|
|||||||
/** @var OperationRunService $runs */
|
/** @var OperationRunService $runs */
|
||||||
$runs = app(OperationRunService::class);
|
$runs = app(OperationRunService::class);
|
||||||
|
|
||||||
$runs->updateRun(
|
$runs->updateRunWithReconciliation(
|
||||||
$run,
|
run: $run,
|
||||||
status: $opStatus,
|
status: $opStatus,
|
||||||
outcome: $opOutcome,
|
outcome: $opOutcome,
|
||||||
summaryCounts: $summaryCounts,
|
summaryCounts: $summaryCounts,
|
||||||
failures: $failures,
|
failures: $failures,
|
||||||
|
reasonCode: LifecycleReconciliationReason::AdapterOutOfSync->value,
|
||||||
|
reasonMessage: LifecycleReconciliationReason::AdapterOutOfSync->defaultMessage(),
|
||||||
|
source: 'adapter_reconciler',
|
||||||
|
evidence: [
|
||||||
|
'restore_run_id' => (int) $restoreRun->getKey(),
|
||||||
|
'restore_status' => $restoreStatus?->value,
|
||||||
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
$run->refresh();
|
$run->refresh();
|
||||||
|
|
||||||
$updatedContext = is_array($run->context) ? $run->context : [];
|
|
||||||
$reconciliation = is_array($updatedContext['reconciliation'] ?? null) ? $updatedContext['reconciliation'] : [];
|
|
||||||
$reconciliation['reconciled_at'] = CarbonImmutable::now()->toIso8601String();
|
|
||||||
$reconciliation['reason'] = 'adapter_out_of_sync';
|
|
||||||
|
|
||||||
$updatedContext['reconciliation'] = $reconciliation;
|
|
||||||
|
|
||||||
$run->context = $updatedContext;
|
|
||||||
|
|
||||||
if ($run->started_at === null && $restoreRun->started_at !== null) {
|
if ($run->started_at === null && $restoreRun->started_at !== null) {
|
||||||
$run->started_at = $restoreRun->started_at;
|
$run->started_at = $restoreRun->started_at;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -15,6 +15,7 @@
|
|||||||
use App\Support\Baselines\BaselineProfileStatus;
|
use App\Support\Baselines\BaselineProfileStatus;
|
||||||
use App\Support\Baselines\BaselineReasonCodes;
|
use App\Support\Baselines\BaselineReasonCodes;
|
||||||
use App\Support\Baselines\BaselineScope;
|
use App\Support\Baselines\BaselineScope;
|
||||||
|
use App\Support\Baselines\BaselineSupportCapabilityGuard;
|
||||||
use App\Support\OperationRunType;
|
use App\Support\OperationRunType;
|
||||||
|
|
||||||
final class BaselineCaptureService
|
final class BaselineCaptureService
|
||||||
@ -22,6 +23,7 @@ final class BaselineCaptureService
|
|||||||
public function __construct(
|
public function __construct(
|
||||||
private readonly OperationRunService $runs,
|
private readonly OperationRunService $runs,
|
||||||
private readonly BaselineFullContentRolloutGate $rolloutGate,
|
private readonly BaselineFullContentRolloutGate $rolloutGate,
|
||||||
|
private readonly BaselineSupportCapabilityGuard $capabilityGuard,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -53,7 +55,7 @@ public function startCapture(
|
|||||||
],
|
],
|
||||||
'baseline_profile_id' => (int) $profile->getKey(),
|
'baseline_profile_id' => (int) $profile->getKey(),
|
||||||
'source_tenant_id' => (int) $sourceTenant->getKey(),
|
'source_tenant_id' => (int) $sourceTenant->getKey(),
|
||||||
'effective_scope' => $effectiveScope->toEffectiveScopeContext(),
|
'effective_scope' => $effectiveScope->toEffectiveScopeContext($this->capabilityGuard, 'capture'),
|
||||||
'capture_mode' => $captureMode->value,
|
'capture_mode' => $captureMode->value,
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
@ -17,17 +17,21 @@
|
|||||||
use App\Support\Baselines\BaselineProfileStatus;
|
use App\Support\Baselines\BaselineProfileStatus;
|
||||||
use App\Support\Baselines\BaselineReasonCodes;
|
use App\Support\Baselines\BaselineReasonCodes;
|
||||||
use App\Support\Baselines\BaselineScope;
|
use App\Support\Baselines\BaselineScope;
|
||||||
|
use App\Support\Baselines\BaselineSupportCapabilityGuard;
|
||||||
use App\Support\OperationRunType;
|
use App\Support\OperationRunType;
|
||||||
|
use App\Support\ReasonTranslation\ReasonPresenter;
|
||||||
|
|
||||||
final class BaselineCompareService
|
final class BaselineCompareService
|
||||||
{
|
{
|
||||||
public function __construct(
|
public function __construct(
|
||||||
private readonly OperationRunService $runs,
|
private readonly OperationRunService $runs,
|
||||||
private readonly BaselineFullContentRolloutGate $rolloutGate,
|
private readonly BaselineFullContentRolloutGate $rolloutGate,
|
||||||
|
private readonly BaselineSnapshotTruthResolver $snapshotTruthResolver,
|
||||||
|
private readonly BaselineSupportCapabilityGuard $capabilityGuard,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @return array{ok: bool, run?: OperationRun, reason_code?: string}
|
* @return array{ok: bool, run?: OperationRun, reason_code?: string, reason_translation?: array<string, mixed>}
|
||||||
*/
|
*/
|
||||||
public function startCompare(
|
public function startCompare(
|
||||||
Tenant $tenant,
|
Tenant $tenant,
|
||||||
@ -40,38 +44,45 @@ public function startCompare(
|
|||||||
->first();
|
->first();
|
||||||
|
|
||||||
if (! $assignment instanceof BaselineTenantAssignment) {
|
if (! $assignment instanceof BaselineTenantAssignment) {
|
||||||
return ['ok' => false, 'reason_code' => BaselineReasonCodes::COMPARE_NO_ASSIGNMENT];
|
return $this->failedStart(BaselineReasonCodes::COMPARE_NO_ASSIGNMENT);
|
||||||
}
|
}
|
||||||
|
|
||||||
$profile = BaselineProfile::query()->find($assignment->baseline_profile_id);
|
$profile = BaselineProfile::query()->find($assignment->baseline_profile_id);
|
||||||
|
|
||||||
if (! $profile instanceof BaselineProfile) {
|
if (! $profile instanceof BaselineProfile) {
|
||||||
return ['ok' => false, 'reason_code' => BaselineReasonCodes::COMPARE_PROFILE_NOT_ACTIVE];
|
return $this->failedStart(BaselineReasonCodes::COMPARE_PROFILE_NOT_ACTIVE);
|
||||||
}
|
}
|
||||||
|
|
||||||
$hasExplicitSnapshotSelection = is_int($baselineSnapshotId) && $baselineSnapshotId > 0;
|
$precondition = $this->validatePreconditions($profile);
|
||||||
$precondition = $this->validatePreconditions($profile, hasExplicitSnapshotSelection: $hasExplicitSnapshotSelection);
|
|
||||||
|
|
||||||
if ($precondition !== null) {
|
if ($precondition !== null) {
|
||||||
return ['ok' => false, 'reason_code' => $precondition];
|
return $this->failedStart($precondition);
|
||||||
}
|
}
|
||||||
|
|
||||||
$snapshotId = $baselineSnapshotId !== null ? (int) $baselineSnapshotId : 0;
|
$selectedSnapshot = null;
|
||||||
|
|
||||||
if ($snapshotId > 0) {
|
if (is_int($baselineSnapshotId) && $baselineSnapshotId > 0) {
|
||||||
$snapshot = BaselineSnapshot::query()
|
$selectedSnapshot = BaselineSnapshot::query()
|
||||||
->where('workspace_id', (int) $profile->workspace_id)
|
->where('workspace_id', (int) $profile->workspace_id)
|
||||||
->where('baseline_profile_id', (int) $profile->getKey())
|
->where('baseline_profile_id', (int) $profile->getKey())
|
||||||
->whereKey($snapshotId)
|
->whereKey((int) $baselineSnapshotId)
|
||||||
->first(['id']);
|
->first();
|
||||||
|
|
||||||
if (! $snapshot instanceof BaselineSnapshot) {
|
if (! $selectedSnapshot instanceof BaselineSnapshot) {
|
||||||
return ['ok' => false, 'reason_code' => BaselineReasonCodes::COMPARE_INVALID_SNAPSHOT];
|
return $this->failedStart(BaselineReasonCodes::COMPARE_INVALID_SNAPSHOT);
|
||||||
}
|
}
|
||||||
} else {
|
|
||||||
$snapshotId = (int) $profile->active_snapshot_id;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$snapshotResolution = $this->snapshotTruthResolver->resolveCompareSnapshot($profile, $selectedSnapshot);
|
||||||
|
|
||||||
|
if (! ($snapshotResolution['ok'] ?? false)) {
|
||||||
|
return $this->failedStart($snapshotResolution['reason_code'] ?? BaselineReasonCodes::COMPARE_NO_CONSUMABLE_SNAPSHOT);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @var BaselineSnapshot $snapshot */
|
||||||
|
$snapshot = $snapshotResolution['snapshot'];
|
||||||
|
$snapshotId = (int) $snapshot->getKey();
|
||||||
|
|
||||||
$profileScope = BaselineScope::fromJsonb(
|
$profileScope = BaselineScope::fromJsonb(
|
||||||
is_array($profile->scope_jsonb) ? $profile->scope_jsonb : null,
|
is_array($profile->scope_jsonb) ? $profile->scope_jsonb : null,
|
||||||
);
|
);
|
||||||
@ -92,7 +103,7 @@ public function startCompare(
|
|||||||
],
|
],
|
||||||
'baseline_profile_id' => (int) $profile->getKey(),
|
'baseline_profile_id' => (int) $profile->getKey(),
|
||||||
'baseline_snapshot_id' => $snapshotId,
|
'baseline_snapshot_id' => $snapshotId,
|
||||||
'effective_scope' => $effectiveScope->toEffectiveScopeContext(),
|
'effective_scope' => $effectiveScope->toEffectiveScopeContext($this->capabilityGuard, 'compare'),
|
||||||
'capture_mode' => $captureMode->value,
|
'capture_mode' => $captureMode->value,
|
||||||
];
|
];
|
||||||
|
|
||||||
@ -113,7 +124,7 @@ public function startCompare(
|
|||||||
return ['ok' => true, 'run' => $run];
|
return ['ok' => true, 'run' => $run];
|
||||||
}
|
}
|
||||||
|
|
||||||
private function validatePreconditions(BaselineProfile $profile, bool $hasExplicitSnapshotSelection = false): ?string
|
private function validatePreconditions(BaselineProfile $profile): ?string
|
||||||
{
|
{
|
||||||
if ($profile->status !== BaselineProfileStatus::Active) {
|
if ($profile->status !== BaselineProfileStatus::Active) {
|
||||||
return BaselineReasonCodes::COMPARE_PROFILE_NOT_ACTIVE;
|
return BaselineReasonCodes::COMPARE_PROFILE_NOT_ACTIVE;
|
||||||
@ -123,10 +134,20 @@ private function validatePreconditions(BaselineProfile $profile, bool $hasExplic
|
|||||||
return BaselineReasonCodes::COMPARE_ROLLOUT_DISABLED;
|
return BaselineReasonCodes::COMPARE_ROLLOUT_DISABLED;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (! $hasExplicitSnapshotSelection && $profile->active_snapshot_id === null) {
|
|
||||||
return BaselineReasonCodes::COMPARE_NO_ACTIVE_SNAPSHOT;
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array{ok: false, reason_code: string, reason_translation?: array<string, mixed>}
|
||||||
|
*/
|
||||||
|
private function failedStart(string $reasonCode): array
|
||||||
|
{
|
||||||
|
$translation = app(ReasonPresenter::class)->forArtifactTruth($reasonCode, 'baseline_compare');
|
||||||
|
|
||||||
|
return array_filter([
|
||||||
|
'ok' => false,
|
||||||
|
'reason_code' => $reasonCode,
|
||||||
|
'reason_translation' => $translation?->toArray(),
|
||||||
|
], static fn (mixed $value): bool => $value !== null);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -10,22 +10,28 @@
|
|||||||
use App\Services\Intune\PolicyCaptureOrchestrator;
|
use App\Services\Intune\PolicyCaptureOrchestrator;
|
||||||
use App\Support\Baselines\BaselineEvidenceResumeToken;
|
use App\Support\Baselines\BaselineEvidenceResumeToken;
|
||||||
use App\Support\Baselines\PolicyVersionCapturePurpose;
|
use App\Support\Baselines\PolicyVersionCapturePurpose;
|
||||||
|
use App\Support\Baselines\ResolutionOutcomeRecord;
|
||||||
|
use App\Support\Baselines\ResolutionPath;
|
||||||
|
use App\Support\Baselines\SubjectDescriptor;
|
||||||
|
use App\Support\Baselines\SubjectResolver;
|
||||||
use Throwable;
|
use Throwable;
|
||||||
|
|
||||||
final class BaselineContentCapturePhase
|
final class BaselineContentCapturePhase
|
||||||
{
|
{
|
||||||
public function __construct(
|
public function __construct(
|
||||||
private readonly PolicyCaptureOrchestrator $captureOrchestrator,
|
private readonly PolicyCaptureOrchestrator $captureOrchestrator,
|
||||||
|
private readonly ?SubjectResolver $subjectResolver = null,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Capture baseline-purpose policy versions (content + assignments + scope tags) within a run budget.
|
* Capture baseline-purpose policy versions (content + assignments + scope tags) within a run budget.
|
||||||
*
|
*
|
||||||
* @param list<array{policy_type: string, subject_external_id: string}> $subjects
|
* @param list<array{policy_type: string, subject_external_id: string, subject_key?: string}> $subjects
|
||||||
* @param array{max_items_per_run: int, max_concurrency: int, max_retries: int} $budgets
|
* @param array{max_items_per_run: int, max_concurrency: int, max_retries: int} $budgets
|
||||||
* @return array{
|
* @return array{
|
||||||
* stats: array{requested: int, succeeded: int, skipped: int, failed: int, throttled: int},
|
* stats: array{requested: int, succeeded: int, skipped: int, failed: int, throttled: int},
|
||||||
* gaps: array<string, int>,
|
* gaps: array<string, int>,
|
||||||
|
* gap_subjects: list<array<string, mixed>>,
|
||||||
* resume_token: ?string,
|
* resume_token: ?string,
|
||||||
* captured_versions: array<string, array{
|
* captured_versions: array<string, array{
|
||||||
* policy_type: string,
|
* policy_type: string,
|
||||||
@ -76,6 +82,8 @@ public function capture(
|
|||||||
|
|
||||||
/** @var array<string, int> $gaps */
|
/** @var array<string, int> $gaps */
|
||||||
$gaps = [];
|
$gaps = [];
|
||||||
|
/** @var list<array<string, mixed>> $gapSubjects */
|
||||||
|
$gapSubjects = [];
|
||||||
$capturedVersions = [];
|
$capturedVersions = [];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -87,24 +95,40 @@ public function capture(
|
|||||||
foreach ($chunk as $subject) {
|
foreach ($chunk as $subject) {
|
||||||
$policyType = trim((string) ($subject['policy_type'] ?? ''));
|
$policyType = trim((string) ($subject['policy_type'] ?? ''));
|
||||||
$externalId = trim((string) ($subject['subject_external_id'] ?? ''));
|
$externalId = trim((string) ($subject['subject_external_id'] ?? ''));
|
||||||
|
$subjectKey = trim((string) ($subject['subject_key'] ?? ''));
|
||||||
|
$descriptor = $this->resolver()->describeForCapture(
|
||||||
|
$policyType !== '' ? $policyType : 'unknown',
|
||||||
|
$externalId !== '' ? $externalId : null,
|
||||||
|
$subjectKey !== '' ? $subjectKey : null,
|
||||||
|
);
|
||||||
|
|
||||||
if ($policyType === '' || $externalId === '') {
|
if ($policyType === '' || $externalId === '') {
|
||||||
$gaps['invalid_subject'] = ($gaps['invalid_subject'] ?? 0) + 1;
|
$this->recordGap($gaps, $gapSubjects, $descriptor, $this->resolver()->invalidSubject($descriptor));
|
||||||
$stats['failed']++;
|
$stats['failed']++;
|
||||||
|
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
$subjectKey = $policyType.'|'.$externalId;
|
$captureKey = $policyType.'|'.$externalId;
|
||||||
|
|
||||||
if (isset($seen[$subjectKey])) {
|
if (isset($seen[$captureKey])) {
|
||||||
$gaps['duplicate_subject'] = ($gaps['duplicate_subject'] ?? 0) + 1;
|
$this->recordGap($gaps, $gapSubjects, $descriptor, $this->resolver()->duplicateSubject($descriptor));
|
||||||
$stats['skipped']++;
|
$stats['skipped']++;
|
||||||
|
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
$seen[$subjectKey] = true;
|
$seen[$captureKey] = true;
|
||||||
|
|
||||||
|
if (
|
||||||
|
$descriptor->resolutionPath === ResolutionPath::FoundationInventory
|
||||||
|
|| $descriptor->resolutionPath === ResolutionPath::Inventory
|
||||||
|
) {
|
||||||
|
$this->recordGap($gaps, $gapSubjects, $descriptor, $this->resolver()->structuralInventoryOnly($descriptor));
|
||||||
|
$stats['skipped']++;
|
||||||
|
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
$policy = Policy::query()
|
$policy = Policy::query()
|
||||||
->where('tenant_id', (int) $tenant->getKey())
|
->where('tenant_id', (int) $tenant->getKey())
|
||||||
@ -113,7 +137,7 @@ public function capture(
|
|||||||
->first();
|
->first();
|
||||||
|
|
||||||
if (! $policy instanceof Policy) {
|
if (! $policy instanceof Policy) {
|
||||||
$gaps['policy_not_found'] = ($gaps['policy_not_found'] ?? 0) + 1;
|
$this->recordGap($gaps, $gapSubjects, $descriptor, $this->resolver()->missingExpectedRecord($descriptor));
|
||||||
$stats['failed']++;
|
$stats['failed']++;
|
||||||
|
|
||||||
continue;
|
continue;
|
||||||
@ -152,7 +176,7 @@ public function capture(
|
|||||||
$version = $result['version'] ?? null;
|
$version = $result['version'] ?? null;
|
||||||
|
|
||||||
if ($version instanceof PolicyVersion) {
|
if ($version instanceof PolicyVersion) {
|
||||||
$capturedVersions[$subjectKey] = [
|
$capturedVersions[$captureKey] = [
|
||||||
'policy_type' => $policyType,
|
'policy_type' => $policyType,
|
||||||
'subject_external_id' => $externalId,
|
'subject_external_id' => $externalId,
|
||||||
'version' => $version,
|
'version' => $version,
|
||||||
@ -178,10 +202,10 @@ public function capture(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if ($isThrottled) {
|
if ($isThrottled) {
|
||||||
$gaps['throttled'] = ($gaps['throttled'] ?? 0) + 1;
|
$this->recordGap($gaps, $gapSubjects, $descriptor, $this->resolver()->throttled($descriptor));
|
||||||
$stats['throttled']++;
|
$stats['throttled']++;
|
||||||
} else {
|
} else {
|
||||||
$gaps['capture_failed'] = ($gaps['capture_failed'] ?? 0) + 1;
|
$this->recordGap($gaps, $gapSubjects, $descriptor, $this->resolver()->captureFailed($descriptor));
|
||||||
$stats['failed']++;
|
$stats['failed']++;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -201,7 +225,22 @@ public function capture(
|
|||||||
|
|
||||||
$remainingCount = max(0, count($subjects) - $processed);
|
$remainingCount = max(0, count($subjects) - $processed);
|
||||||
if ($remainingCount > 0) {
|
if ($remainingCount > 0) {
|
||||||
$gaps['budget_exhausted'] = ($gaps['budget_exhausted'] ?? 0) + $remainingCount;
|
foreach (array_slice($subjects, $processed) as $remainingSubject) {
|
||||||
|
$remainingPolicyType = trim((string) ($remainingSubject['policy_type'] ?? ''));
|
||||||
|
$remainingExternalId = trim((string) ($remainingSubject['subject_external_id'] ?? ''));
|
||||||
|
$remainingSubjectKey = trim((string) ($remainingSubject['subject_key'] ?? ''));
|
||||||
|
|
||||||
|
if ($remainingPolicyType === '' || $remainingExternalId === '') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$remainingDescriptor = $this->resolver()->describeForCapture(
|
||||||
|
$remainingPolicyType,
|
||||||
|
$remainingExternalId,
|
||||||
|
$remainingSubjectKey !== '' ? $remainingSubjectKey : null,
|
||||||
|
);
|
||||||
|
$this->recordGap($gaps, $gapSubjects, $remainingDescriptor, $this->resolver()->budgetExhausted($remainingDescriptor));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -210,11 +249,27 @@ public function capture(
|
|||||||
return [
|
return [
|
||||||
'stats' => $stats,
|
'stats' => $stats,
|
||||||
'gaps' => $gaps,
|
'gaps' => $gaps,
|
||||||
|
'gap_subjects' => $gapSubjects,
|
||||||
'resume_token' => $resumeTokenOut,
|
'resume_token' => $resumeTokenOut,
|
||||||
'captured_versions' => $capturedVersions,
|
'captured_versions' => $capturedVersions,
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, int> $gaps
|
||||||
|
* @param list<array<string, mixed>> $gapSubjects
|
||||||
|
*/
|
||||||
|
private function recordGap(array &$gaps, array &$gapSubjects, SubjectDescriptor $descriptor, ResolutionOutcomeRecord $outcome): void
|
||||||
|
{
|
||||||
|
$gaps[$outcome->reasonCode] = ($gaps[$outcome->reasonCode] ?? 0) + 1;
|
||||||
|
$gapSubjects[] = array_merge($descriptor->toArray(), $outcome->toArray());
|
||||||
|
}
|
||||||
|
|
||||||
|
private function resolver(): SubjectResolver
|
||||||
|
{
|
||||||
|
return $this->subjectResolver ?? app(SubjectResolver::class);
|
||||||
|
}
|
||||||
|
|
||||||
private function retryDelayMs(int $attempt): int
|
private function retryDelayMs(int $attempt): int
|
||||||
{
|
{
|
||||||
$attempt = max(0, $attempt);
|
$attempt = max(0, $attempt);
|
||||||
|
|||||||
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 : '';
|
||||||
|
}
|
||||||
|
}
|
||||||
175
app/Services/Baselines/BaselineSnapshotTruthResolver.php
Normal file
175
app/Services/Baselines/BaselineSnapshotTruthResolver.php
Normal file
@ -0,0 +1,175 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Services\Baselines;
|
||||||
|
|
||||||
|
use App\Models\BaselineProfile;
|
||||||
|
use App\Models\BaselineSnapshot;
|
||||||
|
use App\Support\Baselines\BaselineReasonCodes;
|
||||||
|
use App\Support\Baselines\BaselineSnapshotLifecycleState;
|
||||||
|
|
||||||
|
final class BaselineSnapshotTruthResolver
|
||||||
|
{
|
||||||
|
public function resolveEffectiveSnapshot(BaselineProfile $profile): ?BaselineSnapshot
|
||||||
|
{
|
||||||
|
return BaselineSnapshot::query()
|
||||||
|
->where('workspace_id', (int) $profile->workspace_id)
|
||||||
|
->where('baseline_profile_id', (int) $profile->getKey())
|
||||||
|
->where('lifecycle_state', BaselineSnapshotLifecycleState::Complete->value)
|
||||||
|
->orderByDesc('completed_at')
|
||||||
|
->orderByDesc('captured_at')
|
||||||
|
->orderByDesc('id')
|
||||||
|
->first();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function resolveLatestAttemptedSnapshot(BaselineProfile $profile): ?BaselineSnapshot
|
||||||
|
{
|
||||||
|
return BaselineSnapshot::query()
|
||||||
|
->where('workspace_id', (int) $profile->workspace_id)
|
||||||
|
->where('baseline_profile_id', (int) $profile->getKey())
|
||||||
|
->orderByDesc('captured_at')
|
||||||
|
->orderByDesc('id')
|
||||||
|
->first();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array{
|
||||||
|
* ok: bool,
|
||||||
|
* snapshot: ?BaselineSnapshot,
|
||||||
|
* effective_snapshot: ?BaselineSnapshot,
|
||||||
|
* latest_attempted_snapshot: ?BaselineSnapshot,
|
||||||
|
* reason_code: ?string
|
||||||
|
* }
|
||||||
|
*/
|
||||||
|
public function resolveCompareSnapshot(BaselineProfile $profile, ?BaselineSnapshot $explicitSnapshot = null): array
|
||||||
|
{
|
||||||
|
$effectiveSnapshot = $this->resolveEffectiveSnapshot($profile);
|
||||||
|
$latestAttemptedSnapshot = $this->resolveLatestAttemptedSnapshot($profile);
|
||||||
|
|
||||||
|
if ($explicitSnapshot instanceof BaselineSnapshot) {
|
||||||
|
if ((int) $explicitSnapshot->workspace_id !== (int) $profile->workspace_id
|
||||||
|
|| (int) $explicitSnapshot->baseline_profile_id !== (int) $profile->getKey()) {
|
||||||
|
return [
|
||||||
|
'ok' => false,
|
||||||
|
'snapshot' => null,
|
||||||
|
'effective_snapshot' => $effectiveSnapshot,
|
||||||
|
'latest_attempted_snapshot' => $latestAttemptedSnapshot,
|
||||||
|
'reason_code' => BaselineReasonCodes::COMPARE_INVALID_SNAPSHOT,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
$reasonCode = $this->compareBlockedReasonForSnapshot($explicitSnapshot, $effectiveSnapshot, explicitSelection: true);
|
||||||
|
|
||||||
|
return [
|
||||||
|
'ok' => $reasonCode === null,
|
||||||
|
'snapshot' => $reasonCode === null ? $explicitSnapshot : null,
|
||||||
|
'effective_snapshot' => $effectiveSnapshot,
|
||||||
|
'latest_attempted_snapshot' => $latestAttemptedSnapshot,
|
||||||
|
'reason_code' => $reasonCode,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($effectiveSnapshot instanceof BaselineSnapshot) {
|
||||||
|
return [
|
||||||
|
'ok' => true,
|
||||||
|
'snapshot' => $effectiveSnapshot,
|
||||||
|
'effective_snapshot' => $effectiveSnapshot,
|
||||||
|
'latest_attempted_snapshot' => $latestAttemptedSnapshot,
|
||||||
|
'reason_code' => null,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
'ok' => false,
|
||||||
|
'snapshot' => null,
|
||||||
|
'effective_snapshot' => null,
|
||||||
|
'latest_attempted_snapshot' => $latestAttemptedSnapshot,
|
||||||
|
'reason_code' => $this->profileBlockedReason($latestAttemptedSnapshot),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function isHistoricallySuperseded(BaselineSnapshot $snapshot, ?BaselineSnapshot $effectiveSnapshot = null): bool
|
||||||
|
{
|
||||||
|
$effectiveSnapshot ??= BaselineSnapshot::query()
|
||||||
|
->where('workspace_id', (int) $snapshot->workspace_id)
|
||||||
|
->where('baseline_profile_id', (int) $snapshot->baseline_profile_id)
|
||||||
|
->where('lifecycle_state', BaselineSnapshotLifecycleState::Complete->value)
|
||||||
|
->orderByDesc('completed_at')
|
||||||
|
->orderByDesc('captured_at')
|
||||||
|
->orderByDesc('id')
|
||||||
|
->first();
|
||||||
|
|
||||||
|
if (! $effectiveSnapshot instanceof BaselineSnapshot) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $snapshot->isConsumable()
|
||||||
|
&& (int) $effectiveSnapshot->getKey() !== (int) $snapshot->getKey();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function artifactReasonCode(BaselineSnapshot $snapshot, ?BaselineSnapshot $effectiveSnapshot = null): ?string
|
||||||
|
{
|
||||||
|
if (! $effectiveSnapshot instanceof BaselineSnapshot) {
|
||||||
|
$snapshot->loadMissing('baselineProfile');
|
||||||
|
|
||||||
|
$profile = $snapshot->baselineProfile;
|
||||||
|
|
||||||
|
if ($profile instanceof BaselineProfile) {
|
||||||
|
$effectiveSnapshot = $this->resolveEffectiveSnapshot($profile);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($snapshot->isBuilding()) {
|
||||||
|
return BaselineReasonCodes::SNAPSHOT_BUILDING;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($snapshot->isIncomplete()) {
|
||||||
|
$completionMeta = is_array($snapshot->completion_meta_jsonb) ? $snapshot->completion_meta_jsonb : [];
|
||||||
|
$reasonCode = $completionMeta['finalization_reason_code'] ?? null;
|
||||||
|
|
||||||
|
return is_string($reasonCode) && trim($reasonCode) !== ''
|
||||||
|
? trim($reasonCode)
|
||||||
|
: BaselineReasonCodes::SNAPSHOT_INCOMPLETE;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($this->isHistoricallySuperseded($snapshot, $effectiveSnapshot)) {
|
||||||
|
return BaselineReasonCodes::SNAPSHOT_SUPERSEDED;
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function compareBlockedReasonForSnapshot(
|
||||||
|
BaselineSnapshot $snapshot,
|
||||||
|
?BaselineSnapshot $effectiveSnapshot,
|
||||||
|
bool $explicitSelection,
|
||||||
|
): ?string {
|
||||||
|
if ($snapshot->isBuilding()) {
|
||||||
|
return BaselineReasonCodes::COMPARE_SNAPSHOT_BUILDING;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($snapshot->isIncomplete()) {
|
||||||
|
return BaselineReasonCodes::COMPARE_SNAPSHOT_INCOMPLETE;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! $snapshot->isConsumable()) {
|
||||||
|
return BaselineReasonCodes::COMPARE_NO_CONSUMABLE_SNAPSHOT;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($explicitSelection && $effectiveSnapshot instanceof BaselineSnapshot && (int) $effectiveSnapshot->getKey() !== (int) $snapshot->getKey()) {
|
||||||
|
return BaselineReasonCodes::COMPARE_SNAPSHOT_SUPERSEDED;
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function profileBlockedReason(?BaselineSnapshot $latestAttemptedSnapshot): string
|
||||||
|
{
|
||||||
|
return match (true) {
|
||||||
|
$latestAttemptedSnapshot?->isBuilding() === true => BaselineReasonCodes::COMPARE_SNAPSHOT_BUILDING,
|
||||||
|
$latestAttemptedSnapshot?->isIncomplete() === true => BaselineReasonCodes::COMPARE_SNAPSHOT_INCOMPLETE,
|
||||||
|
default => BaselineReasonCodes::COMPARE_NO_CONSUMABLE_SNAPSHOT,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -6,6 +6,7 @@
|
|||||||
|
|
||||||
use App\Models\BaselineSnapshot;
|
use App\Models\BaselineSnapshot;
|
||||||
use App\Models\BaselineSnapshotItem;
|
use App\Models\BaselineSnapshotItem;
|
||||||
|
use App\Support\Badges\BadgeCatalog;
|
||||||
use App\Support\Badges\BadgeDomain;
|
use App\Support\Badges\BadgeDomain;
|
||||||
use App\Support\Badges\BadgeRenderer;
|
use App\Support\Badges\BadgeRenderer;
|
||||||
use App\Support\Inventory\InventoryPolicyTypeMeta;
|
use App\Support\Inventory\InventoryPolicyTypeMeta;
|
||||||
@ -13,6 +14,8 @@
|
|||||||
use App\Support\Ui\EnterpriseDetail\EnterpriseDetailPageData;
|
use App\Support\Ui\EnterpriseDetail\EnterpriseDetailPageData;
|
||||||
use App\Support\Ui\EnterpriseDetail\EnterpriseDetailSectionFactory;
|
use App\Support\Ui\EnterpriseDetail\EnterpriseDetailSectionFactory;
|
||||||
use App\Support\Ui\EnterpriseDetail\SummaryHeaderData;
|
use App\Support\Ui\EnterpriseDetail\SummaryHeaderData;
|
||||||
|
use App\Support\Ui\GovernanceArtifactTruth\ArtifactTruthEnvelope;
|
||||||
|
use App\Support\Ui\GovernanceArtifactTruth\ArtifactTruthPresenter;
|
||||||
use Illuminate\Database\Eloquent\Collection as EloquentCollection;
|
use Illuminate\Database\Eloquent\Collection as EloquentCollection;
|
||||||
use Illuminate\Support\Carbon;
|
use Illuminate\Support\Carbon;
|
||||||
use Illuminate\Support\Collection;
|
use Illuminate\Support\Collection;
|
||||||
@ -57,7 +60,8 @@ public function present(BaselineSnapshot $snapshot): RenderedSnapshot
|
|||||||
$groups,
|
$groups,
|
||||||
);
|
);
|
||||||
|
|
||||||
$overallGapCount = $this->summaryGapCount($summary);
|
$overallGapSummary = $this->summaryGapSummary($summary);
|
||||||
|
$overallGapCount = $overallGapSummary->count;
|
||||||
$overallFidelity = FidelityState::fromSummary($summary, $items->isNotEmpty());
|
$overallFidelity = FidelityState::fromSummary($summary, $items->isNotEmpty());
|
||||||
|
|
||||||
return new RenderedSnapshot(
|
return new RenderedSnapshot(
|
||||||
@ -67,7 +71,7 @@ public function present(BaselineSnapshot $snapshot): RenderedSnapshot
|
|||||||
snapshotIdentityHash: is_string($snapshot->snapshot_identity_hash) && trim($snapshot->snapshot_identity_hash) !== ''
|
snapshotIdentityHash: is_string($snapshot->snapshot_identity_hash) && trim($snapshot->snapshot_identity_hash) !== ''
|
||||||
? trim($snapshot->snapshot_identity_hash)
|
? trim($snapshot->snapshot_identity_hash)
|
||||||
: null,
|
: null,
|
||||||
stateLabel: $overallGapCount > 0 ? 'Captured with gaps' : 'Complete',
|
stateLabel: $this->gapStatusSpec($overallGapCount)->label,
|
||||||
fidelitySummary: $this->fidelitySummary($summary),
|
fidelitySummary: $this->fidelitySummary($summary),
|
||||||
overallFidelity: $overallFidelity,
|
overallFidelity: $overallFidelity,
|
||||||
overallGapCount: $overallGapCount,
|
overallGapCount: $overallGapCount,
|
||||||
@ -96,10 +100,21 @@ public function presentEnterpriseDetail(BaselineSnapshot $snapshot, array $relat
|
|||||||
{
|
{
|
||||||
$rendered = $this->present($snapshot);
|
$rendered = $this->present($snapshot);
|
||||||
$factory = new EnterpriseDetailSectionFactory;
|
$factory = new EnterpriseDetailSectionFactory;
|
||||||
|
$truth = app(ArtifactTruthPresenter::class)->forBaselineSnapshot($snapshot);
|
||||||
|
|
||||||
$stateBadge = $factory->statusBadge(
|
$truthBadge = $factory->statusBadge(
|
||||||
$rendered->stateLabel,
|
$truth->primaryBadgeSpec()->label,
|
||||||
$rendered->overallGapCount > 0 ? 'warning' : 'success',
|
$truth->primaryBadgeSpec()->color,
|
||||||
|
$truth->primaryBadgeSpec()->icon,
|
||||||
|
$truth->primaryBadgeSpec()->iconColor,
|
||||||
|
);
|
||||||
|
|
||||||
|
$lifecycleSpec = BadgeRenderer::spec(BadgeDomain::BaselineSnapshotLifecycle, $snapshot->lifecycleState()->value);
|
||||||
|
$lifecycleBadge = $factory->statusBadge(
|
||||||
|
$lifecycleSpec->label,
|
||||||
|
$lifecycleSpec->color,
|
||||||
|
$lifecycleSpec->icon,
|
||||||
|
$lifecycleSpec->iconColor,
|
||||||
);
|
);
|
||||||
|
|
||||||
$fidelitySpec = BadgeRenderer::spec(BadgeDomain::BaselineSnapshotFidelity, $rendered->overallFidelity->value);
|
$fidelitySpec = BadgeRenderer::spec(BadgeDomain::BaselineSnapshotFidelity, $rendered->overallFidelity->value);
|
||||||
@ -114,21 +129,37 @@ public function presentEnterpriseDetail(BaselineSnapshot $snapshot, array $relat
|
|||||||
static fn (array $row): int => (int) ($row['itemCount'] ?? 0),
|
static fn (array $row): int => (int) ($row['itemCount'] ?? 0),
|
||||||
$rendered->summaryRows,
|
$rendered->summaryRows,
|
||||||
));
|
));
|
||||||
|
$currentTruth = $this->currentTruthPresentation($truth);
|
||||||
|
$currentTruthBadge = $factory->statusBadge(
|
||||||
|
$currentTruth['label'],
|
||||||
|
$currentTruth['color'],
|
||||||
|
$currentTruth['icon'],
|
||||||
|
$currentTruth['iconColor'],
|
||||||
|
);
|
||||||
|
$operatorExplanation = $truth->operatorExplanation;
|
||||||
|
|
||||||
return EnterpriseDetailBuilder::make('baseline_snapshot', 'workspace')
|
return EnterpriseDetailBuilder::make('baseline_snapshot', 'workspace')
|
||||||
->header(new SummaryHeaderData(
|
->header(new SummaryHeaderData(
|
||||||
title: $rendered->baselineProfileName ?? 'Baseline snapshot',
|
title: $rendered->baselineProfileName ?? 'Baseline snapshot',
|
||||||
subtitle: 'Snapshot #'.$rendered->snapshotId,
|
subtitle: 'Snapshot #'.$rendered->snapshotId,
|
||||||
statusBadges: [$stateBadge, $fidelityBadge],
|
statusBadges: [$truthBadge, $lifecycleBadge, $fidelityBadge],
|
||||||
keyFacts: [
|
keyFacts: [
|
||||||
$factory->keyFact('Captured', $this->formatTimestamp($rendered->capturedAt)),
|
$factory->keyFact('Captured', $this->formatTimestamp($rendered->capturedAt)),
|
||||||
|
$factory->keyFact('Current truth', $currentTruth['label'], badge: $currentTruthBadge),
|
||||||
$factory->keyFact('Evidence mix', $rendered->fidelitySummary),
|
$factory->keyFact('Evidence mix', $rendered->fidelitySummary),
|
||||||
$factory->keyFact('Evidence gaps', $rendered->overallGapCount),
|
|
||||||
$factory->keyFact('Captured items', $capturedItemCount),
|
$factory->keyFact('Captured items', $capturedItemCount),
|
||||||
],
|
],
|
||||||
descriptionHint: 'Capture context, coverage, and governance links stay ahead of technical payload detail.',
|
descriptionHint: 'Current baseline truth, lifecycle proof, and coverage stay ahead of technical payload detail.',
|
||||||
))
|
))
|
||||||
->addSection(
|
->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(
|
$factory->viewSection(
|
||||||
id: 'coverage_summary',
|
id: 'coverage_summary',
|
||||||
kind: 'current_status',
|
kind: 'current_status',
|
||||||
@ -160,11 +191,30 @@ public function presentEnterpriseDetail(BaselineSnapshot $snapshot, array $relat
|
|||||||
->addSupportingCard(
|
->addSupportingCard(
|
||||||
$factory->supportingFactsCard(
|
$factory->supportingFactsCard(
|
||||||
kind: 'status',
|
kind: 'status',
|
||||||
title: 'Snapshot status',
|
title: 'Snapshot truth',
|
||||||
|
items: array_values(array_filter([
|
||||||
|
$factory->keyFact('Artifact truth', $truth->primaryLabel, badge: $truthBadge),
|
||||||
|
$operatorExplanation !== null
|
||||||
|
? $factory->keyFact('Result meaning', $operatorExplanation->evaluationResultLabel())
|
||||||
|
: null,
|
||||||
|
$operatorExplanation !== null
|
||||||
|
? $factory->keyFact('Result trust', $operatorExplanation->trustworthinessLabel())
|
||||||
|
: null,
|
||||||
|
$factory->keyFact('Lifecycle', $lifecycleSpec->label, badge: $lifecycleBadge),
|
||||||
|
$factory->keyFact('Current truth', $currentTruth['label'], badge: $currentTruthBadge),
|
||||||
|
$operatorExplanation !== null && $operatorExplanation->coverageStatement !== null
|
||||||
|
? $factory->keyFact('Coverage', $operatorExplanation->coverageStatement)
|
||||||
|
: null,
|
||||||
|
$factory->keyFact('Next step', $operatorExplanation?->nextActionText ?? $truth->nextStepText()),
|
||||||
|
])),
|
||||||
|
),
|
||||||
|
$factory->supportingFactsCard(
|
||||||
|
kind: 'coverage',
|
||||||
|
title: 'Coverage',
|
||||||
items: [
|
items: [
|
||||||
$factory->keyFact('State', $rendered->stateLabel, badge: $stateBadge),
|
|
||||||
$factory->keyFact('Overall fidelity', $fidelitySpec->label, badge: $fidelityBadge),
|
$factory->keyFact('Overall fidelity', $fidelitySpec->label, badge: $fidelityBadge),
|
||||||
$factory->keyFact('Evidence gaps', $rendered->overallGapCount),
|
$factory->keyFact('Evidence gaps', $rendered->overallGapCount),
|
||||||
|
$factory->keyFact('Captured items', $capturedItemCount),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
$factory->supportingFactsCard(
|
$factory->supportingFactsCard(
|
||||||
@ -172,6 +222,8 @@ public function presentEnterpriseDetail(BaselineSnapshot $snapshot, array $relat
|
|||||||
title: 'Capture timing',
|
title: 'Capture timing',
|
||||||
items: [
|
items: [
|
||||||
$factory->keyFact('Captured', $this->formatTimestamp($rendered->capturedAt)),
|
$factory->keyFact('Captured', $this->formatTimestamp($rendered->capturedAt)),
|
||||||
|
$factory->keyFact('Completed', $this->formatTimestamp($snapshot->completed_at?->toIso8601String())),
|
||||||
|
$factory->keyFact('Failed', $this->formatTimestamp($snapshot->failed_at?->toIso8601String())),
|
||||||
$factory->keyFact('Identity hash', $rendered->snapshotIdentityHash),
|
$factory->keyFact('Identity hash', $rendered->snapshotIdentityHash),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
@ -223,10 +275,6 @@ private function presentGroup(string $policyType, Collection $items): RenderedSn
|
|||||||
$renderedItems,
|
$renderedItems,
|
||||||
));
|
));
|
||||||
|
|
||||||
if ($renderingError !== null) {
|
|
||||||
$gapSummary = $gapSummary->withMessage($renderingError);
|
|
||||||
}
|
|
||||||
|
|
||||||
$capturedAt = collect($renderedItems)
|
$capturedAt = collect($renderedItems)
|
||||||
->pluck('observedAt')
|
->pluck('observedAt')
|
||||||
->filter(static fn (mixed $value): bool => is_string($value) && trim($value) !== '')
|
->filter(static fn (mixed $value): bool => is_string($value) && trim($value) !== '')
|
||||||
@ -276,10 +324,7 @@ private function technicalPayload(Collection $items): array
|
|||||||
*/
|
*/
|
||||||
private function summaryGapCount(array $summary): int
|
private function summaryGapCount(array $summary): int
|
||||||
{
|
{
|
||||||
$gaps = is_array($summary['gaps'] ?? null) ? $summary['gaps'] : [];
|
return $this->summaryGapSummary($summary)->count;
|
||||||
$count = $gaps['count'] ?? 0;
|
|
||||||
|
|
||||||
return is_numeric($count) ? (int) $count : 0;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -294,7 +339,67 @@ private function fidelitySummary(array $summary): string
|
|||||||
$content = is_numeric($counts['content'] ?? null) ? (int) $counts['content'] : 0;
|
$content = is_numeric($counts['content'] ?? null) ? (int) $counts['content'] : 0;
|
||||||
$meta = is_numeric($counts['meta'] ?? null) ? (int) $counts['meta'] : 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',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array{label: string, color: string, icon: string, iconColor: string}
|
||||||
|
*/
|
||||||
|
private function currentTruthPresentation(ArtifactTruthEnvelope $truth): array
|
||||||
|
{
|
||||||
|
return match ($truth->artifactExistence) {
|
||||||
|
'historical_only' => [
|
||||||
|
'label' => 'Historical trace',
|
||||||
|
'color' => 'gray',
|
||||||
|
'icon' => 'heroicon-m-clock',
|
||||||
|
'iconColor' => 'gray',
|
||||||
|
],
|
||||||
|
'created_but_not_usable' => [
|
||||||
|
'label' => 'Not compare input',
|
||||||
|
'color' => 'warning',
|
||||||
|
'icon' => 'heroicon-m-exclamation-triangle',
|
||||||
|
'iconColor' => 'warning',
|
||||||
|
],
|
||||||
|
default => [
|
||||||
|
'label' => 'Current baseline',
|
||||||
|
'color' => 'success',
|
||||||
|
'icon' => 'heroicon-m-check-badge',
|
||||||
|
'iconColor' => 'success',
|
||||||
|
],
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
private function typeLabel(string $policyType): string
|
private function typeLabel(string $policyType): string
|
||||||
|
|||||||
@ -95,9 +95,9 @@ public function coverageHint(): ?string
|
|||||||
{
|
{
|
||||||
return match ($this) {
|
return match ($this) {
|
||||||
self::Full => null,
|
self::Full => null,
|
||||||
self::Partial => 'Mixed evidence fidelity across this group.',
|
self::Partial => 'Mixed evidence detail is available for this group.',
|
||||||
self::ReferenceOnly => 'Metadata-only evidence is available.',
|
self::ReferenceOnly => 'Metadata-only evidence is available for this group.',
|
||||||
self::Unsupported => 'Fallback metadata rendering is being used.',
|
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);
|
$messages = self::uniqueMessages($messages);
|
||||||
|
|
||||||
return new self(
|
return new self(
|
||||||
count: count($messages),
|
count: 0,
|
||||||
messages: $messages,
|
messages: $messages,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@ -60,17 +60,25 @@ public static function fromItemMeta(array $meta, FidelityState $fidelity, bool $
|
|||||||
public static function fromReasonMap(array $reasons): self
|
public static function fromReasonMap(array $reasons): self
|
||||||
{
|
{
|
||||||
$messages = [];
|
$messages = [];
|
||||||
|
$primaryCount = 0;
|
||||||
|
|
||||||
foreach ($reasons as $reason => $count) {
|
foreach ($reasons as $reason => $reasonCount) {
|
||||||
if (! is_string($reason) || ! is_numeric($count) || (int) $count <= 0) {
|
if (! is_string($reason) || ! is_numeric($reasonCount) || (int) $reasonCount <= 0) {
|
||||||
continue;
|
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(
|
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),
|
messages: self::uniqueMessages($messages),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@ -90,10 +98,6 @@ public static function merge(array $summaries): self
|
|||||||
|
|
||||||
$messages = self::uniqueMessages($messages);
|
$messages = self::uniqueMessages($messages);
|
||||||
|
|
||||||
if ($count === 0) {
|
|
||||||
$count = count($messages);
|
|
||||||
}
|
|
||||||
|
|
||||||
return new self(
|
return new self(
|
||||||
count: $count,
|
count: $count,
|
||||||
messages: $messages,
|
messages: $messages,
|
||||||
@ -111,14 +115,14 @@ public function withMessage(string $message): self
|
|||||||
$messages = self::uniqueMessages([...$this->messages, $message]);
|
$messages = self::uniqueMessages([...$this->messages, $message]);
|
||||||
|
|
||||||
return new self(
|
return new self(
|
||||||
count: max($this->count, count($messages)),
|
count: $this->count,
|
||||||
messages: $messages,
|
messages: $messages,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function hasGaps(): bool
|
public function hasGaps(): bool
|
||||||
{
|
{
|
||||||
return $this->count > 0 || $this->messages !== [];
|
return $this->count > 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
public function badgeState(): string
|
public function badgeState(): string
|
||||||
@ -158,4 +162,17 @@ private static function humanizeReason(string $reason): string
|
|||||||
->headline()
|
->headline()
|
||||||
->toString();
|
->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
|
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
|
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([
|
$tenant->update([
|
||||||
'rbac_status' => $status,
|
'rbac_status' => $status,
|
||||||
'rbac_status_reason' => $reason,
|
'rbac_status_reason' => $reason,
|
||||||
@ -119,6 +133,7 @@ private function record(Tenant $tenant, string $status, ?string $reason, bool $u
|
|||||||
'status' => $status,
|
'status' => $status,
|
||||||
'reason' => $reason,
|
'reason' => $reason,
|
||||||
'used_artifacts' => $usedArtifacts,
|
'used_artifacts' => $usedArtifacts,
|
||||||
|
'reason_translation' => $reasonTranslation,
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -17,11 +17,18 @@
|
|||||||
use App\Support\OperationRunStatus;
|
use App\Support\OperationRunStatus;
|
||||||
use App\Support\Operations\ExecutionAuthorityMode;
|
use App\Support\Operations\ExecutionAuthorityMode;
|
||||||
use App\Support\Operations\ExecutionDenialReasonCode;
|
use App\Support\Operations\ExecutionDenialReasonCode;
|
||||||
|
use App\Support\Operations\LifecycleReconciliationReason;
|
||||||
use App\Support\Operations\OperationRunCapabilityResolver;
|
use App\Support\Operations\OperationRunCapabilityResolver;
|
||||||
use App\Support\Operations\QueuedExecutionLegitimacyDecision;
|
use App\Support\Operations\QueuedExecutionLegitimacyDecision;
|
||||||
use App\Support\OpsUx\BulkRunContext;
|
use App\Support\OpsUx\BulkRunContext;
|
||||||
use App\Support\OpsUx\RunFailureSanitizer;
|
use App\Support\OpsUx\RunFailureSanitizer;
|
||||||
use App\Support\OpsUx\SummaryCountsNormalizer;
|
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\Database\QueryException;
|
||||||
use Illuminate\Support\Facades\DB;
|
use Illuminate\Support\Facades\DB;
|
||||||
use InvalidArgumentException;
|
use InvalidArgumentException;
|
||||||
@ -34,6 +41,7 @@ class OperationRunService
|
|||||||
public function __construct(
|
public function __construct(
|
||||||
private readonly AuditRecorder $auditRecorder,
|
private readonly AuditRecorder $auditRecorder,
|
||||||
private readonly OperationRunCapabilityResolver $operationRunCapabilityResolver,
|
private readonly OperationRunCapabilityResolver $operationRunCapabilityResolver,
|
||||||
|
private readonly ReasonTranslator $reasonTranslator,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
public function isStaleQueuedRun(OperationRun $run, int $thresholdMinutes = 5): bool
|
public function isStaleQueuedRun(OperationRun $run, int $thresholdMinutes = 5): bool
|
||||||
@ -55,15 +63,45 @@ public function isStaleQueuedRun(OperationRun $run, int $thresholdMinutes = 5):
|
|||||||
|
|
||||||
public function failStaleQueuedRun(OperationRun $run, string $message = 'Run was queued but never started.'): OperationRun
|
public function failStaleQueuedRun(OperationRun $run, string $message = 'Run was queued but never started.'): OperationRun
|
||||||
{
|
{
|
||||||
return $this->updateRun(
|
return $this->forceFailNonTerminalRun(
|
||||||
$run,
|
$run,
|
||||||
status: OperationRunStatus::Completed->value,
|
reasonCode: LifecycleReconciliationReason::StaleQueued->value,
|
||||||
outcome: OperationRunOutcome::Failed->value,
|
message: $message,
|
||||||
failures: [
|
source: 'scheduled_reconciler',
|
||||||
[
|
evidence: [
|
||||||
'code' => 'run.stale_queued',
|
'status' => OperationRunStatus::Queued->value,
|
||||||
'message' => $message,
|
'created_at' => $run->created_at?->toIso8601String(),
|
||||||
],
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function isStaleRunningRun(OperationRun $run, int $thresholdMinutes = 15): bool
|
||||||
|
{
|
||||||
|
if ($run->status !== OperationRunStatus::Running->value) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$startedAt = $run->started_at ?? $run->created_at;
|
||||||
|
|
||||||
|
if ($startedAt === null) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $startedAt->lte(now()->subMinutes(max(1, $thresholdMinutes)));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function failStaleRunningRun(
|
||||||
|
OperationRun $run,
|
||||||
|
string $message = 'Run stopped reporting progress and was marked failed.',
|
||||||
|
): OperationRun {
|
||||||
|
return $this->forceFailNonTerminalRun(
|
||||||
|
$run,
|
||||||
|
reasonCode: LifecycleReconciliationReason::StaleRunning->value,
|
||||||
|
message: $message,
|
||||||
|
source: 'scheduled_reconciler',
|
||||||
|
evidence: [
|
||||||
|
'status' => OperationRunStatus::Running->value,
|
||||||
|
'started_at' => ($run->started_at ?? $run->created_at)?->toIso8601String(),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@ -487,6 +525,16 @@ public function updateRun(
|
|||||||
$updateData['failure_summary'] = $this->sanitizeFailures($failures);
|
$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)) {
|
if ($status === OperationRunStatus::Running->value && is_null($run->started_at)) {
|
||||||
$updateData['started_at'] = now();
|
$updateData['started_at'] = now();
|
||||||
}
|
}
|
||||||
@ -704,6 +752,136 @@ public function failRun(OperationRun $run, Throwable $e): OperationRun
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $evidence
|
||||||
|
* @param array<string, mixed> $summaryCounts
|
||||||
|
*/
|
||||||
|
public function forceFailNonTerminalRun(
|
||||||
|
OperationRun $run,
|
||||||
|
string $reasonCode,
|
||||||
|
string $message,
|
||||||
|
string $source = 'scheduled_reconciler',
|
||||||
|
array $evidence = [],
|
||||||
|
array $summaryCounts = [],
|
||||||
|
): OperationRun {
|
||||||
|
return $this->updateRunWithReconciliation(
|
||||||
|
run: $run,
|
||||||
|
status: OperationRunStatus::Completed->value,
|
||||||
|
outcome: OperationRunOutcome::Failed->value,
|
||||||
|
summaryCounts: $summaryCounts,
|
||||||
|
failures: [[
|
||||||
|
'code' => $reasonCode,
|
||||||
|
'reason_code' => $reasonCode,
|
||||||
|
'message' => $message,
|
||||||
|
]],
|
||||||
|
reasonCode: $reasonCode,
|
||||||
|
reasonMessage: $message,
|
||||||
|
source: $source,
|
||||||
|
evidence: $evidence,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function bridgeFailedJobFailure(
|
||||||
|
OperationRun $run,
|
||||||
|
Throwable $exception,
|
||||||
|
string $source = 'failed_callback',
|
||||||
|
): OperationRun {
|
||||||
|
$reason = $this->bridgeReasonForThrowable($exception);
|
||||||
|
$message = $reason->defaultMessage();
|
||||||
|
$exceptionMessage = $this->sanitizeMessage($exception->getMessage());
|
||||||
|
|
||||||
|
if ($exceptionMessage !== '') {
|
||||||
|
$message = $exceptionMessage;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->forceFailNonTerminalRun(
|
||||||
|
$run,
|
||||||
|
reasonCode: $reason->value,
|
||||||
|
message: $message,
|
||||||
|
source: $source,
|
||||||
|
evidence: [
|
||||||
|
'exception_class' => $exception::class,
|
||||||
|
'bridge_source' => $source,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $summaryCounts
|
||||||
|
* @param array<int, array{code?: mixed, reason_code?: mixed, message?: mixed}> $failures
|
||||||
|
* @param array<string, mixed> $evidence
|
||||||
|
*/
|
||||||
|
public function updateRunWithReconciliation(
|
||||||
|
OperationRun $run,
|
||||||
|
string $status,
|
||||||
|
string $outcome,
|
||||||
|
array $summaryCounts,
|
||||||
|
array $failures,
|
||||||
|
string $reasonCode,
|
||||||
|
string $reasonMessage,
|
||||||
|
string $source = 'scheduled_reconciler',
|
||||||
|
array $evidence = [],
|
||||||
|
): OperationRun {
|
||||||
|
/** @var OperationRun $updated */
|
||||||
|
$updated = DB::transaction(function () use (
|
||||||
|
$run,
|
||||||
|
$status,
|
||||||
|
$outcome,
|
||||||
|
$summaryCounts,
|
||||||
|
$failures,
|
||||||
|
$reasonCode,
|
||||||
|
$reasonMessage,
|
||||||
|
$source,
|
||||||
|
$evidence,
|
||||||
|
): OperationRun {
|
||||||
|
$locked = OperationRun::query()
|
||||||
|
->whereKey($run->getKey())
|
||||||
|
->lockForUpdate()
|
||||||
|
->first();
|
||||||
|
|
||||||
|
if (! $locked instanceof OperationRun) {
|
||||||
|
return $run;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ((string) $locked->status === OperationRunStatus::Completed->value) {
|
||||||
|
return $locked;
|
||||||
|
}
|
||||||
|
|
||||||
|
$context = is_array($locked->context) ? $locked->context : [];
|
||||||
|
$context['reason_code'] = RunFailureSanitizer::normalizeReasonCode($reasonCode);
|
||||||
|
$context['reconciliation'] = $this->reconciliationMetadata(
|
||||||
|
reasonCode: $reasonCode,
|
||||||
|
reasonMessage: $reasonMessage,
|
||||||
|
source: $source,
|
||||||
|
evidence: $evidence,
|
||||||
|
);
|
||||||
|
|
||||||
|
$translatedContext = $this->withReasonTranslationContext(
|
||||||
|
run: $locked,
|
||||||
|
context: $context,
|
||||||
|
failures: $failures,
|
||||||
|
);
|
||||||
|
|
||||||
|
$locked->update([
|
||||||
|
'context' => $translatedContext ?? $context,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$locked->refresh();
|
||||||
|
|
||||||
|
return $this->updateRun(
|
||||||
|
$locked,
|
||||||
|
status: $status,
|
||||||
|
outcome: $outcome,
|
||||||
|
summaryCounts: $summaryCounts,
|
||||||
|
failures: $failures,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
$updated->refresh();
|
||||||
|
|
||||||
|
return $updated;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Finalize a run as blocked with deterministic reason_code + link-only next steps.
|
* Finalize a run as blocked with deterministic reason_code + link-only next steps.
|
||||||
*
|
*
|
||||||
@ -721,6 +899,13 @@ public function finalizeBlockedRun(
|
|||||||
$context = is_array($run->context) ? $run->context : [];
|
$context = is_array($run->context) ? $run->context : [];
|
||||||
$context['reason_code'] = $reasonCode;
|
$context['reason_code'] = $reasonCode;
|
||||||
$context['next_steps'] = $nextSteps;
|
$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 : []);
|
$summaryCounts = $this->sanitizeSummaryCounts(is_array($run->summary_counts ?? null) ? $run->summary_counts : []);
|
||||||
|
|
||||||
$run->update([
|
$run->update([
|
||||||
@ -943,12 +1128,115 @@ protected function sanitizeNextSteps(array $nextSteps): array
|
|||||||
return $sanitized;
|
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
|
||||||
|
|| LifecycleReconciliationReason::tryFrom($reasonCode) instanceof LifecycleReconciliationReason
|
||||||
|
|| TenantOperabilityReasonCode::tryFrom($reasonCode) instanceof TenantOperabilityReasonCode
|
||||||
|
|| RbacReason::tryFrom($reasonCode) instanceof RbacReason;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $evidence
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
private function reconciliationMetadata(
|
||||||
|
string $reasonCode,
|
||||||
|
string $reasonMessage,
|
||||||
|
string $source,
|
||||||
|
array $evidence,
|
||||||
|
): array {
|
||||||
|
return [
|
||||||
|
'reconciled_at' => now()->toIso8601String(),
|
||||||
|
'reason' => RunFailureSanitizer::normalizeReasonCode($reasonCode),
|
||||||
|
'reason_code' => RunFailureSanitizer::normalizeReasonCode($reasonCode),
|
||||||
|
'reason_message' => $this->sanitizeMessage($reasonMessage),
|
||||||
|
'source' => $this->sanitizeFailureCode($source),
|
||||||
|
'evidence' => $evidence,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
private function bridgeReasonForThrowable(Throwable $exception): LifecycleReconciliationReason
|
||||||
|
{
|
||||||
|
$className = strtolower(class_basename($exception));
|
||||||
|
|
||||||
|
if (str_contains($className, 'timeout') || str_contains($className, 'attempts')) {
|
||||||
|
return LifecycleReconciliationReason::InfrastructureTimeoutOrAbandonment;
|
||||||
|
}
|
||||||
|
|
||||||
|
return LifecycleReconciliationReason::QueueFailureBridge;
|
||||||
|
}
|
||||||
|
|
||||||
private function writeTerminalAudit(OperationRun $run): void
|
private function writeTerminalAudit(OperationRun $run): void
|
||||||
{
|
{
|
||||||
$tenant = $run->tenant;
|
$tenant = $run->tenant;
|
||||||
$workspace = $run->workspace;
|
$workspace = $run->workspace;
|
||||||
$context = is_array($run->context) ? $run->context : [];
|
$context = is_array($run->context) ? $run->context : [];
|
||||||
$executionLegitimacy = is_array($context['execution_legitimacy'] ?? null) ? $context['execution_legitimacy'] : [];
|
$executionLegitimacy = is_array($context['execution_legitimacy'] ?? null) ? $context['execution_legitimacy'] : [];
|
||||||
|
$reconciliation = is_array($context['reconciliation'] ?? null) ? $context['reconciliation'] : [];
|
||||||
$operationLabel = OperationCatalog::label((string) $run->type);
|
$operationLabel = OperationCatalog::label((string) $run->type);
|
||||||
|
|
||||||
$action = match ($run->outcome) {
|
$action = match ($run->outcome) {
|
||||||
@ -978,6 +1266,7 @@ private function writeTerminalAudit(OperationRun $run): void
|
|||||||
'authority_mode' => $executionLegitimacy['authority_mode'] ?? ($context['execution_authority_mode'] ?? null),
|
'authority_mode' => $executionLegitimacy['authority_mode'] ?? ($context['execution_authority_mode'] ?? null),
|
||||||
'acting_identity_type' => $executionLegitimacy['initiator']['identity_type'] ?? ($run->user instanceof User ? 'user' : 'system'),
|
'acting_identity_type' => $executionLegitimacy['initiator']['identity_type'] ?? ($run->user instanceof User ? 'user' : 'system'),
|
||||||
'blocked_by' => $context['blocked_by'] ?? null,
|
'blocked_by' => $context['blocked_by'] ?? null,
|
||||||
|
'reconciliation' => $reconciliation !== [] ? $reconciliation : null,
|
||||||
],
|
],
|
||||||
],
|
],
|
||||||
workspace: $workspace,
|
workspace: $workspace,
|
||||||
|
|||||||
139
app/Services/Operations/OperationLifecyclePolicyValidator.php
Normal file
139
app/Services/Operations/OperationLifecyclePolicyValidator.php
Normal file
@ -0,0 +1,139 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Services\Operations;
|
||||||
|
|
||||||
|
use App\Jobs\Concerns\BridgesFailedOperationRun;
|
||||||
|
use App\Support\Operations\OperationLifecyclePolicy;
|
||||||
|
use RuntimeException;
|
||||||
|
|
||||||
|
final class OperationLifecyclePolicyValidator
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private readonly OperationLifecyclePolicy $policy,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array{
|
||||||
|
* valid:bool,
|
||||||
|
* errors:array<int, string>,
|
||||||
|
* definitions:array<string, array<string, mixed>>
|
||||||
|
* }
|
||||||
|
*/
|
||||||
|
public function validate(): array
|
||||||
|
{
|
||||||
|
$errors = [];
|
||||||
|
$definitions = [];
|
||||||
|
|
||||||
|
foreach ($this->policy->coveredTypeNames() as $operationType) {
|
||||||
|
$definition = $this->policy->definition($operationType);
|
||||||
|
|
||||||
|
if ($definition === null) {
|
||||||
|
$errors[] = sprintf('Missing lifecycle policy definition for [%s].', $operationType);
|
||||||
|
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$definitions[$operationType] = $definition;
|
||||||
|
$jobClass = $this->policy->jobClass($operationType);
|
||||||
|
|
||||||
|
if ($jobClass === null || ! class_exists($jobClass)) {
|
||||||
|
$errors[] = sprintf('Lifecycle policy [%s] points to a missing job class.', $operationType);
|
||||||
|
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$timeout = $this->jobTimeoutSeconds($operationType);
|
||||||
|
|
||||||
|
if (! is_int($timeout) || $timeout <= 0) {
|
||||||
|
$errors[] = sprintf('Lifecycle policy [%s] requires an explicit positive job timeout.', $operationType);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! $this->jobFailsOnTimeout($operationType)) {
|
||||||
|
$errors[] = sprintf('Lifecycle policy [%s] requires failOnTimeout=true.', $operationType);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($this->policy->requiresDirectFailedBridge($operationType) && ! $this->jobUsesDirectFailedBridge($operationType)) {
|
||||||
|
$errors[] = sprintf('Lifecycle policy [%s] requires a direct failed-job bridge.', $operationType);
|
||||||
|
}
|
||||||
|
|
||||||
|
$retryAfter = $this->policy->queueRetryAfterSeconds($this->policy->queueConnection($operationType));
|
||||||
|
$safetyMargin = $this->policy->retryAfterSafetyMarginSeconds();
|
||||||
|
|
||||||
|
if (is_int($timeout) && is_int($retryAfter) && $timeout >= ($retryAfter - $safetyMargin)) {
|
||||||
|
$errors[] = sprintf(
|
||||||
|
'Lifecycle policy [%s] has timeout %d which is not safely below retry_after %d (margin %d).',
|
||||||
|
$operationType,
|
||||||
|
$timeout,
|
||||||
|
$retryAfter,
|
||||||
|
$safetyMargin,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
$expectedMaxRuntime = $this->policy->expectedMaxRuntimeSeconds($operationType);
|
||||||
|
|
||||||
|
if (is_int($expectedMaxRuntime) && is_int($retryAfter) && $expectedMaxRuntime >= ($retryAfter - $safetyMargin)) {
|
||||||
|
$errors[] = sprintf(
|
||||||
|
'Lifecycle policy [%s] expected runtime %d is not safely below retry_after %d (margin %d).',
|
||||||
|
$operationType,
|
||||||
|
$expectedMaxRuntime,
|
||||||
|
$retryAfter,
|
||||||
|
$safetyMargin,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
'valid' => $errors === [],
|
||||||
|
'errors' => $errors,
|
||||||
|
'definitions' => $definitions,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function assertValid(): void
|
||||||
|
{
|
||||||
|
$result = $this->validate();
|
||||||
|
|
||||||
|
if (($result['valid'] ?? false) === true) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new RuntimeException(implode(' ', $result['errors'] ?? ['Lifecycle policy validation failed.']));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function jobTimeoutSeconds(string $operationType): ?int
|
||||||
|
{
|
||||||
|
$jobClass = $this->policy->jobClass($operationType);
|
||||||
|
|
||||||
|
if ($jobClass === null || ! class_exists($jobClass)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$timeout = get_class_vars($jobClass)['timeout'] ?? null;
|
||||||
|
|
||||||
|
return is_numeric($timeout) ? (int) $timeout : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function jobFailsOnTimeout(string $operationType): bool
|
||||||
|
{
|
||||||
|
$jobClass = $this->policy->jobClass($operationType);
|
||||||
|
|
||||||
|
if ($jobClass === null || ! class_exists($jobClass)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (bool) (get_class_vars($jobClass)['failOnTimeout'] ?? false);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function jobUsesDirectFailedBridge(string $operationType): bool
|
||||||
|
{
|
||||||
|
$jobClass = $this->policy->jobClass($operationType);
|
||||||
|
|
||||||
|
if ($jobClass === null || ! class_exists($jobClass)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return in_array(BridgesFailedOperationRun::class, class_uses_recursive($jobClass), true);
|
||||||
|
}
|
||||||
|
}
|
||||||
200
app/Services/Operations/OperationLifecycleReconciler.php
Normal file
200
app/Services/Operations/OperationLifecycleReconciler.php
Normal file
@ -0,0 +1,200 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Services\Operations;
|
||||||
|
|
||||||
|
use App\Models\OperationRun;
|
||||||
|
use App\Services\OperationRunService;
|
||||||
|
use App\Support\OperationRunOutcome;
|
||||||
|
use App\Support\OperationRunStatus;
|
||||||
|
use App\Support\Operations\LifecycleReconciliationReason;
|
||||||
|
use App\Support\Operations\OperationLifecyclePolicy;
|
||||||
|
use App\Support\Operations\OperationRunFreshnessState;
|
||||||
|
use Illuminate\Database\Eloquent\Builder;
|
||||||
|
|
||||||
|
final class OperationLifecycleReconciler
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private readonly OperationLifecyclePolicy $policy,
|
||||||
|
private readonly OperationRunService $operationRunService,
|
||||||
|
private readonly QueuedExecutionLegitimacyGate $queuedExecutionLegitimacyGate,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array{
|
||||||
|
* types?: array<int, string>,
|
||||||
|
* tenant_ids?: array<int, int>,
|
||||||
|
* workspace_ids?: array<int, int>,
|
||||||
|
* limit?: int,
|
||||||
|
* dry_run?: bool
|
||||||
|
* } $options
|
||||||
|
* @return array{candidates:int,reconciled:int,skipped:int,changes:array<int, array<string, mixed>>}
|
||||||
|
*/
|
||||||
|
public function reconcile(array $options = []): array
|
||||||
|
{
|
||||||
|
$types = array_values(array_filter(
|
||||||
|
$options['types'] ?? $this->policy->coveredTypeNames(),
|
||||||
|
static fn (mixed $type): bool => is_string($type) && trim($type) !== '',
|
||||||
|
));
|
||||||
|
$tenantIds = array_values(array_filter(
|
||||||
|
$options['tenant_ids'] ?? [],
|
||||||
|
static fn (mixed $tenantId): bool => is_int($tenantId) && $tenantId > 0,
|
||||||
|
));
|
||||||
|
$workspaceIds = array_values(array_filter(
|
||||||
|
$options['workspace_ids'] ?? [],
|
||||||
|
static fn (mixed $workspaceId): bool => is_int($workspaceId) && $workspaceId > 0,
|
||||||
|
));
|
||||||
|
$limit = min(max(1, (int) ($options['limit'] ?? $this->policy->reconciliationBatchLimit())), 500);
|
||||||
|
$dryRun = (bool) ($options['dry_run'] ?? false);
|
||||||
|
|
||||||
|
$runs = OperationRun::query()
|
||||||
|
->with(['tenant', 'user'])
|
||||||
|
->whereIn('type', $types)
|
||||||
|
->whereIn('status', [
|
||||||
|
OperationRunStatus::Queued->value,
|
||||||
|
OperationRunStatus::Running->value,
|
||||||
|
])
|
||||||
|
->when(
|
||||||
|
$tenantIds !== [],
|
||||||
|
fn (Builder $query): Builder => $query->whereIn('tenant_id', $tenantIds),
|
||||||
|
)
|
||||||
|
->when(
|
||||||
|
$workspaceIds !== [],
|
||||||
|
fn (Builder $query): Builder => $query->whereIn('workspace_id', $workspaceIds),
|
||||||
|
)
|
||||||
|
->orderBy('id')
|
||||||
|
->limit($limit)
|
||||||
|
->get();
|
||||||
|
|
||||||
|
$changes = [];
|
||||||
|
$reconciled = 0;
|
||||||
|
$skipped = 0;
|
||||||
|
|
||||||
|
foreach ($runs as $run) {
|
||||||
|
$change = $this->reconcileRun($run, $dryRun);
|
||||||
|
|
||||||
|
if ($change === null) {
|
||||||
|
$skipped++;
|
||||||
|
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$changes[] = $change;
|
||||||
|
|
||||||
|
if (($change['applied'] ?? false) === true) {
|
||||||
|
$reconciled++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
'candidates' => $runs->count(),
|
||||||
|
'reconciled' => $reconciled,
|
||||||
|
'skipped' => $skipped,
|
||||||
|
'changes' => $changes,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, mixed>|null
|
||||||
|
*/
|
||||||
|
public function reconcileRun(OperationRun $run, bool $dryRun = false): ?array
|
||||||
|
{
|
||||||
|
$assessment = $this->assessment($run);
|
||||||
|
|
||||||
|
if ($assessment === null || ($assessment['should_reconcile'] ?? false) !== true) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$before = [
|
||||||
|
'status' => (string) $run->status,
|
||||||
|
'outcome' => (string) $run->outcome,
|
||||||
|
'freshness_state' => OperationRunFreshnessState::forRun($run, $this->policy)->value,
|
||||||
|
];
|
||||||
|
$after = [
|
||||||
|
'status' => OperationRunStatus::Completed->value,
|
||||||
|
'outcome' => OperationRunOutcome::Failed->value,
|
||||||
|
'freshness_state' => OperationRunFreshnessState::ReconciledFailed->value,
|
||||||
|
];
|
||||||
|
|
||||||
|
if ($dryRun) {
|
||||||
|
return [
|
||||||
|
'applied' => false,
|
||||||
|
'operation_run_id' => (int) $run->getKey(),
|
||||||
|
'type' => (string) $run->type,
|
||||||
|
'before' => $before,
|
||||||
|
'after' => $after,
|
||||||
|
'reason_code' => $assessment['reason_code'],
|
||||||
|
'reason_message' => $assessment['reason_message'],
|
||||||
|
'evidence' => $assessment['evidence'],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
$updated = $this->operationRunService->forceFailNonTerminalRun(
|
||||||
|
run: $run,
|
||||||
|
reasonCode: (string) $assessment['reason_code'],
|
||||||
|
message: (string) $assessment['reason_message'],
|
||||||
|
source: 'scheduled_reconciler',
|
||||||
|
evidence: is_array($assessment['evidence'] ?? null) ? $assessment['evidence'] : [],
|
||||||
|
);
|
||||||
|
|
||||||
|
return [
|
||||||
|
'applied' => true,
|
||||||
|
'operation_run_id' => (int) $updated->getKey(),
|
||||||
|
'type' => (string) $updated->type,
|
||||||
|
'before' => $before,
|
||||||
|
'after' => $after,
|
||||||
|
'reason_code' => $assessment['reason_code'],
|
||||||
|
'reason_message' => $assessment['reason_message'],
|
||||||
|
'evidence' => $assessment['evidence'],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array{should_reconcile:bool,reason_code:string,reason_message:string,evidence:array<string, mixed>}|null
|
||||||
|
*/
|
||||||
|
public function assessment(OperationRun $run): ?array
|
||||||
|
{
|
||||||
|
if ((string) $run->status === OperationRunStatus::Completed->value) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! $this->policy->supports((string) $run->type) || ! $this->policy->supportsScheduledReconciliation((string) $run->type)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$freshnessState = OperationRunFreshnessState::forRun($run, $this->policy);
|
||||||
|
|
||||||
|
if (! $freshnessState->isLikelyStale()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$reason = (string) $run->status === OperationRunStatus::Queued->value
|
||||||
|
? LifecycleReconciliationReason::StaleQueued
|
||||||
|
: LifecycleReconciliationReason::StaleRunning;
|
||||||
|
$referenceTime = (string) $run->status === OperationRunStatus::Queued->value
|
||||||
|
? $run->created_at
|
||||||
|
: ($run->started_at ?? $run->created_at);
|
||||||
|
$thresholdSeconds = (string) $run->status === OperationRunStatus::Queued->value
|
||||||
|
? $this->policy->queuedStaleAfterSeconds((string) $run->type)
|
||||||
|
: $this->policy->runningStaleAfterSeconds((string) $run->type);
|
||||||
|
$legitimacy = $this->queuedExecutionLegitimacyGate->evaluate($run)->toArray();
|
||||||
|
|
||||||
|
return [
|
||||||
|
'should_reconcile' => true,
|
||||||
|
'reason_code' => $reason->value,
|
||||||
|
'reason_message' => $reason->defaultMessage(),
|
||||||
|
'evidence' => [
|
||||||
|
'evaluated_at' => now()->toIso8601String(),
|
||||||
|
'freshness_state' => $freshnessState->value,
|
||||||
|
'threshold_seconds' => $thresholdSeconds,
|
||||||
|
'reference_time' => $referenceTime?->toIso8601String(),
|
||||||
|
'status' => (string) $run->status,
|
||||||
|
'execution_legitimacy' => $legitimacy,
|
||||||
|
'terminal_truth_path' => $this->policy->requiresDirectFailedBridge((string) $run->type)
|
||||||
|
? 'direct_and_scheduled'
|
||||||
|
: 'scheduled_only',
|
||||||
|
],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -8,6 +8,7 @@
|
|||||||
use App\Models\TenantOnboardingSession;
|
use App\Models\TenantOnboardingSession;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
use App\Services\Auth\CapabilityResolver;
|
use App\Services\Auth\CapabilityResolver;
|
||||||
|
use App\Support\ReasonTranslation\ReasonResolutionEnvelope;
|
||||||
use App\Support\Tenants\TenantInteractionLane;
|
use App\Support\Tenants\TenantInteractionLane;
|
||||||
use App\Support\Tenants\TenantLifecycle;
|
use App\Support\Tenants\TenantLifecycle;
|
||||||
use App\Support\Tenants\TenantOperabilityContext;
|
use App\Support\Tenants\TenantOperabilityContext;
|
||||||
@ -217,6 +218,11 @@ public function canReferenceInWorkspaceMonitoring(Tenant $tenant): bool
|
|||||||
)->allowed;
|
)->allowed;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function presentReason(TenantOperabilityOutcome $outcome): ?ReasonResolutionEnvelope
|
||||||
|
{
|
||||||
|
return $outcome->reasonCode?->toReasonResolutionEnvelope('detail');
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param Collection<int, Tenant> $tenants
|
* @param Collection<int, Tenant> $tenants
|
||||||
* @return Collection<int, Tenant>
|
* @return Collection<int, Tenant>
|
||||||
|
|||||||
@ -15,6 +15,14 @@ final class BadgeCatalog
|
|||||||
private const DOMAIN_MAPPERS = [
|
private const DOMAIN_MAPPERS = [
|
||||||
BadgeDomain::AuditOutcome->value => Domains\AuditOutcomeBadge::class,
|
BadgeDomain::AuditOutcome->value => Domains\AuditOutcomeBadge::class,
|
||||||
BadgeDomain::AuditActorType->value => Domains\AuditActorTypeBadge::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::OperatorExplanationEvaluationResult->value => Domains\OperatorExplanationEvaluationResultBadge::class,
|
||||||
|
BadgeDomain::OperatorExplanationTrustworthiness->value => Domains\OperatorExplanationTrustworthinessBadge::class,
|
||||||
|
BadgeDomain::BaselineSnapshotLifecycle->value => Domains\BaselineSnapshotLifecycleBadge::class,
|
||||||
BadgeDomain::BaselineSnapshotFidelity->value => Domains\BaselineSnapshotFidelityBadge::class,
|
BadgeDomain::BaselineSnapshotFidelity->value => Domains\BaselineSnapshotFidelityBadge::class,
|
||||||
BadgeDomain::BaselineSnapshotGapStatus->value => Domains\BaselineSnapshotGapStatusBadge::class,
|
BadgeDomain::BaselineSnapshotGapStatus->value => Domains\BaselineSnapshotGapStatusBadge::class,
|
||||||
BadgeDomain::OperationRunStatus->value => Domains\OperationRunStatusBadge::class,
|
BadgeDomain::OperationRunStatus->value => Domains\OperationRunStatusBadge::class,
|
||||||
@ -93,6 +101,27 @@ public static function mapper(BadgeDomain $domain): ?BadgeMapper
|
|||||||
return $mapper;
|
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
|
public static function normalizeState(mixed $value): ?string
|
||||||
{
|
{
|
||||||
if ($value === null) {
|
if ($value === null) {
|
||||||
|
|||||||
@ -6,6 +6,14 @@ enum BadgeDomain: string
|
|||||||
{
|
{
|
||||||
case AuditOutcome = 'audit_outcome';
|
case AuditOutcome = 'audit_outcome';
|
||||||
case AuditActorType = 'audit_actor_type';
|
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 OperatorExplanationEvaluationResult = 'operator_explanation_evaluation_result';
|
||||||
|
case OperatorExplanationTrustworthiness = 'operator_explanation_trustworthiness';
|
||||||
|
case BaselineSnapshotLifecycle = 'baseline_snapshot_lifecycle';
|
||||||
case BaselineSnapshotFidelity = 'baseline_snapshot_fidelity';
|
case BaselineSnapshotFidelity = 'baseline_snapshot_fidelity';
|
||||||
case BaselineSnapshotGapStatus = 'baseline_snapshot_gap_status';
|
case BaselineSnapshotGapStatus = 'baseline_snapshot_gap_status';
|
||||||
case OperationRunStatus = 'operation_run_status';
|
case OperationRunStatus = 'operation_run_status';
|
||||||
|
|||||||
@ -23,6 +23,15 @@ public function __construct(
|
|||||||
public readonly string $color,
|
public readonly string $color,
|
||||||
public readonly ?string $icon = null,
|
public readonly ?string $icon = null,
|
||||||
public readonly ?string $iconColor = 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) === '') {
|
if (trim($this->label) === '') {
|
||||||
throw new InvalidArgumentException('BadgeSpec label must be a non-empty string.');
|
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)) {
|
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));
|
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\Services\Baselines\SnapshotRendering\FidelityState;
|
||||||
use App\Support\Badges\BadgeCatalog;
|
use App\Support\Badges\BadgeCatalog;
|
||||||
|
use App\Support\Badges\BadgeDomain;
|
||||||
use App\Support\Badges\BadgeMapper;
|
use App\Support\Badges\BadgeMapper;
|
||||||
use App\Support\Badges\BadgeSpec;
|
use App\Support\Badges\BadgeSpec;
|
||||||
|
use App\Support\Badges\OperatorOutcomeTaxonomy;
|
||||||
|
|
||||||
final class BaselineSnapshotFidelityBadge implements BadgeMapper
|
final class BaselineSnapshotFidelityBadge implements BadgeMapper
|
||||||
{
|
{
|
||||||
@ -16,11 +18,11 @@ public function spec(mixed $value): BadgeSpec
|
|||||||
$state = BadgeCatalog::normalizeState($value);
|
$state = BadgeCatalog::normalizeState($value);
|
||||||
|
|
||||||
return match ($state) {
|
return match ($state) {
|
||||||
FidelityState::Full->value => new BadgeSpec('Full', 'success', 'heroicon-m-check-circle'),
|
FidelityState::Full->value => OperatorOutcomeTaxonomy::spec(BadgeDomain::BaselineSnapshotFidelity, $state, 'heroicon-m-check-circle'),
|
||||||
FidelityState::Partial->value => new BadgeSpec('Partial', 'warning', 'heroicon-m-exclamation-triangle'),
|
FidelityState::Partial->value => OperatorOutcomeTaxonomy::spec(BadgeDomain::BaselineSnapshotFidelity, $state, 'heroicon-m-document-magnifying-glass'),
|
||||||
FidelityState::ReferenceOnly->value => new BadgeSpec('Reference only', 'info', 'heroicon-m-document-text'),
|
FidelityState::ReferenceOnly->value => OperatorOutcomeTaxonomy::spec(BadgeDomain::BaselineSnapshotFidelity, $state, 'heroicon-m-document-text'),
|
||||||
FidelityState::Unsupported->value => new BadgeSpec('Unsupported', 'gray', 'heroicon-m-question-mark-circle'),
|
FidelityState::Unsupported->value => OperatorOutcomeTaxonomy::spec(BadgeDomain::BaselineSnapshotFidelity, $state, 'heroicon-m-question-mark-circle'),
|
||||||
default => BadgeSpec::unknown(),
|
default => BadgeSpec::unknown(),
|
||||||
};
|
} ?? BadgeSpec::unknown();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -5,8 +5,10 @@
|
|||||||
namespace App\Support\Badges\Domains;
|
namespace App\Support\Badges\Domains;
|
||||||
|
|
||||||
use App\Support\Badges\BadgeCatalog;
|
use App\Support\Badges\BadgeCatalog;
|
||||||
|
use App\Support\Badges\BadgeDomain;
|
||||||
use App\Support\Badges\BadgeMapper;
|
use App\Support\Badges\BadgeMapper;
|
||||||
use App\Support\Badges\BadgeSpec;
|
use App\Support\Badges\BadgeSpec;
|
||||||
|
use App\Support\Badges\OperatorOutcomeTaxonomy;
|
||||||
|
|
||||||
final class BaselineSnapshotGapStatusBadge implements BadgeMapper
|
final class BaselineSnapshotGapStatusBadge implements BadgeMapper
|
||||||
{
|
{
|
||||||
@ -15,9 +17,9 @@ public function spec(mixed $value): BadgeSpec
|
|||||||
$state = BadgeCatalog::normalizeState($value);
|
$state = BadgeCatalog::normalizeState($value);
|
||||||
|
|
||||||
return match ($state) {
|
return match ($state) {
|
||||||
'clear' => new BadgeSpec('No gaps', 'success', 'heroicon-m-check-circle'),
|
'clear' => OperatorOutcomeTaxonomy::spec(BadgeDomain::BaselineSnapshotGapStatus, $state, 'heroicon-m-check-circle'),
|
||||||
'gaps_present' => new BadgeSpec('Gaps present', 'warning', 'heroicon-m-exclamation-triangle'),
|
'gaps_present' => OperatorOutcomeTaxonomy::spec(BadgeDomain::BaselineSnapshotGapStatus, $state, 'heroicon-m-exclamation-triangle'),
|
||||||
default => BadgeSpec::unknown(),
|
default => BadgeSpec::unknown(),
|
||||||
};
|
} ?? BadgeSpec::unknown();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -0,0 +1,28 @@
|
|||||||
|
<?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;
|
||||||
|
use App\Support\Baselines\BaselineSnapshotLifecycleState;
|
||||||
|
|
||||||
|
final class BaselineSnapshotLifecycleBadge implements BadgeMapper
|
||||||
|
{
|
||||||
|
public function spec(mixed $value): BadgeSpec
|
||||||
|
{
|
||||||
|
$state = BadgeCatalog::normalizeState($value);
|
||||||
|
|
||||||
|
return match ($state) {
|
||||||
|
BaselineSnapshotLifecycleState::Building->value => OperatorOutcomeTaxonomy::spec(BadgeDomain::BaselineSnapshotLifecycle, $state, 'heroicon-m-arrow-path'),
|
||||||
|
BaselineSnapshotLifecycleState::Complete->value => OperatorOutcomeTaxonomy::spec(BadgeDomain::BaselineSnapshotLifecycle, $state, 'heroicon-m-check-circle'),
|
||||||
|
BaselineSnapshotLifecycleState::Incomplete->value => OperatorOutcomeTaxonomy::spec(BadgeDomain::BaselineSnapshotLifecycle, $state, 'heroicon-m-x-circle'),
|
||||||
|
'superseded' => OperatorOutcomeTaxonomy::spec(BadgeDomain::BaselineSnapshotLifecycle, $state, 'heroicon-m-clock'),
|
||||||
|
default => BadgeSpec::unknown(),
|
||||||
|
} ?? BadgeSpec::unknown();
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -5,8 +5,10 @@
|
|||||||
namespace App\Support\Badges\Domains;
|
namespace App\Support\Badges\Domains;
|
||||||
|
|
||||||
use App\Support\Badges\BadgeCatalog;
|
use App\Support\Badges\BadgeCatalog;
|
||||||
|
use App\Support\Badges\BadgeDomain;
|
||||||
use App\Support\Badges\BadgeMapper;
|
use App\Support\Badges\BadgeMapper;
|
||||||
use App\Support\Badges\BadgeSpec;
|
use App\Support\Badges\BadgeSpec;
|
||||||
|
use App\Support\Badges\OperatorOutcomeTaxonomy;
|
||||||
use App\Support\Evidence\EvidenceCompletenessState;
|
use App\Support\Evidence\EvidenceCompletenessState;
|
||||||
|
|
||||||
final class EvidenceCompletenessBadge implements BadgeMapper
|
final class EvidenceCompletenessBadge implements BadgeMapper
|
||||||
@ -16,11 +18,11 @@ public function spec(mixed $value): BadgeSpec
|
|||||||
$state = BadgeCatalog::normalizeState($value);
|
$state = BadgeCatalog::normalizeState($value);
|
||||||
|
|
||||||
return match ($state) {
|
return match ($state) {
|
||||||
EvidenceCompletenessState::Complete->value => new BadgeSpec('Complete', 'success', 'heroicon-m-check-badge'),
|
EvidenceCompletenessState::Complete->value => OperatorOutcomeTaxonomy::spec(BadgeDomain::EvidenceCompleteness, $state, 'heroicon-m-check-badge'),
|
||||||
EvidenceCompletenessState::Partial->value => new BadgeSpec('Partial', 'warning', 'heroicon-m-exclamation-triangle'),
|
EvidenceCompletenessState::Partial->value => OperatorOutcomeTaxonomy::spec(BadgeDomain::EvidenceCompleteness, $state, 'heroicon-m-exclamation-triangle'),
|
||||||
EvidenceCompletenessState::Missing->value => new BadgeSpec('Missing', 'danger', 'heroicon-m-x-circle'),
|
EvidenceCompletenessState::Missing->value => OperatorOutcomeTaxonomy::spec(BadgeDomain::EvidenceCompleteness, $state, 'heroicon-m-clock'),
|
||||||
EvidenceCompletenessState::Stale->value => new BadgeSpec('Stale', 'gray', 'heroicon-m-clock'),
|
EvidenceCompletenessState::Stale->value => OperatorOutcomeTaxonomy::spec(BadgeDomain::EvidenceCompleteness, $state, 'heroicon-m-arrow-path'),
|
||||||
default => BadgeSpec::unknown(),
|
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,24 +3,60 @@
|
|||||||
namespace App\Support\Badges\Domains;
|
namespace App\Support\Badges\Domains;
|
||||||
|
|
||||||
use App\Support\Badges\BadgeCatalog;
|
use App\Support\Badges\BadgeCatalog;
|
||||||
|
use App\Support\Badges\BadgeDomain;
|
||||||
use App\Support\Badges\BadgeMapper;
|
use App\Support\Badges\BadgeMapper;
|
||||||
use App\Support\Badges\BadgeSpec;
|
use App\Support\Badges\BadgeSpec;
|
||||||
|
use App\Support\Badges\OperatorOutcomeTaxonomy;
|
||||||
use App\Support\OperationRunOutcome;
|
use App\Support\OperationRunOutcome;
|
||||||
|
use App\Support\OperationRunStatus;
|
||||||
|
use App\Support\Operations\OperationRunFreshnessState;
|
||||||
|
|
||||||
final class OperationRunOutcomeBadge implements BadgeMapper
|
final class OperationRunOutcomeBadge implements BadgeMapper
|
||||||
{
|
{
|
||||||
public function spec(mixed $value): BadgeSpec
|
public function spec(mixed $value): BadgeSpec
|
||||||
{
|
{
|
||||||
$state = BadgeCatalog::normalizeState($value);
|
$state = null;
|
||||||
|
|
||||||
|
if (is_array($value)) {
|
||||||
|
$outcome = BadgeCatalog::normalizeState($value['outcome'] ?? null);
|
||||||
|
$status = BadgeCatalog::normalizeState($value['status'] ?? null);
|
||||||
|
$freshnessState = BadgeCatalog::normalizeState($value['freshness_state'] ?? null);
|
||||||
|
|
||||||
|
if ($outcome === null) {
|
||||||
|
if ($freshnessState === OperationRunFreshnessState::ReconciledFailed->value) {
|
||||||
|
$outcome = OperationRunOutcome::Failed->value;
|
||||||
|
} elseif (
|
||||||
|
$freshnessState === OperationRunFreshnessState::LikelyStale->value
|
||||||
|
|| in_array($status, [OperationRunStatus::Queued->value, OperationRunStatus::Running->value], true)
|
||||||
|
) {
|
||||||
|
$outcome = OperationRunOutcome::Pending->value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($outcome === OperationRunOutcome::Failed->value
|
||||||
|
&& $freshnessState === OperationRunFreshnessState::ReconciledFailed->value
|
||||||
|
) {
|
||||||
|
return new BadgeSpec(
|
||||||
|
label: 'Reconciled failed',
|
||||||
|
color: 'danger',
|
||||||
|
icon: 'heroicon-m-arrow-path-rounded-square',
|
||||||
|
iconColor: 'danger',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
$state = $outcome;
|
||||||
|
}
|
||||||
|
|
||||||
|
$state ??= BadgeCatalog::normalizeState($value);
|
||||||
|
|
||||||
return match ($state) {
|
return match ($state) {
|
||||||
OperationRunOutcome::Pending->value => new BadgeSpec('Pending', 'gray', 'heroicon-m-clock'),
|
OperationRunOutcome::Pending->value => OperatorOutcomeTaxonomy::spec(BadgeDomain::OperationRunOutcome, $state, 'heroicon-m-clock'),
|
||||||
OperationRunOutcome::Succeeded->value => new BadgeSpec('Succeeded', 'success', 'heroicon-m-check-circle'),
|
OperationRunOutcome::Succeeded->value => OperatorOutcomeTaxonomy::spec(BadgeDomain::OperationRunOutcome, $state, 'heroicon-m-check-circle'),
|
||||||
OperationRunOutcome::PartiallySucceeded->value => new BadgeSpec('Partially succeeded', 'warning', 'heroicon-m-exclamation-triangle'),
|
OperationRunOutcome::PartiallySucceeded->value => OperatorOutcomeTaxonomy::spec(BadgeDomain::OperationRunOutcome, $state, 'heroicon-m-exclamation-triangle'),
|
||||||
OperationRunOutcome::Blocked->value, 'operation.blocked' => new BadgeSpec('Blocked', 'warning', 'heroicon-m-no-symbol'),
|
OperationRunOutcome::Blocked->value, 'operation.blocked' => OperatorOutcomeTaxonomy::spec(BadgeDomain::OperationRunOutcome, $state, 'heroicon-m-no-symbol'),
|
||||||
OperationRunOutcome::Failed->value => new BadgeSpec('Failed', 'danger', 'heroicon-m-x-circle'),
|
OperationRunOutcome::Failed->value => OperatorOutcomeTaxonomy::spec(BadgeDomain::OperationRunOutcome, $state, 'heroicon-m-x-circle'),
|
||||||
OperationRunOutcome::Cancelled->value => new BadgeSpec('Cancelled', 'gray', 'heroicon-m-minus-circle'),
|
OperationRunOutcome::Cancelled->value => OperatorOutcomeTaxonomy::spec(BadgeDomain::OperationRunOutcome, $state, 'heroicon-m-minus-circle'),
|
||||||
default => BadgeSpec::unknown(),
|
default => BadgeSpec::unknown(),
|
||||||
};
|
} ?? BadgeSpec::unknown();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -3,20 +3,43 @@
|
|||||||
namespace App\Support\Badges\Domains;
|
namespace App\Support\Badges\Domains;
|
||||||
|
|
||||||
use App\Support\Badges\BadgeCatalog;
|
use App\Support\Badges\BadgeCatalog;
|
||||||
|
use App\Support\Badges\BadgeDomain;
|
||||||
use App\Support\Badges\BadgeMapper;
|
use App\Support\Badges\BadgeMapper;
|
||||||
use App\Support\Badges\BadgeSpec;
|
use App\Support\Badges\BadgeSpec;
|
||||||
|
use App\Support\Badges\OperatorOutcomeTaxonomy;
|
||||||
use App\Support\OperationRunStatus;
|
use App\Support\OperationRunStatus;
|
||||||
|
use App\Support\Operations\OperationRunFreshnessState;
|
||||||
|
|
||||||
final class OperationRunStatusBadge implements BadgeMapper
|
final class OperationRunStatusBadge implements BadgeMapper
|
||||||
{
|
{
|
||||||
public function spec(mixed $value): BadgeSpec
|
public function spec(mixed $value): BadgeSpec
|
||||||
{
|
{
|
||||||
$state = BadgeCatalog::normalizeState($value);
|
$state = null;
|
||||||
|
|
||||||
|
if (is_array($value)) {
|
||||||
|
$status = BadgeCatalog::normalizeState($value['status'] ?? null);
|
||||||
|
$freshnessState = BadgeCatalog::normalizeState($value['freshness_state'] ?? null);
|
||||||
|
|
||||||
|
if (in_array($status, [OperationRunStatus::Queued->value, OperationRunStatus::Running->value], true)
|
||||||
|
&& $freshnessState === OperationRunFreshnessState::LikelyStale->value
|
||||||
|
) {
|
||||||
|
return new BadgeSpec(
|
||||||
|
label: 'Likely stale',
|
||||||
|
color: 'warning',
|
||||||
|
icon: 'heroicon-m-exclamation-triangle',
|
||||||
|
iconColor: 'warning',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
$state = $status;
|
||||||
|
}
|
||||||
|
|
||||||
|
$state ??= BadgeCatalog::normalizeState($value);
|
||||||
|
|
||||||
return match ($state) {
|
return match ($state) {
|
||||||
OperationRunStatus::Queued->value => new BadgeSpec('Queued', 'warning', 'heroicon-m-clock'),
|
OperationRunStatus::Queued->value => OperatorOutcomeTaxonomy::spec(BadgeDomain::OperationRunStatus, $state, 'heroicon-m-clock'),
|
||||||
OperationRunStatus::Running->value => new BadgeSpec('Running', 'info', 'heroicon-m-arrow-path'),
|
OperationRunStatus::Running->value => OperatorOutcomeTaxonomy::spec(BadgeDomain::OperationRunStatus, $state, 'heroicon-m-arrow-path'),
|
||||||
OperationRunStatus::Completed->value => new BadgeSpec('Completed', 'gray', 'heroicon-m-check-circle'),
|
OperationRunStatus::Completed->value => OperatorOutcomeTaxonomy::spec(BadgeDomain::OperationRunStatus, $state, 'heroicon-m-check-circle'),
|
||||||
default => BadgeSpec::unknown(),
|
default => BadgeSpec::unknown(),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@ -0,0 +1,29 @@
|
|||||||
|
<?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 OperatorExplanationEvaluationResultBadge implements BadgeMapper
|
||||||
|
{
|
||||||
|
public function spec(mixed $value): BadgeSpec
|
||||||
|
{
|
||||||
|
$state = BadgeCatalog::normalizeState($value);
|
||||||
|
|
||||||
|
return match ($state) {
|
||||||
|
'full_result' => OperatorOutcomeTaxonomy::spec(BadgeDomain::OperatorExplanationEvaluationResult, $state, 'heroicon-m-check-badge'),
|
||||||
|
'incomplete_result' => OperatorOutcomeTaxonomy::spec(BadgeDomain::OperatorExplanationEvaluationResult, $state, 'heroicon-m-exclamation-triangle'),
|
||||||
|
'suppressed_result' => OperatorOutcomeTaxonomy::spec(BadgeDomain::OperatorExplanationEvaluationResult, $state, 'heroicon-m-eye-slash'),
|
||||||
|
'failed_result' => OperatorOutcomeTaxonomy::spec(BadgeDomain::OperatorExplanationEvaluationResult, $state, 'heroicon-m-x-circle'),
|
||||||
|
'no_result' => OperatorOutcomeTaxonomy::spec(BadgeDomain::OperatorExplanationEvaluationResult, $state, 'heroicon-m-check'),
|
||||||
|
'unavailable' => OperatorOutcomeTaxonomy::spec(BadgeDomain::OperatorExplanationEvaluationResult, $state, 'heroicon-m-no-symbol'),
|
||||||
|
default => BadgeSpec::unknown(),
|
||||||
|
} ?? 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 OperatorExplanationTrustworthinessBadge implements BadgeMapper
|
||||||
|
{
|
||||||
|
public function spec(mixed $value): BadgeSpec
|
||||||
|
{
|
||||||
|
$state = BadgeCatalog::normalizeState($value);
|
||||||
|
|
||||||
|
return match ($state) {
|
||||||
|
'trustworthy' => OperatorOutcomeTaxonomy::spec(BadgeDomain::OperatorExplanationTrustworthiness, $state, 'heroicon-m-check-badge'),
|
||||||
|
'limited_confidence' => OperatorOutcomeTaxonomy::spec(BadgeDomain::OperatorExplanationTrustworthiness, $state, 'heroicon-m-exclamation-triangle'),
|
||||||
|
'diagnostic_only' => OperatorOutcomeTaxonomy::spec(BadgeDomain::OperatorExplanationTrustworthiness, $state, 'heroicon-m-beaker'),
|
||||||
|
'unusable' => OperatorOutcomeTaxonomy::spec(BadgeDomain::OperatorExplanationTrustworthiness, $state, 'heroicon-m-x-circle'),
|
||||||
|
default => BadgeSpec::unknown(),
|
||||||
|
} ?? BadgeSpec::unknown();
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -3,8 +3,10 @@
|
|||||||
namespace App\Support\Badges\Domains;
|
namespace App\Support\Badges\Domains;
|
||||||
|
|
||||||
use App\Support\Badges\BadgeCatalog;
|
use App\Support\Badges\BadgeCatalog;
|
||||||
|
use App\Support\Badges\BadgeDomain;
|
||||||
use App\Support\Badges\BadgeMapper;
|
use App\Support\Badges\BadgeMapper;
|
||||||
use App\Support\Badges\BadgeSpec;
|
use App\Support\Badges\BadgeSpec;
|
||||||
|
use App\Support\Badges\OperatorOutcomeTaxonomy;
|
||||||
|
|
||||||
final class RestoreCheckSeverityBadge implements BadgeMapper
|
final class RestoreCheckSeverityBadge implements BadgeMapper
|
||||||
{
|
{
|
||||||
@ -13,10 +15,10 @@ public function spec(mixed $value): BadgeSpec
|
|||||||
$state = BadgeCatalog::normalizeState($value);
|
$state = BadgeCatalog::normalizeState($value);
|
||||||
|
|
||||||
return match ($state) {
|
return match ($state) {
|
||||||
'blocking' => new BadgeSpec('Blocking', 'danger', 'heroicon-m-x-circle'),
|
'blocking' => OperatorOutcomeTaxonomy::spec(BadgeDomain::RestoreCheckSeverity, $state, 'heroicon-m-x-circle'),
|
||||||
'warning' => new BadgeSpec('Warning', 'warning', 'heroicon-m-exclamation-triangle'),
|
'warning' => OperatorOutcomeTaxonomy::spec(BadgeDomain::RestoreCheckSeverity, $state, 'heroicon-m-exclamation-triangle'),
|
||||||
'safe' => new BadgeSpec('Safe', 'success', 'heroicon-m-check-circle'),
|
'safe' => OperatorOutcomeTaxonomy::spec(BadgeDomain::RestoreCheckSeverity, $state, 'heroicon-m-check-circle'),
|
||||||
default => BadgeSpec::unknown(),
|
default => BadgeSpec::unknown(),
|
||||||
};
|
} ?? BadgeSpec::unknown();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -3,8 +3,10 @@
|
|||||||
namespace App\Support\Badges\Domains;
|
namespace App\Support\Badges\Domains;
|
||||||
|
|
||||||
use App\Support\Badges\BadgeCatalog;
|
use App\Support\Badges\BadgeCatalog;
|
||||||
|
use App\Support\Badges\BadgeDomain;
|
||||||
use App\Support\Badges\BadgeMapper;
|
use App\Support\Badges\BadgeMapper;
|
||||||
use App\Support\Badges\BadgeSpec;
|
use App\Support\Badges\BadgeSpec;
|
||||||
|
use App\Support\Badges\OperatorOutcomeTaxonomy;
|
||||||
|
|
||||||
final class RestorePreviewDecisionBadge implements BadgeMapper
|
final class RestorePreviewDecisionBadge implements BadgeMapper
|
||||||
{
|
{
|
||||||
@ -13,12 +15,12 @@ public function spec(mixed $value): BadgeSpec
|
|||||||
$state = BadgeCatalog::normalizeState($value);
|
$state = BadgeCatalog::normalizeState($value);
|
||||||
|
|
||||||
return match ($state) {
|
return match ($state) {
|
||||||
'created' => new BadgeSpec('Created', 'success', 'heroicon-m-check-circle'),
|
'created' => OperatorOutcomeTaxonomy::spec(BadgeDomain::RestorePreviewDecision, $state, 'heroicon-m-plus-circle'),
|
||||||
'created_copy' => new BadgeSpec('Created copy', 'warning', 'heroicon-m-exclamation-triangle'),
|
'created_copy' => OperatorOutcomeTaxonomy::spec(BadgeDomain::RestorePreviewDecision, $state, 'heroicon-m-document-duplicate'),
|
||||||
'mapped_existing' => new BadgeSpec('Mapped existing', 'info', 'heroicon-m-arrow-path'),
|
'mapped_existing' => OperatorOutcomeTaxonomy::spec(BadgeDomain::RestorePreviewDecision, $state, 'heroicon-m-arrow-right-circle'),
|
||||||
'skipped' => new BadgeSpec('Skipped', 'warning', 'heroicon-m-minus-circle'),
|
'skipped' => OperatorOutcomeTaxonomy::spec(BadgeDomain::RestorePreviewDecision, $state, 'heroicon-m-minus-circle'),
|
||||||
'failed' => new BadgeSpec('Failed', 'danger', 'heroicon-m-x-circle'),
|
'failed' => OperatorOutcomeTaxonomy::spec(BadgeDomain::RestorePreviewDecision, $state, 'heroicon-m-x-circle'),
|
||||||
default => BadgeSpec::unknown(),
|
default => BadgeSpec::unknown(),
|
||||||
};
|
} ?? BadgeSpec::unknown();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -3,8 +3,10 @@
|
|||||||
namespace App\Support\Badges\Domains;
|
namespace App\Support\Badges\Domains;
|
||||||
|
|
||||||
use App\Support\Badges\BadgeCatalog;
|
use App\Support\Badges\BadgeCatalog;
|
||||||
|
use App\Support\Badges\BadgeDomain;
|
||||||
use App\Support\Badges\BadgeMapper;
|
use App\Support\Badges\BadgeMapper;
|
||||||
use App\Support\Badges\BadgeSpec;
|
use App\Support\Badges\BadgeSpec;
|
||||||
|
use App\Support\Badges\OperatorOutcomeTaxonomy;
|
||||||
|
|
||||||
final class RestoreResultStatusBadge implements BadgeMapper
|
final class RestoreResultStatusBadge implements BadgeMapper
|
||||||
{
|
{
|
||||||
@ -13,14 +15,14 @@ public function spec(mixed $value): BadgeSpec
|
|||||||
$state = BadgeCatalog::normalizeState($value);
|
$state = BadgeCatalog::normalizeState($value);
|
||||||
|
|
||||||
return match ($state) {
|
return match ($state) {
|
||||||
'applied' => new BadgeSpec('Applied', 'success', 'heroicon-m-check-circle'),
|
'applied' => OperatorOutcomeTaxonomy::spec(BadgeDomain::RestoreResultStatus, $state, 'heroicon-m-check-circle'),
|
||||||
'dry_run' => new BadgeSpec('Dry run', 'info', 'heroicon-m-arrow-path'),
|
'dry_run' => OperatorOutcomeTaxonomy::spec(BadgeDomain::RestoreResultStatus, $state, 'heroicon-m-eye'),
|
||||||
'mapped' => new BadgeSpec('Mapped', 'success', 'heroicon-m-check-circle'),
|
'mapped' => OperatorOutcomeTaxonomy::spec(BadgeDomain::RestoreResultStatus, $state, 'heroicon-m-arrow-right-circle'),
|
||||||
'skipped' => new BadgeSpec('Skipped', 'warning', 'heroicon-m-minus-circle'),
|
'skipped' => OperatorOutcomeTaxonomy::spec(BadgeDomain::RestoreResultStatus, $state, 'heroicon-m-minus-circle'),
|
||||||
'partial' => new BadgeSpec('Partial', 'warning', 'heroicon-m-exclamation-triangle'),
|
'partial' => OperatorOutcomeTaxonomy::spec(BadgeDomain::RestoreResultStatus, $state, 'heroicon-m-exclamation-triangle'),
|
||||||
'manual_required' => new BadgeSpec('Manual required', 'warning', 'heroicon-m-exclamation-triangle'),
|
'manual_required' => OperatorOutcomeTaxonomy::spec(BadgeDomain::RestoreResultStatus, $state, 'heroicon-m-wrench-screwdriver'),
|
||||||
'failed' => new BadgeSpec('Failed', 'danger', 'heroicon-m-x-circle'),
|
'failed' => OperatorOutcomeTaxonomy::spec(BadgeDomain::RestoreResultStatus, $state, 'heroicon-m-x-circle'),
|
||||||
default => BadgeSpec::unknown(),
|
default => BadgeSpec::unknown(),
|
||||||
};
|
} ?? BadgeSpec::unknown();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -3,8 +3,10 @@
|
|||||||
namespace App\Support\Badges\Domains;
|
namespace App\Support\Badges\Domains;
|
||||||
|
|
||||||
use App\Support\Badges\BadgeCatalog;
|
use App\Support\Badges\BadgeCatalog;
|
||||||
|
use App\Support\Badges\BadgeDomain;
|
||||||
use App\Support\Badges\BadgeMapper;
|
use App\Support\Badges\BadgeMapper;
|
||||||
use App\Support\Badges\BadgeSpec;
|
use App\Support\Badges\BadgeSpec;
|
||||||
|
use App\Support\Badges\OperatorOutcomeTaxonomy;
|
||||||
use App\Support\RestoreRunStatus;
|
use App\Support\RestoreRunStatus;
|
||||||
|
|
||||||
final class RestoreRunStatusBadge implements BadgeMapper
|
final class RestoreRunStatusBadge implements BadgeMapper
|
||||||
@ -14,20 +16,20 @@ public function spec(mixed $value): BadgeSpec
|
|||||||
$state = BadgeCatalog::normalizeState($value);
|
$state = BadgeCatalog::normalizeState($value);
|
||||||
|
|
||||||
return match ($state) {
|
return match ($state) {
|
||||||
RestoreRunStatus::Draft->value => new BadgeSpec('Draft', 'gray', 'heroicon-m-minus-circle'),
|
RestoreRunStatus::Draft->value => OperatorOutcomeTaxonomy::spec(BadgeDomain::RestoreRunStatus, $state, 'heroicon-m-minus-circle'),
|
||||||
RestoreRunStatus::Scoped->value => new BadgeSpec('Scoped', 'gray', 'heroicon-m-minus-circle'),
|
RestoreRunStatus::Scoped->value => OperatorOutcomeTaxonomy::spec(BadgeDomain::RestoreRunStatus, $state, 'heroicon-m-funnel'),
|
||||||
RestoreRunStatus::Checked->value => new BadgeSpec('Checked', 'gray', 'heroicon-m-minus-circle'),
|
RestoreRunStatus::Checked->value => OperatorOutcomeTaxonomy::spec(BadgeDomain::RestoreRunStatus, $state, 'heroicon-m-shield-check'),
|
||||||
RestoreRunStatus::Previewed->value => new BadgeSpec('Previewed', 'gray', 'heroicon-m-minus-circle'),
|
RestoreRunStatus::Previewed->value => OperatorOutcomeTaxonomy::spec(BadgeDomain::RestoreRunStatus, $state, 'heroicon-m-eye'),
|
||||||
RestoreRunStatus::Pending->value => new BadgeSpec('Pending', 'warning', 'heroicon-m-clock'),
|
RestoreRunStatus::Pending->value => OperatorOutcomeTaxonomy::spec(BadgeDomain::RestoreRunStatus, $state, 'heroicon-m-clock'),
|
||||||
RestoreRunStatus::Queued->value => new BadgeSpec('Queued', 'warning', 'heroicon-m-clock'),
|
RestoreRunStatus::Queued->value => OperatorOutcomeTaxonomy::spec(BadgeDomain::RestoreRunStatus, $state, 'heroicon-m-queue-list'),
|
||||||
RestoreRunStatus::Running->value => new BadgeSpec('Running', 'info', 'heroicon-m-arrow-path'),
|
RestoreRunStatus::Running->value => OperatorOutcomeTaxonomy::spec(BadgeDomain::RestoreRunStatus, $state, 'heroicon-m-arrow-path'),
|
||||||
RestoreRunStatus::Completed->value => new BadgeSpec('Completed', 'success', 'heroicon-m-check-circle'),
|
RestoreRunStatus::Completed->value => OperatorOutcomeTaxonomy::spec(BadgeDomain::RestoreRunStatus, $state, 'heroicon-m-check-circle'),
|
||||||
RestoreRunStatus::Partial->value => new BadgeSpec('Partial', 'warning', 'heroicon-m-exclamation-triangle'),
|
RestoreRunStatus::Partial->value => OperatorOutcomeTaxonomy::spec(BadgeDomain::RestoreRunStatus, $state, 'heroicon-m-exclamation-triangle'),
|
||||||
RestoreRunStatus::Failed->value => new BadgeSpec('Failed', 'danger', 'heroicon-m-x-circle'),
|
RestoreRunStatus::Failed->value => OperatorOutcomeTaxonomy::spec(BadgeDomain::RestoreRunStatus, $state, 'heroicon-m-x-circle'),
|
||||||
RestoreRunStatus::Cancelled->value => new BadgeSpec('Cancelled', 'gray', 'heroicon-m-minus-circle'),
|
RestoreRunStatus::Cancelled->value => OperatorOutcomeTaxonomy::spec(BadgeDomain::RestoreRunStatus, $state, 'heroicon-m-minus-circle'),
|
||||||
RestoreRunStatus::Aborted->value => new BadgeSpec('Aborted', 'gray', 'heroicon-m-minus-circle'),
|
RestoreRunStatus::Aborted->value => OperatorOutcomeTaxonomy::spec(BadgeDomain::RestoreRunStatus, $state, 'heroicon-m-stop-circle'),
|
||||||
RestoreRunStatus::CompletedWithErrors->value => new BadgeSpec('Completed with errors', 'warning', 'heroicon-m-exclamation-triangle'),
|
RestoreRunStatus::CompletedWithErrors->value => OperatorOutcomeTaxonomy::spec(BadgeDomain::RestoreRunStatus, $state, 'heroicon-m-exclamation-triangle'),
|
||||||
default => BadgeSpec::unknown(),
|
default => BadgeSpec::unknown(),
|
||||||
};
|
} ?? BadgeSpec::unknown();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -5,8 +5,10 @@
|
|||||||
namespace App\Support\Badges\Domains;
|
namespace App\Support\Badges\Domains;
|
||||||
|
|
||||||
use App\Support\Badges\BadgeCatalog;
|
use App\Support\Badges\BadgeCatalog;
|
||||||
|
use App\Support\Badges\BadgeDomain;
|
||||||
use App\Support\Badges\BadgeMapper;
|
use App\Support\Badges\BadgeMapper;
|
||||||
use App\Support\Badges\BadgeSpec;
|
use App\Support\Badges\BadgeSpec;
|
||||||
|
use App\Support\Badges\OperatorOutcomeTaxonomy;
|
||||||
use App\Support\TenantReviewCompletenessState;
|
use App\Support\TenantReviewCompletenessState;
|
||||||
|
|
||||||
final class TenantReviewCompletenessStateBadge implements BadgeMapper
|
final class TenantReviewCompletenessStateBadge implements BadgeMapper
|
||||||
@ -16,11 +18,11 @@ public function spec(mixed $value): BadgeSpec
|
|||||||
$state = BadgeCatalog::normalizeState($value);
|
$state = BadgeCatalog::normalizeState($value);
|
||||||
|
|
||||||
return match ($state) {
|
return match ($state) {
|
||||||
TenantReviewCompletenessState::Complete->value => new BadgeSpec('Complete', 'success', 'heroicon-m-check-circle'),
|
TenantReviewCompletenessState::Complete->value => OperatorOutcomeTaxonomy::spec(BadgeDomain::TenantReviewCompleteness, $state, 'heroicon-m-check-circle'),
|
||||||
TenantReviewCompletenessState::Partial->value => new BadgeSpec('Partial', 'warning', 'heroicon-m-exclamation-triangle'),
|
TenantReviewCompletenessState::Partial->value => OperatorOutcomeTaxonomy::spec(BadgeDomain::TenantReviewCompleteness, $state, 'heroicon-m-exclamation-triangle'),
|
||||||
TenantReviewCompletenessState::Missing->value => new BadgeSpec('Missing', 'danger', 'heroicon-m-x-circle'),
|
TenantReviewCompletenessState::Missing->value => OperatorOutcomeTaxonomy::spec(BadgeDomain::TenantReviewCompleteness, $state, 'heroicon-m-clock'),
|
||||||
TenantReviewCompletenessState::Stale->value => new BadgeSpec('Stale', 'gray', 'heroicon-m-clock'),
|
TenantReviewCompletenessState::Stale->value => OperatorOutcomeTaxonomy::spec(BadgeDomain::TenantReviewCompleteness, $state, 'heroicon-m-arrow-path'),
|
||||||
default => BadgeSpec::unknown(),
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
982
app/Support/Badges/OperatorOutcomeTaxonomy.php
Normal file
982
app/Support/Badges/OperatorOutcomeTaxonomy.php
Normal file
@ -0,0 +1,982 @@
|
|||||||
|
<?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' => 'Partially complete',
|
||||||
|
'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' => 'Refresh recommended',
|
||||||
|
'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' => 'Publication 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.',
|
||||||
|
],
|
||||||
|
],
|
||||||
|
'operator_explanation_evaluation_result' => [
|
||||||
|
'full_result' => [
|
||||||
|
'axis' => 'execution_outcome',
|
||||||
|
'label' => 'Complete result',
|
||||||
|
'color' => 'success',
|
||||||
|
'classification' => 'primary',
|
||||||
|
'next_action_policy' => 'none',
|
||||||
|
'legacy_aliases' => ['Full result'],
|
||||||
|
'notes' => 'The result can be read as complete for the intended operator decision.',
|
||||||
|
],
|
||||||
|
'incomplete_result' => [
|
||||||
|
'axis' => 'data_coverage',
|
||||||
|
'label' => 'Incomplete result',
|
||||||
|
'color' => 'warning',
|
||||||
|
'classification' => 'primary',
|
||||||
|
'next_action_policy' => 'required',
|
||||||
|
'legacy_aliases' => ['Partial result'],
|
||||||
|
'notes' => 'A result exists, but missing or partial coverage limits what it means.',
|
||||||
|
],
|
||||||
|
'suppressed_result' => [
|
||||||
|
'axis' => 'data_coverage',
|
||||||
|
'label' => 'Suppressed result',
|
||||||
|
'color' => 'warning',
|
||||||
|
'classification' => 'primary',
|
||||||
|
'next_action_policy' => 'required',
|
||||||
|
'legacy_aliases' => ['Suppressed'],
|
||||||
|
'notes' => 'Normal output was intentionally suppressed because the system could not safely present it as final.',
|
||||||
|
],
|
||||||
|
'failed_result' => [
|
||||||
|
'axis' => 'execution_outcome',
|
||||||
|
'label' => 'Failed result',
|
||||||
|
'color' => 'danger',
|
||||||
|
'classification' => 'primary',
|
||||||
|
'next_action_policy' => 'required',
|
||||||
|
'legacy_aliases' => ['Execution failed'],
|
||||||
|
'notes' => 'The workflow ended without producing a usable result and needs operator investigation.',
|
||||||
|
],
|
||||||
|
'no_result' => [
|
||||||
|
'axis' => 'execution_outcome',
|
||||||
|
'label' => 'No issues detected',
|
||||||
|
'color' => 'success',
|
||||||
|
'classification' => 'primary',
|
||||||
|
'next_action_policy' => 'none',
|
||||||
|
'legacy_aliases' => ['No result'],
|
||||||
|
'notes' => 'The workflow produced no decision-relevant follow-up for the operator.',
|
||||||
|
],
|
||||||
|
'unavailable' => [
|
||||||
|
'axis' => 'execution_outcome',
|
||||||
|
'label' => 'Result unavailable',
|
||||||
|
'color' => 'warning',
|
||||||
|
'classification' => 'primary',
|
||||||
|
'next_action_policy' => 'required',
|
||||||
|
'legacy_aliases' => ['Unavailable'],
|
||||||
|
'notes' => 'A usable result is not currently available for this surface.',
|
||||||
|
],
|
||||||
|
],
|
||||||
|
'operator_explanation_trustworthiness' => [
|
||||||
|
'trustworthy' => [
|
||||||
|
'axis' => 'data_coverage',
|
||||||
|
'label' => 'Trustworthy',
|
||||||
|
'color' => 'success',
|
||||||
|
'classification' => 'primary',
|
||||||
|
'next_action_policy' => 'none',
|
||||||
|
'legacy_aliases' => ['Decision grade'],
|
||||||
|
'notes' => 'The operator can rely on this result for the intended task.',
|
||||||
|
],
|
||||||
|
'limited_confidence' => [
|
||||||
|
'axis' => 'data_coverage',
|
||||||
|
'label' => 'Limited confidence',
|
||||||
|
'color' => 'warning',
|
||||||
|
'classification' => 'primary',
|
||||||
|
'next_action_policy' => 'optional',
|
||||||
|
'legacy_aliases' => ['Use with caution'],
|
||||||
|
'notes' => 'The result is still useful, but the operator should account for documented limitations.',
|
||||||
|
],
|
||||||
|
'diagnostic_only' => [
|
||||||
|
'axis' => 'evidence_depth',
|
||||||
|
'label' => 'Diagnostic only',
|
||||||
|
'color' => 'info',
|
||||||
|
'classification' => 'diagnostic',
|
||||||
|
'next_action_policy' => 'none',
|
||||||
|
'legacy_aliases' => ['Diagnostics only'],
|
||||||
|
'notes' => 'The result is suitable for diagnostics only, not for a final decision.',
|
||||||
|
],
|
||||||
|
'unusable' => [
|
||||||
|
'axis' => 'operator_actionability',
|
||||||
|
'label' => 'Not usable yet',
|
||||||
|
'color' => 'danger',
|
||||||
|
'classification' => 'primary',
|
||||||
|
'next_action_policy' => 'required',
|
||||||
|
'legacy_aliases' => ['Unusable'],
|
||||||
|
'notes' => 'The operator should not rely on this result until the blocking issue is resolved.',
|
||||||
|
],
|
||||||
|
],
|
||||||
|
'baseline_snapshot_lifecycle' => [
|
||||||
|
'building' => [
|
||||||
|
'axis' => 'execution_lifecycle',
|
||||||
|
'label' => 'Building',
|
||||||
|
'color' => 'info',
|
||||||
|
'classification' => 'primary',
|
||||||
|
'next_action_policy' => 'none',
|
||||||
|
'legacy_aliases' => ['In progress'],
|
||||||
|
'notes' => 'The snapshot row exists, but completion proof has not finished yet.',
|
||||||
|
],
|
||||||
|
'complete' => [
|
||||||
|
'axis' => 'data_coverage',
|
||||||
|
'label' => 'Complete',
|
||||||
|
'color' => 'success',
|
||||||
|
'classification' => 'primary',
|
||||||
|
'next_action_policy' => 'none',
|
||||||
|
'legacy_aliases' => ['Ready'],
|
||||||
|
'notes' => 'The snapshot passed completion proof and is eligible for compare.',
|
||||||
|
],
|
||||||
|
'incomplete' => [
|
||||||
|
'axis' => 'data_coverage',
|
||||||
|
'label' => 'Incomplete',
|
||||||
|
'color' => 'warning',
|
||||||
|
'classification' => 'primary',
|
||||||
|
'next_action_policy' => 'required',
|
||||||
|
'legacy_aliases' => ['Partial'],
|
||||||
|
'notes' => 'The snapshot exists but did not finish cleanly and is not usable for compare.',
|
||||||
|
],
|
||||||
|
'superseded' => [
|
||||||
|
'axis' => 'data_freshness',
|
||||||
|
'label' => 'Superseded',
|
||||||
|
'color' => 'gray',
|
||||||
|
'classification' => 'primary',
|
||||||
|
'next_action_policy' => 'none',
|
||||||
|
'legacy_aliases' => ['Historical'],
|
||||||
|
'notes' => 'A newer complete snapshot is the effective current baseline truth.',
|
||||||
|
],
|
||||||
|
],
|
||||||
|
'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;
|
||||||
|
}
|
||||||
|
}
|
||||||
661
app/Support/Baselines/BaselineCompareEvidenceGapDetails.php
Normal file
661
app/Support/Baselines/BaselineCompareEvidenceGapDetails.php
Normal file
@ -0,0 +1,661 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Support\Baselines;
|
||||||
|
|
||||||
|
use App\Models\OperationRun;
|
||||||
|
use Illuminate\Support\Str;
|
||||||
|
|
||||||
|
final class BaselineCompareEvidenceGapDetails
|
||||||
|
{
|
||||||
|
public static function fromOperationRun(?OperationRun $run): array
|
||||||
|
{
|
||||||
|
if (! $run instanceof OperationRun || ! is_array($run->context)) {
|
||||||
|
return self::empty();
|
||||||
|
}
|
||||||
|
|
||||||
|
return self::fromContext($run->context);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $context
|
||||||
|
*/
|
||||||
|
public static function fromContext(array $context): array
|
||||||
|
{
|
||||||
|
$baselineCompare = $context['baseline_compare'] ?? null;
|
||||||
|
|
||||||
|
if (! is_array($baselineCompare)) {
|
||||||
|
return self::empty();
|
||||||
|
}
|
||||||
|
|
||||||
|
return self::fromBaselineCompare($baselineCompare);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $baselineCompare
|
||||||
|
*/
|
||||||
|
public static function fromBaselineCompare(array $baselineCompare): array
|
||||||
|
{
|
||||||
|
$evidenceGaps = $baselineCompare['evidence_gaps'] ?? null;
|
||||||
|
$evidenceGaps = is_array($evidenceGaps) ? $evidenceGaps : [];
|
||||||
|
|
||||||
|
$byReason = self::normalizeCounts($evidenceGaps['by_reason'] ?? null);
|
||||||
|
$normalizedSubjects = self::normalizeSubjects($evidenceGaps['subjects'] ?? null);
|
||||||
|
|
||||||
|
foreach ($normalizedSubjects['subjects'] as $reasonCode => $subjects) {
|
||||||
|
if (! array_key_exists($reasonCode, $byReason)) {
|
||||||
|
$byReason[$reasonCode] = count($subjects);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$count = self::normalizeTotalCount(
|
||||||
|
$evidenceGaps['count'] ?? null,
|
||||||
|
$byReason,
|
||||||
|
$normalizedSubjects['subjects'],
|
||||||
|
);
|
||||||
|
$detailState = self::detailState($count, $normalizedSubjects);
|
||||||
|
$buckets = [];
|
||||||
|
|
||||||
|
foreach (self::orderedReasons($byReason, $normalizedSubjects['subjects']) as $reasonCode) {
|
||||||
|
$rows = $detailState === 'structured_details_recorded'
|
||||||
|
? array_map(
|
||||||
|
static fn (array $subject): array => self::projectSubjectRow($subject),
|
||||||
|
$normalizedSubjects['subjects'][$reasonCode] ?? [],
|
||||||
|
)
|
||||||
|
: [];
|
||||||
|
$reasonCount = $byReason[$reasonCode] ?? count($rows);
|
||||||
|
|
||||||
|
if ($reasonCount <= 0 && $rows === []) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$recordedCount = count($rows);
|
||||||
|
$structuralCount = count(array_filter(
|
||||||
|
$rows,
|
||||||
|
static fn (array $row): bool => (bool) ($row['structural'] ?? false),
|
||||||
|
));
|
||||||
|
$transientCount = count(array_filter(
|
||||||
|
$rows,
|
||||||
|
static fn (array $row): bool => (bool) ($row['retryable'] ?? false),
|
||||||
|
));
|
||||||
|
$operationalCount = max(0, $recordedCount - $structuralCount - $transientCount);
|
||||||
|
|
||||||
|
$searchText = trim(implode(' ', array_filter([
|
||||||
|
Str::lower($reasonCode),
|
||||||
|
Str::lower(self::reasonLabel($reasonCode)),
|
||||||
|
...array_map(
|
||||||
|
static fn (array $row): string => (string) ($row['search_text'] ?? ''),
|
||||||
|
$rows,
|
||||||
|
),
|
||||||
|
])));
|
||||||
|
|
||||||
|
$buckets[] = [
|
||||||
|
'reason_code' => $reasonCode,
|
||||||
|
'reason_label' => self::reasonLabel($reasonCode),
|
||||||
|
'count' => $reasonCount,
|
||||||
|
'recorded_count' => $recordedCount,
|
||||||
|
'missing_detail_count' => max(0, $reasonCount - $recordedCount),
|
||||||
|
'structural_count' => $structuralCount,
|
||||||
|
'operational_count' => $operationalCount,
|
||||||
|
'transient_count' => $transientCount,
|
||||||
|
'detail_state' => self::bucketDetailState($detailState, $recordedCount),
|
||||||
|
'search_text' => $searchText,
|
||||||
|
'rows' => $rows,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
$recordedSubjectsTotal = array_sum(array_map(
|
||||||
|
static fn (array $bucket): int => (int) ($bucket['recorded_count'] ?? 0),
|
||||||
|
$buckets,
|
||||||
|
));
|
||||||
|
$structuralCount = array_sum(array_map(
|
||||||
|
static fn (array $bucket): int => (int) ($bucket['structural_count'] ?? 0),
|
||||||
|
$buckets,
|
||||||
|
));
|
||||||
|
$operationalCount = array_sum(array_map(
|
||||||
|
static fn (array $bucket): int => (int) ($bucket['operational_count'] ?? 0),
|
||||||
|
$buckets,
|
||||||
|
));
|
||||||
|
$transientCount = array_sum(array_map(
|
||||||
|
static fn (array $bucket): int => (int) ($bucket['transient_count'] ?? 0),
|
||||||
|
$buckets,
|
||||||
|
));
|
||||||
|
$legacyMode = $detailState === 'legacy_broad_reason';
|
||||||
|
|
||||||
|
return [
|
||||||
|
'summary' => [
|
||||||
|
'count' => $count,
|
||||||
|
'by_reason' => $byReason,
|
||||||
|
'detail_state' => $detailState,
|
||||||
|
'recorded_subjects_total' => $recordedSubjectsTotal,
|
||||||
|
'missing_detail_count' => max(0, $count - $recordedSubjectsTotal),
|
||||||
|
'structural_count' => $structuralCount,
|
||||||
|
'operational_count' => $operationalCount,
|
||||||
|
'transient_count' => $transientCount,
|
||||||
|
'legacy_mode' => $legacyMode,
|
||||||
|
'requires_regeneration' => $legacyMode,
|
||||||
|
],
|
||||||
|
'buckets' => $buckets,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $baselineCompare
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
public static function diagnosticsPayload(array $baselineCompare): array
|
||||||
|
{
|
||||||
|
return array_filter([
|
||||||
|
'reason_code' => self::stringOrNull($baselineCompare['reason_code'] ?? null),
|
||||||
|
'subjects_total' => self::intOrNull($baselineCompare['subjects_total'] ?? null),
|
||||||
|
'resume_token' => self::stringOrNull($baselineCompare['resume_token'] ?? null),
|
||||||
|
'fidelity' => self::stringOrNull($baselineCompare['fidelity'] ?? null),
|
||||||
|
'coverage' => is_array($baselineCompare['coverage'] ?? null) ? $baselineCompare['coverage'] : null,
|
||||||
|
'evidence_capture' => is_array($baselineCompare['evidence_capture'] ?? null) ? $baselineCompare['evidence_capture'] : null,
|
||||||
|
'evidence_gaps' => is_array($baselineCompare['evidence_gaps'] ?? null) ? $baselineCompare['evidence_gaps'] : null,
|
||||||
|
], static fn (mixed $value): bool => $value !== null && $value !== []);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function reasonLabel(string $reason): string
|
||||||
|
{
|
||||||
|
$reason = trim($reason);
|
||||||
|
|
||||||
|
return match ($reason) {
|
||||||
|
'ambiguous_match' => 'Ambiguous inventory match',
|
||||||
|
'policy_record_missing' => 'Policy record missing',
|
||||||
|
'inventory_record_missing' => 'Inventory record missing',
|
||||||
|
'foundation_not_policy_backed' => 'Foundation not policy-backed',
|
||||||
|
'invalid_subject' => 'Invalid subject',
|
||||||
|
'duplicate_subject' => 'Duplicate subject',
|
||||||
|
'capture_failed' => 'Evidence capture failed',
|
||||||
|
'retryable_capture_failure' => 'Retryable evidence capture failure',
|
||||||
|
'budget_exhausted' => 'Capture budget exhausted',
|
||||||
|
'throttled' => 'Graph throttled',
|
||||||
|
'invalid_support_config' => 'Invalid support configuration',
|
||||||
|
'missing_current' => 'Missing current evidence',
|
||||||
|
'missing_role_definition_baseline_version_reference' => 'Missing baseline role definition evidence',
|
||||||
|
'missing_role_definition_current_version_reference' => 'Missing current role definition evidence',
|
||||||
|
'missing_role_definition_compare_surface' => 'Missing role definition compare surface',
|
||||||
|
'rollout_disabled' => 'Rollout disabled',
|
||||||
|
'policy_not_found' => 'Legacy policy not found',
|
||||||
|
default => Str::of($reason)->replace('_', ' ')->trim()->ucfirst()->toString(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function subjectClassLabel(string $subjectClass): string
|
||||||
|
{
|
||||||
|
return match (trim($subjectClass)) {
|
||||||
|
SubjectClass::PolicyBacked->value => 'Policy-backed',
|
||||||
|
SubjectClass::InventoryBacked->value => 'Inventory-backed',
|
||||||
|
SubjectClass::FoundationBacked->value => 'Foundation-backed',
|
||||||
|
default => 'Derived',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function resolutionOutcomeLabel(string $resolutionOutcome): string
|
||||||
|
{
|
||||||
|
return match (trim($resolutionOutcome)) {
|
||||||
|
ResolutionOutcome::ResolvedPolicy->value => 'Resolved policy',
|
||||||
|
ResolutionOutcome::ResolvedInventory->value => 'Resolved inventory',
|
||||||
|
ResolutionOutcome::PolicyRecordMissing->value => 'Policy record missing',
|
||||||
|
ResolutionOutcome::InventoryRecordMissing->value => 'Inventory record missing',
|
||||||
|
ResolutionOutcome::FoundationInventoryOnly->value => 'Foundation inventory only',
|
||||||
|
ResolutionOutcome::InvalidSubject->value => 'Invalid subject',
|
||||||
|
ResolutionOutcome::DuplicateSubject->value => 'Duplicate subject',
|
||||||
|
ResolutionOutcome::AmbiguousMatch->value => 'Ambiguous match',
|
||||||
|
ResolutionOutcome::InvalidSupportConfig->value => 'Invalid support configuration',
|
||||||
|
ResolutionOutcome::Throttled->value => 'Graph throttled',
|
||||||
|
ResolutionOutcome::CaptureFailed->value => 'Capture failed',
|
||||||
|
ResolutionOutcome::RetryableCaptureFailure->value => 'Retryable capture failure',
|
||||||
|
ResolutionOutcome::BudgetExhausted->value => 'Budget exhausted',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function operatorActionCategoryLabel(string $operatorActionCategory): string
|
||||||
|
{
|
||||||
|
return match (trim($operatorActionCategory)) {
|
||||||
|
OperatorActionCategory::Retry->value => 'Retry',
|
||||||
|
OperatorActionCategory::RunInventorySync->value => 'Run inventory sync',
|
||||||
|
OperatorActionCategory::RunPolicySyncOrBackup->value => 'Run policy sync or backup',
|
||||||
|
OperatorActionCategory::ReviewPermissions->value => 'Review permissions',
|
||||||
|
OperatorActionCategory::InspectSubjectMapping->value => 'Inspect subject mapping',
|
||||||
|
OperatorActionCategory::ProductFollowUp->value => 'Product follow-up',
|
||||||
|
default => 'No action',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, int> $byReason
|
||||||
|
* @return list<array{reason_code: string, reason_label: string, count: int}>
|
||||||
|
*/
|
||||||
|
public static function topReasons(array $byReason, int $limit = 5): array
|
||||||
|
{
|
||||||
|
$normalized = self::normalizeCounts($byReason);
|
||||||
|
arsort($normalized);
|
||||||
|
|
||||||
|
return array_map(
|
||||||
|
static fn (string $reason, int $count): array => [
|
||||||
|
'reason_code' => $reason,
|
||||||
|
'reason_label' => self::reasonLabel($reason),
|
||||||
|
'count' => $count,
|
||||||
|
],
|
||||||
|
array_slice(array_keys($normalized), 0, $limit),
|
||||||
|
array_slice(array_values($normalized), 0, $limit),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param list<array<string, mixed>> $buckets
|
||||||
|
* @return list<array<string, mixed>>
|
||||||
|
*/
|
||||||
|
public static function tableRows(array $buckets): array
|
||||||
|
{
|
||||||
|
$rows = [];
|
||||||
|
|
||||||
|
foreach ($buckets as $bucket) {
|
||||||
|
if (! is_array($bucket)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$bucketRows = is_array($bucket['rows'] ?? null) ? $bucket['rows'] : [];
|
||||||
|
|
||||||
|
foreach ($bucketRows as $row) {
|
||||||
|
if (! is_array($row)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$reasonCode = self::stringOrNull($row['reason_code'] ?? null);
|
||||||
|
$policyType = self::stringOrNull($row['policy_type'] ?? null);
|
||||||
|
$subjectKey = self::stringOrNull($row['subject_key'] ?? null);
|
||||||
|
$subjectClass = self::stringOrNull($row['subject_class'] ?? null);
|
||||||
|
$resolutionOutcome = self::stringOrNull($row['resolution_outcome'] ?? null);
|
||||||
|
$operatorActionCategory = self::stringOrNull($row['operator_action_category'] ?? null);
|
||||||
|
|
||||||
|
if ($reasonCode === null || $policyType === null || $subjectKey === null || $subjectClass === null || $resolutionOutcome === null || $operatorActionCategory === null) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$rows[] = [
|
||||||
|
'__id' => md5(implode('|', [$reasonCode, $policyType, $subjectKey, $resolutionOutcome])),
|
||||||
|
'reason_code' => $reasonCode,
|
||||||
|
'reason_label' => self::reasonLabel($reasonCode),
|
||||||
|
'policy_type' => $policyType,
|
||||||
|
'subject_key' => $subjectKey,
|
||||||
|
'subject_class' => $subjectClass,
|
||||||
|
'subject_class_label' => self::subjectClassLabel($subjectClass),
|
||||||
|
'resolution_path' => self::stringOrNull($row['resolution_path'] ?? null),
|
||||||
|
'resolution_outcome' => $resolutionOutcome,
|
||||||
|
'resolution_outcome_label' => self::resolutionOutcomeLabel($resolutionOutcome),
|
||||||
|
'operator_action_category' => $operatorActionCategory,
|
||||||
|
'operator_action_category_label' => self::operatorActionCategoryLabel($operatorActionCategory),
|
||||||
|
'structural' => (bool) ($row['structural'] ?? false),
|
||||||
|
'retryable' => (bool) ($row['retryable'] ?? false),
|
||||||
|
'search_text' => self::stringOrNull($row['search_text'] ?? null) ?? '',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $rows;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param list<array<string, mixed>> $rows
|
||||||
|
* @return array<string, string>
|
||||||
|
*/
|
||||||
|
public static function reasonFilterOptions(array $rows): array
|
||||||
|
{
|
||||||
|
return collect($rows)
|
||||||
|
->filter(fn (array $row): bool => filled($row['reason_code'] ?? null) && filled($row['reason_label'] ?? null))
|
||||||
|
->mapWithKeys(fn (array $row): array => [
|
||||||
|
(string) $row['reason_code'] => (string) $row['reason_label'],
|
||||||
|
])
|
||||||
|
->sortBy(fn (string $label): string => Str::lower($label))
|
||||||
|
->all();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param list<array<string, mixed>> $rows
|
||||||
|
* @return array<string, string>
|
||||||
|
*/
|
||||||
|
public static function policyTypeFilterOptions(array $rows): array
|
||||||
|
{
|
||||||
|
return collect($rows)
|
||||||
|
->pluck('policy_type')
|
||||||
|
->filter(fn (mixed $value): bool => is_string($value) && trim($value) !== '')
|
||||||
|
->mapWithKeys(fn (string $value): array => [$value => $value])
|
||||||
|
->sortKeysUsing('strnatcasecmp')
|
||||||
|
->all();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param list<array<string, mixed>> $rows
|
||||||
|
* @return array<string, string>
|
||||||
|
*/
|
||||||
|
public static function subjectClassFilterOptions(array $rows): array
|
||||||
|
{
|
||||||
|
return collect($rows)
|
||||||
|
->filter(fn (array $row): bool => filled($row['subject_class'] ?? null))
|
||||||
|
->mapWithKeys(fn (array $row): array => [
|
||||||
|
(string) $row['subject_class'] => (string) ($row['subject_class_label'] ?? self::subjectClassLabel((string) $row['subject_class'])),
|
||||||
|
])
|
||||||
|
->sortBy(fn (string $label): string => Str::lower($label))
|
||||||
|
->all();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param list<array<string, mixed>> $rows
|
||||||
|
* @return array<string, string>
|
||||||
|
*/
|
||||||
|
public static function actionCategoryFilterOptions(array $rows): array
|
||||||
|
{
|
||||||
|
return collect($rows)
|
||||||
|
->filter(fn (array $row): bool => filled($row['operator_action_category'] ?? null))
|
||||||
|
->mapWithKeys(fn (array $row): array => [
|
||||||
|
(string) $row['operator_action_category'] => (string) ($row['operator_action_category_label'] ?? self::operatorActionCategoryLabel((string) $row['operator_action_category'])),
|
||||||
|
])
|
||||||
|
->sortBy(fn (string $label): string => Str::lower($label))
|
||||||
|
->all();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function empty(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'summary' => [
|
||||||
|
'count' => 0,
|
||||||
|
'by_reason' => [],
|
||||||
|
'detail_state' => 'no_gaps',
|
||||||
|
'recorded_subjects_total' => 0,
|
||||||
|
'missing_detail_count' => 0,
|
||||||
|
'structural_count' => 0,
|
||||||
|
'operational_count' => 0,
|
||||||
|
'transient_count' => 0,
|
||||||
|
'legacy_mode' => false,
|
||||||
|
'requires_regeneration' => false,
|
||||||
|
],
|
||||||
|
'buckets' => [],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, int>
|
||||||
|
*/
|
||||||
|
private static function normalizeCounts(mixed $value): array
|
||||||
|
{
|
||||||
|
if (! is_array($value)) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
$normalized = [];
|
||||||
|
|
||||||
|
foreach ($value as $reason => $count) {
|
||||||
|
if (! is_string($reason) || trim($reason) === '' || ! is_numeric($count)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$intCount = (int) $count;
|
||||||
|
|
||||||
|
if ($intCount <= 0) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$normalized[trim($reason)] = $intCount;
|
||||||
|
}
|
||||||
|
|
||||||
|
arsort($normalized);
|
||||||
|
|
||||||
|
return $normalized;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array{
|
||||||
|
* subjects: array<string, list<array<string, mixed>>>,
|
||||||
|
* legacy_mode: bool
|
||||||
|
* }
|
||||||
|
*/
|
||||||
|
private static function normalizeSubjects(mixed $value): array
|
||||||
|
{
|
||||||
|
if ($value === null) {
|
||||||
|
return [
|
||||||
|
'subjects' => [],
|
||||||
|
'legacy_mode' => false,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! is_array($value)) {
|
||||||
|
return [
|
||||||
|
'subjects' => [],
|
||||||
|
'legacy_mode' => true,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! array_is_list($value)) {
|
||||||
|
return [
|
||||||
|
'subjects' => [],
|
||||||
|
'legacy_mode' => true,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
$subjects = [];
|
||||||
|
|
||||||
|
foreach ($value as $item) {
|
||||||
|
$normalized = self::normalizeStructuredSubject($item);
|
||||||
|
|
||||||
|
if ($normalized === null) {
|
||||||
|
return [
|
||||||
|
'subjects' => [],
|
||||||
|
'legacy_mode' => true,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
$subjects[$normalized['reason_code']][] = $normalized;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($subjects as &$bucket) {
|
||||||
|
usort($bucket, static function (array $left, array $right): int {
|
||||||
|
return [$left['policy_type'], $left['subject_key'], $left['resolution_outcome']]
|
||||||
|
<=> [$right['policy_type'], $right['subject_key'], $right['resolution_outcome']];
|
||||||
|
});
|
||||||
|
}
|
||||||
|
unset($bucket);
|
||||||
|
|
||||||
|
ksort($subjects);
|
||||||
|
|
||||||
|
return [
|
||||||
|
'subjects' => $subjects,
|
||||||
|
'legacy_mode' => false,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, mixed>|null
|
||||||
|
*/
|
||||||
|
private static function normalizeStructuredSubject(mixed $value): ?array
|
||||||
|
{
|
||||||
|
if (! is_array($value)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$policyType = self::stringOrNull($value['policy_type'] ?? null);
|
||||||
|
$subjectKey = self::stringOrNull($value['subject_key'] ?? null);
|
||||||
|
$subjectClass = self::stringOrNull($value['subject_class'] ?? null);
|
||||||
|
$resolutionPath = self::stringOrNull($value['resolution_path'] ?? null);
|
||||||
|
$resolutionOutcome = self::stringOrNull($value['resolution_outcome'] ?? null);
|
||||||
|
$reasonCode = self::stringOrNull($value['reason_code'] ?? null);
|
||||||
|
$operatorActionCategory = self::stringOrNull($value['operator_action_category'] ?? null);
|
||||||
|
|
||||||
|
if ($policyType === null
|
||||||
|
|| $subjectKey === null
|
||||||
|
|| $subjectClass === null
|
||||||
|
|| $resolutionPath === null
|
||||||
|
|| $resolutionOutcome === null
|
||||||
|
|| $reasonCode === null
|
||||||
|
|| $operatorActionCategory === null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! SubjectClass::tryFrom($subjectClass) instanceof SubjectClass
|
||||||
|
|| ! ResolutionPath::tryFrom($resolutionPath) instanceof ResolutionPath
|
||||||
|
|| ! ResolutionOutcome::tryFrom($resolutionOutcome) instanceof ResolutionOutcome
|
||||||
|
|| ! OperatorActionCategory::tryFrom($operatorActionCategory) instanceof OperatorActionCategory) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$sourceModelExpected = self::stringOrNull($value['source_model_expected'] ?? null);
|
||||||
|
$sourceModelExpected = in_array($sourceModelExpected, ['policy', 'inventory', 'derived'], true) ? $sourceModelExpected : null;
|
||||||
|
|
||||||
|
$sourceModelFound = self::stringOrNull($value['source_model_found'] ?? null);
|
||||||
|
$sourceModelFound = in_array($sourceModelFound, ['policy', 'inventory', 'derived'], true) ? $sourceModelFound : null;
|
||||||
|
|
||||||
|
return [
|
||||||
|
'policy_type' => $policyType,
|
||||||
|
'subject_external_id' => self::stringOrNull($value['subject_external_id'] ?? null),
|
||||||
|
'subject_key' => $subjectKey,
|
||||||
|
'subject_class' => $subjectClass,
|
||||||
|
'resolution_path' => $resolutionPath,
|
||||||
|
'resolution_outcome' => $resolutionOutcome,
|
||||||
|
'reason_code' => $reasonCode,
|
||||||
|
'operator_action_category' => $operatorActionCategory,
|
||||||
|
'structural' => self::boolOrFalse($value['structural'] ?? null),
|
||||||
|
'retryable' => self::boolOrFalse($value['retryable'] ?? null),
|
||||||
|
'source_model_expected' => $sourceModelExpected,
|
||||||
|
'source_model_found' => $sourceModelFound,
|
||||||
|
'legacy_reason_code' => self::stringOrNull($value['legacy_reason_code'] ?? null),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, int> $byReason
|
||||||
|
* @param array<string, list<array<string, mixed>>> $subjects
|
||||||
|
* @return list<string>
|
||||||
|
*/
|
||||||
|
private static function orderedReasons(array $byReason, array $subjects): array
|
||||||
|
{
|
||||||
|
$reasons = array_keys($byReason);
|
||||||
|
|
||||||
|
foreach (array_keys($subjects) as $reason) {
|
||||||
|
if (! in_array($reason, $reasons, true)) {
|
||||||
|
$reasons[] = $reason;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $reasons;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, int> $byReason
|
||||||
|
* @param array<string, list<array<string, mixed>>> $subjects
|
||||||
|
*/
|
||||||
|
private static function normalizeTotalCount(mixed $count, array $byReason, array $subjects): int
|
||||||
|
{
|
||||||
|
if (is_numeric($count)) {
|
||||||
|
$intCount = (int) $count;
|
||||||
|
|
||||||
|
if ($intCount >= 0) {
|
||||||
|
return $intCount;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$byReasonCount = array_sum($byReason);
|
||||||
|
|
||||||
|
if ($byReasonCount > 0) {
|
||||||
|
return $byReasonCount;
|
||||||
|
}
|
||||||
|
|
||||||
|
return array_sum(array_map(
|
||||||
|
static fn (array $rows): int => count($rows),
|
||||||
|
$subjects,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array{subjects: array<string, list<array<string, mixed>>>, legacy_mode: bool} $subjects
|
||||||
|
*/
|
||||||
|
private static function detailState(int $count, array $subjects): string
|
||||||
|
{
|
||||||
|
if ($count <= 0) {
|
||||||
|
return 'no_gaps';
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($subjects['legacy_mode']) {
|
||||||
|
return 'legacy_broad_reason';
|
||||||
|
}
|
||||||
|
|
||||||
|
return $subjects['subjects'] !== [] ? 'structured_details_recorded' : 'details_not_recorded';
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function bucketDetailState(string $detailState, int $recordedCount): string
|
||||||
|
{
|
||||||
|
if ($detailState === 'legacy_broad_reason') {
|
||||||
|
return 'legacy_broad_reason';
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($recordedCount > 0) {
|
||||||
|
return 'structured_details_recorded';
|
||||||
|
}
|
||||||
|
|
||||||
|
return 'details_not_recorded';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $subject
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
private static function projectSubjectRow(array $subject): array
|
||||||
|
{
|
||||||
|
$reasonCode = (string) $subject['reason_code'];
|
||||||
|
$subjectClass = (string) $subject['subject_class'];
|
||||||
|
$resolutionOutcome = (string) $subject['resolution_outcome'];
|
||||||
|
$operatorActionCategory = (string) $subject['operator_action_category'];
|
||||||
|
|
||||||
|
return array_merge($subject, [
|
||||||
|
'reason_label' => self::reasonLabel($reasonCode),
|
||||||
|
'subject_class_label' => self::subjectClassLabel($subjectClass),
|
||||||
|
'resolution_outcome_label' => self::resolutionOutcomeLabel($resolutionOutcome),
|
||||||
|
'operator_action_category_label' => self::operatorActionCategoryLabel($operatorActionCategory),
|
||||||
|
'search_text' => Str::lower(trim(implode(' ', array_filter([
|
||||||
|
$reasonCode,
|
||||||
|
self::reasonLabel($reasonCode),
|
||||||
|
(string) ($subject['policy_type'] ?? ''),
|
||||||
|
(string) ($subject['subject_key'] ?? ''),
|
||||||
|
$subjectClass,
|
||||||
|
self::subjectClassLabel($subjectClass),
|
||||||
|
(string) ($subject['resolution_path'] ?? ''),
|
||||||
|
$resolutionOutcome,
|
||||||
|
self::resolutionOutcomeLabel($resolutionOutcome),
|
||||||
|
$operatorActionCategory,
|
||||||
|
self::operatorActionCategoryLabel($operatorActionCategory),
|
||||||
|
(string) ($subject['subject_external_id'] ?? ''),
|
||||||
|
])))),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function stringOrNull(mixed $value): ?string
|
||||||
|
{
|
||||||
|
if (! is_string($value)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$trimmed = trim($value);
|
||||||
|
|
||||||
|
return $trimmed !== '' ? $trimmed : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function intOrNull(mixed $value): ?int
|
||||||
|
{
|
||||||
|
return is_numeric($value) ? (int) $value : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function boolOrFalse(mixed $value): bool
|
||||||
|
{
|
||||||
|
if (is_bool($value)) {
|
||||||
|
return $value;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (is_int($value) || is_float($value) || is_string($value)) {
|
||||||
|
return filter_var($value, FILTER_VALIDATE_BOOL);
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
247
app/Support/Baselines/BaselineCompareExplanationRegistry.php
Normal file
247
app/Support/Baselines/BaselineCompareExplanationRegistry.php
Normal file
@ -0,0 +1,247 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Support\Baselines;
|
||||||
|
|
||||||
|
use App\Support\ReasonTranslation\ReasonPresenter;
|
||||||
|
use App\Support\Ui\OperatorExplanation\CountDescriptor;
|
||||||
|
use App\Support\Ui\OperatorExplanation\ExplanationFamily;
|
||||||
|
use App\Support\Ui\OperatorExplanation\OperatorExplanationBuilder;
|
||||||
|
use App\Support\Ui\OperatorExplanation\OperatorExplanationPattern;
|
||||||
|
use App\Support\Ui\OperatorExplanation\TrustworthinessLevel;
|
||||||
|
|
||||||
|
final class BaselineCompareExplanationRegistry
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private readonly OperatorExplanationBuilder $builder,
|
||||||
|
private readonly ReasonPresenter $reasonPresenter,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public function forStats(BaselineCompareStats $stats): OperatorExplanationPattern
|
||||||
|
{
|
||||||
|
$reason = $stats->reasonCode !== null
|
||||||
|
? $this->reasonPresenter->forArtifactTruth($stats->reasonCode, 'baseline_compare')
|
||||||
|
: null;
|
||||||
|
$isFailed = $stats->state === 'failed';
|
||||||
|
$isInProgress = $stats->state === 'comparing';
|
||||||
|
$hasCoverageWarnings = in_array($stats->coverageStatus, ['warning', 'unproven'], true);
|
||||||
|
$hasEvidenceGaps = (int) ($stats->evidenceGapsCount ?? 0) > 0;
|
||||||
|
$hasWarnings = $hasCoverageWarnings || $hasEvidenceGaps;
|
||||||
|
$findingsCount = (int) ($stats->findingsCount ?? 0);
|
||||||
|
$executionOutcome = match ($stats->state) {
|
||||||
|
'comparing' => 'in_progress',
|
||||||
|
'failed' => 'failed',
|
||||||
|
default => $hasWarnings ? 'completed_with_follow_up' : 'completed',
|
||||||
|
};
|
||||||
|
$executionOutcomeLabel = match ($executionOutcome) {
|
||||||
|
'in_progress' => 'In progress',
|
||||||
|
'failed' => 'Execution failed',
|
||||||
|
'completed_with_follow_up' => 'Completed with follow-up',
|
||||||
|
default => 'Completed successfully',
|
||||||
|
};
|
||||||
|
$family = $reason?->absencePattern !== null
|
||||||
|
? ExplanationFamily::tryFrom(str_replace('true_no_result', 'no_issues_detected', $reason->absencePattern)) ?? null
|
||||||
|
: null;
|
||||||
|
$family ??= match (true) {
|
||||||
|
$isInProgress => ExplanationFamily::InProgress,
|
||||||
|
$isFailed => ExplanationFamily::BlockedPrerequisite,
|
||||||
|
$stats->state === 'no_tenant',
|
||||||
|
$stats->state === 'no_assignment',
|
||||||
|
$stats->state === 'no_snapshot',
|
||||||
|
$stats->state === 'idle' => ExplanationFamily::Unavailable,
|
||||||
|
$findingsCount === 0 && ! $hasWarnings => ExplanationFamily::NoIssuesDetected,
|
||||||
|
$hasWarnings => ExplanationFamily::CompletedButLimited,
|
||||||
|
default => ExplanationFamily::TrustworthyResult,
|
||||||
|
};
|
||||||
|
$trustworthiness = $reason?->trustImpact !== null
|
||||||
|
? TrustworthinessLevel::tryFrom($reason->trustImpact)
|
||||||
|
: null;
|
||||||
|
$trustworthiness ??= match (true) {
|
||||||
|
$family === ExplanationFamily::NoIssuesDetected,
|
||||||
|
$family === ExplanationFamily::TrustworthyResult => TrustworthinessLevel::Trustworthy,
|
||||||
|
$family === ExplanationFamily::CompletedButLimited => TrustworthinessLevel::LimitedConfidence,
|
||||||
|
$family === ExplanationFamily::InProgress => TrustworthinessLevel::DiagnosticOnly,
|
||||||
|
default => TrustworthinessLevel::Unusable,
|
||||||
|
};
|
||||||
|
$evaluationResult = $isFailed
|
||||||
|
? 'failed_result'
|
||||||
|
: match ($family) {
|
||||||
|
ExplanationFamily::TrustworthyResult => 'full_result',
|
||||||
|
ExplanationFamily::NoIssuesDetected => 'no_result',
|
||||||
|
ExplanationFamily::SuppressedOutput => 'suppressed_result',
|
||||||
|
ExplanationFamily::MissingInput,
|
||||||
|
ExplanationFamily::BlockedPrerequisite,
|
||||||
|
ExplanationFamily::Unavailable,
|
||||||
|
ExplanationFamily::InProgress => 'unavailable',
|
||||||
|
ExplanationFamily::CompletedButLimited => $reason?->absencePattern === 'suppressed_output'
|
||||||
|
? 'suppressed_result'
|
||||||
|
: 'incomplete_result',
|
||||||
|
};
|
||||||
|
$headline = match (true) {
|
||||||
|
$isFailed => 'The comparison failed before it produced a usable result.',
|
||||||
|
default => match ($family) {
|
||||||
|
ExplanationFamily::NoIssuesDetected => 'The comparison found no actionable drift.',
|
||||||
|
ExplanationFamily::TrustworthyResult => 'The comparison produced a usable drift result.',
|
||||||
|
ExplanationFamily::CompletedButLimited => $findingsCount > 0
|
||||||
|
? 'The comparison found drift, but the result needs caution.'
|
||||||
|
: 'The comparison finished, but the current result is not an all-clear.',
|
||||||
|
ExplanationFamily::SuppressedOutput => 'The comparison finished, but normal result output was suppressed.',
|
||||||
|
ExplanationFamily::MissingInput => 'The comparison could not produce a usable result because required inputs were missing.',
|
||||||
|
ExplanationFamily::BlockedPrerequisite => 'The comparison could not produce a usable result because a prerequisite blocked it.',
|
||||||
|
ExplanationFamily::InProgress => 'The comparison is still running.',
|
||||||
|
ExplanationFamily::Unavailable => 'A usable comparison result is not currently available.',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
$coverageStatement = match (true) {
|
||||||
|
$stats->state === 'idle' => 'No compare run has produced a result yet for this tenant.',
|
||||||
|
$isFailed => 'The last compare run did not finish cleanly, so current counts should not be treated as decision-grade.',
|
||||||
|
$stats->coverageStatus === 'unproven' => 'Coverage proof was unavailable, so suppressed output is possible even when the findings count is zero.',
|
||||||
|
$stats->uncoveredTypes !== [] => 'One or more in-scope policy types were not fully covered in this compare run.',
|
||||||
|
$hasEvidenceGaps => 'Evidence gaps limited the quality of the visible result for some subjects.',
|
||||||
|
$isInProgress => 'Counts will become decision-grade after the compare run finishes.',
|
||||||
|
in_array($family, [ExplanationFamily::MissingInput, ExplanationFamily::BlockedPrerequisite, ExplanationFamily::Unavailable], true) => $reason?->shortExplanation ?? 'The compare inputs were not complete enough to produce a normal result.',
|
||||||
|
default => 'Coverage matched the in-scope compare input for this run.',
|
||||||
|
};
|
||||||
|
$reliabilityStatement = $isFailed
|
||||||
|
? 'The last compare failed, so the tenant needs review before you rely on this posture.'
|
||||||
|
: match ($trustworthiness) {
|
||||||
|
TrustworthinessLevel::Trustworthy => $findingsCount > 0
|
||||||
|
? 'The compare completed with enough coverage to treat the recorded drift as decision-grade.'
|
||||||
|
: 'The compare completed with enough coverage to treat the absence of findings as trustworthy.',
|
||||||
|
TrustworthinessLevel::LimitedConfidence => 'The compare completed, but coverage or evidence limitations mean the visible result should be treated as partial.',
|
||||||
|
TrustworthinessLevel::DiagnosticOnly => 'The compare is still in progress, so current counts are only diagnostic.',
|
||||||
|
TrustworthinessLevel::Unusable => 'The compare did not produce a result that should be used for the intended decision yet.',
|
||||||
|
};
|
||||||
|
$nextActionText = $isFailed
|
||||||
|
? 'Review the failed compare run before relying on this tenant posture'
|
||||||
|
: ($reason?->firstNextStep()?->label ?? match ($family) {
|
||||||
|
ExplanationFamily::NoIssuesDetected => 'No action needed',
|
||||||
|
ExplanationFamily::TrustworthyResult => 'Review the detected drift findings',
|
||||||
|
ExplanationFamily::SuppressedOutput => 'Review the missing coverage or evidence before treating this compare as complete',
|
||||||
|
ExplanationFamily::CompletedButLimited => 'Review coverage limits before acting on this compare',
|
||||||
|
ExplanationFamily::InProgress => 'Wait for the compare to finish',
|
||||||
|
ExplanationFamily::MissingInput,
|
||||||
|
ExplanationFamily::BlockedPrerequisite,
|
||||||
|
ExplanationFamily::Unavailable => $stats->state === 'idle'
|
||||||
|
? 'Run the baseline compare to generate a result'
|
||||||
|
: 'Review the blocking baseline or scope prerequisite',
|
||||||
|
});
|
||||||
|
|
||||||
|
return $this->builder->build(
|
||||||
|
family: $family,
|
||||||
|
headline: $headline,
|
||||||
|
executionOutcome: $executionOutcome,
|
||||||
|
executionOutcomeLabel: $executionOutcomeLabel,
|
||||||
|
evaluationResult: $evaluationResult,
|
||||||
|
trustworthinessLevel: $trustworthiness,
|
||||||
|
reliabilityStatement: $reliabilityStatement,
|
||||||
|
coverageStatement: $coverageStatement,
|
||||||
|
dominantCauseCode: $reason?->internalCode ?? $stats->reasonCode,
|
||||||
|
dominantCauseLabel: $reason?->operatorLabel,
|
||||||
|
dominantCauseExplanation: $reason?->shortExplanation ?? $stats->message ?? $stats->failureReason,
|
||||||
|
nextActionCategory: $isFailed
|
||||||
|
? 'inspect_run'
|
||||||
|
: ($family === ExplanationFamily::NoIssuesDetected
|
||||||
|
? 'none'
|
||||||
|
: match ($family) {
|
||||||
|
ExplanationFamily::TrustworthyResult => 'manual_validate',
|
||||||
|
ExplanationFamily::MissingInput,
|
||||||
|
ExplanationFamily::BlockedPrerequisite,
|
||||||
|
ExplanationFamily::Unavailable => 'fix_prerequisite',
|
||||||
|
default => 'review_evidence_gaps',
|
||||||
|
}),
|
||||||
|
nextActionText: $nextActionText,
|
||||||
|
countDescriptors: $this->countDescriptors($stats, $hasCoverageWarnings, $hasEvidenceGaps),
|
||||||
|
diagnosticsAvailable: $stats->operationRunId !== null || $reason !== null,
|
||||||
|
diagnosticsSummary: 'Run evidence and low-level compare diagnostics remain available below the primary explanation.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<int, CountDescriptor>
|
||||||
|
*/
|
||||||
|
private function countDescriptors(
|
||||||
|
BaselineCompareStats $stats,
|
||||||
|
bool $hasCoverageWarnings,
|
||||||
|
bool $hasEvidenceGaps,
|
||||||
|
): array {
|
||||||
|
$descriptors = [];
|
||||||
|
|
||||||
|
if ($stats->findingsCount !== null) {
|
||||||
|
$descriptors[] = new CountDescriptor(
|
||||||
|
label: 'Findings shown',
|
||||||
|
value: (int) $stats->findingsCount,
|
||||||
|
role: CountDescriptor::ROLE_EVALUATION_OUTPUT,
|
||||||
|
qualifier: $hasCoverageWarnings || $hasEvidenceGaps ? 'not complete' : null,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($stats->uncoveredTypesCount !== null) {
|
||||||
|
$descriptors[] = new CountDescriptor(
|
||||||
|
label: 'Uncovered types',
|
||||||
|
value: (int) $stats->uncoveredTypesCount,
|
||||||
|
role: CountDescriptor::ROLE_COVERAGE,
|
||||||
|
qualifier: (int) $stats->uncoveredTypesCount > 0 ? 'coverage gap' : null,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($stats->evidenceGapsCount !== null) {
|
||||||
|
$descriptors[] = new CountDescriptor(
|
||||||
|
label: 'Evidence gaps',
|
||||||
|
value: (int) $stats->evidenceGapsCount,
|
||||||
|
role: CountDescriptor::ROLE_RELIABILITY_SIGNAL,
|
||||||
|
qualifier: (int) $stats->evidenceGapsCount > 0 ? 'review needed' : null,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($stats->evidenceGapStructuralCount !== null && $stats->evidenceGapStructuralCount > 0) {
|
||||||
|
$descriptors[] = new CountDescriptor(
|
||||||
|
label: 'Structural gaps',
|
||||||
|
value: (int) $stats->evidenceGapStructuralCount,
|
||||||
|
role: CountDescriptor::ROLE_RELIABILITY_SIGNAL,
|
||||||
|
qualifier: 'product or support limit',
|
||||||
|
visibilityTier: CountDescriptor::VISIBILITY_DIAGNOSTIC,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($stats->evidenceGapOperationalCount !== null && $stats->evidenceGapOperationalCount > 0) {
|
||||||
|
$descriptors[] = new CountDescriptor(
|
||||||
|
label: 'Operational gaps',
|
||||||
|
value: (int) $stats->evidenceGapOperationalCount,
|
||||||
|
role: CountDescriptor::ROLE_RELIABILITY_SIGNAL,
|
||||||
|
qualifier: 'local evidence missing',
|
||||||
|
visibilityTier: CountDescriptor::VISIBILITY_DIAGNOSTIC,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($stats->evidenceGapTransientCount !== null && $stats->evidenceGapTransientCount > 0) {
|
||||||
|
$descriptors[] = new CountDescriptor(
|
||||||
|
label: 'Transient gaps',
|
||||||
|
value: (int) $stats->evidenceGapTransientCount,
|
||||||
|
role: CountDescriptor::ROLE_RELIABILITY_SIGNAL,
|
||||||
|
qualifier: 'retry may help',
|
||||||
|
visibilityTier: CountDescriptor::VISIBILITY_DIAGNOSTIC,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($stats->severityCounts !== []) {
|
||||||
|
foreach (['high' => 'High severity', 'medium' => 'Medium severity', 'low' => 'Low severity'] as $key => $label) {
|
||||||
|
$value = (int) ($stats->severityCounts[$key] ?? 0);
|
||||||
|
|
||||||
|
if ($value === 0) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$descriptors[] = new CountDescriptor(
|
||||||
|
label: $label,
|
||||||
|
value: $value,
|
||||||
|
role: CountDescriptor::ROLE_EVALUATION_OUTPUT,
|
||||||
|
visibilityTier: CountDescriptor::VISIBILITY_DIAGNOSTIC,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $descriptors;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -4,6 +4,9 @@
|
|||||||
|
|
||||||
namespace App\Support\Baselines;
|
namespace App\Support\Baselines;
|
||||||
|
|
||||||
|
use App\Support\Ui\OperatorExplanation\ExplanationFamily;
|
||||||
|
use App\Support\Ui\OperatorExplanation\TrustworthinessLevel;
|
||||||
|
|
||||||
enum BaselineCompareReasonCode: string
|
enum BaselineCompareReasonCode: string
|
||||||
{
|
{
|
||||||
case NoSubjectsInScope = 'no_subjects_in_scope';
|
case NoSubjectsInScope = 'no_subjects_in_scope';
|
||||||
@ -22,4 +25,42 @@ public function message(): string
|
|||||||
self::NoDriftDetected => 'No drift was detected for in-scope subjects.',
|
self::NoDriftDetected => 'No drift was detected for in-scope subjects.',
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function explanationFamily(): ExplanationFamily
|
||||||
|
{
|
||||||
|
return match ($this) {
|
||||||
|
self::NoDriftDetected => ExplanationFamily::NoIssuesDetected,
|
||||||
|
self::CoverageUnproven,
|
||||||
|
self::EvidenceCaptureIncomplete,
|
||||||
|
self::RolloutDisabled => ExplanationFamily::CompletedButLimited,
|
||||||
|
self::NoSubjectsInScope => ExplanationFamily::MissingInput,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public function trustworthinessLevel(): TrustworthinessLevel
|
||||||
|
{
|
||||||
|
return match ($this) {
|
||||||
|
self::NoDriftDetected => TrustworthinessLevel::Trustworthy,
|
||||||
|
self::CoverageUnproven,
|
||||||
|
self::EvidenceCaptureIncomplete => TrustworthinessLevel::LimitedConfidence,
|
||||||
|
self::RolloutDisabled,
|
||||||
|
self::NoSubjectsInScope => TrustworthinessLevel::Unusable,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public function absencePattern(): ?string
|
||||||
|
{
|
||||||
|
return match ($this) {
|
||||||
|
self::NoDriftDetected => 'true_no_result',
|
||||||
|
self::CoverageUnproven,
|
||||||
|
self::EvidenceCaptureIncomplete => 'suppressed_output',
|
||||||
|
self::RolloutDisabled => 'blocked_prerequisite',
|
||||||
|
self::NoSubjectsInScope => 'missing_input',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public function supportsPositiveClaim(): bool
|
||||||
|
{
|
||||||
|
return $this === self::NoDriftDetected;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user