Compare commits
8 Commits
160-operat
...
dev
| Author | SHA1 | Date | |
|---|---|---|---|
| d98dc30520 | |||
| 55aef627aa | |||
| 02e75e1cda | |||
| 20b6aa6a32 | |||
| c17255f854 | |||
| 7d4d607475 | |||
| 1f0cc5de56 | |||
| 845d21db6d |
18
.github/agents/copilot-instructions.md
vendored
18
.github/agents/copilot-instructions.md
vendored
@ -102,6 +102,18 @@ ## Active Technologies
|
||||
- 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 + Laravel 12, Filament v5, Livewire v4, Pest v4, Tailwind CSS v4, existing `Finding`, `FindingException`, `FindingRiskGovernanceResolver`, `BadgeCatalog`, `BadgeRenderer`, `FilterOptionCatalog`, and tenant dashboard widgets (166-finding-governance-health)
|
||||
- PostgreSQL using existing `findings`, `finding_exceptions`, related decision tables, and existing DB-backed summary sources; no schema changes required (166-finding-governance-health)
|
||||
- PHP 8.4.15 + Laravel 12, Filament v5, Livewire v4, Pest v4, Tailwind CSS v4, existing `ArtifactTruthPresenter`, `OperationUxPresenter`, `RelatedNavigationResolver`, `AppServiceProvider`, `BadgeCatalog`, `BadgeRenderer`, and current Filament resource/page seams (167-derived-state-memoization)
|
||||
- PostgreSQL unchanged; feature adds no persistence and relies on request-local in-memory state only (167-derived-state-memoization)
|
||||
|
||||
- PHP 8.4.15 (feat/005-bulk-operations)
|
||||
|
||||
@ -121,8 +133,8 @@ ## Code Style
|
||||
PHP 8.4.15: Follow standard conventions
|
||||
|
||||
## Recent Changes
|
||||
- 160-operation-lifecycle-guarantees: Added 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
|
||||
- 159-baseline-snapshot-truth: Added PHP 8.4 + Laravel 12, Filament v5, Livewire v4
|
||||
- 158-artifact-truth-semantics: Added PHP 8.4.15 + Laravel 12, Filament v5, Livewire v4, Pest v4, existing `BadgeCatalog` / `BadgeRenderer` / `OperatorOutcomeTaxonomy`, `ReasonPresenter`, `OperationRunService`, `TenantReviewReadinessGate`, existing baseline/evidence/review/review-pack resources and canonical pages
|
||||
- 167-derived-state-memoization: Added PHP 8.4.15 + Laravel 12, Filament v5, Livewire v4, Pest v4, Tailwind CSS v4, existing `ArtifactTruthPresenter`, `OperationUxPresenter`, `RelatedNavigationResolver`, `AppServiceProvider`, `BadgeCatalog`, `BadgeRenderer`, and current Filament resource/page seams
|
||||
- 166-finding-governance-health: Added PHP 8.4.15 + Laravel 12, Filament v5, Livewire v4, Pest v4, Tailwind CSS v4, existing `Finding`, `FindingException`, `FindingRiskGovernanceResolver`, `BadgeCatalog`, `BadgeRenderer`, `FilterOptionCatalog`, and tenant dashboard widgets
|
||||
- 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`
|
||||
<!-- MANUAL ADDITIONS START -->
|
||||
<!-- MANUAL ADDITIONS END -->
|
||||
|
||||
@ -1,19 +1,34 @@
|
||||
<!--
|
||||
Sync Impact Report
|
||||
|
||||
- Version change: 1.11.0 → 1.12.0
|
||||
- Version change: 1.13.0 → 1.14.0
|
||||
- Modified principles:
|
||||
- None
|
||||
- Governance / Scope & Compliance → Governance / Scope, Compliance, and Review Expectations
|
||||
- Added sections:
|
||||
- Operator Surface Principles (OPSURF-001)
|
||||
- Proportionality First (PROP-001)
|
||||
- No Premature Abstraction (ABSTR-001)
|
||||
- No New Persisted Truth Without Source-of-Truth Need (PERSIST-001)
|
||||
- No New State Without Behavioral Consequence (STATE-001)
|
||||
- UI Semantics Must Not Become Their Own Framework (UI-SEM-001)
|
||||
- V1 Prefers Explicit Narrow Implementations (V1-EXP-001)
|
||||
- One Truth, Few Layers (LAYER-001)
|
||||
- Spec Discipline Over Slice Proliferation (SPEC-DISC-001)
|
||||
- Tests Must Protect Business Truth (TEST-TRUTH-001)
|
||||
- Enterprise Complexity Is Allowed Only Where Risk Demands It (RISK-COMP-001)
|
||||
- Mandatory Bloat Check for New Specs (BLOAT-001)
|
||||
- Default Bias (BIAS-001)
|
||||
- Removed sections: None
|
||||
- Templates requiring updates:
|
||||
- ✅ .specify/memory/constitution.md
|
||||
- ✅ .specify/templates/spec-template.md
|
||||
- ✅ .specify/templates/plan-template.md
|
||||
- ✅ .specify/templates/tasks-template.md
|
||||
- ✅ docs/product/principles.md
|
||||
- ✅ docs/product/standards/README.md
|
||||
- ✅ docs/HANDOVER.md
|
||||
- ✅ docs/product/principles.md
|
||||
- ✅ Agents.md
|
||||
- Commands checked:
|
||||
- N/A `.specify/templates/commands/*.md` directory is not present in this repo
|
||||
- Follow-up TODOs:
|
||||
- None.
|
||||
-->
|
||||
@ -42,6 +57,73 @@ ### Deterministic Capabilities
|
||||
- Backup/restore/risk/support flags MUST be derived deterministically from config/contracts via a Capabilities Resolver.
|
||||
- The resolver output MUST be programmatically testable (snapshot/golden tests) so config changes cannot silently break behavior.
|
||||
|
||||
### Proportionality First (PROP-001)
|
||||
- New structure, layering, persistence, or semantic machinery MUST be justified by current release truth, current operator workflow, and a concrete reason a narrower implementation is insufficient.
|
||||
- Code MUST NOT become more generic, more layered, or more persistent than the current product actually needs.
|
||||
- Reviews MUST reject speculative generalization framed only as future flexibility.
|
||||
|
||||
### No Premature Abstraction (ABSTR-001)
|
||||
- New factories, registries, resolvers, strategy systems, interfaces, extension-point frameworks, type registries, or orchestration pipelines MUST NOT be introduced before at least two real concrete cases require them.
|
||||
- Test convenience alone is not sufficient justification for a new abstraction.
|
||||
- Narrow abstractions are allowed when required for security, tenant isolation, auditability, compliance evidence, or queue/job execution correctness.
|
||||
|
||||
### No New Persisted Truth Without Source-of-Truth Need (PERSIST-001)
|
||||
- New tables, persisted entities, or stored artifacts MUST represent real product truth that survives independently of the originating request, run, or view.
|
||||
- Persisted storage is justified only when at least one of these is true: it is a source of truth, has an independent lifecycle, must be audited independently, must outlive its originating run/request, is required for permissions/routing/compliance evidence, or is required for stable operator workflows over time.
|
||||
- Convenience projections, UI helpers, speculative artifacts, derived summaries, and temporary semantic wrappers MUST remain derived unless current-release operator workflows require independent persistence.
|
||||
- Release 2/3 entities MUST NOT be fully built in Release 1 unless they are foundational and already exercised by the shipped workflow.
|
||||
|
||||
### No New State Without Behavioral Consequence (STATE-001)
|
||||
- New states, statuses, reason codes, lifecycle labels, and semantic categories MUST change operator action, workflow routing, permission or policy enforcement, lifecycle behavior, persistence truth, audit responsibility, retention behavior, or retry/failure handling.
|
||||
- Presentation-only distinctions MUST remain derived labels rather than persisted domain state.
|
||||
- Reason code families MUST NOT expand unless each added value has a distinct system or operator consequence.
|
||||
|
||||
### UI Semantics Must Not Become Their Own Framework (UI-SEM-001)
|
||||
- Badges, explanation text, trust/confidence labels, detail cards, and status summaries MUST remain lightweight presentation helpers unless they are proven product contracts.
|
||||
- New UI semantics MUST NOT require mandatory presenter, badge, explanation, taxonomy, or multi-step interpretation pipelines by default.
|
||||
- Direct mapping from canonical domain truth to UI is preferred over intermediate semantic superstructures.
|
||||
- Presentation helpers SHOULD remain optional adapters, not mandatory architecture.
|
||||
|
||||
### V1 Prefers Explicit Narrow Implementations (V1-EXP-001)
|
||||
- For V1 and early product maturity, direct implementation, local mapping, explicit per-domain logic, small focused helpers, derived read models, and minimal UI adapters are preferred.
|
||||
- Generic platform engines, meta-frameworks, universal resolver systems, workflow frameworks, and broad semantic taxonomies are disfavored until real variance proves them necessary.
|
||||
- The burden of proof is always on the broader abstraction.
|
||||
|
||||
### One Truth, Few Layers (LAYER-001)
|
||||
- A single domain truth MUST NOT be redundantly modeled across model fields, service result objects, presenters, UI summaries, explanation builders, badge taxonomies, run context wrappers, and persisted mirror entities without clear necessity.
|
||||
- Prefer one canonical truth with thin adapters.
|
||||
- Any new layer MUST replace an existing layer or prove why the existing layer cannot serve the need.
|
||||
- Additive semantic layering is discouraged; absorption is preferred over accumulation.
|
||||
|
||||
### Spec Discipline Over Slice Proliferation (SPEC-DISC-001)
|
||||
- Related semantic, taxonomy, and presentation-contract changes SHOULD be grouped into one coherent spec instead of many micro-specs that each add classes, enums, DTOs, and tests.
|
||||
- Every spec MUST explicitly state whether it introduces a new source of truth, persisted entity, abstraction, state, or cross-cutting framework.
|
||||
- If the answer is yes, the spec MUST explain why the addition is necessary now.
|
||||
|
||||
### Tests Must Protect Business Truth (TEST-TRUTH-001)
|
||||
- Testing is mandatory, but test growth MUST follow business truth rather than indirection created for its own sake.
|
||||
- Tests MUST prioritize domain behavior, permissions, isolation, lifecycle correctness, and operator-critical outcomes.
|
||||
- Large dedicated test surfaces for thin presentation indirection SHOULD be avoided.
|
||||
- If a pattern creates more test burden than product certainty, the pattern SHOULD be simplified.
|
||||
|
||||
### Enterprise Complexity Is Allowed Only Where Risk Demands It (RISK-COMP-001)
|
||||
- Heavier architecture is explicitly legitimate for workspace or tenant isolation, RBAC and policy enforcement, auditability, immutable history and snapshot truth, queue/job execution legitimacy, provider credential safety, retention/compliance evidence, and operator-critical lifecycle correctness.
|
||||
- Badge systems, explanation builders, trust/confidence overlays, presentation taxonomies, generic provider frameworks without real provider variance, speculative export/report/review infrastructure, UI meta-governance frameworks, and derived helper entities promoted into persisted truth are high-risk overproduction zones and require extra restraint.
|
||||
|
||||
### Mandatory Bloat Check for New Specs (BLOAT-001)
|
||||
- Any spec that introduces a new enum or status family, DTO/envelope/presenter layer, persisted entity or table, interface/contract/registry/resolver, cross-domain UI framework, or taxonomy/classification system MUST include a proportionality review.
|
||||
- That review MUST answer:
|
||||
1. What current operator problem does this solve?
|
||||
2. Why is existing structure insufficient?
|
||||
3. Why is this the narrowest correct implementation?
|
||||
4. What ownership cost does this create?
|
||||
5. What alternative was intentionally rejected?
|
||||
6. Is this current-release truth or future-release preparation?
|
||||
- Specs that cannot answer these questions clearly MUST NOT merge.
|
||||
|
||||
### Default Bias (BIAS-001)
|
||||
- Default codebase bias is: derive before persist, map before frameworkize, localize before generalize, simplify before extend, replace before layer, explicit before generic, and present directly before interpreting recursively.
|
||||
|
||||
### Workspace Isolation is Non-negotiable
|
||||
- Workspace membership is an isolation boundary. If the actor is not entitled to the workspace scope, the system MUST respond as
|
||||
deny-as-not-found (404).
|
||||
@ -416,6 +498,39 @@ ### Badge Semantics Are Centralized (BADGE-001)
|
||||
- Introducing or changing a status-like value MUST include updating the relevant badge mapper and adding/updating tests for the mapping.
|
||||
- Tag/category chips (e.g., type/platform/environment) are not status-like and are not governed by BADGE-001.
|
||||
|
||||
### Filament Native First / No Ad-hoc Styling (UI-FIL-001)
|
||||
- Admin and operator-facing surfaces MUST use native Filament components, existing shared UI primitives, and centralized design patterns first.
|
||||
- If Filament already provides the required semantic element, feature code MUST use the Filament-native component instead of a locally assembled replacement.
|
||||
- Preferred native elements include `x-filament::badge`, `x-filament::button`, `x-filament::icon`, and Filament Forms, Infolists, Tables, Sections, Tabs, Grids, and Actions.
|
||||
|
||||
Forbidden local replacements
|
||||
- Feature code MUST NOT hand-build badges, pills, status chips, alert cards, or action buttons from raw `<span>`, `<div>`, or `<button>` markup plus Tailwind classes when Filament-native or shared project primitives can express the same meaning.
|
||||
- Feature code MUST NOT introduce page-local visual status languages for status, risk, outcome, drift, trust, importance, or severity.
|
||||
- Feature code MUST NOT make local color, border, rounding, or emphasis decisions for semantic UI states using ad-hoc classes such as `bg-danger-100`, `text-warning-900`, `border-dashed`, or `rounded-full` when the same state can be expressed through Filament props or shared primitives.
|
||||
|
||||
Shared primitive before local override
|
||||
- If the same UI pattern can recur, it MUST use an existing shared primitive or introduce a new central primitive instead of reassembling the pattern inside a Blade view.
|
||||
- Central badge and status catalogs remain the canonical source for status semantics; local views MUST consume them rather than re-map them.
|
||||
|
||||
Upgrade-safe preference
|
||||
- Update-safe, framework-native implementations take priority over page-local styling shortcuts.
|
||||
- Filament props such as `color`, `icon`, and standard component composition are the default mechanism for semantic emphasis.
|
||||
- Publishing or emulating Filament internals for cosmetic speed is not acceptable when native composition or shared primitives are sufficient.
|
||||
|
||||
Exception rule
|
||||
- Ad-hoc markup or styling is allowed only when all of the following are true:
|
||||
- native Filament components cannot express the required semantics,
|
||||
- no suitable shared primitive exists,
|
||||
- and the deviation is justified briefly in code and in the governing spec or PR.
|
||||
- Approved exceptions MUST stay layout-neutral, use the minimum local classes necessary, and MUST NOT invent a new page-local status language.
|
||||
|
||||
Review and enforcement
|
||||
- Every UI review MUST answer:
|
||||
- which native Filament element or shared primitive was used,
|
||||
- why an existing component was insufficient if an exception was taken,
|
||||
- and whether any ad-hoc status or emphasis styling was introduced.
|
||||
- UI work is not Done if it introduces ad-hoc status styling or framework-foreign replacement components where a native Filament or shared UI solution was viable.
|
||||
|
||||
### Incremental UI Standards Enforcement (UI-STD-001)
|
||||
- UI consistency is enforced incrementally, not by recurring cleanup passes.
|
||||
- New tables, filters, and list surfaces MUST follow established Filament-native standards from the first implementation.
|
||||
@ -437,9 +552,12 @@ ## Quality Gates
|
||||
|
||||
## Governance
|
||||
|
||||
### Scope & Compliance
|
||||
### Scope, Compliance, and Review Expectations
|
||||
- This constitution applies across the repo. Feature specs may add stricter constraints but not weaker ones.
|
||||
- Restore semantics changes require: spec update, checklist update (if applicable), and tests proving safety.
|
||||
- Specs and PRs that introduce new persisted truth, abstractions, states, DTO/presenter layers, or taxonomies MUST include the proportionality review required by BLOAT-001.
|
||||
- Review and approval MUST favor simplification, replacement, and absorption over additive semantic layering.
|
||||
- Future-release preparation alone is not sufficient justification for new persistence or frameworkization unless security, tenant isolation, auditability, compliance evidence, or queue correctness already require it.
|
||||
|
||||
### Amendment Procedure
|
||||
- Propose changes as a PR that updates `.specify/memory/constitution.md`.
|
||||
@ -451,4 +569,4 @@ ### Versioning Policy (SemVer)
|
||||
- **MINOR**: new principle/section or materially expanded guidance.
|
||||
- **MAJOR**: removing/redefining principles in a backward-incompatible way.
|
||||
|
||||
**Version**: 1.12.0 | **Ratified**: 2026-01-03 | **Last Amended**: 2026-03-21
|
||||
**Version**: 1.14.0 | **Ratified**: 2026-01-03 | **Last Amended**: 2026-03-27
|
||||
|
||||
@ -48,7 +48,15 @@ ## Constitution Check
|
||||
- Ops-UX system runs: initiator-null runs emit no terminal DB notification; audit remains via Monitoring; tenant-wide alerting goes through Alerts (not OperationRun notifications)
|
||||
- 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
|
||||
- Proportionality (PROP-001): any new structure, layer, persisted truth, or semantic machinery is justified by current release truth, current operator workflow, and why a narrower solution is insufficient
|
||||
- No premature abstraction (ABSTR-001): no new factories, registries, resolvers, strategy systems, interfaces, type registries, or orchestration pipelines before at least 2 real concrete cases exist, unless security, tenant isolation, auditability, compliance evidence, or queue correctness require it now
|
||||
- Persisted truth (PERSIST-001): new tables/entities/artifacts represent independent product truth or lifecycle; convenience projections and UI helpers stay derived
|
||||
- Behavioral state (STATE-001): new states/statuses/reason codes change behavior, routing, permissions, lifecycle, audit, retention, or retry handling; presentation-only distinctions stay derived
|
||||
- UI semantics (UI-SEM-001): avoid turning badges, explanation text, trust/confidence labels, or detail summaries into mandatory interpretation frameworks; prefer direct domain-to-UI mapping
|
||||
- V1 explicitness / few layers (V1-EXP-001, LAYER-001): prefer direct implementation, local mappings, and small helpers; any new layer replaces an old one or proves the old one cannot serve
|
||||
- Spec discipline / bloat check (SPEC-DISC-001, BLOAT-001): related semantic changes are grouped coherently, and any new enum, DTO/presenter, persisted entity, interface/registry/resolver, or taxonomy includes a proportionality review covering operator problem, insufficiency, narrowness, ownership cost, rejected alternative, and whether it is current-release truth
|
||||
- Badge semantics (BADGE-001): status-like badges use `BadgeCatalog` / `BadgeRenderer`; no ad-hoc mappings; new values include tests
|
||||
- Filament-native UI (UI-FIL-001): admin/operator surfaces use native Filament components or shared primitives first; no ad-hoc status UI, local semantic color/border decisions, or hand-built replacements when native/shared semantics exist; any exception is explicitly justified
|
||||
- UI naming (UI-NAMING-001): operator-facing labels use `Verb + Object`; scope (`Workspace`, `Tenant`) is never the primary action label; source/domain is secondary unless disambiguation is required; runs/toasts/audit prose use the same domain vocabulary; implementation-first terms do not appear in primary operator UI
|
||||
- Operator surfaces (OPSURF-001): `/admin` defaults are operator-first; default-visible content avoids raw implementation detail; diagnostics are explicitly revealed secondarily
|
||||
- Operator surfaces (OPSURF-001): execution outcome, data completeness, governance result, and lifecycle/readiness are modeled as distinct status dimensions when all apply; they are not collapsed into one ambiguous status
|
||||
@ -121,9 +129,20 @@ # [REMOVE IF UNUSED] Option 3: Mobile + API (when "iOS/Android" detected)
|
||||
|
||||
## Complexity Tracking
|
||||
|
||||
> **Fill ONLY if Constitution Check has violations that must be justified**
|
||||
> **Fill when Constitution Check has violations that must be justified OR when BLOAT-001 is triggered by new persistence, abstractions, states, or semantic frameworks.**
|
||||
|
||||
| Violation | Why Needed | Simpler Alternative Rejected Because |
|
||||
|-----------|------------|-------------------------------------|
|
||||
| [e.g., 4th project] | [current need] | [why 3 projects insufficient] |
|
||||
| [e.g., Repository pattern] | [specific problem] | [why direct DB access insufficient] |
|
||||
|
||||
## Proportionality Review
|
||||
|
||||
> **Fill when the feature introduces a new enum/status family, DTO/presenter/envelope, persisted entity/table/artifact, interface/contract/registry/resolver, taxonomy/classification system, or cross-domain UI framework.**
|
||||
|
||||
- **Current operator problem**: [What present-day workflow or risk requires this?]
|
||||
- **Existing structure is insufficient because**: [Why the current code cannot serve safely or clearly]
|
||||
- **Narrowest correct implementation**: [Why this shape is the smallest viable one]
|
||||
- **Ownership cost created**: [Maintenance, testing, cognitive load, migration, or review burden]
|
||||
- **Alternative intentionally rejected**: [Simpler option and why it failed]
|
||||
- **Release truth**: [Current-release truth or future-release preparation]
|
||||
|
||||
@ -25,6 +25,27 @@ ## Operator Surface Contract *(mandatory when operator-facing surfaces are chang
|
||||
|---|---|---|---|---|---|---|---|---|---|
|
||||
| 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 |
|
||||
|
||||
## Proportionality Review *(mandatory when structural complexity is introduced)*
|
||||
|
||||
Fill this section if the feature introduces any of the following:
|
||||
- a new source of truth
|
||||
- a new persisted entity, table, or artifact
|
||||
- a new abstraction (interface, contract, registry, resolver, strategy, factory, orchestration layer)
|
||||
- a new enum, status family, reason code family, or lifecycle category
|
||||
- a new cross-domain UI framework, taxonomy, or classification system
|
||||
|
||||
- **New source of truth?**: [yes/no]
|
||||
- **New persisted entity/table/artifact?**: [yes/no]
|
||||
- **New abstraction?**: [yes/no]
|
||||
- **New enum/state/reason family?**: [yes/no]
|
||||
- **New cross-domain UI framework/taxonomy?**: [yes/no]
|
||||
- **Current operator problem**: [What present-day workflow or risk does this solve?]
|
||||
- **Existing structure is insufficient because**: [Why the current implementation shape cannot safely or clearly solve it]
|
||||
- **Narrowest correct implementation**: [Why this is the smallest viable solution]
|
||||
- **Ownership cost**: [What maintenance, testing, review, migration, or conceptual cost this adds]
|
||||
- **Alternative intentionally rejected**: [What simpler option was considered and why it was not sufficient]
|
||||
- **Release truth**: [Current-release truth or future-release preparation]
|
||||
|
||||
## User Scenarios & Testing *(mandatory)*
|
||||
|
||||
<!--
|
||||
@ -102,6 +123,16 @@ ## Requirements *(mandatory)*
|
||||
(preview/confirmation/audit), tenant isolation, run observability (`OperationRun` type/identity/visibility), and tests.
|
||||
If security-relevant DB-only actions intentionally skip `OperationRun`, the spec MUST describe `AuditLog` entries.
|
||||
|
||||
**Constitution alignment (PROP-001 / ABSTR-001 / PERSIST-001 / STATE-001 / BLOAT-001):** If this feature introduces new persistence,
|
||||
new abstractions, new states, or new semantic layers, the spec MUST explain:
|
||||
- which current operator workflow or current product truth requires the addition now,
|
||||
- why a narrower implementation is insufficient,
|
||||
- whether the addition is current-release truth or future-release preparation,
|
||||
- what ownership cost it creates,
|
||||
- and how the choice follows the default bias of deriving before persisting, replacing before layering, and being explicit before generic.
|
||||
If the feature introduces a new enum/status family, DTO/presenter/envelope, persisted entity/table, interface/contract/registry/resolver,
|
||||
or taxonomy/classification system, the Proportionality Review section above is mandatory.
|
||||
|
||||
**Constitution alignment (OPS-UX):** If this feature creates/reuses an `OperationRun`, the spec MUST:
|
||||
- explicitly state compliance with the Ops-UX 3-surface feedback contract (toast intent-only, progress surfaces, terminal DB notification),
|
||||
- state that `OperationRun.status` / `OperationRun.outcome` transitions are service-owned (only via `OperationRunService`),
|
||||
@ -127,6 +158,12 @@ ## Requirements *(mandatory)*
|
||||
**Constitution alignment (BADGE-001):** If this feature changes status-like badges (status/outcome/severity/risk/availability/boolean),
|
||||
the spec MUST describe how badge semantics stay centralized (no ad-hoc mappings) and which tests cover any new/changed values.
|
||||
|
||||
**Constitution alignment (UI-FIL-001):** If this feature adds or changes Filament or Blade UI for admin/operator surfaces, the spec MUST describe:
|
||||
- which native Filament components or shared UI primitives are used,
|
||||
- whether any local replacement markup was avoided for badges, alerts, buttons, or status surfaces,
|
||||
- how semantic emphasis is expressed through Filament props or central primitives rather than page-local color/border classes,
|
||||
- and any exception where Filament or a shared primitive was insufficient, including why the exception is necessary and how it avoids introducing a new local status language.
|
||||
|
||||
**Constitution alignment (UI-NAMING-001):** If this feature adds or changes operator-facing buttons, header actions, run titles,
|
||||
notifications, audit prose, or related helper copy, the spec MUST describe:
|
||||
- the target object,
|
||||
@ -144,9 +181,17 @@ ## Requirements *(mandatory)*
|
||||
- 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 (UI-SEM-001 / LAYER-001 / TEST-TRUTH-001):** If this feature adds UI semantics, presenters, explanation layers,
|
||||
status taxonomies, or other interpretation layers, the spec MUST describe:
|
||||
- why direct mapping from canonical domain truth to UI is insufficient,
|
||||
- which existing layer is replaced or why no existing layer can serve,
|
||||
- how the feature avoids creating redundant truth across models, service results, presenters, summaries, wrappers, and persisted mirrors,
|
||||
- and how tests focus on business consequences rather than thin indirection alone.
|
||||
|
||||
**Constitution alignment (Filament Action Surfaces):** If this feature adds or modifies any Filament Resource / RelationManager / Page,
|
||||
the spec MUST include a “UI Action Matrix” (see below) and explicitly state whether the Action Surface Contract is satisfied.
|
||||
If the contract is not satisfied, the spec MUST include an explicit exemption with rationale.
|
||||
The same section MUST also state whether UI-FIL-001 is satisfied and identify any approved exception.
|
||||
**Constitution alignment (UX-001 — Layout & Information Architecture):** If this feature adds or modifies any Filament screen,
|
||||
the spec MUST describe compliance with UX-001: Create/Edit uses Main/Aside layout (3-col grid), all fields inside Sections/Cards
|
||||
(no naked inputs), View pages use Infolists (not disabled edit forms), status badges use BADGE-001, empty states have a specific
|
||||
|
||||
@ -53,6 +53,9 @@ # Tasks: [FEATURE NAME]
|
||||
- grouping bulk actions via BulkActionGroup,
|
||||
- adding confirmations for destructive actions (and typed confirmation where required by scale),
|
||||
- adding `AuditLog` entries for relevant mutations,
|
||||
- using native Filament components or shared UI primitives before any local Blade/Tailwind assembly for badges, alerts, buttons, and semantic status surfaces,
|
||||
- avoiding page-local semantic color, border, rounding, or highlight styling when Filament props or shared primitives can express the same state,
|
||||
- documenting any UI-FIL-001 exception with rationale in the spec/PR,
|
||||
- adding/updated tests that enforce the contract and block merge on violations, OR documenting an explicit exemption with rationale.
|
||||
**Filament UI UX-001 (Layout & IA)**: If this feature adds/modifies any Filament screen, tasks MUST include:
|
||||
- ensuring Create/Edit pages use Main/Aside layout (3-col grid, Main=columnSpan(2), Aside=columnSpan(1)),
|
||||
@ -64,6 +67,13 @@ # Tasks: [FEATURE NAME]
|
||||
- OR documenting an explicit exemption with rationale if UX-001 is not fully satisfied.
|
||||
**Badges**: If this feature changes status-like badge semantics, tasks MUST use `BadgeCatalog` / `BadgeRenderer` (BADGE-001),
|
||||
avoid ad-hoc mappings in Filament, and include mapping tests for any new/changed values.
|
||||
**Proportionality / Anti-Bloat**: If this feature introduces a new enum/status family, DTO/presenter/envelope, persisted entity/table/artifact,
|
||||
interface/contract/registry/resolver, taxonomy/classification system, or cross-domain UI framework, tasks MUST include:
|
||||
- completing the spec’s Proportionality Review,
|
||||
- implementing the narrowest correct shape justified by current-release truth,
|
||||
- removing or replacing superseded layers where practical instead of stacking new ones on top,
|
||||
- keeping convenience projections and UI helpers derived unless independent persistence is explicitly justified,
|
||||
- and adding tests around business consequences, permissions, lifecycle behavior, isolation, or audit responsibilities rather than thin indirection alone.
|
||||
|
||||
**Organization**: Tasks are grouped by user story to enable independent implementation and testing of each story.
|
||||
|
||||
@ -210,6 +220,7 @@ ## Phase N: Polish & Cross-Cutting Concerns
|
||||
- [ ] TXXX Performance optimization across all stories
|
||||
- [ ] TXXX [P] Additional unit tests (if requested) in tests/unit/
|
||||
- [ ] TXXX Security hardening
|
||||
- [ ] TXXX Proportionality cleanup: remove or collapse superseded layers introduced during implementation
|
||||
- [ ] TXXX Run quickstart.md validation
|
||||
|
||||
---
|
||||
|
||||
@ -25,12 +25,14 @@ ## Scope Reference
|
||||
- Tenant-scoped RBAC and audit logs
|
||||
|
||||
## Workflow (Spec Kit)
|
||||
1. Read `.specify/constitution.md`
|
||||
1. Read `.specify/memory/constitution.md`
|
||||
2. For new work: create/update `specs/<NNN>-<slug>/spec.md`
|
||||
3. Produce `specs/<NNN>-<slug>/plan.md`
|
||||
4. Break into `specs/<NNN>-<slug>/tasks.md`
|
||||
5. Implement changes in small PRs
|
||||
|
||||
Any spec that introduces a new persisted entity, abstraction, enum/status family, or taxonomy/framework must include the proportionality review required by the constitution before implementation starts.
|
||||
|
||||
If requirements change during implementation, update spec/plan before continuing.
|
||||
|
||||
## Workflow (SDD in diesem Repo)
|
||||
|
||||
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';
|
||||
}
|
||||
}
|
||||
@ -13,6 +13,7 @@
|
||||
use App\Services\Baselines\BaselineCompareService;
|
||||
use App\Support\Auth\Capabilities;
|
||||
use App\Support\Baselines\BaselineCaptureMode;
|
||||
use App\Support\Baselines\BaselineCompareEvidenceGapDetails;
|
||||
use App\Support\Baselines\BaselineCompareStats;
|
||||
use App\Support\OperationRunLinks;
|
||||
use App\Support\OpsUx\OperationUxPresenter;
|
||||
@ -59,6 +60,8 @@ class BaselineCompareLanding extends Page
|
||||
|
||||
public ?int $duplicateNamePoliciesCount = null;
|
||||
|
||||
public ?int $duplicateNameSubjectsCount = null;
|
||||
|
||||
public ?int $operationRunId = null;
|
||||
|
||||
public ?int $findingsCount = null;
|
||||
@ -86,9 +89,24 @@ class BaselineCompareLanding extends Page
|
||||
/** @var array<string, int>|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 */
|
||||
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
|
||||
{
|
||||
$user = auth()->user();
|
||||
@ -123,6 +141,7 @@ public function refreshStats(): void
|
||||
$this->profileId = $stats->profileId;
|
||||
$this->snapshotId = $stats->snapshotId;
|
||||
$this->duplicateNamePoliciesCount = $stats->duplicateNamePoliciesCount;
|
||||
$this->duplicateNameSubjectsCount = $stats->duplicateNameSubjectsCount;
|
||||
$this->operationRunId = $stats->operationRunId;
|
||||
$this->findingsCount = $stats->findingsCount;
|
||||
$this->severityCounts = $stats->severityCounts !== [] ? $stats->severityCounts : null;
|
||||
@ -139,7 +158,18 @@ public function refreshStats(): void
|
||||
|
||||
$this->evidenceGapsCount = $stats->evidenceGapsCount;
|
||||
$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->operatorExplanation = $stats->operatorExplanation()->toArray();
|
||||
$this->summaryAssessment = $stats->summaryAssessment()->toArray();
|
||||
}
|
||||
|
||||
/**
|
||||
@ -152,26 +182,32 @@ public function refreshStats(): void
|
||||
*/
|
||||
protected function getViewData(): array
|
||||
{
|
||||
$evidenceGapSummary = is_array($this->evidenceGapSummary) ? $this->evidenceGapSummary : [];
|
||||
$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;
|
||||
$hasWarnings = $hasCoverageWarnings || $hasEvidenceGaps;
|
||||
$hasRbacRoleDefinitionSummary = is_array($this->rbacRoleDefinitionSummary)
|
||||
&& 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;
|
||||
$evidenceGapsTooltip = null;
|
||||
|
||||
if ($hasEvidenceGaps && is_array($this->evidenceGapsTopReasons) && $this->evidenceGapsTopReasons !== []) {
|
||||
$parts = [];
|
||||
|
||||
foreach (array_slice($this->evidenceGapsTopReasons, 0, 5, true) as $reason => $count) {
|
||||
if (! is_string($reason) || $reason === '' || ! is_numeric($count)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$parts[] = $reason.' ('.((int) $count).')';
|
||||
}
|
||||
if ($hasEvidenceGaps) {
|
||||
$parts = array_map(
|
||||
static fn (array $reason): string => $reason['reason_label'].' ('.$reason['count'].')',
|
||||
BaselineCompareEvidenceGapDetails::topReasons(
|
||||
is_array($evidenceGapSummary['by_reason'] ?? null) ? $evidenceGapSummary['by_reason'] : [],
|
||||
5,
|
||||
),
|
||||
);
|
||||
|
||||
if ($parts !== []) {
|
||||
$evidenceGapsSummary = implode(', ', $parts);
|
||||
@ -207,12 +243,16 @@ protected function getViewData(): array
|
||||
'hasEvidenceGaps' => $hasEvidenceGaps,
|
||||
'hasWarnings' => $hasWarnings,
|
||||
'hasRbacRoleDefinitionSummary' => $hasRbacRoleDefinitionSummary,
|
||||
'evidenceGapDetailState' => $evidenceGapDetailState,
|
||||
'hasEvidenceGapDetailSection' => $hasEvidenceGapDetailSection,
|
||||
'hasEvidenceGapDiagnostics' => $hasEvidenceGapDiagnostics,
|
||||
'evidenceGapsSummary' => $evidenceGapsSummary,
|
||||
'evidenceGapsTooltip' => $evidenceGapsTooltip,
|
||||
'findingsColorClass' => $findingsColorClass,
|
||||
'whyNoFindingsMessage' => $whyNoFindingsMessage,
|
||||
'whyNoFindingsFallback' => $whyNoFindingsFallback,
|
||||
'whyNoFindingsColor' => $whyNoFindingsColor,
|
||||
'summaryAssessment' => is_array($this->summaryAssessment) ? $this->summaryAssessment : null,
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
@ -43,6 +43,8 @@ class AuditLog extends Page implements HasTable
|
||||
{
|
||||
use InteractsWithTable;
|
||||
|
||||
public ?int $selectedAuditLogId = null;
|
||||
|
||||
protected static bool $isDiscovered = false;
|
||||
|
||||
protected static bool $shouldRegisterNavigation = false;
|
||||
@ -89,6 +91,7 @@ public function mount(): void
|
||||
|
||||
if ($requestedEventId !== null) {
|
||||
$this->resolveAuditLog($requestedEventId);
|
||||
$this->selectedAuditLogId = $requestedEventId;
|
||||
$this->mountTableAction('inspect', (string) $requestedEventId);
|
||||
}
|
||||
}
|
||||
@ -174,6 +177,9 @@ public function table(Table $table): Table
|
||||
->label('Inspect event')
|
||||
->icon('heroicon-o-eye')
|
||||
->color('gray')
|
||||
->before(function (AuditLogModel $record): void {
|
||||
$this->selectedAuditLogId = (int) $record->getKey();
|
||||
})
|
||||
->slideOver()
|
||||
->stickyModalHeader()
|
||||
->modalSubmitAction(false)
|
||||
@ -285,6 +291,33 @@ private function resolveAuditLog(int $auditLogId): AuditLogModel
|
||||
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
|
||||
*/
|
||||
|
||||
@ -14,6 +14,7 @@
|
||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceInspectAffordance;
|
||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceProfile;
|
||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceSlot;
|
||||
use App\Support\Ui\GovernanceArtifactTruth\ArtifactTruthEnvelope;
|
||||
use App\Support\Ui\GovernanceArtifactTruth\ArtifactTruthPresenter;
|
||||
use App\Support\Workspaces\WorkspaceContext;
|
||||
use BackedEnum;
|
||||
@ -90,7 +91,7 @@ public function mount(): void
|
||||
$snapshots = $query->get()->unique('tenant_id')->values();
|
||||
|
||||
$this->rows = $snapshots->map(function (EvidenceSnapshot $snapshot): array {
|
||||
$truth = app(ArtifactTruthPresenter::class)->forEvidenceSnapshot($snapshot);
|
||||
$truth = $this->snapshotTruth($snapshot);
|
||||
$freshnessSpec = BadgeCatalog::spec(BadgeDomain::GovernanceArtifactFreshness, $truth->freshnessState);
|
||||
|
||||
return [
|
||||
@ -133,4 +134,13 @@ protected function getHeaderActions(): array
|
||||
->url(route('admin.evidence.overview')),
|
||||
];
|
||||
}
|
||||
|
||||
private function snapshotTruth(EvidenceSnapshot $snapshot, bool $fresh = false): ArtifactTruthEnvelope
|
||||
{
|
||||
$presenter = app(ArtifactTruthPresenter::class);
|
||||
|
||||
return $fresh
|
||||
? $presenter->forEvidenceSnapshotFresh($snapshot)
|
||||
: $presenter->forEvidenceSnapshot($snapshot);
|
||||
}
|
||||
}
|
||||
|
||||
@ -12,6 +12,7 @@
|
||||
use App\Models\Workspace;
|
||||
use App\Services\Auth\WorkspaceCapabilityResolver;
|
||||
use App\Services\Findings\FindingExceptionService;
|
||||
use App\Services\Findings\FindingRiskGovernanceResolver;
|
||||
use App\Support\Auth\Capabilities;
|
||||
use App\Support\Badges\BadgeDomain;
|
||||
use App\Support\Badges\BadgeRenderer;
|
||||
@ -281,6 +282,11 @@ public function table(Table $table): Table
|
||||
->label('Finding')
|
||||
->state(fn (FindingException $record): string => $record->finding?->resolvedSubjectDisplayName() ?: 'Finding #'.$record->finding_id)
|
||||
->searchable(),
|
||||
TextColumn::make('governance_warning')
|
||||
->label('Governance warning')
|
||||
->state(fn (FindingException $record): ?string => $this->governanceWarning($record))
|
||||
->color(fn (FindingException $record): string => $this->governanceWarningColor($record))
|
||||
->wrap(),
|
||||
TextColumn::make('requester.name')
|
||||
->label('Requested by')
|
||||
->placeholder('—'),
|
||||
@ -500,4 +506,34 @@ private function hasActiveQueueFilters(): bool
|
||||
|| is_string(data_get($this->tableFilters, 'status.value'))
|
||||
|| is_string(data_get($this->tableFilters, 'current_validity_state.value'));
|
||||
}
|
||||
|
||||
private function governanceWarning(FindingException $record): ?string
|
||||
{
|
||||
$finding = $record->relationLoaded('finding')
|
||||
? $record->finding
|
||||
: $record->finding()->withSubjectDisplayName()->first();
|
||||
|
||||
if (! $finding instanceof \App\Models\Finding) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return app(FindingRiskGovernanceResolver::class)->resolveWarningMessage($finding, $record);
|
||||
}
|
||||
|
||||
private function governanceWarningColor(FindingException $record): string
|
||||
{
|
||||
if ((string) $record->current_validity_state === FindingException::VALIDITY_EXPIRING) {
|
||||
return 'warning';
|
||||
}
|
||||
|
||||
$finding = $record->relationLoaded('finding')
|
||||
? $record->finding
|
||||
: $record->finding()->withSubjectDisplayName()->first();
|
||||
|
||||
if ($finding instanceof \App\Models\Finding && $record->requiresFreshDecisionForFinding($finding)) {
|
||||
return 'warning';
|
||||
}
|
||||
|
||||
return 'danger';
|
||||
}
|
||||
}
|
||||
|
||||
@ -14,6 +14,7 @@
|
||||
use App\Services\Tenants\TenantOperabilityService;
|
||||
use App\Support\Auth\Capabilities;
|
||||
use App\Support\Navigation\CanonicalNavigationContext;
|
||||
use App\Support\Navigation\RelatedNavigationResolver;
|
||||
use App\Support\OperateHub\OperateHubShell;
|
||||
use App\Support\OperationRunLinks;
|
||||
use App\Support\OpsUx\OperationUxPresenter;
|
||||
@ -24,6 +25,7 @@
|
||||
use App\Support\Tenants\ReferencedTenantLifecyclePresentation;
|
||||
use App\Support\Tenants\TenantInteractionLane;
|
||||
use App\Support\Tenants\TenantOperabilityQuestion;
|
||||
use App\Support\Ui\OperatorExplanation\OperatorExplanationPattern;
|
||||
use Filament\Actions\Action;
|
||||
use Filament\Actions\ActionGroup;
|
||||
use Filament\Notifications\Notification;
|
||||
@ -52,8 +54,6 @@ class TenantlessOperationRunViewer extends Page
|
||||
*/
|
||||
public ?array $navigationContextPayload = null;
|
||||
|
||||
public bool $opsUxIsTabHidden = false;
|
||||
|
||||
/**
|
||||
* @return array<Action|ActionGroup>
|
||||
*/
|
||||
@ -107,7 +107,7 @@ protected function getHeaderActions(): array
|
||||
return $actions;
|
||||
}
|
||||
|
||||
$related = OperationRunLinks::related($this->run, $this->relatedLinksTenant());
|
||||
$related = $this->relatedLinks();
|
||||
|
||||
$relatedActions = [];
|
||||
|
||||
@ -170,16 +170,21 @@ public function blockedExecutionBanner(): ?array
|
||||
return null;
|
||||
}
|
||||
|
||||
$operatorExplanation = $this->governanceOperatorExplanation();
|
||||
$reasonEnvelope = app(ReasonPresenter::class)->forOperationRun($this->run, 'run_detail');
|
||||
$lines = $reasonEnvelope?->toBodyLines() ?? [
|
||||
OperationUxPresenter::surfaceFailureDetail($this->run) ?? 'The queued run was refused before side effects could begin.',
|
||||
OperationUxPresenter::surfaceGuidance($this->run) ?? 'Review the blocked prerequisite before retrying.',
|
||||
];
|
||||
$lines = $operatorExplanation instanceof OperatorExplanationPattern
|
||||
? array_values(array_filter([
|
||||
$operatorExplanation->headline,
|
||||
$operatorExplanation->dominantCauseExplanation,
|
||||
]))
|
||||
: ($reasonEnvelope?->toBodyLines(false) ?? [
|
||||
$this->surfaceFailureDetail() ?? 'The queued run was refused before side effects could begin.',
|
||||
]);
|
||||
|
||||
return [
|
||||
'tone' => 'amber',
|
||||
'title' => 'Blocked by prerequisite',
|
||||
'body' => implode(' ', $lines),
|
||||
'body' => implode(' ', array_values(array_unique($lines))),
|
||||
];
|
||||
}
|
||||
|
||||
@ -192,26 +197,24 @@ public function lifecycleBanner(): ?array
|
||||
return null;
|
||||
}
|
||||
|
||||
$attention = OperationUxPresenter::lifecycleAttentionSummary($this->run);
|
||||
$attention = $this->lifecycleAttentionSummary();
|
||||
|
||||
if ($attention === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$detail = OperationUxPresenter::surfaceFailureDetail($this->run) ?? 'Lifecycle truth needs operator review.';
|
||||
$guidance = OperationUxPresenter::surfaceGuidance($this->run);
|
||||
$body = $guidance !== null ? $detail.' '.$guidance : $detail;
|
||||
$detail = $this->surfaceFailureDetail() ?? 'Lifecycle truth needs operator review.';
|
||||
|
||||
return match ($this->run->freshnessState()->value) {
|
||||
'likely_stale' => [
|
||||
'tone' => 'amber',
|
||||
'title' => 'Likely stale run',
|
||||
'body' => $body,
|
||||
'body' => $detail,
|
||||
],
|
||||
'reconciled_failed' => [
|
||||
'tone' => 'rose',
|
||||
'title' => 'Automatically reconciled',
|
||||
'body' => $body,
|
||||
'body' => $detail,
|
||||
],
|
||||
default => null,
|
||||
};
|
||||
@ -281,10 +284,6 @@ public function pollInterval(): ?string
|
||||
return null;
|
||||
}
|
||||
|
||||
if ($this->opsUxIsTabHidden === true) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (filled($this->mountedActions ?? null)) {
|
||||
return null;
|
||||
}
|
||||
@ -451,4 +450,51 @@ private function relatedLinksTenant(): ?Tenant
|
||||
lane: TenantInteractionLane::StandardActiveOperating,
|
||||
)->allowed ? $tenant : null;
|
||||
}
|
||||
|
||||
private function governanceOperatorExplanation(): ?OperatorExplanationPattern
|
||||
{
|
||||
if (! isset($this->run) || ! $this->run->supportsOperatorExplanation()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return OperationUxPresenter::governanceOperatorExplanation($this->run);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, string>
|
||||
*/
|
||||
private function relatedLinks(bool $fresh = false): array
|
||||
{
|
||||
if (! isset($this->run)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$resolver = app(RelatedNavigationResolver::class);
|
||||
|
||||
return $fresh
|
||||
? $resolver->operationLinksFresh($this->run, $this->relatedLinksTenant())
|
||||
: $resolver->operationLinks($this->run, $this->relatedLinksTenant());
|
||||
}
|
||||
|
||||
private function lifecycleAttentionSummary(bool $fresh = false): ?string
|
||||
{
|
||||
if (! isset($this->run)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $fresh
|
||||
? OperationUxPresenter::lifecycleAttentionSummaryFresh($this->run)
|
||||
: OperationUxPresenter::lifecycleAttentionSummary($this->run);
|
||||
}
|
||||
|
||||
private function surfaceFailureDetail(bool $fresh = false): ?string
|
||||
{
|
||||
if (! isset($this->run)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $fresh
|
||||
? OperationUxPresenter::surfaceFailureDetailFresh($this->run)
|
||||
: OperationUxPresenter::surfaceFailureDetail($this->run);
|
||||
}
|
||||
}
|
||||
|
||||
@ -22,6 +22,7 @@
|
||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceInspectAffordance;
|
||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceProfile;
|
||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceSlot;
|
||||
use App\Support\Ui\GovernanceArtifactTruth\ArtifactTruthEnvelope;
|
||||
use App\Support\Ui\GovernanceArtifactTruth\ArtifactTruthPresenter;
|
||||
use App\Support\Workspaces\WorkspaceContext;
|
||||
use BackedEnum;
|
||||
@ -118,11 +119,11 @@ public function table(Table $table): Table
|
||||
TextColumn::make('artifact_truth')
|
||||
->label('Artifact truth')
|
||||
->badge()
|
||||
->getStateUsing(fn (TenantReview $record): string => app(ArtifactTruthPresenter::class)->forTenantReview($record)->primaryLabel)
|
||||
->color(fn (TenantReview $record): string => app(ArtifactTruthPresenter::class)->forTenantReview($record)->primaryBadgeSpec()->color)
|
||||
->icon(fn (TenantReview $record): ?string => app(ArtifactTruthPresenter::class)->forTenantReview($record)->primaryBadgeSpec()->icon)
|
||||
->iconColor(fn (TenantReview $record): ?string => app(ArtifactTruthPresenter::class)->forTenantReview($record)->primaryBadgeSpec()->iconColor)
|
||||
->description(fn (TenantReview $record): ?string => app(ArtifactTruthPresenter::class)->forTenantReview($record)->primaryExplanation)
|
||||
->getStateUsing(fn (TenantReview $record): string => $this->reviewTruth($record)->primaryLabel)
|
||||
->color(fn (TenantReview $record): string => $this->reviewTruth($record)->primaryBadgeSpec()->color)
|
||||
->icon(fn (TenantReview $record): ?string => $this->reviewTruth($record)->primaryBadgeSpec()->icon)
|
||||
->iconColor(fn (TenantReview $record): ?string => $this->reviewTruth($record)->primaryBadgeSpec()->iconColor)
|
||||
->description(fn (TenantReview $record): ?string => $this->reviewTruth($record)->operatorExplanation?->headline ?? $this->reviewTruth($record)->primaryExplanation)
|
||||
->wrap(),
|
||||
TextColumn::make('completeness_state')
|
||||
->label('Completeness')
|
||||
@ -138,23 +139,23 @@ public function table(Table $table): Table
|
||||
->badge()
|
||||
->getStateUsing(fn (TenantReview $record): string => BadgeCatalog::spec(
|
||||
BadgeDomain::GovernanceArtifactPublicationReadiness,
|
||||
app(ArtifactTruthPresenter::class)->forTenantReview($record)->publicationReadiness ?? 'internal_only',
|
||||
$this->reviewTruth($record)->publicationReadiness ?? 'internal_only',
|
||||
)->label)
|
||||
->color(fn (TenantReview $record): string => BadgeCatalog::spec(
|
||||
BadgeDomain::GovernanceArtifactPublicationReadiness,
|
||||
app(ArtifactTruthPresenter::class)->forTenantReview($record)->publicationReadiness ?? 'internal_only',
|
||||
$this->reviewTruth($record)->publicationReadiness ?? 'internal_only',
|
||||
)->color)
|
||||
->icon(fn (TenantReview $record): ?string => BadgeCatalog::spec(
|
||||
BadgeDomain::GovernanceArtifactPublicationReadiness,
|
||||
app(ArtifactTruthPresenter::class)->forTenantReview($record)->publicationReadiness ?? 'internal_only',
|
||||
$this->reviewTruth($record)->publicationReadiness ?? 'internal_only',
|
||||
)->icon)
|
||||
->iconColor(fn (TenantReview $record): ?string => BadgeCatalog::spec(
|
||||
BadgeDomain::GovernanceArtifactPublicationReadiness,
|
||||
app(ArtifactTruthPresenter::class)->forTenantReview($record)->publicationReadiness ?? 'internal_only',
|
||||
$this->reviewTruth($record)->publicationReadiness ?? 'internal_only',
|
||||
)->iconColor),
|
||||
TextColumn::make('artifact_next_step')
|
||||
->label('Next step')
|
||||
->getStateUsing(fn (TenantReview $record): string => app(ArtifactTruthPresenter::class)->forTenantReview($record)->nextStepText())
|
||||
->getStateUsing(fn (TenantReview $record): string => $this->reviewTruth($record)->operatorExplanation?->nextActionText ?? $this->reviewTruth($record)->nextStepText())
|
||||
->wrap(),
|
||||
])
|
||||
->filters([
|
||||
@ -325,4 +326,13 @@ private function workspace(): ?Workspace
|
||||
? Workspace::query()->whereKey((int) $workspaceId)->first()
|
||||
: null;
|
||||
}
|
||||
|
||||
private function reviewTruth(TenantReview $record, bool $fresh = false): ArtifactTruthEnvelope
|
||||
{
|
||||
$presenter = app(ArtifactTruthPresenter::class);
|
||||
|
||||
return $fresh
|
||||
? $presenter->forTenantReviewFresh($record)
|
||||
: $presenter->forTenantReview($record);
|
||||
}
|
||||
}
|
||||
|
||||
@ -179,7 +179,7 @@ public static function table(Table $table): Table
|
||||
->color(static fn (BaselineSnapshot $record): string => self::truthEnvelope($record)->primaryBadgeSpec()->color)
|
||||
->icon(static fn (BaselineSnapshot $record): ?string => self::truthEnvelope($record)->primaryBadgeSpec()->icon)
|
||||
->iconColor(static fn (BaselineSnapshot $record): ?string => self::truthEnvelope($record)->primaryBadgeSpec()->iconColor)
|
||||
->description(static fn (BaselineSnapshot $record): ?string => self::truthEnvelope($record)->primaryExplanation)
|
||||
->description(static fn (BaselineSnapshot $record): ?string => self::truthEnvelope($record)->operatorExplanation?->headline ?? self::truthEnvelope($record)->primaryExplanation)
|
||||
->wrap(),
|
||||
TextColumn::make('lifecycle_state')
|
||||
->label('Lifecycle')
|
||||
@ -203,7 +203,7 @@ public static function table(Table $table): Table
|
||||
->wrap(),
|
||||
TextColumn::make('artifact_next_step')
|
||||
->label('Next step')
|
||||
->getStateUsing(static fn (BaselineSnapshot $record): string => self::truthEnvelope($record)->nextStepText())
|
||||
->getStateUsing(static fn (BaselineSnapshot $record): string => self::truthEnvelope($record)->operatorExplanation?->nextActionText ?? self::truthEnvelope($record)->nextStepText())
|
||||
->wrap(),
|
||||
])
|
||||
->recordUrl(static fn (BaselineSnapshot $record): ?string => static::canView($record)
|
||||
@ -371,9 +371,9 @@ private static function applyLifecycleFilter(Builder $query, mixed $value): Buil
|
||||
private static function gapCountExpression(Builder $query): string
|
||||
{
|
||||
return match ($query->getConnection()->getDriverName()) {
|
||||
'sqlite' => "COALESCE(CAST(json_extract(summary_jsonb, '$.gaps.by_reason.missing_evidence') AS INTEGER), 0) + COALESCE(CAST(json_extract(summary_jsonb, '$.gaps.by_reason.missing_role_definition_version_reference') AS INTEGER), 0) + COALESCE(CAST(json_extract(summary_jsonb, '$.gaps.by_reason.invalid_subject') AS INTEGER), 0) + COALESCE(CAST(json_extract(summary_jsonb, '$.gaps.by_reason.duplicate_subject') AS INTEGER), 0) + COALESCE(CAST(json_extract(summary_jsonb, '$.gaps.by_reason.policy_not_found') AS INTEGER), 0) + COALESCE(CAST(json_extract(summary_jsonb, '$.gaps.by_reason.throttled') AS INTEGER), 0) + COALESCE(CAST(json_extract(summary_jsonb, '$.gaps.by_reason.capture_failed') AS INTEGER), 0) + COALESCE(CAST(json_extract(summary_jsonb, '$.gaps.by_reason.budget_exhausted') AS INTEGER), 0)",
|
||||
'pgsql' => "COALESCE((summary_jsonb #>> '{gaps,by_reason,missing_evidence}')::int, 0) + COALESCE((summary_jsonb #>> '{gaps,by_reason,missing_role_definition_version_reference}')::int, 0) + COALESCE((summary_jsonb #>> '{gaps,by_reason,invalid_subject}')::int, 0) + COALESCE((summary_jsonb #>> '{gaps,by_reason,duplicate_subject}')::int, 0) + COALESCE((summary_jsonb #>> '{gaps,by_reason,policy_not_found}')::int, 0) + COALESCE((summary_jsonb #>> '{gaps,by_reason,throttled}')::int, 0) + COALESCE((summary_jsonb #>> '{gaps,by_reason,capture_failed}')::int, 0) + COALESCE((summary_jsonb #>> '{gaps,by_reason,budget_exhausted}')::int, 0)",
|
||||
default => "COALESCE(CAST(JSON_EXTRACT(summary_jsonb, '$.gaps.by_reason.missing_evidence') AS UNSIGNED), 0) + COALESCE(CAST(JSON_EXTRACT(summary_jsonb, '$.gaps.by_reason.missing_role_definition_version_reference') AS UNSIGNED), 0) + COALESCE(CAST(JSON_EXTRACT(summary_jsonb, '$.gaps.by_reason.invalid_subject') AS UNSIGNED), 0) + COALESCE(CAST(JSON_EXTRACT(summary_jsonb, '$.gaps.by_reason.duplicate_subject') AS UNSIGNED), 0) + COALESCE(CAST(JSON_EXTRACT(summary_jsonb, '$.gaps.by_reason.policy_not_found') AS UNSIGNED), 0) + COALESCE(CAST(JSON_EXTRACT(summary_jsonb, '$.gaps.by_reason.throttled') AS UNSIGNED), 0) + COALESCE(CAST(JSON_EXTRACT(summary_jsonb, '$.gaps.by_reason.capture_failed') AS UNSIGNED), 0) + COALESCE(CAST(JSON_EXTRACT(summary_jsonb, '$.gaps.by_reason.budget_exhausted') AS UNSIGNED), 0)",
|
||||
'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' => "GREATEST(0, COALESCE((summary_jsonb #>> '{gaps,count}')::int, 0) - COALESCE((summary_jsonb #>> '{gaps,by_reason,meta_fallback}')::int, 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))",
|
||||
};
|
||||
}
|
||||
|
||||
@ -385,9 +385,13 @@ private static function gapSpec(BaselineSnapshot $snapshot): \App\Support\Badges
|
||||
);
|
||||
}
|
||||
|
||||
private static function truthEnvelope(BaselineSnapshot $snapshot): ArtifactTruthEnvelope
|
||||
private static function truthEnvelope(BaselineSnapshot $snapshot, bool $fresh = false): ArtifactTruthEnvelope
|
||||
{
|
||||
return app(ArtifactTruthPresenter::class)->forBaselineSnapshot($snapshot);
|
||||
$presenter = app(ArtifactTruthPresenter::class);
|
||||
|
||||
return $fresh
|
||||
? $presenter->forBaselineSnapshotFresh($snapshot)
|
||||
: $presenter->forBaselineSnapshot($snapshot);
|
||||
}
|
||||
|
||||
private static function currentTruthLabel(BaselineSnapshot $snapshot): string
|
||||
|
||||
@ -274,6 +274,7 @@ public static function table(Table $table): Table
|
||||
}
|
||||
|
||||
app(EvidenceSnapshotService::class)->expire($record, $user);
|
||||
static::truthEnvelope($record->refresh(), fresh: true);
|
||||
|
||||
Notification::make()->success()->title('Snapshot expired')->send();
|
||||
}),
|
||||
@ -612,9 +613,13 @@ private static function badgeLabel(BadgeDomain $domain, ?string $state): ?string
|
||||
return $label === 'Unknown' ? null : $label;
|
||||
}
|
||||
|
||||
private static function truthEnvelope(EvidenceSnapshot $record): ArtifactTruthEnvelope
|
||||
private static function truthEnvelope(EvidenceSnapshot $record, bool $fresh = false): ArtifactTruthEnvelope
|
||||
{
|
||||
return app(ArtifactTruthPresenter::class)->forEvidenceSnapshot($record);
|
||||
$presenter = app(ArtifactTruthPresenter::class);
|
||||
|
||||
return $fresh
|
||||
? $presenter->forEvidenceSnapshotFresh($record)
|
||||
: $presenter->forEvidenceSnapshot($record);
|
||||
}
|
||||
|
||||
private static function stringifySummaryValue(mixed $value): string
|
||||
@ -646,6 +651,7 @@ public static function executeGeneration(array $data): void
|
||||
user: $user,
|
||||
allowStale: (bool) ($data['allow_stale'] ?? false),
|
||||
);
|
||||
static::truthEnvelope($snapshot->refresh(), fresh: true);
|
||||
|
||||
if (! $snapshot->wasRecentlyCreated) {
|
||||
Notification::make()
|
||||
|
||||
@ -11,6 +11,8 @@
|
||||
use App\Models\FindingExceptionEvidenceReference;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
use App\Models\Workspace;
|
||||
use App\Services\Auth\WorkspaceCapabilityResolver;
|
||||
use App\Services\Findings\FindingExceptionService;
|
||||
use App\Services\Findings\FindingRiskGovernanceResolver;
|
||||
use App\Support\Auth\Capabilities;
|
||||
@ -122,6 +124,36 @@ public static function getEloquentQuery(): Builder
|
||||
->with(static::relationshipsForView());
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{active: int, expiring: int, expired: int, pending: int, total: int}
|
||||
*/
|
||||
public static function exceptionStatsForCurrentTenant(): array
|
||||
{
|
||||
$tenant = static::resolveTenantContextForCurrentPanel();
|
||||
|
||||
if (! $tenant instanceof Tenant) {
|
||||
return ['active' => 0, 'expiring' => 0, 'expired' => 0, 'pending' => 0, 'total' => 0];
|
||||
}
|
||||
|
||||
$counts = FindingException::query()
|
||||
->where('tenant_id', (int) $tenant->getKey())
|
||||
->where('workspace_id', (int) $tenant->workspace_id)
|
||||
->selectRaw('count(*) as total')
|
||||
->selectRaw("count(*) filter (where status = 'active') as active")
|
||||
->selectRaw("count(*) filter (where status = 'expiring') as expiring")
|
||||
->selectRaw("count(*) filter (where status = 'expired') as expired")
|
||||
->selectRaw("count(*) filter (where status = 'pending') as pending")
|
||||
->first();
|
||||
|
||||
return [
|
||||
'active' => (int) ($counts?->active ?? 0),
|
||||
'expiring' => (int) ($counts?->expiring ?? 0),
|
||||
'expired' => (int) ($counts?->expired ?? 0),
|
||||
'pending' => (int) ($counts?->pending ?? 0),
|
||||
'total' => (int) ($counts?->total ?? 0),
|
||||
];
|
||||
}
|
||||
|
||||
public static function resolveScopedRecordOrFail(int|string|null $record): Model
|
||||
{
|
||||
return static::resolveTenantOwnedRecordOrFail($record, parent::getEloquentQuery()->with(static::relationshipsForView()));
|
||||
@ -238,10 +270,21 @@ public static function table(Table $table): Table
|
||||
->icon(BadgeRenderer::icon(BadgeDomain::FindingRiskGovernanceValidity))
|
||||
->iconColor(BadgeRenderer::iconColor(BadgeDomain::FindingRiskGovernanceValidity))
|
||||
->sortable(),
|
||||
Tables\Columns\TextColumn::make('finding.severity')
|
||||
->label('Severity')
|
||||
->badge()
|
||||
->formatStateUsing(BadgeRenderer::label(BadgeDomain::FindingSeverity))
|
||||
->color(BadgeRenderer::color(BadgeDomain::FindingSeverity))
|
||||
->icon(BadgeRenderer::icon(BadgeDomain::FindingSeverity))
|
||||
->iconColor(BadgeRenderer::iconColor(BadgeDomain::FindingSeverity))
|
||||
->placeholder('—')
|
||||
->sortable(),
|
||||
Tables\Columns\TextColumn::make('finding_summary')
|
||||
->label('Finding')
|
||||
->state(fn (FindingException $record): string => static::findingSummary($record))
|
||||
->searchable(),
|
||||
->searchable()
|
||||
->wrap()
|
||||
->limit(60),
|
||||
Tables\Columns\TextColumn::make('governance_warning')
|
||||
->label('Governance warning')
|
||||
->state(fn (FindingException $record): ?string => static::governanceWarning($record))
|
||||
@ -257,7 +300,14 @@ public static function table(Table $table): Table
|
||||
->label('Review due')
|
||||
->dateTime()
|
||||
->placeholder('—')
|
||||
->sortable(),
|
||||
->sortable()
|
||||
->description(fn (FindingException $record): ?string => static::relativeTimeDescription($record->review_due_at)),
|
||||
Tables\Columns\TextColumn::make('expires_at')
|
||||
->label('Expires')
|
||||
->dateTime()
|
||||
->placeholder('—')
|
||||
->sortable()
|
||||
->description(fn (FindingException $record): ?string => static::relativeTimeDescription($record->expires_at)),
|
||||
Tables\Columns\TextColumn::make('requested_at')
|
||||
->label('Requested')
|
||||
->dateTime()
|
||||
@ -269,6 +319,12 @@ public static function table(Table $table): Table
|
||||
SelectFilter::make('current_validity_state')
|
||||
->label('Validity')
|
||||
->options(FilterOptionCatalog::findingExceptionValidityStates()),
|
||||
SelectFilter::make('finding_severity')
|
||||
->label('Finding severity')
|
||||
->options(FilterOptionCatalog::findingSeverities())
|
||||
->query(fn (Builder $query, array $data): Builder => filled($data['value'])
|
||||
? $query->whereHas('finding', fn (Builder $q) => $q->where('severity', $data['value']))
|
||||
: $query),
|
||||
])
|
||||
->actions([
|
||||
Action::make('renew_exception')
|
||||
@ -441,15 +497,62 @@ private static function tenantMemberOptions(): array
|
||||
|
||||
private static function findingSummary(FindingException $record): string
|
||||
{
|
||||
$summary = $record->finding?->resolvedSubjectDisplayName();
|
||||
$finding = $record->finding;
|
||||
|
||||
if (is_string($summary) && trim($summary) !== '') {
|
||||
return trim($summary);
|
||||
if (! $finding instanceof \App\Models\Finding) {
|
||||
return 'Finding #'.$record->finding_id;
|
||||
}
|
||||
|
||||
$displayName = $finding->resolvedSubjectDisplayName();
|
||||
$findingType = $finding->finding_type;
|
||||
|
||||
$parts = [];
|
||||
|
||||
if (is_string($displayName) && trim($displayName) !== '') {
|
||||
$parts[] = trim($displayName);
|
||||
}
|
||||
|
||||
if (is_string($findingType) && trim($findingType) !== '') {
|
||||
$label = str_replace('_', ' ', trim($findingType));
|
||||
$parts[] = '('.ucfirst($label).')';
|
||||
}
|
||||
|
||||
if ($parts !== []) {
|
||||
return implode(' ', $parts);
|
||||
}
|
||||
|
||||
return 'Finding #'.$record->finding_id;
|
||||
}
|
||||
|
||||
public static function relativeTimeDescription(mixed $date): ?string
|
||||
{
|
||||
if (! $date instanceof \DateTimeInterface) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$carbon = \Illuminate\Support\Carbon::instance($date);
|
||||
|
||||
if ($carbon->isToday()) {
|
||||
return 'Today';
|
||||
}
|
||||
|
||||
if ($carbon->isPast()) {
|
||||
return $carbon->diffForHumans();
|
||||
}
|
||||
|
||||
if ($carbon->isTomorrow()) {
|
||||
return 'Tomorrow';
|
||||
}
|
||||
|
||||
$daysUntil = (int) now()->startOfDay()->diffInDays($carbon->startOfDay());
|
||||
|
||||
if ($daysUntil <= 14) {
|
||||
return 'In '.$daysUntil.' days';
|
||||
}
|
||||
|
||||
return $carbon->diffForHumans();
|
||||
}
|
||||
|
||||
private static function canManageRecord(FindingException $record): bool
|
||||
{
|
||||
$user = auth()->user();
|
||||
@ -479,10 +582,49 @@ private static function governanceWarningColor(FindingException $record): string
|
||||
? $record->finding
|
||||
: $record->finding()->withSubjectDisplayName()->first();
|
||||
|
||||
if ((string) $record->current_validity_state === FindingException::VALIDITY_EXPIRING) {
|
||||
return 'warning';
|
||||
}
|
||||
|
||||
if ($finding instanceof \App\Models\Finding && $record->requiresFreshDecisionForFinding($finding)) {
|
||||
return 'warning';
|
||||
}
|
||||
|
||||
return 'danger';
|
||||
}
|
||||
|
||||
public static function canAccessApprovalQueueForTenant(?Tenant $tenant = null): bool
|
||||
{
|
||||
$tenant ??= static::resolveTenantContextForCurrentPanel();
|
||||
$user = auth()->user();
|
||||
|
||||
if (! $tenant instanceof Tenant || ! $user instanceof User) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$workspace = Workspace::query()->whereKey($tenant->workspace_id)->first();
|
||||
|
||||
if (! $workspace instanceof Workspace) {
|
||||
return false;
|
||||
}
|
||||
|
||||
/** @var WorkspaceCapabilityResolver $resolver */
|
||||
$resolver = app(WorkspaceCapabilityResolver::class);
|
||||
|
||||
return $resolver->isMember($user, $workspace)
|
||||
&& $resolver->can($user, $workspace, Capabilities::FINDING_EXCEPTION_APPROVE);
|
||||
}
|
||||
|
||||
public static function approvalQueueUrl(?Tenant $tenant = null): ?string
|
||||
{
|
||||
$tenant ??= static::resolveTenantContextForCurrentPanel();
|
||||
|
||||
if (! $tenant instanceof Tenant) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return route('admin.finding-exceptions.open-queue', [
|
||||
'tenant' => (string) $tenant->external_id,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
@ -6,13 +6,57 @@
|
||||
|
||||
use App\Filament\Resources\FindingExceptionResource;
|
||||
use App\Filament\Resources\FindingResource;
|
||||
use App\Filament\Widgets\Tenant\FindingExceptionStatsOverview;
|
||||
use App\Models\FindingException;
|
||||
use Filament\Actions\Action;
|
||||
use Filament\Resources\Pages\ListRecords;
|
||||
use Filament\Schemas\Components\Tabs\Tab;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
|
||||
class ListFindingExceptions extends ListRecords
|
||||
{
|
||||
protected static string $resource = FindingExceptionResource::class;
|
||||
|
||||
protected function getHeaderWidgets(): array
|
||||
{
|
||||
return [
|
||||
FindingExceptionStatsOverview::class,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, Tab>
|
||||
*/
|
||||
public function getTabs(): array
|
||||
{
|
||||
$stats = FindingExceptionResource::exceptionStatsForCurrentTenant();
|
||||
$needsAction = $stats['pending'] + $stats['expiring'] + $stats['expired'];
|
||||
|
||||
return [
|
||||
'all' => Tab::make('All')
|
||||
->icon('heroicon-m-list-bullet'),
|
||||
'needs_action' => Tab::make('Needs action')
|
||||
->icon('heroicon-m-exclamation-triangle')
|
||||
->modifyQueryUsing(fn (Builder $query) => $query->whereIn('status', [
|
||||
FindingException::STATUS_PENDING,
|
||||
FindingException::STATUS_EXPIRING,
|
||||
FindingException::STATUS_EXPIRED,
|
||||
]))
|
||||
->badge($needsAction > 0 ? $needsAction : null)
|
||||
->badgeColor('warning'),
|
||||
'active' => Tab::make('Active')
|
||||
->icon('heroicon-m-check-badge')
|
||||
->modifyQueryUsing(fn (Builder $query) => $query->where('status', FindingException::STATUS_ACTIVE)),
|
||||
'historical' => Tab::make('Historical')
|
||||
->icon('heroicon-m-archive-box')
|
||||
->modifyQueryUsing(fn (Builder $query) => $query->whereIn('status', [
|
||||
FindingException::STATUS_REJECTED,
|
||||
FindingException::STATUS_REVOKED,
|
||||
FindingException::STATUS_SUPERSEDED,
|
||||
])),
|
||||
];
|
||||
}
|
||||
|
||||
protected function getHeaderActions(): array
|
||||
{
|
||||
return [
|
||||
@ -21,6 +65,12 @@ protected function getHeaderActions(): array
|
||||
->icon('heroicon-o-arrow-top-right-on-square')
|
||||
->color('gray')
|
||||
->url(FindingResource::getUrl('index')),
|
||||
Action::make('open_approval_queue')
|
||||
->label('Open approval queue')
|
||||
->icon('heroicon-o-arrow-top-right-on-square')
|
||||
->color('gray')
|
||||
->visible(fn (): bool => FindingExceptionResource::canAccessApprovalQueueForTenant())
|
||||
->url(fn (): ?string => FindingExceptionResource::approvalQueueUrl()),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@ -47,6 +47,23 @@ protected function getHeaderActions(): array
|
||||
|
||||
return FindingResource::getUrl('view', ['record' => $record->finding], panel: 'tenant', tenant: $record->tenant);
|
||||
}),
|
||||
Action::make('open_approval_queue')
|
||||
->label('Open approval queue')
|
||||
->icon('heroicon-o-arrow-top-right-on-square')
|
||||
->color('gray')
|
||||
->visible(function (): bool {
|
||||
$record = $this->getRecord();
|
||||
|
||||
return $record instanceof FindingException
|
||||
&& FindingExceptionResource::canAccessApprovalQueueForTenant($record->tenant);
|
||||
})
|
||||
->url(function (): ?string {
|
||||
$record = $this->getRecord();
|
||||
|
||||
return $record instanceof FindingException
|
||||
? FindingExceptionResource::approvalQueueUrl($record->tenant)
|
||||
: null;
|
||||
}),
|
||||
Action::make('renew_exception')
|
||||
->label('Renew exception')
|
||||
->icon('heroicon-o-arrow-path')
|
||||
|
||||
@ -146,6 +146,65 @@ public static function infolist(Schema $schema): Schema
|
||||
{
|
||||
return $schema
|
||||
->schema([
|
||||
Section::make('Status and next action')
|
||||
->schema([
|
||||
TextEntry::make('status')
|
||||
->badge()
|
||||
->formatStateUsing(BadgeRenderer::label(BadgeDomain::FindingStatus))
|
||||
->color(BadgeRenderer::color(BadgeDomain::FindingStatus))
|
||||
->icon(BadgeRenderer::icon(BadgeDomain::FindingStatus))
|
||||
->iconColor(BadgeRenderer::iconColor(BadgeDomain::FindingStatus)),
|
||||
TextEntry::make('severity')
|
||||
->badge()
|
||||
->formatStateUsing(BadgeRenderer::label(BadgeDomain::FindingSeverity))
|
||||
->color(BadgeRenderer::color(BadgeDomain::FindingSeverity))
|
||||
->icon(BadgeRenderer::icon(BadgeDomain::FindingSeverity))
|
||||
->iconColor(BadgeRenderer::iconColor(BadgeDomain::FindingSeverity)),
|
||||
TextEntry::make('finding_due_attention')
|
||||
->label('Due state')
|
||||
->badge()
|
||||
->state(fn (Finding $record): ?string => static::dueAttentionLabel($record))
|
||||
->color(fn (Finding $record): string => static::dueAttentionColor($record))
|
||||
->visible(fn (Finding $record): bool => static::dueAttentionLabel($record) !== null),
|
||||
TextEntry::make('finding_governance_validity_leading')
|
||||
->label('Governance')
|
||||
->badge()
|
||||
->state(fn (Finding $record): ?string => static::governanceValidityState($record))
|
||||
->formatStateUsing(BadgeRenderer::label(BadgeDomain::FindingRiskGovernanceValidity))
|
||||
->color(BadgeRenderer::color(BadgeDomain::FindingRiskGovernanceValidity))
|
||||
->icon(BadgeRenderer::icon(BadgeDomain::FindingRiskGovernanceValidity))
|
||||
->iconColor(BadgeRenderer::iconColor(BadgeDomain::FindingRiskGovernanceValidity))
|
||||
->visible(fn (Finding $record): bool => static::governanceValidityState($record) !== null),
|
||||
TextEntry::make('owner_user_id_leading')
|
||||
->label('Owner')
|
||||
->state(fn (Finding $record): string => $record->ownerUser?->name ?? 'Unassigned'),
|
||||
TextEntry::make('assignee_user_id_leading')
|
||||
->label('Assignee')
|
||||
->state(fn (Finding $record): string => $record->assigneeUser?->name ?? 'Unassigned'),
|
||||
TextEntry::make('finding_primary_narrative')
|
||||
->label('Current reading')
|
||||
->state(fn (Finding $record): string => static::primaryNarrative($record))
|
||||
->columnSpanFull(),
|
||||
TextEntry::make('finding_governance_warning_leading')
|
||||
->label('Governance warning')
|
||||
->state(fn (Finding $record): ?string => static::governanceWarning($record))
|
||||
->color(fn (Finding $record): string => static::governanceWarningColor($record))
|
||||
->columnSpanFull()
|
||||
->visible(fn (Finding $record): bool => static::governanceWarning($record) !== null),
|
||||
TextEntry::make('finding_historical_context')
|
||||
->label('Historical context')
|
||||
->state(fn (Finding $record): ?string => static::historicalContext($record))
|
||||
->columnSpanFull()
|
||||
->visible(fn (Finding $record): bool => static::historicalContext($record) !== null),
|
||||
TextEntry::make('finding_primary_next_action')
|
||||
->label('Next action')
|
||||
->state(fn (Finding $record): ?string => static::primaryNextAction($record))
|
||||
->columnSpanFull()
|
||||
->visible(fn (Finding $record): bool => static::primaryNextAction($record) !== null),
|
||||
])
|
||||
->columns(2)
|
||||
->columnSpanFull(),
|
||||
|
||||
Section::make('Finding')
|
||||
->schema([
|
||||
TextEntry::make('finding_type')->badge()->label('Type'),
|
||||
@ -628,19 +687,45 @@ public static function table(Table $table): Table
|
||||
->persistSearchInSession()
|
||||
->persistSortInSession()
|
||||
->columns([
|
||||
Tables\Columns\TextColumn::make('finding_type')->badge()->label('Type'),
|
||||
Tables\Columns\TextColumn::make('finding_type')
|
||||
->badge()
|
||||
->label('Type')
|
||||
->formatStateUsing(BadgeRenderer::label(BadgeDomain::FindingType))
|
||||
->color(BadgeRenderer::color(BadgeDomain::FindingType))
|
||||
->icon(BadgeRenderer::icon(BadgeDomain::FindingType)),
|
||||
Tables\Columns\TextColumn::make('subject_display_name')
|
||||
->label('Subject')
|
||||
->placeholder('—')
|
||||
->searchable()
|
||||
->limit(40)
|
||||
->wrap()
|
||||
->state(fn (Finding $record): ?string => $record->resolvedSubjectDisplayName())
|
||||
->tooltip(fn (Finding $record): ?string => $record->resolvedSubjectDisplayName())
|
||||
->description(fn (Finding $record): ?string => static::driftContextDescription($record)),
|
||||
Tables\Columns\TextColumn::make('severity')
|
||||
->badge()
|
||||
->sortable()
|
||||
->formatStateUsing(BadgeRenderer::label(BadgeDomain::FindingSeverity))
|
||||
->color(BadgeRenderer::color(BadgeDomain::FindingSeverity))
|
||||
->icon(BadgeRenderer::icon(BadgeDomain::FindingSeverity))
|
||||
->iconColor(BadgeRenderer::iconColor(BadgeDomain::FindingSeverity)),
|
||||
Tables\Columns\TextColumn::make('status')
|
||||
->badge()
|
||||
->formatStateUsing(BadgeRenderer::label(BadgeDomain::FindingStatus))
|
||||
->color(BadgeRenderer::color(BadgeDomain::FindingStatus))
|
||||
->icon(BadgeRenderer::icon(BadgeDomain::FindingStatus))
|
||||
->iconColor(BadgeRenderer::iconColor(BadgeDomain::FindingStatus)),
|
||||
Tables\Columns\TextColumn::make('severity')
|
||||
->iconColor(BadgeRenderer::iconColor(BadgeDomain::FindingStatus))
|
||||
->description(fn (Finding $record): string => static::primaryNarrative($record)),
|
||||
Tables\Columns\TextColumn::make('governance_validity')
|
||||
->label('Governance')
|
||||
->badge()
|
||||
->formatStateUsing(BadgeRenderer::label(BadgeDomain::FindingSeverity))
|
||||
->color(BadgeRenderer::color(BadgeDomain::FindingSeverity))
|
||||
->icon(BadgeRenderer::icon(BadgeDomain::FindingSeverity))
|
||||
->iconColor(BadgeRenderer::iconColor(BadgeDomain::FindingSeverity)),
|
||||
->state(fn (Finding $record): ?string => static::governanceValidityState($record))
|
||||
->formatStateUsing(BadgeRenderer::label(BadgeDomain::FindingRiskGovernanceValidity))
|
||||
->color(BadgeRenderer::color(BadgeDomain::FindingRiskGovernanceValidity))
|
||||
->icon(BadgeRenderer::icon(BadgeDomain::FindingRiskGovernanceValidity))
|
||||
->iconColor(BadgeRenderer::iconColor(BadgeDomain::FindingRiskGovernanceValidity))
|
||||
->placeholder('—')
|
||||
->description(fn (Finding $record): ?string => static::governanceWarning($record)),
|
||||
Tables\Columns\TextColumn::make('evidence_fidelity')
|
||||
->label('Fidelity')
|
||||
->badge()
|
||||
@ -652,12 +737,6 @@ public static function table(Table $table): Table
|
||||
})
|
||||
->sortable()
|
||||
->toggleable(isToggledHiddenByDefault: true),
|
||||
Tables\Columns\TextColumn::make('subject_display_name')
|
||||
->label('Subject')
|
||||
->placeholder('—')
|
||||
->searchable()
|
||||
->state(fn (Finding $record): ?string => $record->resolvedSubjectDisplayName())
|
||||
->description(fn (Finding $record): ?string => static::driftContextDescription($record)),
|
||||
Tables\Columns\TextColumn::make('subject_type')
|
||||
->label('Subject type')
|
||||
->searchable()
|
||||
@ -667,18 +746,19 @@ public static function table(Table $table): Table
|
||||
->label('Due')
|
||||
->dateTime()
|
||||
->sortable()
|
||||
->placeholder('—'),
|
||||
->placeholder('—')
|
||||
->description(fn (Finding $record): ?string => FindingExceptionResource::relativeTimeDescription($record->due_at) ?? static::dueAttentionLabel($record)),
|
||||
Tables\Columns\TextColumn::make('assigneeUser.name')
|
||||
->label('Assignee')
|
||||
->placeholder('—'),
|
||||
->placeholder('—')
|
||||
->description(fn (Finding $record): string => $record->ownerUser?->name !== null ? 'Owner: '.$record->ownerUser->name : 'Owner: unassigned'),
|
||||
Tables\Columns\TextColumn::make('subject_external_id')->label('External ID')->toggleable(isToggledHiddenByDefault: true),
|
||||
Tables\Columns\TextColumn::make('scope_key')->label('Scope')->toggleable(isToggledHiddenByDefault: true),
|
||||
Tables\Columns\TextColumn::make('created_at')->since()->label('Created'),
|
||||
])
|
||||
->filters([
|
||||
Tables\Filters\Filter::make('open')
|
||||
->label('Open')
|
||||
->default()
|
||||
->label('Active workflow')
|
||||
->query(fn (Builder $query): Builder => $query->whereIn('status', Finding::openStatusesForQuery())),
|
||||
Tables\Filters\Filter::make('overdue')
|
||||
->label('Overdue')
|
||||
@ -706,6 +786,45 @@ public static function table(Table $table): Table
|
||||
Tables\Filters\SelectFilter::make('status')
|
||||
->options(FilterOptionCatalog::findingStatuses())
|
||||
->label('Status'),
|
||||
Tables\Filters\SelectFilter::make('workflow_family')
|
||||
->label('Workflow family')
|
||||
->options(FilterOptionCatalog::findingWorkflowFamilies())
|
||||
->query(function (Builder $query, array $data): Builder {
|
||||
$value = $data['value'] ?? null;
|
||||
|
||||
if (! is_string($value) || $value === '') {
|
||||
return $query;
|
||||
}
|
||||
|
||||
return match ($value) {
|
||||
'active' => $query->whereIn('status', Finding::openStatusesForQuery()),
|
||||
'accepted_risk' => $query->where('status', Finding::STATUS_RISK_ACCEPTED),
|
||||
'historical' => $query->whereIn('status', [Finding::STATUS_RESOLVED, Finding::STATUS_CLOSED]),
|
||||
default => $query,
|
||||
};
|
||||
}),
|
||||
Tables\Filters\SelectFilter::make('governance_validity')
|
||||
->label('Governance')
|
||||
->options(FilterOptionCatalog::findingExceptionValidityStates())
|
||||
->query(function (Builder $query, array $data): Builder {
|
||||
$value = $data['value'] ?? null;
|
||||
|
||||
if (! is_string($value) || $value === '') {
|
||||
return $query;
|
||||
}
|
||||
|
||||
if ($value === FindingException::VALIDITY_MISSING_SUPPORT) {
|
||||
return $query
|
||||
->where('status', Finding::STATUS_RISK_ACCEPTED)
|
||||
->whereDoesntHave('findingException');
|
||||
}
|
||||
|
||||
return $query
|
||||
->where('status', Finding::STATUS_RISK_ACCEPTED)
|
||||
->whereHas('findingException', function (Builder $exceptionQuery) use ($value): void {
|
||||
$exceptionQuery->where('current_validity_state', $value);
|
||||
});
|
||||
}),
|
||||
Tables\Filters\SelectFilter::make('finding_type')
|
||||
->options([
|
||||
Finding::FINDING_TYPE_DRIFT => 'Drift',
|
||||
@ -760,9 +879,7 @@ public static function table(Table $table): Table
|
||||
}),
|
||||
FilterPresets::dateRange('created_at', 'Created', 'created_at'),
|
||||
])
|
||||
->recordUrl(static fn (Finding $record): ?string => static::canView($record)
|
||||
? static::getUrl('view', ['record' => $record])
|
||||
: null)
|
||||
->recordUrl(static fn (Finding $record): string => static::getUrl('view', ['record' => $record]))
|
||||
->actions([
|
||||
static::primaryRelatedAction(),
|
||||
Actions\ActionGroup::make([
|
||||
@ -1078,7 +1195,7 @@ public static function table(Table $table): Table
|
||||
])->label('More'),
|
||||
])
|
||||
->emptyStateHeading('No findings match this view')
|
||||
->emptyStateDescription('Adjust the current filters or wait for the next detection run to surface new findings.')
|
||||
->emptyStateDescription('Adjust the current filters or wait for the next detection run to surface findings and governance follow-up.')
|
||||
->emptyStateIcon('heroicon-o-exclamation-triangle');
|
||||
}
|
||||
|
||||
@ -1129,10 +1246,13 @@ private static function primaryRelatedAction(): Actions\Action
|
||||
->color('gray');
|
||||
}
|
||||
|
||||
private static function primaryRelatedEntry(Finding $record): ?RelatedContextEntry
|
||||
private static function primaryRelatedEntry(Finding $record, bool $fresh = false): ?RelatedContextEntry
|
||||
{
|
||||
return app(RelatedNavigationResolver::class)
|
||||
->primaryListAction(CrossResourceNavigationMatrix::SOURCE_FINDING, $record);
|
||||
$resolver = app(RelatedNavigationResolver::class);
|
||||
|
||||
return $fresh
|
||||
? $resolver->primaryListActionFresh(CrossResourceNavigationMatrix::SOURCE_FINDING, $record)
|
||||
: $resolver->primaryListAction(CrossResourceNavigationMatrix::SOURCE_FINDING, $record);
|
||||
}
|
||||
|
||||
private static function findingRunNavigationContext(Finding $record): CanonicalNavigationContext
|
||||
@ -1186,7 +1306,7 @@ public static function triageAction(): Actions\Action
|
||||
->label('Triage')
|
||||
->icon('heroicon-o-check')
|
||||
->color('gray')
|
||||
->visible(fn (Finding $record): bool => in_array(static::freshWorkflowStatus($record), [
|
||||
->visible(fn (Finding $record): bool => in_array((string) $record->status, [
|
||||
Finding::STATUS_NEW,
|
||||
Finding::STATUS_REOPENED,
|
||||
Finding::STATUS_ACKNOWLEDGED,
|
||||
@ -1212,7 +1332,7 @@ public static function startProgressAction(): Actions\Action
|
||||
->label('Start progress')
|
||||
->icon('heroicon-o-play')
|
||||
->color('info')
|
||||
->visible(fn (Finding $record): bool => in_array(static::freshWorkflowStatus($record), [
|
||||
->visible(fn (Finding $record): bool => in_array((string) $record->status, [
|
||||
Finding::STATUS_TRIAGED,
|
||||
Finding::STATUS_ACKNOWLEDGED,
|
||||
], true))
|
||||
@ -1237,7 +1357,7 @@ public static function assignAction(): Actions\Action
|
||||
->label('Assign')
|
||||
->icon('heroicon-o-user-plus')
|
||||
->color('gray')
|
||||
->visible(fn (Finding $record): bool => static::freshWorkflowRecord($record)->hasOpenStatus())
|
||||
->visible(fn (Finding $record): bool => $record->hasOpenStatus())
|
||||
->fillForm(fn (Finding $record): array => [
|
||||
'assignee_user_id' => $record->assignee_user_id,
|
||||
'owner_user_id' => $record->owner_user_id,
|
||||
@ -1281,7 +1401,7 @@ public static function resolveAction(): Actions\Action
|
||||
->label('Resolve')
|
||||
->icon('heroicon-o-check-badge')
|
||||
->color('success')
|
||||
->visible(fn (Finding $record): bool => static::freshWorkflowRecord($record)->hasOpenStatus())
|
||||
->visible(fn (Finding $record): bool => $record->hasOpenStatus())
|
||||
->requiresConfirmation()
|
||||
->form([
|
||||
Textarea::make('resolved_reason')
|
||||
@ -1316,7 +1436,7 @@ public static function closeAction(): Actions\Action
|
||||
->label('Close')
|
||||
->icon('heroicon-o-x-circle')
|
||||
->color('danger')
|
||||
->visible(fn (Finding $record): bool => static::freshWorkflowRecord($record)->hasOpenStatus())
|
||||
->visible(fn (Finding $record): bool => $record->hasOpenStatus())
|
||||
->requiresConfirmation()
|
||||
->form([
|
||||
Textarea::make('closed_reason')
|
||||
@ -1351,7 +1471,7 @@ public static function requestExceptionAction(): Actions\Action
|
||||
->label('Request exception')
|
||||
->icon('heroicon-o-shield-exclamation')
|
||||
->color('warning')
|
||||
->visible(fn (Finding $record): bool => static::freshWorkflowRecord($record)->hasOpenStatus())
|
||||
->visible(fn (Finding $record): bool => $record->hasOpenStatus())
|
||||
->requiresConfirmation()
|
||||
->form([
|
||||
Select::make('owner_user_id')
|
||||
@ -1412,9 +1532,9 @@ public static function renewExceptionAction(): Actions\Action
|
||||
->label('Renew exception')
|
||||
->icon('heroicon-o-arrow-path')
|
||||
->color('warning')
|
||||
->visible(fn (Finding $record): bool => static::currentFindingException($record)?->canBeRenewed() ?? false)
|
||||
->visible(fn (Finding $record): bool => static::loadedFindingException($record)?->canBeRenewed() ?? false)
|
||||
->fillForm(fn (Finding $record): array => [
|
||||
'owner_user_id' => static::currentFindingException($record)?->owner_user_id,
|
||||
'owner_user_id' => static::loadedFindingException($record)?->owner_user_id,
|
||||
])
|
||||
->requiresConfirmation()
|
||||
->form([
|
||||
@ -1476,7 +1596,7 @@ public static function revokeExceptionAction(): Actions\Action
|
||||
->label('Revoke exception')
|
||||
->icon('heroicon-o-no-symbol')
|
||||
->color('danger')
|
||||
->visible(fn (Finding $record): bool => static::currentFindingException($record)?->canBeRevoked() ?? false)
|
||||
->visible(fn (Finding $record): bool => static::loadedFindingException($record)?->canBeRevoked() ?? false)
|
||||
->requiresConfirmation()
|
||||
->form([
|
||||
Textarea::make('revocation_reason')
|
||||
@ -1503,7 +1623,7 @@ public static function reopenAction(): Actions\Action
|
||||
->icon('heroicon-o-arrow-uturn-left')
|
||||
->color('warning')
|
||||
->requiresConfirmation()
|
||||
->visible(fn (Finding $record): bool => Finding::isTerminalStatus(static::freshWorkflowStatus($record)))
|
||||
->visible(fn (Finding $record): bool => Finding::isTerminalStatus((string) $record->status))
|
||||
->action(function (Finding $record, FindingWorkflowService $workflow): void {
|
||||
static::runWorkflowMutation(
|
||||
record: $record,
|
||||
@ -1701,6 +1821,21 @@ private static function currentFindingException(Finding $record): ?FindingExcept
|
||||
return static::resolvedFindingException($finding);
|
||||
}
|
||||
|
||||
private static function loadedFindingException(Finding $finding): ?FindingException
|
||||
{
|
||||
$exception = $finding->relationLoaded('findingException')
|
||||
? $finding->findingException
|
||||
: null;
|
||||
|
||||
if (! $exception instanceof FindingException) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$exception->loadMissing('currentDecision');
|
||||
|
||||
return $exception;
|
||||
}
|
||||
|
||||
private static function resolvedFindingException(Finding $finding): ?FindingException
|
||||
{
|
||||
$exception = $finding->relationLoaded('findingException')
|
||||
@ -1748,6 +1883,10 @@ private static function governanceWarningColor(Finding $finding): string
|
||||
{
|
||||
$exception = static::resolvedFindingException($finding);
|
||||
|
||||
if (static::governanceValidityState($finding) === FindingException::VALIDITY_EXPIRING) {
|
||||
return 'warning';
|
||||
}
|
||||
|
||||
if ($exception instanceof FindingException && $exception->requiresFreshDecisionForFinding($finding)) {
|
||||
return 'warning';
|
||||
}
|
||||
@ -1755,6 +1894,56 @@ private static function governanceWarningColor(Finding $finding): string
|
||||
return 'danger';
|
||||
}
|
||||
|
||||
private static function governanceValidityState(Finding $finding): ?string
|
||||
{
|
||||
return app(FindingRiskGovernanceResolver::class)
|
||||
->resolveGovernanceValidity($finding, static::resolvedFindingException($finding));
|
||||
}
|
||||
|
||||
private static function primaryNarrative(Finding $finding): string
|
||||
{
|
||||
return app(FindingRiskGovernanceResolver::class)
|
||||
->resolvePrimaryNarrative($finding, static::resolvedFindingException($finding));
|
||||
}
|
||||
|
||||
private static function historicalContext(Finding $finding): ?string
|
||||
{
|
||||
return app(FindingRiskGovernanceResolver::class)
|
||||
->resolveHistoricalContext($finding);
|
||||
}
|
||||
|
||||
private static function primaryNextAction(Finding $finding): ?string
|
||||
{
|
||||
return app(FindingRiskGovernanceResolver::class)
|
||||
->resolvePrimaryNextAction($finding, static::resolvedFindingException($finding));
|
||||
}
|
||||
|
||||
private static function dueAttentionLabel(Finding $finding): ?string
|
||||
{
|
||||
if (! $finding->hasOpenStatus() || ! $finding->due_at) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if ($finding->due_at->isPast()) {
|
||||
return 'Overdue';
|
||||
}
|
||||
|
||||
if ($finding->due_at->lessThanOrEqualTo(now()->addDays(3))) {
|
||||
return 'Due soon';
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static function dueAttentionColor(Finding $finding): string
|
||||
{
|
||||
return match (static::dueAttentionLabel($finding)) {
|
||||
'Overdue' => 'danger',
|
||||
'Due soon' => 'warning',
|
||||
default => 'gray',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, string>
|
||||
*/
|
||||
@ -1774,4 +1963,35 @@ private static function tenantMemberOptions(): array
|
||||
->mapWithKeys(fn (string $name, int|string $id): array => [(int) $id => $name])
|
||||
->all();
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{open: int, overdue: int, high_severity: int, risk_accepted: int, total: int}
|
||||
*/
|
||||
public static function findingStatsForCurrentTenant(): array
|
||||
{
|
||||
$tenant = static::resolveTenantContextForCurrentPanel();
|
||||
|
||||
if (! $tenant instanceof Tenant) {
|
||||
return ['open' => 0, 'overdue' => 0, 'high_severity' => 0, 'risk_accepted' => 0, 'total' => 0];
|
||||
}
|
||||
|
||||
$now = now()->toDateTimeString();
|
||||
|
||||
$counts = Finding::query()
|
||||
->where('tenant_id', (int) $tenant->getKey())
|
||||
->selectRaw('count(*) as total')
|
||||
->selectRaw("sum(case when status in ('new', 'triaged', 'in_progress', 'reopened') then 1 else 0 end) as open")
|
||||
->selectRaw("sum(case when status in ('new', 'triaged', 'in_progress', 'reopened') and due_at is not null and due_at < ? then 1 else 0 end) as overdue", [$now])
|
||||
->selectRaw("sum(case when severity in ('high', 'critical') and status in ('new', 'triaged', 'in_progress', 'reopened') then 1 else 0 end) as high_severity")
|
||||
->selectRaw("sum(case when status = 'risk_accepted' then 1 else 0 end) as risk_accepted")
|
||||
->first();
|
||||
|
||||
return [
|
||||
'open' => (int) ($counts?->open ?? 0),
|
||||
'overdue' => (int) ($counts?->overdue ?? 0),
|
||||
'high_severity' => (int) ($counts?->high_severity ?? 0),
|
||||
'risk_accepted' => (int) ($counts?->risk_accepted ?? 0),
|
||||
'total' => (int) ($counts?->total ?? 0),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@ -5,6 +5,7 @@
|
||||
use App\Filament\Concerns\ResolvesPanelTenantContext;
|
||||
use App\Filament\Resources\FindingResource;
|
||||
use App\Filament\Widgets\Tenant\BaselineCompareCoverageBanner;
|
||||
use App\Filament\Widgets\Tenant\FindingStatsOverview;
|
||||
use App\Jobs\BackfillFindingLifecycleJob;
|
||||
use App\Models\Finding;
|
||||
use App\Models\Tenant;
|
||||
@ -22,6 +23,7 @@
|
||||
use Filament\Forms\Components\TextInput;
|
||||
use Filament\Notifications\Notification;
|
||||
use Filament\Resources\Pages\ListRecords;
|
||||
use Filament\Schemas\Components\Tabs\Tab;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Database\Eloquent\ModelNotFoundException;
|
||||
use Illuminate\Support\Arr;
|
||||
@ -61,6 +63,42 @@ protected function getHeaderWidgets(): array
|
||||
{
|
||||
return [
|
||||
BaselineCompareCoverageBanner::class,
|
||||
FindingStatsOverview::class,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, Tab>
|
||||
*/
|
||||
public function getTabs(): array
|
||||
{
|
||||
$stats = FindingResource::findingStatsForCurrentTenant();
|
||||
|
||||
return [
|
||||
'all' => Tab::make('All')
|
||||
->icon('heroicon-m-list-bullet'),
|
||||
'needs_action' => Tab::make('Needs action')
|
||||
->icon('heroicon-m-exclamation-triangle')
|
||||
->modifyQueryUsing(fn (Builder $query): Builder => $query
|
||||
->whereIn('status', Finding::openStatusesForQuery()))
|
||||
->badge($stats['open'] > 0 ? $stats['open'] : null)
|
||||
->badgeColor('warning'),
|
||||
'overdue' => Tab::make('Overdue')
|
||||
->icon('heroicon-m-clock')
|
||||
->modifyQueryUsing(fn (Builder $query): Builder => $query
|
||||
->whereIn('status', Finding::openStatusesForQuery())
|
||||
->whereNotNull('due_at')
|
||||
->where('due_at', '<', now()))
|
||||
->badge($stats['overdue'] > 0 ? $stats['overdue'] : null)
|
||||
->badgeColor('danger'),
|
||||
'risk_accepted' => Tab::make('Risk accepted')
|
||||
->icon('heroicon-m-shield-check')
|
||||
->modifyQueryUsing(fn (Builder $query): Builder => $query
|
||||
->where('status', Finding::STATUS_RISK_ACCEPTED)),
|
||||
'resolved' => Tab::make('Resolved')
|
||||
->icon('heroicon-m-archive-box')
|
||||
->modifyQueryUsing(fn (Builder $query): Builder => $query
|
||||
->whereIn('status', [Finding::STATUS_RESOLVED, Finding::STATUS_CLOSED])),
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
@ -2,7 +2,9 @@
|
||||
|
||||
namespace App\Filament\Resources\FindingResource\Pages;
|
||||
|
||||
use App\Filament\Resources\FindingExceptionResource;
|
||||
use App\Filament\Resources\FindingResource;
|
||||
use App\Models\Finding;
|
||||
use App\Support\Navigation\CrossResourceNavigationMatrix;
|
||||
use App\Support\Navigation\RelatedNavigationResolver;
|
||||
use Filament\Actions;
|
||||
@ -30,6 +32,23 @@ protected function getHeaderActions(): array
|
||||
->hidden(fn (): bool => ! (app(RelatedNavigationResolver::class)
|
||||
->primaryListAction(CrossResourceNavigationMatrix::SOURCE_FINDING, $this->getRecord())?->isAvailable() ?? false))
|
||||
->color('gray'),
|
||||
Actions\Action::make('open_approval_queue')
|
||||
->label('Open approval queue')
|
||||
->icon('heroicon-o-arrow-top-right-on-square')
|
||||
->color('gray')
|
||||
->visible(function (): bool {
|
||||
$record = $this->getRecord();
|
||||
|
||||
return $record instanceof Finding
|
||||
&& FindingExceptionResource::canAccessApprovalQueueForTenant($record->tenant);
|
||||
})
|
||||
->url(function (): ?string {
|
||||
$record = $this->getRecord();
|
||||
|
||||
return $record instanceof Finding
|
||||
? FindingExceptionResource::approvalQueueUrl($record->tenant)
|
||||
: null;
|
||||
}),
|
||||
Actions\ActionGroup::make(FindingResource::workflowActions())
|
||||
->label('Actions')
|
||||
->icon('heroicon-o-ellipsis-vertical')
|
||||
|
||||
@ -11,6 +11,7 @@
|
||||
use App\Support\Badges\BadgeCatalog;
|
||||
use App\Support\Badges\BadgeDomain;
|
||||
use App\Support\Badges\BadgeRenderer;
|
||||
use App\Support\Baselines\BaselineCompareEvidenceGapDetails;
|
||||
use App\Support\Baselines\BaselineCompareReasonCode;
|
||||
use App\Support\Filament\FilterOptionCatalog;
|
||||
use App\Support\Filament\FilterPresets;
|
||||
@ -32,7 +33,9 @@
|
||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceInspectAffordance;
|
||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceProfile;
|
||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceSlot;
|
||||
use App\Support\Ui\GovernanceArtifactTruth\ArtifactTruthEnvelope;
|
||||
use App\Support\Ui\GovernanceArtifactTruth\ArtifactTruthPresenter;
|
||||
use App\Support\Ui\OperatorExplanation\OperatorExplanationPattern;
|
||||
use App\Support\Workspaces\WorkspaceContext;
|
||||
use BackedEnum;
|
||||
use Filament\Actions;
|
||||
@ -132,7 +135,7 @@ public static function table(Table $table): Table
|
||||
->color(fn (mixed $state, OperationRun $record): string => BadgeRenderer::spec(BadgeDomain::OperationRunStatus, static::statusBadgeState($record))->color)
|
||||
->icon(fn (mixed $state, OperationRun $record): ?string => BadgeRenderer::spec(BadgeDomain::OperationRunStatus, static::statusBadgeState($record))->icon)
|
||||
->iconColor(fn (mixed $state, OperationRun $record): ?string => BadgeRenderer::spec(BadgeDomain::OperationRunStatus, static::statusBadgeState($record))->iconColor)
|
||||
->description(fn (OperationRun $record): ?string => OperationUxPresenter::lifecycleAttentionSummary($record)),
|
||||
->description(fn (OperationRun $record): ?string => static::lifecycleAttentionSummary($record)),
|
||||
Tables\Columns\TextColumn::make('type')
|
||||
->label('Operation')
|
||||
->formatStateUsing(fn (?string $state): string => OperationCatalog::label((string) $state))
|
||||
@ -159,7 +162,7 @@ public static function table(Table $table): Table
|
||||
->color(fn (mixed $state, OperationRun $record): string => BadgeRenderer::spec(BadgeDomain::OperationRunOutcome, static::outcomeBadgeState($record))->color)
|
||||
->icon(fn (mixed $state, OperationRun $record): ?string => BadgeRenderer::spec(BadgeDomain::OperationRunOutcome, static::outcomeBadgeState($record))->icon)
|
||||
->iconColor(fn (mixed $state, OperationRun $record): ?string => BadgeRenderer::spec(BadgeDomain::OperationRunOutcome, static::outcomeBadgeState($record))->iconColor)
|
||||
->description(fn (OperationRun $record): ?string => OperationUxPresenter::surfaceGuidance($record)),
|
||||
->description(fn (OperationRun $record): ?string => static::surfaceGuidance($record)),
|
||||
])
|
||||
->filters([
|
||||
Tables\Filters\SelectFilter::make('tenant_id')
|
||||
@ -256,22 +259,21 @@ private static function enterpriseDetailPage(OperationRun $record): \App\Support
|
||||
|
||||
$statusSpec = BadgeRenderer::spec(BadgeDomain::OperationRunStatus, static::statusBadgeState($record));
|
||||
$outcomeSpec = BadgeRenderer::spec(BadgeDomain::OperationRunOutcome, static::outcomeBadgeState($record));
|
||||
$targetScope = static::targetScopeDisplay($record);
|
||||
$targetScope = static::targetScopeDisplay($record) ?? 'No target scope details were recorded for this run.';
|
||||
$summaryLine = \App\Support\OpsUx\SummaryCountsNormalizer::renderSummaryLine(is_array($record->summary_counts) ? $record->summary_counts : []);
|
||||
$referencedTenantLifecycle = $record->tenant instanceof Tenant
|
||||
? ReferencedTenantLifecyclePresentation::forOperationRun($record->tenant)
|
||||
: null;
|
||||
$artifactTruth = $record->isGovernanceArtifactOperation()
|
||||
? app(ArtifactTruthPresenter::class)->forOperationRun($record)
|
||||
: null;
|
||||
$artifactTruthBadge = $artifactTruth !== null
|
||||
? $factory->statusBadge(
|
||||
$artifactTruth->primaryBadgeSpec()->label,
|
||||
$artifactTruth->primaryBadgeSpec()->color,
|
||||
$artifactTruth->primaryBadgeSpec()->icon,
|
||||
$artifactTruth->primaryBadgeSpec()->iconColor,
|
||||
)
|
||||
: null;
|
||||
$artifactTruth = static::artifactTruthEnvelope($record);
|
||||
$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')
|
||||
->header(new \App\Support\Ui\EnterpriseDetail\SummaryHeaderData(
|
||||
@ -282,162 +284,120 @@ private static function enterpriseDetailPage(OperationRun $record): \App\Support
|
||||
$factory->statusBadge($outcomeSpec->label, $outcomeSpec->color, $outcomeSpec->icon, $outcomeSpec->iconColor),
|
||||
],
|
||||
keyFacts: [
|
||||
$factory->keyFact('Target', $targetScope ?? 'No target scope details were recorded for this run.'),
|
||||
$factory->keyFact('Initiator', $record->initiator_name),
|
||||
$factory->keyFact('Target', $targetScope),
|
||||
$factory->keyFact('Elapsed', RunDurationInsights::elapsedHuman($record)),
|
||||
$factory->keyFact('Expected', RunDurationInsights::expectedHuman($record)),
|
||||
],
|
||||
descriptionHint: 'Run identity, outcome, scope, and related next steps stay ahead of stored payloads and diagnostic fragments.',
|
||||
descriptionHint: 'Decision guidance and high-signal context stay ahead of diagnostic payloads and raw JSON.',
|
||||
))
|
||||
->addSection(
|
||||
$factory->factsSection(
|
||||
id: 'run_summary',
|
||||
kind: 'core_details',
|
||||
title: 'Run summary',
|
||||
items: [
|
||||
$factory->keyFact('Operation', OperationCatalog::label((string) $record->type)),
|
||||
$factory->keyFact('Initiator', $record->initiator_name),
|
||||
$factory->keyFact('Target scope', $targetScope ?? 'No target scope details were recorded for this run.'),
|
||||
$factory->keyFact('Expected duration', RunDurationInsights::expectedHuman($record)),
|
||||
],
|
||||
->decisionZone($factory->decisionZone(
|
||||
facts: array_values(array_filter([
|
||||
$factory->keyFact(
|
||||
'Execution state',
|
||||
$statusSpec->label,
|
||||
badge: $factory->statusBadge($statusSpec->label, $statusSpec->color, $statusSpec->icon, $statusSpec->iconColor),
|
||||
),
|
||||
$factory->keyFact(
|
||||
'Outcome',
|
||||
$outcomeSpec->label,
|
||||
badge: $factory->statusBadge($outcomeSpec->label, $outcomeSpec->color, $outcomeSpec->icon, $outcomeSpec->iconColor),
|
||||
),
|
||||
static::artifactTruthFact($factory, $artifactTruth),
|
||||
$operatorExplanation instanceof OperatorExplanationPattern
|
||||
? $factory->keyFact(
|
||||
'Result meaning',
|
||||
$operatorExplanation->evaluationResultLabel(),
|
||||
$operatorExplanation->headline,
|
||||
)
|
||||
: null,
|
||||
$operatorExplanation instanceof OperatorExplanationPattern
|
||||
? $factory->keyFact(
|
||||
'Result trust',
|
||||
$operatorExplanation->trustworthinessLabel(),
|
||||
static::detailHintUnlessDuplicate(
|
||||
$operatorExplanation->reliabilityStatement,
|
||||
$artifactTruth?->primaryExplanation,
|
||||
),
|
||||
)
|
||||
: null,
|
||||
])),
|
||||
primaryNextStep: $factory->primaryNextStep(
|
||||
$primaryNextStep['text'],
|
||||
$primaryNextStep['source'],
|
||||
$primaryNextStep['secondaryGuidance'],
|
||||
),
|
||||
$factory->viewSection(
|
||||
id: 'artifact_truth',
|
||||
kind: 'current_status',
|
||||
title: 'Artifact truth',
|
||||
view: 'filament.infolists.entries.governance-artifact-truth',
|
||||
viewData: ['artifactTruthState' => $artifactTruth?->toArray()],
|
||||
visible: $record->isGovernanceArtifactOperation(),
|
||||
description: 'Run lifecycle stays separate from whether the intended governance artifact was actually produced and usable.',
|
||||
),
|
||||
$factory->viewSection(
|
||||
id: 'related_context',
|
||||
kind: 'related_context',
|
||||
title: 'Related context',
|
||||
view: 'filament.infolists.entries.related-context',
|
||||
viewData: ['entries' => app(RelatedNavigationResolver::class)
|
||||
->detailEntries(CrossResourceNavigationMatrix::SOURCE_OPERATION_RUN, $record)],
|
||||
emptyState: $factory->emptyState('No related context is available for this record.'),
|
||||
),
|
||||
)
|
||||
->addSupportingCard(
|
||||
$factory->supportingFactsCard(
|
||||
kind: 'status',
|
||||
title: 'Current state',
|
||||
items: array_values(array_filter([
|
||||
$factory->keyFact('Status', $statusSpec->label, badge: $factory->statusBadge($statusSpec->label, $statusSpec->color, $statusSpec->icon, $statusSpec->iconColor)),
|
||||
$factory->keyFact('Outcome', $outcomeSpec->label, badge: $factory->statusBadge($outcomeSpec->label, $outcomeSpec->color, $outcomeSpec->icon, $outcomeSpec->iconColor)),
|
||||
$artifactTruth !== null
|
||||
? $factory->keyFact('Artifact truth', $artifactTruth->primaryLabel, badge: $artifactTruthBadge)
|
||||
: null,
|
||||
$referencedTenantLifecycle !== null
|
||||
? $factory->keyFact(
|
||||
'Tenant lifecycle',
|
||||
$referencedTenantLifecycle->presentation->label,
|
||||
badge: $factory->statusBadge(
|
||||
$referencedTenantLifecycle->presentation->label,
|
||||
$referencedTenantLifecycle->presentation->badgeColor,
|
||||
$referencedTenantLifecycle->presentation->badgeIcon,
|
||||
$referencedTenantLifecycle->presentation->badgeIconColor,
|
||||
),
|
||||
)
|
||||
: null,
|
||||
$referencedTenantLifecycle?->selectorAvailabilityMessage() !== null
|
||||
? $factory->keyFact('Tenant selector context', $referencedTenantLifecycle->selectorAvailabilityMessage())
|
||||
: null,
|
||||
$referencedTenantLifecycle?->contextNote !== null
|
||||
? $factory->keyFact('Viewer context', $referencedTenantLifecycle->contextNote)
|
||||
: null,
|
||||
static::freshnessLabel($record) !== null
|
||||
? $factory->keyFact('Freshness', (string) static::freshnessLabel($record))
|
||||
: null,
|
||||
static::reconciliationHeadline($record) !== null
|
||||
? $factory->keyFact('Lifecycle truth', (string) static::reconciliationHeadline($record))
|
||||
: null,
|
||||
static::reconciledAtLabel($record) !== null
|
||||
? $factory->keyFact('Reconciled at', (string) static::reconciledAtLabel($record))
|
||||
: null,
|
||||
static::reconciliationSourceLabel($record) !== null
|
||||
? $factory->keyFact('Reconciled by', (string) static::reconciliationSourceLabel($record))
|
||||
: null,
|
||||
$artifactTruth !== null
|
||||
? $factory->keyFact('Artifact next step', $artifactTruth->nextStepText())
|
||||
: null,
|
||||
OperationUxPresenter::surfaceGuidance($record) !== null
|
||||
? $factory->keyFact('Next step', OperationUxPresenter::surfaceGuidance($record))
|
||||
: null,
|
||||
$summaryLine !== null ? $factory->keyFact('Counts', $summaryLine) : null,
|
||||
static::blockedExecutionReasonCode($record) !== null
|
||||
? $factory->keyFact('Blocked reason', static::blockedExecutionReasonCode($record))
|
||||
: null,
|
||||
static::blockedExecutionDetail($record) !== null
|
||||
? $factory->keyFact('Blocked detail', static::blockedExecutionDetail($record))
|
||||
: null,
|
||||
static::blockedExecutionSource($record) !== null
|
||||
? $factory->keyFact('Blocked by', static::blockedExecutionSource($record))
|
||||
: null,
|
||||
RunDurationInsights::stuckGuidance($record) !== null ? $factory->keyFact('Guidance', RunDurationInsights::stuckGuidance($record)) : null,
|
||||
])),
|
||||
),
|
||||
$factory->supportingFactsCard(
|
||||
kind: 'timestamps',
|
||||
title: 'Timing',
|
||||
items: [
|
||||
$factory->keyFact('Created', static::formatDetailTimestamp($record->created_at)),
|
||||
$factory->keyFact('Started', static::formatDetailTimestamp($record->started_at)),
|
||||
$factory->keyFact('Completed', static::formatDetailTimestamp($record->completed_at)),
|
||||
$factory->keyFact('Elapsed', RunDurationInsights::elapsedHuman($record)),
|
||||
],
|
||||
),
|
||||
)
|
||||
->addTechnicalSection(
|
||||
$factory->technicalDetail(
|
||||
title: 'Context',
|
||||
entries: [
|
||||
$factory->keyFact('Identity hash', $record->run_identity_hash),
|
||||
$factory->keyFact('Workspace scope', $record->workspace_id),
|
||||
$factory->keyFact('Tenant scope', $record->tenant_id),
|
||||
],
|
||||
description: 'Stored run context stays available for debugging without dominating the default reading path.',
|
||||
view: 'filament.infolists.entries.snapshot-json',
|
||||
viewData: ['payload' => static::contextPayload($record)],
|
||||
),
|
||||
);
|
||||
description: 'Start here to see how the run ended, whether the result is trustworthy enough to use, and the one primary next step.',
|
||||
compactCounts: $summaryLine !== null
|
||||
? $factory->countPresentation(summaryLine: $summaryLine)
|
||||
: null,
|
||||
attentionNote: static::decisionAttentionNote($record),
|
||||
));
|
||||
|
||||
if ($supportingGroups !== []) {
|
||||
$builder->addSupportingGroup(...$supportingGroups);
|
||||
}
|
||||
|
||||
$builder->addSection(
|
||||
$factory->viewSection(
|
||||
id: 'related_context',
|
||||
kind: 'related_context',
|
||||
title: 'Related context',
|
||||
view: 'filament.infolists.entries.related-context',
|
||||
viewData: ['entries' => static::relatedContextEntries($record)],
|
||||
emptyState: $factory->emptyState('No related context is available for this record.'),
|
||||
),
|
||||
$factory->viewSection(
|
||||
id: 'artifact_truth',
|
||||
kind: 'supporting_detail',
|
||||
title: 'Artifact truth details',
|
||||
view: 'filament.infolists.entries.governance-artifact-truth',
|
||||
viewData: [
|
||||
'artifactTruthState' => $artifactTruth?->toArray(),
|
||||
'surface' => 'expanded',
|
||||
],
|
||||
visible: $artifactTruth !== null,
|
||||
description: 'Detailed artifact-truth context explains evidence quality and caveats without repeating the top decision summary.',
|
||||
collapsible: true,
|
||||
collapsed: true,
|
||||
),
|
||||
);
|
||||
|
||||
$counts = static::summaryCountFacts($record, $factory);
|
||||
|
||||
if ($counts !== []) {
|
||||
$builder->addSection(
|
||||
$factory->factsSection(
|
||||
id: 'counts',
|
||||
kind: 'current_status',
|
||||
title: 'Counts',
|
||||
items: $counts,
|
||||
$builder->addTechnicalSection(
|
||||
$factory->technicalDetail(
|
||||
title: 'Count diagnostics',
|
||||
entries: $counts,
|
||||
description: 'Normalized run counters remain available for deeper inspection without competing with the primary decision.',
|
||||
collapsible: true,
|
||||
collapsed: true,
|
||||
variant: 'diagnostic',
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
if (! empty($record->failure_summary)) {
|
||||
$builder->addSection(
|
||||
$factory->viewSection(
|
||||
id: 'failures',
|
||||
kind: 'operational_context',
|
||||
$builder->addTechnicalSection(
|
||||
$factory->technicalDetail(
|
||||
title: (string) $record->outcome === OperationRunOutcome::Blocked->value ? 'Blocked execution details' : 'Failures',
|
||||
description: 'Detailed failure evidence stays available for investigation after the decision and supporting context.',
|
||||
view: 'filament.infolists.entries.snapshot-json',
|
||||
viewData: ['payload' => $record->failure_summary ?? []],
|
||||
collapsible: true,
|
||||
collapsed: false,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
if (static::reconciliationPayload($record) !== []) {
|
||||
$builder->addSection(
|
||||
$factory->viewSection(
|
||||
id: 'reconciliation',
|
||||
kind: 'operational_context',
|
||||
$builder->addTechnicalSection(
|
||||
$factory->technicalDetail(
|
||||
title: 'Lifecycle reconciliation',
|
||||
description: 'Lifecycle reconciliation is diagnostic evidence showing when TenantPilot force-resolved the run.',
|
||||
view: 'filament.infolists.entries.snapshot-json',
|
||||
viewData: ['payload' => static::reconciliationPayload($record)],
|
||||
description: 'Lifecycle reconciliation is diagnostic evidence showing when TenantPilot force-resolved the run.',
|
||||
collapsible: true,
|
||||
collapsed: true,
|
||||
),
|
||||
);
|
||||
}
|
||||
@ -445,14 +405,39 @@ private static function enterpriseDetailPage(OperationRun $record): \App\Support
|
||||
if ((string) $record->type === 'baseline_compare') {
|
||||
$baselineCompareFacts = static::baselineCompareFacts($record, $factory);
|
||||
$baselineCompareEvidence = static::baselineCompareEvidencePayload($record);
|
||||
$gapDetails = BaselineCompareEvidenceGapDetails::fromOperationRun($record);
|
||||
$gapSummary = is_array($gapDetails['summary'] ?? null) ? $gapDetails['summary'] : [];
|
||||
$gapBuckets = is_array($gapDetails['buckets'] ?? null) ? $gapDetails['buckets'] : [];
|
||||
|
||||
if ($baselineCompareFacts !== []) {
|
||||
$builder->addSection(
|
||||
$factory->factsSection(
|
||||
id: 'baseline_compare',
|
||||
kind: 'operational_context',
|
||||
kind: 'type_specific_detail',
|
||||
title: 'Baseline compare',
|
||||
items: $baselineCompareFacts,
|
||||
description: 'Type-specific comparison detail stays below the canonical decision and supporting layers.',
|
||||
collapsible: true,
|
||||
collapsed: true,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
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,
|
||||
),
|
||||
);
|
||||
}
|
||||
@ -461,10 +446,12 @@ private static function enterpriseDetailPage(OperationRun $record): \App\Support
|
||||
$builder->addSection(
|
||||
$factory->viewSection(
|
||||
id: 'baseline_compare_evidence',
|
||||
kind: 'operational_context',
|
||||
kind: 'type_specific_detail',
|
||||
title: 'Baseline compare evidence',
|
||||
view: 'filament.infolists.entries.snapshot-json',
|
||||
viewData: ['payload' => $baselineCompareEvidence],
|
||||
collapsible: true,
|
||||
collapsed: true,
|
||||
),
|
||||
);
|
||||
}
|
||||
@ -477,10 +464,12 @@ private static function enterpriseDetailPage(OperationRun $record): \App\Support
|
||||
$builder->addSection(
|
||||
$factory->viewSection(
|
||||
id: 'baseline_capture_evidence',
|
||||
kind: 'operational_context',
|
||||
kind: 'type_specific_detail',
|
||||
title: 'Baseline capture evidence',
|
||||
view: 'filament.infolists.entries.snapshot-json',
|
||||
viewData: ['payload' => $baselineCaptureEvidence],
|
||||
collapsible: true,
|
||||
collapsed: true,
|
||||
),
|
||||
);
|
||||
}
|
||||
@ -490,7 +479,7 @@ private static function enterpriseDetailPage(OperationRun $record): \App\Support
|
||||
$builder->addSection(
|
||||
$factory->viewSection(
|
||||
id: 'verification_report',
|
||||
kind: 'operational_context',
|
||||
kind: 'type_specific_detail',
|
||||
title: 'Verification report',
|
||||
view: 'filament.components.verification-report-viewer',
|
||||
viewData: static::verificationReportViewData($record),
|
||||
@ -498,9 +487,321 @@ private static function enterpriseDetailPage(OperationRun $record): \App\Support
|
||||
);
|
||||
}
|
||||
|
||||
$builder->addTechnicalSection(
|
||||
$factory->technicalDetail(
|
||||
title: 'Context',
|
||||
entries: [
|
||||
$factory->keyFact('Identity hash', $record->run_identity_hash, mono: true),
|
||||
$factory->keyFact('Workspace scope', $record->workspace_id),
|
||||
$factory->keyFact('Tenant scope', $record->tenant_id),
|
||||
],
|
||||
description: 'Stored run context stays available for debugging without dominating the default reading path.',
|
||||
view: 'filament.infolists.entries.snapshot-json',
|
||||
viewData: ['payload' => static::contextPayload($record)],
|
||||
),
|
||||
);
|
||||
|
||||
return $builder->build();
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<\App\Support\Ui\EnterpriseDetail\SupportingCardData>
|
||||
*/
|
||||
private static function supportingGroups(
|
||||
OperationRun $record,
|
||||
\App\Support\Ui\EnterpriseDetail\EnterpriseDetailSectionFactory $factory,
|
||||
?ReferencedTenantLifecyclePresentation $referencedTenantLifecycle,
|
||||
?OperatorExplanationPattern $operatorExplanation,
|
||||
array $primaryNextStep,
|
||||
): array {
|
||||
$groups = [];
|
||||
$hasElevatedLifecycleState = static::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',
|
||||
static::lifecycleAttentionSummary($record) !== null => 'lifecycle_attention',
|
||||
default => 'ops_ux',
|
||||
};
|
||||
|
||||
static::pushNextStepCandidate($candidates, static::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>>
|
||||
*/
|
||||
@ -511,12 +812,29 @@ private static function summaryCountFacts(
|
||||
$counts = \App\Support\OpsUx\SummaryCountsNormalizer::normalize(is_array($record->summary_counts) ? $record->summary_counts : []);
|
||||
|
||||
return array_map(
|
||||
static fn (string $key, int $value): array => $factory->keyFact(SummaryCountsNormalizer::label($key), $value),
|
||||
static fn (string $key, int $value): array => $factory->keyFact(
|
||||
SummaryCountsNormalizer::label($key),
|
||||
$value,
|
||||
tone: self::countTone($key, $value),
|
||||
),
|
||||
array_keys($counts),
|
||||
array_values($counts),
|
||||
);
|
||||
}
|
||||
|
||||
private static function countTone(string $key, int $value): ?string
|
||||
{
|
||||
if (in_array($key, ['failed', 'errors_recorded', 'findings_reopened'], true)) {
|
||||
return $value > 0 ? 'danger' : 'success';
|
||||
}
|
||||
|
||||
if ($key === 'succeeded' && $value > 0) {
|
||||
return 'success';
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static function blockedExecutionReasonCode(OperationRun $record): ?string
|
||||
{
|
||||
if ((string) $record->outcome !== OperationRunOutcome::Blocked->value) {
|
||||
@ -581,6 +899,8 @@ private static function baselineCompareFacts(
|
||||
\App\Support\Ui\EnterpriseDetail\EnterpriseDetailSectionFactory $factory,
|
||||
): array {
|
||||
$context = is_array($record->context) ? $record->context : [];
|
||||
$gapDetails = BaselineCompareEvidenceGapDetails::fromOperationRun($record);
|
||||
$gapSummary = is_array($gapDetails['summary'] ?? null) ? $gapDetails['summary'] : [];
|
||||
$facts = [];
|
||||
|
||||
$fidelity = data_get($context, 'baseline_compare.fidelity');
|
||||
@ -612,6 +932,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 !== []) {
|
||||
sort($uncoveredTypes, SORT_STRING);
|
||||
$facts[] = $factory->keyFact('Uncovered types', implode(', ', array_slice($uncoveredTypes, 0, 12)).(count($uncoveredTypes) > 12 ? '…' : ''));
|
||||
@ -842,6 +1186,57 @@ public static function getPages(): array
|
||||
return [];
|
||||
}
|
||||
|
||||
private static function artifactTruthEnvelope(OperationRun $record, bool $fresh = false): ?ArtifactTruthEnvelope
|
||||
{
|
||||
if (! $record->supportsOperatorExplanation()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$presenter = app(ArtifactTruthPresenter::class);
|
||||
|
||||
return $fresh
|
||||
? $presenter->forOperationRunFresh($record)
|
||||
: $presenter->forOperationRun($record);
|
||||
}
|
||||
|
||||
private static function lifecycleAttentionSummary(OperationRun $record, bool $fresh = false): ?string
|
||||
{
|
||||
return $fresh
|
||||
? OperationUxPresenter::lifecycleAttentionSummaryFresh($record)
|
||||
: OperationUxPresenter::lifecycleAttentionSummary($record);
|
||||
}
|
||||
|
||||
private static function surfaceGuidance(OperationRun $record, bool $fresh = false): ?string
|
||||
{
|
||||
return $fresh
|
||||
? OperationUxPresenter::surfaceGuidanceFresh($record)
|
||||
: OperationUxPresenter::surfaceGuidance($record);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<array{
|
||||
* key: string,
|
||||
* label: string,
|
||||
* value: string,
|
||||
* secondaryValue: ?string,
|
||||
* targetUrl: ?string,
|
||||
* targetKind: string,
|
||||
* availability: string,
|
||||
* unavailableReason: ?string,
|
||||
* contextBadge: ?string,
|
||||
* priority: int,
|
||||
* actionLabel: string
|
||||
* }>
|
||||
*/
|
||||
private static function relatedContextEntries(OperationRun $record, bool $fresh = false): array
|
||||
{
|
||||
$resolver = app(RelatedNavigationResolver::class);
|
||||
|
||||
return $fresh
|
||||
? $resolver->detailEntriesFresh(CrossResourceNavigationMatrix::SOURCE_OPERATION_RUN, $record)
|
||||
: $resolver->detailEntries(CrossResourceNavigationMatrix::SOURCE_OPERATION_RUN, $record);
|
||||
}
|
||||
|
||||
private static function targetScopeDisplay(OperationRun $record): ?string
|
||||
{
|
||||
$context = is_array($record->context) ? $record->context : [];
|
||||
|
||||
@ -4,6 +4,7 @@
|
||||
|
||||
use App\Filament\Resources\PolicyVersionResource;
|
||||
use App\Support\Navigation\CrossResourceNavigationMatrix;
|
||||
use App\Support\Navigation\RelatedContextEntry;
|
||||
use App\Support\Navigation\RelatedNavigationResolver;
|
||||
use Filament\Actions\Action;
|
||||
use Filament\Resources\Pages\ViewRecord;
|
||||
@ -26,12 +27,9 @@ protected function getHeaderActions(): array
|
||||
{
|
||||
return [
|
||||
Action::make('primary_related')
|
||||
->label(fn (): string => app(RelatedNavigationResolver::class)
|
||||
->primaryListAction(CrossResourceNavigationMatrix::SOURCE_POLICY_VERSION, $this->getRecord())?->actionLabel ?? 'Open related record')
|
||||
->url(fn (): ?string => app(RelatedNavigationResolver::class)
|
||||
->primaryListAction(CrossResourceNavigationMatrix::SOURCE_POLICY_VERSION, $this->getRecord())?->targetUrl)
|
||||
->hidden(fn (): bool => ! (app(RelatedNavigationResolver::class)
|
||||
->primaryListAction(CrossResourceNavigationMatrix::SOURCE_POLICY_VERSION, $this->getRecord())?->isAvailable() ?? false))
|
||||
->label(fn (): string => $this->primaryRelatedEntry()?->actionLabel ?? 'Open related record')
|
||||
->url(fn (): ?string => $this->primaryRelatedEntry()?->targetUrl)
|
||||
->hidden(fn (): bool => ! ($this->primaryRelatedEntry()?->isAvailable() ?? false))
|
||||
->color('gray'),
|
||||
];
|
||||
}
|
||||
@ -42,4 +40,13 @@ public function getFooter(): ?View
|
||||
'record' => $this->getRecord(),
|
||||
]);
|
||||
}
|
||||
|
||||
private function primaryRelatedEntry(bool $fresh = false): ?RelatedContextEntry
|
||||
{
|
||||
$resolver = app(RelatedNavigationResolver::class);
|
||||
|
||||
return $fresh
|
||||
? $resolver->primaryListActionFresh(CrossResourceNavigationMatrix::SOURCE_POLICY_VERSION, $this->getRecord())
|
||||
: $resolver->primaryListAction(CrossResourceNavigationMatrix::SOURCE_POLICY_VERSION, $this->getRecord());
|
||||
}
|
||||
}
|
||||
|
||||
@ -337,6 +337,7 @@ public static function table(Table $table): Table
|
||||
}
|
||||
|
||||
$record->update(['status' => ReviewPackStatus::Expired->value]);
|
||||
static::truthEnvelope($record->refresh(), fresh: true);
|
||||
|
||||
Notification::make()
|
||||
->success()
|
||||
@ -397,9 +398,13 @@ public static function getPages(): array
|
||||
];
|
||||
}
|
||||
|
||||
private static function truthEnvelope(ReviewPack $record): ArtifactTruthEnvelope
|
||||
private static function truthEnvelope(ReviewPack $record, bool $fresh = false): ArtifactTruthEnvelope
|
||||
{
|
||||
return app(ArtifactTruthPresenter::class)->forReviewPack($record);
|
||||
$presenter = app(ArtifactTruthPresenter::class);
|
||||
|
||||
return $fresh
|
||||
? $presenter->forReviewPackFresh($record)
|
||||
: $presenter->forReviewPack($record);
|
||||
}
|
||||
|
||||
/**
|
||||
@ -447,6 +452,8 @@ public static function executeGeneration(array $data): void
|
||||
return;
|
||||
}
|
||||
|
||||
static::truthEnvelope($reviewPack->refresh(), fresh: true);
|
||||
|
||||
if (! $reviewPack->wasRecentlyCreated) {
|
||||
Notification::make()
|
||||
->success()
|
||||
|
||||
@ -257,7 +257,7 @@ public static function table(Table $table): Table
|
||||
->color(fn (TenantReview $record): string => static::truthEnvelope($record)->primaryBadgeSpec()->color)
|
||||
->icon(fn (TenantReview $record): ?string => static::truthEnvelope($record)->primaryBadgeSpec()->icon)
|
||||
->iconColor(fn (TenantReview $record): ?string => static::truthEnvelope($record)->primaryBadgeSpec()->iconColor)
|
||||
->description(fn (TenantReview $record): ?string => static::truthEnvelope($record)->primaryExplanation)
|
||||
->description(fn (TenantReview $record): ?string => static::truthEnvelope($record)->operatorExplanation?->headline ?? static::truthEnvelope($record)->primaryExplanation)
|
||||
->wrap(),
|
||||
Tables\Columns\TextColumn::make('completeness_state')
|
||||
->label('Completeness')
|
||||
@ -295,7 +295,7 @@ public static function table(Table $table): Table
|
||||
->boolean(),
|
||||
Tables\Columns\TextColumn::make('artifact_next_step')
|
||||
->label('Next step')
|
||||
->getStateUsing(fn (TenantReview $record): string => static::truthEnvelope($record)->nextStepText())
|
||||
->getStateUsing(fn (TenantReview $record): string => static::truthEnvelope($record)->operatorExplanation?->nextActionText ?? static::truthEnvelope($record)->nextStepText())
|
||||
->wrap(),
|
||||
Tables\Columns\TextColumn::make('fingerprint')
|
||||
->toggleable(isToggledHiddenByDefault: true)
|
||||
@ -419,6 +419,8 @@ public static function executeCreateReview(array $data): void
|
||||
return;
|
||||
}
|
||||
|
||||
static::truthEnvelope($review->refresh(), fresh: true);
|
||||
|
||||
if (! $review->wasRecentlyCreated) {
|
||||
Notification::make()
|
||||
->success()
|
||||
@ -488,6 +490,9 @@ public static function executeExport(TenantReview $review): void
|
||||
return;
|
||||
}
|
||||
|
||||
static::truthEnvelope($review->refresh(), fresh: true);
|
||||
app(ArtifactTruthPresenter::class)->forReviewPackFresh($pack->refresh());
|
||||
|
||||
if (! $pack->wasRecentlyCreated) {
|
||||
Notification::make()
|
||||
->success()
|
||||
@ -563,6 +568,7 @@ private static function summaryPresentation(TenantReview $record): array
|
||||
$summary = is_array($record->summary) ? $record->summary : [];
|
||||
|
||||
return [
|
||||
'operator_explanation' => static::truthEnvelope($record)->operatorExplanation?->toArray(),
|
||||
'highlights' => is_array($summary['highlights'] ?? null) ? $summary['highlights'] : [],
|
||||
'next_actions' => is_array($summary['recommended_next_actions'] ?? null) ? $summary['recommended_next_actions'] : [],
|
||||
'publish_blockers' => is_array($summary['publish_blockers'] ?? null) ? $summary['publish_blockers'] : [],
|
||||
@ -605,8 +611,12 @@ private static function sectionPresentation(TenantReviewSection $section): array
|
||||
];
|
||||
}
|
||||
|
||||
private static function truthEnvelope(TenantReview $record): ArtifactTruthEnvelope
|
||||
private static function truthEnvelope(TenantReview $record, bool $fresh = false): ArtifactTruthEnvelope
|
||||
{
|
||||
return app(ArtifactTruthPresenter::class)->forTenantReview($record);
|
||||
$presenter = app(ArtifactTruthPresenter::class);
|
||||
|
||||
return $fresh
|
||||
? $presenter->forTenantReviewFresh($record)
|
||||
: $presenter->forTenantReview($record);
|
||||
}
|
||||
}
|
||||
|
||||
@ -4,8 +4,11 @@
|
||||
|
||||
namespace App\Filament\Widgets\Dashboard;
|
||||
|
||||
use App\Filament\Pages\BaselineCompareLanding;
|
||||
use App\Filament\Resources\FindingResource;
|
||||
use App\Models\Tenant;
|
||||
use App\Support\Baselines\BaselineCompareStats;
|
||||
use App\Support\OperationRunLinks;
|
||||
use Filament\Facades\Filament;
|
||||
use Filament\Widgets\Widget;
|
||||
|
||||
@ -22,38 +25,47 @@ protected function getViewData(): array
|
||||
|
||||
$empty = [
|
||||
'hasAssignment' => false,
|
||||
'state' => 'no_assignment',
|
||||
'message' => null,
|
||||
'profileName' => null,
|
||||
'findingsCount' => 0,
|
||||
'highCount' => 0,
|
||||
'mediumCount' => 0,
|
||||
'lowCount' => 0,
|
||||
'lastComparedAt' => null,
|
||||
'landingUrl' => null,
|
||||
'runUrl' => null,
|
||||
'findingsUrl' => null,
|
||||
'nextActionUrl' => null,
|
||||
'summaryAssessment' => null,
|
||||
];
|
||||
|
||||
if (! $tenant instanceof Tenant) {
|
||||
return $empty;
|
||||
}
|
||||
|
||||
$stats = BaselineCompareStats::forWidget($tenant);
|
||||
$stats = BaselineCompareStats::forTenant($tenant);
|
||||
|
||||
if (in_array($stats->state, ['no_tenant', 'no_assignment'], true)) {
|
||||
return $empty;
|
||||
}
|
||||
|
||||
$tenantLandingUrl = BaselineCompareLanding::getUrl(panel: 'tenant', tenant: $tenant);
|
||||
$runUrl = $stats->operationRunId !== null
|
||||
? OperationRunLinks::view($stats->operationRunId, $tenant)
|
||||
: null;
|
||||
$findingsUrl = FindingResource::getUrl('index', panel: 'tenant', tenant: $tenant);
|
||||
$summaryAssessment = $stats->summaryAssessment();
|
||||
$nextActionUrl = match ($summaryAssessment->nextActionTarget()) {
|
||||
'run' => $runUrl,
|
||||
'findings' => $findingsUrl,
|
||||
'landing' => $tenantLandingUrl,
|
||||
default => null,
|
||||
};
|
||||
|
||||
return [
|
||||
'hasAssignment' => true,
|
||||
'state' => $stats->state,
|
||||
'message' => $stats->message,
|
||||
'profileName' => $stats->profileName,
|
||||
'findingsCount' => $stats->findingsCount ?? 0,
|
||||
'highCount' => $stats->severityCounts['high'] ?? 0,
|
||||
'mediumCount' => $stats->severityCounts['medium'] ?? 0,
|
||||
'lowCount' => $stats->severityCounts['low'] ?? 0,
|
||||
'lastComparedAt' => $stats->lastComparedHuman,
|
||||
'landingUrl' => \App\Filament\Pages\BaselineCompareLanding::getUrl(tenant: $tenant),
|
||||
'landingUrl' => $tenantLandingUrl,
|
||||
'runUrl' => $runUrl,
|
||||
'findingsUrl' => $findingsUrl,
|
||||
'nextActionUrl' => $nextActionUrl,
|
||||
'summaryAssessment' => $summaryAssessment->toArray(),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@ -4,12 +4,9 @@
|
||||
|
||||
namespace App\Filament\Widgets\Dashboard;
|
||||
|
||||
use App\Filament\Pages\BaselineCompareLanding;
|
||||
use App\Filament\Resources\FindingResource;
|
||||
use App\Models\Finding;
|
||||
use App\Models\OperationRun;
|
||||
use App\Models\Tenant;
|
||||
use App\Support\OperationRunLinks;
|
||||
use App\Support\Baselines\BaselineCompareStats;
|
||||
use App\Support\OpsUx\ActiveRuns;
|
||||
use Filament\Facades\Filament;
|
||||
use Filament\Widgets\Widget;
|
||||
@ -34,85 +31,107 @@ protected function getViewData(): array
|
||||
}
|
||||
|
||||
$tenantId = (int) $tenant->getKey();
|
||||
$compareStats = BaselineCompareStats::forTenant($tenant);
|
||||
$compareAssessment = $compareStats->summaryAssessment();
|
||||
|
||||
$items = [];
|
||||
|
||||
$overdueOpenCount = (int) Finding::query()
|
||||
->where('tenant_id', $tenantId)
|
||||
->whereIn('status', Finding::openStatusesForQuery())
|
||||
->whereNotNull('due_at')
|
||||
->where('due_at', '<', now())
|
||||
->count();
|
||||
|
||||
$lapsedGovernanceCount = (int) Finding::query()
|
||||
->where('tenant_id', $tenantId)
|
||||
->where('status', Finding::STATUS_RISK_ACCEPTED)
|
||||
->where(function ($query): void {
|
||||
$query
|
||||
->whereDoesntHave('findingException')
|
||||
->orWhereHas('findingException', function ($exceptionQuery): void {
|
||||
$exceptionQuery->whereIn('current_validity_state', [
|
||||
\App\Models\FindingException::VALIDITY_EXPIRED,
|
||||
\App\Models\FindingException::VALIDITY_REVOKED,
|
||||
\App\Models\FindingException::VALIDITY_REJECTED,
|
||||
\App\Models\FindingException::VALIDITY_MISSING_SUPPORT,
|
||||
]);
|
||||
});
|
||||
})
|
||||
->count();
|
||||
|
||||
$expiringGovernanceCount = (int) Finding::query()
|
||||
->where('tenant_id', $tenantId)
|
||||
->where('status', Finding::STATUS_RISK_ACCEPTED)
|
||||
->whereHas('findingException', function ($query): void {
|
||||
$query->where('current_validity_state', \App\Models\FindingException::VALIDITY_EXPIRING);
|
||||
})
|
||||
->count();
|
||||
|
||||
$highSeverityCount = (int) Finding::query()
|
||||
->where('tenant_id', $tenantId)
|
||||
->where('finding_type', Finding::FINDING_TYPE_DRIFT)
|
||||
->where('status', Finding::STATUS_NEW)
|
||||
->where('severity', Finding::SEVERITY_HIGH)
|
||||
->whereIn('status', Finding::openStatusesForQuery())
|
||||
->whereIn('severity', [
|
||||
Finding::SEVERITY_HIGH,
|
||||
Finding::SEVERITY_CRITICAL,
|
||||
])
|
||||
->count();
|
||||
|
||||
if ($lapsedGovernanceCount > 0) {
|
||||
$items[] = [
|
||||
'title' => 'Lapsed accepted-risk governance',
|
||||
'body' => "{$lapsedGovernanceCount} finding(s) need governance follow-up before accepted risk is safe to rely on.",
|
||||
'badge' => 'Governance',
|
||||
'badgeColor' => 'danger',
|
||||
];
|
||||
}
|
||||
|
||||
if ($overdueOpenCount > 0) {
|
||||
$items[] = [
|
||||
'title' => 'Overdue findings',
|
||||
'body' => "{$overdueOpenCount} open finding(s) are overdue and still need workflow follow-up.",
|
||||
'badge' => 'Findings',
|
||||
'badgeColor' => 'danger',
|
||||
];
|
||||
}
|
||||
|
||||
if ($expiringGovernanceCount > 0) {
|
||||
$items[] = [
|
||||
'title' => 'Expiring accepted-risk governance',
|
||||
'body' => "{$expiringGovernanceCount} finding(s) will need governance review soon.",
|
||||
'badge' => 'Governance',
|
||||
'badgeColor' => 'warning',
|
||||
];
|
||||
}
|
||||
|
||||
if ($highSeverityCount > 0) {
|
||||
$items[] = [
|
||||
'title' => 'High severity drift findings',
|
||||
'body' => "{$highSeverityCount} finding(s) need review.",
|
||||
'url' => FindingResource::getUrl('index', tenant: $tenant),
|
||||
'title' => 'High severity active findings',
|
||||
'body' => "{$highSeverityCount} active finding(s) need review.",
|
||||
'badge' => 'Drift',
|
||||
'badgeColor' => 'danger',
|
||||
];
|
||||
}
|
||||
|
||||
$latestBaselineCompareSuccess = OperationRun::query()
|
||||
->where('tenant_id', $tenantId)
|
||||
->where('type', 'baseline_compare')
|
||||
->where('status', 'completed')
|
||||
->where('outcome', 'succeeded')
|
||||
->whereNotNull('completed_at')
|
||||
->latest('completed_at')
|
||||
->first();
|
||||
|
||||
if (! $latestBaselineCompareSuccess) {
|
||||
if ($compareAssessment->stateFamily !== 'positive') {
|
||||
$items[] = [
|
||||
'title' => 'No baseline compare yet',
|
||||
'body' => 'Run a baseline compare after your tenant has an assigned baseline snapshot.',
|
||||
'url' => BaselineCompareLanding::getUrl(tenant: $tenant),
|
||||
'badge' => 'Drift',
|
||||
'badgeColor' => 'warning',
|
||||
];
|
||||
} else {
|
||||
$isStale = $latestBaselineCompareSuccess->completed_at?->lt(now()->subDays(7)) ?? true;
|
||||
|
||||
if ($isStale) {
|
||||
$items[] = [
|
||||
'title' => 'Baseline compare stale',
|
||||
'body' => 'Last baseline compare is older than 7 days.',
|
||||
'url' => BaselineCompareLanding::getUrl(tenant: $tenant),
|
||||
'badge' => 'Drift',
|
||||
'badgeColor' => 'warning',
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
$latestBaselineCompareFailure = OperationRun::query()
|
||||
->where('tenant_id', $tenantId)
|
||||
->where('type', 'baseline_compare')
|
||||
->where('status', 'completed')
|
||||
->where('outcome', 'failed')
|
||||
->latest('id')
|
||||
->first();
|
||||
|
||||
if ($latestBaselineCompareFailure instanceof OperationRun) {
|
||||
$items[] = [
|
||||
'title' => 'Baseline compare failed',
|
||||
'body' => 'Investigate the latest failed run.',
|
||||
'url' => OperationRunLinks::view($latestBaselineCompareFailure, $tenant),
|
||||
'badge' => 'Operations',
|
||||
'badgeColor' => 'danger',
|
||||
'title' => 'Baseline compare posture',
|
||||
'body' => $compareAssessment->headline,
|
||||
'supportingMessage' => $compareAssessment->supportingMessage,
|
||||
'badge' => 'Baseline',
|
||||
'badgeColor' => $compareAssessment->tone,
|
||||
'nextStep' => $compareAssessment->nextActionLabel(),
|
||||
];
|
||||
}
|
||||
|
||||
$activeRuns = (int) OperationRun::query()
|
||||
->where('tenant_id', $tenantId)
|
||||
->active()
|
||||
->count();
|
||||
$activeRuns = ActiveRuns::existForTenant($tenant)
|
||||
? (int) \App\Models\OperationRun::query()->where('tenant_id', $tenantId)->active()->count()
|
||||
: 0;
|
||||
|
||||
if ($activeRuns > 0) {
|
||||
$items[] = [
|
||||
'title' => 'Operations in progress',
|
||||
'body' => "{$activeRuns} run(s) are active.",
|
||||
'url' => OperationRunLinks::index($tenant),
|
||||
'badge' => 'Operations',
|
||||
'badgeColor' => 'warning',
|
||||
];
|
||||
@ -125,24 +144,24 @@ protected function getViewData(): array
|
||||
if ($items === []) {
|
||||
$healthyChecks = [
|
||||
[
|
||||
'title' => 'Drift findings look healthy',
|
||||
'body' => 'No high severity drift findings are open.',
|
||||
'url' => FindingResource::getUrl('index', tenant: $tenant),
|
||||
'linkLabel' => 'View findings',
|
||||
'title' => 'Baseline compare looks trustworthy',
|
||||
'body' => $compareAssessment->headline,
|
||||
],
|
||||
[
|
||||
'title' => 'Baseline compares are up to date',
|
||||
'body' => $latestBaselineCompareSuccess?->completed_at
|
||||
? 'Last baseline compare: '.$latestBaselineCompareSuccess->completed_at->diffForHumans(['short' => true]).'.'
|
||||
: 'Baseline compare history is available in Baseline Compare.',
|
||||
'url' => BaselineCompareLanding::getUrl(tenant: $tenant),
|
||||
'linkLabel' => 'Open Baseline Compare',
|
||||
'title' => 'No overdue findings',
|
||||
'body' => 'No open findings are currently overdue for this tenant.',
|
||||
],
|
||||
[
|
||||
'title' => 'Accepted-risk governance is healthy',
|
||||
'body' => 'No accepted-risk findings currently need governance follow-up.',
|
||||
],
|
||||
[
|
||||
'title' => 'No high severity active findings',
|
||||
'body' => 'No high severity findings are currently open for this tenant.',
|
||||
],
|
||||
[
|
||||
'title' => 'No active operations',
|
||||
'body' => 'Nothing is currently running for this tenant.',
|
||||
'url' => OperationRunLinks::index($tenant),
|
||||
'linkLabel' => 'View operations',
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
@ -4,6 +4,7 @@
|
||||
|
||||
namespace App\Filament\Widgets\Tenant;
|
||||
|
||||
use App\Filament\Pages\BaselineCompareLanding;
|
||||
use App\Models\Tenant;
|
||||
use App\Support\Baselines\BaselineCompareStats;
|
||||
use App\Support\OperationRunLinks;
|
||||
@ -30,28 +31,30 @@ protected function getViewData(): array
|
||||
}
|
||||
|
||||
$stats = BaselineCompareStats::forTenant($tenant);
|
||||
|
||||
$uncoveredTypes = $stats->uncoveredTypes ?? [];
|
||||
$uncoveredTypes = is_array($uncoveredTypes) ? $uncoveredTypes : [];
|
||||
|
||||
$coverageStatus = $stats->coverageStatus;
|
||||
$hasWarnings = in_array($coverageStatus, ['warning', 'unproven'], true) && $uncoveredTypes !== [];
|
||||
|
||||
$summaryAssessment = $stats->summaryAssessment();
|
||||
$runUrl = null;
|
||||
|
||||
if ($stats->operationRunId !== null) {
|
||||
$runUrl = OperationRunLinks::view($stats->operationRunId, $tenant);
|
||||
}
|
||||
|
||||
$landingUrl = BaselineCompareLanding::getUrl(panel: 'tenant', tenant: $tenant);
|
||||
$nextActionUrl = match ($summaryAssessment->nextActionTarget()) {
|
||||
'run' => $runUrl,
|
||||
'findings' => \App\Filament\Resources\FindingResource::getUrl('index', panel: 'tenant', tenant: $tenant),
|
||||
'landing' => $landingUrl,
|
||||
default => null,
|
||||
};
|
||||
$shouldShow = in_array($summaryAssessment->stateFamily, ['caution', 'stale', 'unavailable', 'in_progress'], true)
|
||||
|| $summaryAssessment->stateFamily === 'action_required';
|
||||
|
||||
return [
|
||||
'shouldShow' => ($hasWarnings && $runUrl !== null) || $stats->state === 'no_snapshot',
|
||||
'shouldShow' => $shouldShow,
|
||||
'landingUrl' => $landingUrl,
|
||||
'runUrl' => $runUrl,
|
||||
'nextActionUrl' => $nextActionUrl,
|
||||
'summaryAssessment' => $summaryAssessment->toArray(),
|
||||
'state' => $stats->state,
|
||||
'message' => $stats->message,
|
||||
'coverageStatus' => $coverageStatus,
|
||||
'fidelity' => $stats->fidelity,
|
||||
'uncoveredTypesCount' => $stats->uncoveredTypesCount ?? count($uncoveredTypes),
|
||||
'uncoveredTypes' => $uncoveredTypes,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@ -0,0 +1,39 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Filament\Widgets\Tenant;
|
||||
|
||||
use App\Filament\Resources\FindingExceptionResource;
|
||||
use Filament\Widgets\StatsOverviewWidget as BaseWidget;
|
||||
use Filament\Widgets\StatsOverviewWidget\Stat;
|
||||
|
||||
class FindingExceptionStatsOverview extends BaseWidget
|
||||
{
|
||||
protected static bool $isLazy = false;
|
||||
|
||||
protected ?string $pollingInterval = null;
|
||||
|
||||
/**
|
||||
* @return array<int, Stat>
|
||||
*/
|
||||
protected function getStats(): array
|
||||
{
|
||||
$counts = FindingExceptionResource::exceptionStatsForCurrentTenant();
|
||||
|
||||
return [
|
||||
Stat::make('Active', $counts['active'])
|
||||
->icon('heroicon-o-check-badge')
|
||||
->color('success'),
|
||||
Stat::make('Expiring', $counts['expiring'])
|
||||
->icon('heroicon-o-exclamation-triangle')
|
||||
->color('warning'),
|
||||
Stat::make('Expired', $counts['expired'])
|
||||
->icon('heroicon-o-x-circle')
|
||||
->color('danger'),
|
||||
Stat::make('Pending approval', $counts['pending'])
|
||||
->icon('heroicon-o-clock')
|
||||
->color('gray'),
|
||||
];
|
||||
}
|
||||
}
|
||||
39
app/Filament/Widgets/Tenant/FindingStatsOverview.php
Normal file
39
app/Filament/Widgets/Tenant/FindingStatsOverview.php
Normal file
@ -0,0 +1,39 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Filament\Widgets\Tenant;
|
||||
|
||||
use App\Filament\Resources\FindingResource;
|
||||
use Filament\Widgets\StatsOverviewWidget as BaseWidget;
|
||||
use Filament\Widgets\StatsOverviewWidget\Stat;
|
||||
|
||||
class FindingStatsOverview extends BaseWidget
|
||||
{
|
||||
protected static bool $isLazy = false;
|
||||
|
||||
protected ?string $pollingInterval = null;
|
||||
|
||||
/**
|
||||
* @return array<int, Stat>
|
||||
*/
|
||||
protected function getStats(): array
|
||||
{
|
||||
$counts = FindingResource::findingStatsForCurrentTenant();
|
||||
|
||||
return [
|
||||
Stat::make('Open', $counts['open'])
|
||||
->icon('heroicon-o-exclamation-triangle')
|
||||
->color($counts['open'] > 0 ? 'warning' : 'success'),
|
||||
Stat::make('Overdue', $counts['overdue'])
|
||||
->icon('heroicon-o-clock')
|
||||
->color($counts['overdue'] > 0 ? 'danger' : 'gray'),
|
||||
Stat::make('High severity', $counts['high_severity'])
|
||||
->icon('heroicon-o-fire')
|
||||
->color($counts['high_severity'] > 0 ? 'danger' : 'gray'),
|
||||
Stat::make('Risk accepted', $counts['risk_accepted'])
|
||||
->icon('heroicon-o-shield-check')
|
||||
->color('gray'),
|
||||
];
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,60 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Filament\Pages\Monitoring\FindingExceptionsQueue;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
use App\Models\Workspace;
|
||||
use App\Services\Auth\WorkspaceCapabilityResolver;
|
||||
use App\Support\Auth\Capabilities;
|
||||
use App\Support\Workspaces\WorkspaceContext;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
final class OpenFindingExceptionsQueueController extends Controller
|
||||
{
|
||||
public function __invoke(Request $request, Tenant $tenant): RedirectResponse
|
||||
{
|
||||
$user = auth()->user();
|
||||
|
||||
if (! $user instanceof User) {
|
||||
abort(403);
|
||||
}
|
||||
|
||||
$workspace = Workspace::query()->whereKey($tenant->workspace_id)->first();
|
||||
|
||||
if (! $workspace instanceof Workspace) {
|
||||
abort(404);
|
||||
}
|
||||
|
||||
if (! $user->canAccessTenant($tenant)) {
|
||||
abort(404);
|
||||
}
|
||||
|
||||
$workspaceContext = app(WorkspaceContext::class);
|
||||
|
||||
if (! $workspaceContext->isMember($user, $workspace)) {
|
||||
abort(404);
|
||||
}
|
||||
|
||||
/** @var WorkspaceCapabilityResolver $resolver */
|
||||
$resolver = app(WorkspaceCapabilityResolver::class);
|
||||
|
||||
if (! $resolver->can($user, $workspace, Capabilities::FINDING_EXCEPTION_APPROVE)) {
|
||||
abort(404);
|
||||
}
|
||||
|
||||
$workspaceContext->setCurrentWorkspace($workspace, $user, $request);
|
||||
|
||||
if (! $workspaceContext->rememberTenantContext($tenant, $request)) {
|
||||
abort(404);
|
||||
}
|
||||
|
||||
return redirect()->to(FindingExceptionsQueue::getUrl([
|
||||
'tenant' => (string) $tenant->external_id,
|
||||
], panel: 'admin'));
|
||||
}
|
||||
}
|
||||
@ -108,6 +108,7 @@ public function handle(
|
||||
: null;
|
||||
|
||||
$effectiveScope = BaselineScope::fromJsonb($context['effective_scope'] ?? null);
|
||||
$truthfulTypes = $this->truthfulTypesFromContext($context, $effectiveScope);
|
||||
|
||||
$captureMode = $profile->capture_mode instanceof BaselineCaptureMode
|
||||
? $profile->capture_mode
|
||||
@ -127,6 +128,7 @@ public function handle(
|
||||
scope: $effectiveScope,
|
||||
identity: $identity,
|
||||
latestInventorySyncRunId: $latestInventorySyncRunId,
|
||||
policyTypes: $truthfulTypes,
|
||||
);
|
||||
|
||||
$subjects = $inventoryResult['subjects'];
|
||||
@ -262,6 +264,9 @@ public function handle(
|
||||
'gaps' => [
|
||||
'count' => $gapsCount,
|
||||
'by_reason' => $gapsByReason,
|
||||
'subjects' => is_array($phaseResult['gap_subjects'] ?? null) && $phaseResult['gap_subjects'] !== []
|
||||
? array_values($phaseResult['gap_subjects'])
|
||||
: null,
|
||||
],
|
||||
'resume_token' => $resumeToken,
|
||||
],
|
||||
@ -296,7 +301,7 @@ public function handle(
|
||||
/**
|
||||
* @return array{
|
||||
* 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{
|
||||
* tenant_subject_external_id: string,
|
||||
* workspace_subject_external_id: string,
|
||||
@ -317,6 +322,7 @@ private function collectInventorySubjects(
|
||||
BaselineScope $scope,
|
||||
BaselineSnapshotIdentity $identity,
|
||||
?int $latestInventorySyncRunId = null,
|
||||
?array $policyTypes = null,
|
||||
): array {
|
||||
$query = InventoryItem::query()
|
||||
->where('tenant_id', $sourceTenant->getKey());
|
||||
@ -325,7 +331,7 @@ private function collectInventorySubjects(
|
||||
$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 */
|
||||
$inventoryByKey = [];
|
||||
@ -413,6 +419,7 @@ private function collectInventorySubjects(
|
||||
static fn (array $item): array => [
|
||||
'policy_type' => (string) $item['policy_type'],
|
||||
'subject_external_id' => (string) $item['tenant_subject_external_id'],
|
||||
'subject_key' => (string) $item['subject_key'],
|
||||
],
|
||||
$inventoryByKey,
|
||||
));
|
||||
@ -425,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{
|
||||
* tenant_subject_external_id: string,
|
||||
|
||||
@ -43,10 +43,12 @@
|
||||
use App\Support\Baselines\BaselineScope;
|
||||
use App\Support\Baselines\BaselineSubjectKey;
|
||||
use App\Support\Baselines\PolicyVersionCapturePurpose;
|
||||
use App\Support\Baselines\SubjectResolver;
|
||||
use App\Support\Inventory\InventoryCoverage;
|
||||
use App\Support\OperationRunOutcome;
|
||||
use App\Support\OperationRunStatus;
|
||||
use App\Support\OperationRunType;
|
||||
use App\Support\ReasonTranslation\ReasonPresenter;
|
||||
use Carbon\CarbonImmutable;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
@ -143,7 +145,7 @@ public function handle(
|
||||
: null;
|
||||
|
||||
$effectiveScope = BaselineScope::fromJsonb($context['effective_scope'] ?? null);
|
||||
$effectiveTypes = $effectiveScope->allTypes();
|
||||
$effectiveTypes = $this->truthfulTypesFromContext($context, $effectiveScope);
|
||||
$scopeKey = 'baseline_profile:'.$profile->getKey();
|
||||
|
||||
$captureMode = $profile->capture_mode instanceof BaselineCaptureMode
|
||||
@ -319,6 +321,7 @@ public function handle(
|
||||
'snapshot_id' => (int) $snapshot->getKey(),
|
||||
],
|
||||
);
|
||||
$context = $this->withCompareReasonTranslation($context, $reasonCode);
|
||||
|
||||
$this->operationRun->update(['context' => $context]);
|
||||
$this->operationRun->refresh();
|
||||
@ -361,6 +364,7 @@ public function handle(
|
||||
static fn (array $item): array => [
|
||||
'policy_type' => (string) $item['policy_type'],
|
||||
'subject_external_id' => (string) $item['subject_external_id'],
|
||||
'subject_key' => (string) $item['subject_key'],
|
||||
],
|
||||
$currentItems,
|
||||
));
|
||||
@ -386,6 +390,7 @@ public function handle(
|
||||
];
|
||||
$phaseResult = [];
|
||||
$phaseGaps = [];
|
||||
$phaseGapSubjects = [];
|
||||
$resumeToken = null;
|
||||
|
||||
if ($captureMode === BaselineCaptureMode::FullContent) {
|
||||
@ -414,6 +419,7 @@ public function handle(
|
||||
|
||||
$phaseStats = is_array($phaseResult['stats'] ?? null) ? $phaseResult['stats'] : $phaseStats;
|
||||
$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;
|
||||
}
|
||||
|
||||
@ -493,6 +499,12 @@ public function handle(
|
||||
$gapsByReason = $this->mergeGapCounts($baselineGaps, $currentGaps, $phaseGaps, $driftGaps);
|
||||
$gapsCount = array_sum($gapsByReason);
|
||||
|
||||
$gapSubjects = $this->collectGapSubjects(
|
||||
ambiguousKeys: $ambiguousKeys,
|
||||
phaseGapSubjects: $phaseGapSubjects ?? [],
|
||||
driftGapSubjects: $computeResult['evidence_gap_subjects'] ?? [],
|
||||
);
|
||||
|
||||
$summaryCounts = [
|
||||
'total' => count($driftResults),
|
||||
'processed' => count($driftResults),
|
||||
@ -570,6 +582,7 @@ public function handle(
|
||||
'count' => $gapsCount,
|
||||
'by_reason' => $gapsByReason,
|
||||
...$gapsByReason,
|
||||
'subjects' => $gapSubjects !== [] ? $gapSubjects : null,
|
||||
],
|
||||
'resume_token' => $resumeToken,
|
||||
'coverage' => [
|
||||
@ -597,6 +610,10 @@ public function handle(
|
||||
'findings_resolved' => $resolvedCount,
|
||||
'severity_breakdown' => $severityBreakdown,
|
||||
];
|
||||
$updatedContext = $this->withCompareReasonTranslation(
|
||||
$updatedContext,
|
||||
$reasonCode?->value,
|
||||
);
|
||||
$this->operationRun->update(['context' => $updatedContext]);
|
||||
|
||||
$this->auditCompleted(
|
||||
@ -842,6 +859,7 @@ private function completeWithCoverageWarning(
|
||||
'findings_resolved' => 0,
|
||||
'severity_breakdown' => [],
|
||||
];
|
||||
$updatedContext = $this->withCompareReasonTranslation($updatedContext, $reasonCode->value);
|
||||
|
||||
$this->operationRun->update(['context' => $updatedContext]);
|
||||
|
||||
@ -948,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".
|
||||
*
|
||||
@ -1067,6 +1113,27 @@ private function snapshotBlockedMessage(string $reasonCode): string
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @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.
|
||||
*
|
||||
@ -1099,6 +1166,7 @@ private function computeDrift(
|
||||
): array {
|
||||
$drift = [];
|
||||
$evidenceGaps = [];
|
||||
$evidenceGapSubjects = [];
|
||||
$rbacRoleDefinitionSummary = $this->emptyRbacRoleDefinitionSummary();
|
||||
$roleDefinitionNormalizer = app(IntuneRoleDefinitionNormalizer::class);
|
||||
|
||||
@ -1140,6 +1208,7 @@ private function computeDrift(
|
||||
if (! is_array($currentItem)) {
|
||||
if ($isRbacRoleDefinition && $baselinePolicyVersionId === null) {
|
||||
$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;
|
||||
}
|
||||
@ -1204,6 +1273,7 @@ private function computeDrift(
|
||||
|
||||
if (! $currentEvidence instanceof ResolvedEvidence) {
|
||||
$evidenceGaps['missing_current'] = ($evidenceGaps['missing_current'] ?? 0) + 1;
|
||||
$evidenceGapSubjects['missing_current'][] = $key;
|
||||
|
||||
continue;
|
||||
}
|
||||
@ -1220,12 +1290,14 @@ private function computeDrift(
|
||||
if ($isRbacRoleDefinition) {
|
||||
if ($baselinePolicyVersionId === null) {
|
||||
$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;
|
||||
}
|
||||
|
||||
if ($currentPolicyVersionId === null) {
|
||||
$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;
|
||||
}
|
||||
@ -1239,6 +1311,7 @@ private function computeDrift(
|
||||
|
||||
if ($roleDefinitionDiff === null) {
|
||||
$evidenceGaps['missing_role_definition_compare_surface'] = ($evidenceGaps['missing_role_definition_compare_surface'] ?? 0) + 1;
|
||||
$evidenceGapSubjects['missing_role_definition_compare_surface'][] = $key;
|
||||
|
||||
continue;
|
||||
}
|
||||
@ -1319,6 +1392,7 @@ private function computeDrift(
|
||||
|
||||
if (! $currentEvidence instanceof ResolvedEvidence) {
|
||||
$evidenceGaps['missing_current'] = ($evidenceGaps['missing_current'] ?? 0) + 1;
|
||||
$evidenceGapSubjects['missing_current'][] = $key;
|
||||
|
||||
continue;
|
||||
}
|
||||
@ -1334,6 +1408,7 @@ private function computeDrift(
|
||||
|
||||
if ($isRbacRoleDefinition && $currentPolicyVersionId === null) {
|
||||
$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;
|
||||
}
|
||||
@ -1393,6 +1468,7 @@ private function computeDrift(
|
||||
return [
|
||||
'drift' => $drift,
|
||||
'evidence_gaps' => $evidenceGaps,
|
||||
'evidence_gap_subjects' => $evidenceGapSubjects,
|
||||
'rbac_role_definitions' => $rbacRoleDefinitionSummary,
|
||||
];
|
||||
}
|
||||
@ -1904,6 +1980,163 @@ private function mergeGapCounts(array ...$gaps): array
|
||||
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, ResolvedEvidence|null> $resolvedCurrentEvidence
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
@ -135,6 +135,11 @@ 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);
|
||||
@ -188,4 +193,81 @@ 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();
|
||||
}
|
||||
}
|
||||
|
||||
@ -2,6 +2,7 @@
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use App\Services\Auth\CapabilityResolver;
|
||||
use App\Services\Tenants\TenantOperabilityService;
|
||||
use App\Support\Auth\Capabilities;
|
||||
use App\Support\Workspaces\WorkspaceContext;
|
||||
@ -119,15 +120,10 @@ public function tenantRoleValue(Tenant $tenant): ?string
|
||||
return null;
|
||||
}
|
||||
|
||||
$role = $this->tenants()
|
||||
->whereKey($tenant->getKey())
|
||||
->value('role');
|
||||
/** @var CapabilityResolver $resolver */
|
||||
$resolver = app(CapabilityResolver::class);
|
||||
|
||||
if (! is_string($role)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $role;
|
||||
return $resolver->getRole($this, $tenant)?->value;
|
||||
}
|
||||
|
||||
public function allowsTenantSync(Tenant $tenant): bool
|
||||
@ -145,9 +141,10 @@ public function canAccessTenant(Model $tenant): bool
|
||||
return false;
|
||||
}
|
||||
|
||||
return $this->tenantMemberships()
|
||||
->where('tenant_id', $tenant->getKey())
|
||||
->exists();
|
||||
/** @var CapabilityResolver $resolver */
|
||||
$resolver = app(CapabilityResolver::class);
|
||||
|
||||
return $resolver->isMember($this, $tenant);
|
||||
}
|
||||
|
||||
public function getTenants(Panel $panel): array|Collection
|
||||
|
||||
@ -17,6 +17,8 @@
|
||||
use App\Policies\EntraGroupPolicy;
|
||||
use App\Policies\FindingPolicy;
|
||||
use App\Policies\OperationRunPolicy;
|
||||
use App\Services\Auth\CapabilityResolver;
|
||||
use App\Services\Auth\WorkspaceCapabilityResolver;
|
||||
use App\Services\Baselines\SnapshotRendering\Renderers\DeviceComplianceSnapshotTypeRenderer;
|
||||
use App\Services\Baselines\SnapshotRendering\Renderers\FallbackSnapshotTypeRenderer;
|
||||
use App\Services\Baselines\SnapshotRendering\Renderers\IntuneRoleDefinitionSnapshotTypeRenderer;
|
||||
@ -60,6 +62,7 @@
|
||||
use App\Support\References\Resolvers\PolicyReferenceResolver;
|
||||
use App\Support\References\Resolvers\PolicyVersionReferenceResolver;
|
||||
use App\Support\References\Resolvers\PrincipalReferenceResolver;
|
||||
use App\Support\Ui\DerivedState\RequestScopedDerivedStateStore;
|
||||
use Filament\Events\TenantSet;
|
||||
use Illuminate\Cache\RateLimiting\Limit;
|
||||
use Illuminate\Http\Request;
|
||||
@ -76,6 +79,9 @@ class AppServiceProvider extends ServiceProvider
|
||||
*/
|
||||
public function register(): void
|
||||
{
|
||||
$this->app->singleton(CapabilityResolver::class);
|
||||
$this->app->singleton(WorkspaceCapabilityResolver::class);
|
||||
|
||||
$this->app->bind(FindingGeneratorContract::class, PermissionPostureFindingGenerator::class);
|
||||
|
||||
$this->app->bind(
|
||||
@ -113,6 +119,7 @@ public function register(): void
|
||||
$this->app->singleton(ReferenceTypeLabelCatalog::class);
|
||||
$this->app->singleton(ReferenceStatePresenter::class);
|
||||
$this->app->singleton(ResolvedReferencePresenter::class);
|
||||
$this->app->scoped(RequestScopedDerivedStateStore::class);
|
||||
$this->app->singleton(FallbackReferenceResolver::class);
|
||||
$this->app->singleton(PolicyReferenceResolver::class);
|
||||
$this->app->singleton(PolicyVersionReferenceResolver::class);
|
||||
|
||||
@ -15,6 +15,7 @@
|
||||
use App\Support\Baselines\BaselineProfileStatus;
|
||||
use App\Support\Baselines\BaselineReasonCodes;
|
||||
use App\Support\Baselines\BaselineScope;
|
||||
use App\Support\Baselines\BaselineSupportCapabilityGuard;
|
||||
use App\Support\OperationRunType;
|
||||
|
||||
final class BaselineCaptureService
|
||||
@ -22,6 +23,7 @@ final class BaselineCaptureService
|
||||
public function __construct(
|
||||
private readonly OperationRunService $runs,
|
||||
private readonly BaselineFullContentRolloutGate $rolloutGate,
|
||||
private readonly BaselineSupportCapabilityGuard $capabilityGuard,
|
||||
) {}
|
||||
|
||||
/**
|
||||
@ -53,7 +55,7 @@ public function startCapture(
|
||||
],
|
||||
'baseline_profile_id' => (int) $profile->getKey(),
|
||||
'source_tenant_id' => (int) $sourceTenant->getKey(),
|
||||
'effective_scope' => $effectiveScope->toEffectiveScopeContext(),
|
||||
'effective_scope' => $effectiveScope->toEffectiveScopeContext($this->capabilityGuard, 'capture'),
|
||||
'capture_mode' => $captureMode->value,
|
||||
];
|
||||
|
||||
|
||||
@ -17,7 +17,9 @@
|
||||
use App\Support\Baselines\BaselineProfileStatus;
|
||||
use App\Support\Baselines\BaselineReasonCodes;
|
||||
use App\Support\Baselines\BaselineScope;
|
||||
use App\Support\Baselines\BaselineSupportCapabilityGuard;
|
||||
use App\Support\OperationRunType;
|
||||
use App\Support\ReasonTranslation\ReasonPresenter;
|
||||
|
||||
final class BaselineCompareService
|
||||
{
|
||||
@ -25,10 +27,11 @@ public function __construct(
|
||||
private readonly OperationRunService $runs,
|
||||
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(
|
||||
Tenant $tenant,
|
||||
@ -41,19 +44,19 @@ public function startCompare(
|
||||
->first();
|
||||
|
||||
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);
|
||||
|
||||
if (! $profile instanceof BaselineProfile) {
|
||||
return ['ok' => false, 'reason_code' => BaselineReasonCodes::COMPARE_PROFILE_NOT_ACTIVE];
|
||||
return $this->failedStart(BaselineReasonCodes::COMPARE_PROFILE_NOT_ACTIVE);
|
||||
}
|
||||
|
||||
$precondition = $this->validatePreconditions($profile);
|
||||
|
||||
if ($precondition !== null) {
|
||||
return ['ok' => false, 'reason_code' => $precondition];
|
||||
return $this->failedStart($precondition);
|
||||
}
|
||||
|
||||
$selectedSnapshot = null;
|
||||
@ -66,14 +69,14 @@ public function startCompare(
|
||||
->first();
|
||||
|
||||
if (! $selectedSnapshot instanceof BaselineSnapshot) {
|
||||
return ['ok' => false, 'reason_code' => BaselineReasonCodes::COMPARE_INVALID_SNAPSHOT];
|
||||
return $this->failedStart(BaselineReasonCodes::COMPARE_INVALID_SNAPSHOT);
|
||||
}
|
||||
}
|
||||
|
||||
$snapshotResolution = $this->snapshotTruthResolver->resolveCompareSnapshot($profile, $selectedSnapshot);
|
||||
|
||||
if (! ($snapshotResolution['ok'] ?? false)) {
|
||||
return ['ok' => false, 'reason_code' => $snapshotResolution['reason_code'] ?? BaselineReasonCodes::COMPARE_NO_CONSUMABLE_SNAPSHOT];
|
||||
return $this->failedStart($snapshotResolution['reason_code'] ?? BaselineReasonCodes::COMPARE_NO_CONSUMABLE_SNAPSHOT);
|
||||
}
|
||||
|
||||
/** @var BaselineSnapshot $snapshot */
|
||||
@ -100,7 +103,7 @@ public function startCompare(
|
||||
],
|
||||
'baseline_profile_id' => (int) $profile->getKey(),
|
||||
'baseline_snapshot_id' => $snapshotId,
|
||||
'effective_scope' => $effectiveScope->toEffectiveScopeContext(),
|
||||
'effective_scope' => $effectiveScope->toEffectiveScopeContext($this->capabilityGuard, 'compare'),
|
||||
'capture_mode' => $captureMode->value,
|
||||
];
|
||||
|
||||
@ -133,4 +136,18 @@ private function validatePreconditions(BaselineProfile $profile): ?string
|
||||
|
||||
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\Support\Baselines\BaselineEvidenceResumeToken;
|
||||
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;
|
||||
|
||||
final class BaselineContentCapturePhase
|
||||
{
|
||||
public function __construct(
|
||||
private readonly PolicyCaptureOrchestrator $captureOrchestrator,
|
||||
private readonly ?SubjectResolver $subjectResolver = null,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* 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
|
||||
* @return array{
|
||||
* stats: array{requested: int, succeeded: int, skipped: int, failed: int, throttled: int},
|
||||
* gaps: array<string, int>,
|
||||
* gap_subjects: list<array<string, mixed>>,
|
||||
* resume_token: ?string,
|
||||
* captured_versions: array<string, array{
|
||||
* policy_type: string,
|
||||
@ -76,6 +82,8 @@ public function capture(
|
||||
|
||||
/** @var array<string, int> $gaps */
|
||||
$gaps = [];
|
||||
/** @var list<array<string, mixed>> $gapSubjects */
|
||||
$gapSubjects = [];
|
||||
$capturedVersions = [];
|
||||
|
||||
/**
|
||||
@ -87,24 +95,40 @@ public function capture(
|
||||
foreach ($chunk as $subject) {
|
||||
$policyType = trim((string) ($subject['policy_type'] ?? ''));
|
||||
$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 === '') {
|
||||
$gaps['invalid_subject'] = ($gaps['invalid_subject'] ?? 0) + 1;
|
||||
$this->recordGap($gaps, $gapSubjects, $descriptor, $this->resolver()->invalidSubject($descriptor));
|
||||
$stats['failed']++;
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$subjectKey = $policyType.'|'.$externalId;
|
||||
$captureKey = $policyType.'|'.$externalId;
|
||||
|
||||
if (isset($seen[$subjectKey])) {
|
||||
$gaps['duplicate_subject'] = ($gaps['duplicate_subject'] ?? 0) + 1;
|
||||
if (isset($seen[$captureKey])) {
|
||||
$this->recordGap($gaps, $gapSubjects, $descriptor, $this->resolver()->duplicateSubject($descriptor));
|
||||
$stats['skipped']++;
|
||||
|
||||
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()
|
||||
->where('tenant_id', (int) $tenant->getKey())
|
||||
@ -113,7 +137,7 @@ public function capture(
|
||||
->first();
|
||||
|
||||
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']++;
|
||||
|
||||
continue;
|
||||
@ -152,7 +176,7 @@ public function capture(
|
||||
$version = $result['version'] ?? null;
|
||||
|
||||
if ($version instanceof PolicyVersion) {
|
||||
$capturedVersions[$subjectKey] = [
|
||||
$capturedVersions[$captureKey] = [
|
||||
'policy_type' => $policyType,
|
||||
'subject_external_id' => $externalId,
|
||||
'version' => $version,
|
||||
@ -178,10 +202,10 @@ public function capture(
|
||||
}
|
||||
|
||||
if ($isThrottled) {
|
||||
$gaps['throttled'] = ($gaps['throttled'] ?? 0) + 1;
|
||||
$this->recordGap($gaps, $gapSubjects, $descriptor, $this->resolver()->throttled($descriptor));
|
||||
$stats['throttled']++;
|
||||
} else {
|
||||
$gaps['capture_failed'] = ($gaps['capture_failed'] ?? 0) + 1;
|
||||
$this->recordGap($gaps, $gapSubjects, $descriptor, $this->resolver()->captureFailed($descriptor));
|
||||
$stats['failed']++;
|
||||
}
|
||||
|
||||
@ -201,7 +225,22 @@ public function capture(
|
||||
|
||||
$remainingCount = max(0, count($subjects) - $processed);
|
||||
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 [
|
||||
'stats' => $stats,
|
||||
'gaps' => $gaps,
|
||||
'gap_subjects' => $gapSubjects,
|
||||
'resume_token' => $resumeTokenOut,
|
||||
'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
|
||||
{
|
||||
$attempt = max(0, $attempt);
|
||||
|
||||
@ -136,6 +136,7 @@ public function presentEnterpriseDetail(BaselineSnapshot $snapshot, array $relat
|
||||
$currentTruth['icon'],
|
||||
$currentTruth['iconColor'],
|
||||
);
|
||||
$operatorExplanation = $truth->operatorExplanation;
|
||||
|
||||
return EnterpriseDetailBuilder::make('baseline_snapshot', 'workspace')
|
||||
->header(new SummaryHeaderData(
|
||||
@ -191,12 +192,21 @@ public function presentEnterpriseDetail(BaselineSnapshot $snapshot, array $relat
|
||||
$factory->supportingFactsCard(
|
||||
kind: 'status',
|
||||
title: 'Snapshot truth',
|
||||
items: [
|
||||
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),
|
||||
$factory->keyFact('Next step', $truth->nextStepText()),
|
||||
],
|
||||
$operatorExplanation !== null && $operatorExplanation->coverageStatement !== null
|
||||
? $factory->keyFact('Coverage', $operatorExplanation->coverageStatement)
|
||||
: null,
|
||||
$factory->keyFact('Next step', $operatorExplanation?->nextActionText ?? $truth->nextStepText()),
|
||||
])),
|
||||
),
|
||||
$factory->supportingFactsCard(
|
||||
kind: 'coverage',
|
||||
|
||||
@ -12,6 +12,16 @@
|
||||
|
||||
final class FindingRiskGovernanceResolver
|
||||
{
|
||||
public function resolveWorkflowFamily(Finding $finding): string
|
||||
{
|
||||
return match (Finding::canonicalizeStatus((string) $finding->status)) {
|
||||
Finding::STATUS_RISK_ACCEPTED => 'accepted_risk',
|
||||
Finding::STATUS_RESOLVED,
|
||||
Finding::STATUS_CLOSED => 'historical',
|
||||
default => 'active',
|
||||
};
|
||||
}
|
||||
|
||||
public function resolveExceptionStatus(FindingException $exception, ?CarbonImmutable $now = null): string
|
||||
{
|
||||
$now ??= CarbonImmutable::instance(now());
|
||||
@ -111,6 +121,43 @@ public function isValidGovernedAcceptedRisk(Finding $finding, ?FindingException
|
||||
], true);
|
||||
}
|
||||
|
||||
public function resolveGovernanceValidity(Finding $finding, ?FindingException $exception = null, ?CarbonImmutable $now = null): ?string
|
||||
{
|
||||
$exception ??= $finding->relationLoaded('findingException')
|
||||
? $finding->findingException
|
||||
: $finding->findingException()->first();
|
||||
|
||||
if ($exception instanceof FindingException) {
|
||||
return $this->resolveValidityState($exception, $now);
|
||||
}
|
||||
|
||||
return $finding->isRiskAccepted()
|
||||
? FindingException::VALIDITY_MISSING_SUPPORT
|
||||
: null;
|
||||
}
|
||||
|
||||
public function resolveGovernanceAttention(Finding $finding, ?FindingException $exception = null, ?CarbonImmutable $now = null): string
|
||||
{
|
||||
if (! $finding->isRiskAccepted()) {
|
||||
return 'not_applicable';
|
||||
}
|
||||
|
||||
return match ($this->resolveGovernanceValidity($finding, $exception, $now)) {
|
||||
FindingException::VALIDITY_VALID => 'healthy',
|
||||
FindingException::VALIDITY_EXPIRING,
|
||||
FindingException::VALIDITY_EXPIRED,
|
||||
FindingException::VALIDITY_REVOKED,
|
||||
FindingException::VALIDITY_REJECTED,
|
||||
FindingException::VALIDITY_MISSING_SUPPORT => 'attention_needed',
|
||||
default => 'attention_needed',
|
||||
};
|
||||
}
|
||||
|
||||
public function requiresGovernanceAttention(Finding $finding, ?FindingException $exception = null, ?CarbonImmutable $now = null): bool
|
||||
{
|
||||
return $this->resolveGovernanceAttention($finding, $exception, $now) === 'attention_needed';
|
||||
}
|
||||
|
||||
public function resolveWarningMessage(Finding $finding, ?FindingException $exception = null, ?CarbonImmutable $now = null): ?string
|
||||
{
|
||||
$exception ??= $finding->relationLoaded('findingException')
|
||||
@ -135,6 +182,7 @@ public function resolveWarningMessage(Finding $finding, ?FindingException $excep
|
||||
if ($finding->isRiskAccepted()) {
|
||||
return match ($this->resolveFindingState($finding, $exception, $now)) {
|
||||
'risk_accepted_without_valid_exception' => 'This finding is marked as accepted risk without a valid exception record.',
|
||||
'expiring_exception' => 'The linked exception is still valid, but it is nearing expiry and needs review.',
|
||||
'expired_exception' => 'The linked exception has expired and no longer governs accepted risk.',
|
||||
'revoked_exception' => 'The linked exception was revoked and no longer governs accepted risk.',
|
||||
'rejected_exception' => 'The linked exception was rejected and does not govern accepted risk.',
|
||||
@ -147,6 +195,7 @@ public function resolveWarningMessage(Finding $finding, ?FindingException $excep
|
||||
}
|
||||
|
||||
return match ($exceptionStatus) {
|
||||
FindingException::STATUS_EXPIRING => 'The linked exception is nearing expiry and needs review.',
|
||||
FindingException::STATUS_EXPIRED => 'The linked exception has expired and no longer governs accepted risk.',
|
||||
FindingException::STATUS_REVOKED => 'The linked exception was revoked and no longer governs accepted risk.',
|
||||
FindingException::STATUS_REJECTED => 'The linked exception was rejected and does not govern accepted risk.',
|
||||
@ -154,6 +203,59 @@ public function resolveWarningMessage(Finding $finding, ?FindingException $excep
|
||||
};
|
||||
}
|
||||
|
||||
public function resolveHistoricalContext(Finding $finding): ?string
|
||||
{
|
||||
return match ((string) $finding->status) {
|
||||
Finding::STATUS_RESOLVED => $this->resolvedHistoricalContext($finding),
|
||||
Finding::STATUS_CLOSED => $this->closedHistoricalContext($finding),
|
||||
default => null,
|
||||
};
|
||||
}
|
||||
|
||||
public function resolvePrimaryNarrative(Finding $finding, ?FindingException $exception = null, ?CarbonImmutable $now = null): string
|
||||
{
|
||||
return match ($this->resolveWorkflowFamily($finding)) {
|
||||
'accepted_risk' => $this->resolveGovernanceAttention($finding, $exception, $now) === 'healthy'
|
||||
? 'Accepted risk remains visible because current governance is still valid.'
|
||||
: 'Accepted risk is still on record, but governance follow-up is needed before it can be treated as safe to ignore.',
|
||||
'historical' => match ((string) $finding->status) {
|
||||
Finding::STATUS_RESOLVED => 'Resolved is a historical workflow state. It does not prove the issue is permanently gone.',
|
||||
Finding::STATUS_CLOSED => 'Closed is a historical workflow state. It does not prove the issue is permanently gone.',
|
||||
default => 'This finding is historical workflow context.',
|
||||
},
|
||||
default => 'This finding is still active workflow work and should be reviewed until it is resolved, closed, or formally governed.',
|
||||
};
|
||||
}
|
||||
|
||||
public function resolvePrimaryNextAction(Finding $finding, ?FindingException $exception = null, ?CarbonImmutable $now = null): ?string
|
||||
{
|
||||
if ($finding->hasOpenStatus() && $finding->due_at?->isPast() === true) {
|
||||
return 'Review the overdue finding and update ownership or next workflow step.';
|
||||
}
|
||||
|
||||
if ($this->resolveWorkflowFamily($finding) === 'accepted_risk') {
|
||||
return match ($this->resolveGovernanceValidity($finding, $exception, $now)) {
|
||||
FindingException::VALIDITY_VALID => 'Keep the exception under review until remediation is possible.',
|
||||
FindingException::VALIDITY_EXPIRING => 'Renew or review the exception before the current governance window lapses.',
|
||||
FindingException::VALIDITY_EXPIRED,
|
||||
FindingException::VALIDITY_REVOKED,
|
||||
FindingException::VALIDITY_REJECTED,
|
||||
FindingException::VALIDITY_MISSING_SUPPORT => 'Restore valid governance or move the finding back into active remediation.',
|
||||
default => 'Review the current governance state before treating this accepted risk as stable.',
|
||||
};
|
||||
}
|
||||
|
||||
if ((string) $finding->status === Finding::STATUS_RESOLVED || (string) $finding->status === Finding::STATUS_CLOSED) {
|
||||
return 'Review the closure context and reopen the finding if the issue has returned or governance has lapsed.';
|
||||
}
|
||||
|
||||
if ($finding->assignee_user_id === null || $finding->owner_user_id === null) {
|
||||
return 'Assign an owner and next workflow step so follow-up does not stall.';
|
||||
}
|
||||
|
||||
return 'Review the current workflow state and decide whether to progress, resolve, close, or request governance coverage.';
|
||||
}
|
||||
|
||||
public function syncExceptionState(FindingException $exception, ?CarbonImmutable $now = null): FindingException
|
||||
{
|
||||
$resolvedStatus = $this->resolveExceptionStatus($exception, $now);
|
||||
@ -227,4 +329,26 @@ private function renewalAwareDate(FindingException $exception, string $metadataK
|
||||
? CarbonImmutable::instance($fallback)
|
||||
: null;
|
||||
}
|
||||
|
||||
private function resolvedHistoricalContext(Finding $finding): ?string
|
||||
{
|
||||
$reason = (string) ($finding->resolved_reason ?? '');
|
||||
|
||||
return match ($reason) {
|
||||
'no_longer_drifting' => 'The latest compare did not reproduce the earlier drift, but treat this as the last observed workflow outcome rather than a permanent guarantee.',
|
||||
'permission_granted',
|
||||
'permission_removed_from_registry',
|
||||
'role_assignment_removed',
|
||||
'ga_count_within_threshold' => 'The last observed workflow reason suggests the triggering condition was no longer present, but this remains historical context.',
|
||||
default => 'Resolved records the workflow outcome. Review the reason and latest evidence before treating it as technical proof.',
|
||||
};
|
||||
}
|
||||
|
||||
private function closedHistoricalContext(Finding $finding): ?string
|
||||
{
|
||||
return match ((string) ($finding->closed_reason ?? '')) {
|
||||
'accepted_risk' => 'Closed reflects workflow handling. Governance validity still determines whether accepted risk remains safe to rely on.',
|
||||
default => 'Closed records the workflow outcome. Review the recorded reason before treating it as technical proof.',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@ -20,6 +20,8 @@ final class BadgeCatalog
|
||||
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::BaselineSnapshotGapStatus->value => Domains\BaselineSnapshotGapStatusBadge::class,
|
||||
|
||||
@ -11,6 +11,8 @@ enum BadgeDomain: string
|
||||
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 BaselineSnapshotGapStatus = 'baseline_snapshot_gap_status';
|
||||
|
||||
@ -18,8 +18,8 @@ public function spec(mixed $value): BadgeSpec
|
||||
FindingException::VALIDITY_EXPIRING => new BadgeSpec('Expiring', 'warning', 'heroicon-o-exclamation-triangle'),
|
||||
FindingException::VALIDITY_EXPIRED => new BadgeSpec('Expired', 'danger', 'heroicon-o-clock'),
|
||||
FindingException::VALIDITY_REVOKED => new BadgeSpec('Revoked', 'danger', 'heroicon-o-no-symbol'),
|
||||
FindingException::VALIDITY_REJECTED => new BadgeSpec('Rejected', 'gray', 'heroicon-o-x-circle'),
|
||||
FindingException::VALIDITY_MISSING_SUPPORT => new BadgeSpec('Missing support', 'gray', 'heroicon-o-question-mark-circle'),
|
||||
FindingException::VALIDITY_REJECTED => new BadgeSpec('Rejected', 'danger', 'heroicon-o-x-circle'),
|
||||
FindingException::VALIDITY_MISSING_SUPPORT => new BadgeSpec('Missing support', 'danger', 'heroicon-o-question-mark-circle'),
|
||||
default => BadgeSpec::unknown(),
|
||||
};
|
||||
}
|
||||
|
||||
@ -18,7 +18,7 @@ public function spec(mixed $value): BadgeSpec
|
||||
Finding::STATUS_TRIAGED => new BadgeSpec('Triaged', 'gray', 'heroicon-m-check-circle'),
|
||||
Finding::STATUS_IN_PROGRESS => new BadgeSpec('In progress', 'info', 'heroicon-m-arrow-path'),
|
||||
Finding::STATUS_REOPENED => new BadgeSpec('Reopened', 'danger', 'heroicon-m-arrow-uturn-left'),
|
||||
Finding::STATUS_RESOLVED => new BadgeSpec('Resolved', 'success', 'heroicon-o-check-circle'),
|
||||
Finding::STATUS_RESOLVED => new BadgeSpec('Resolved', 'gray', 'heroicon-o-check-circle'),
|
||||
Finding::STATUS_CLOSED => new BadgeSpec('Closed', 'gray', 'heroicon-o-x-circle'),
|
||||
Finding::STATUS_RISK_ACCEPTED => new BadgeSpec('Risk accepted', 'gray', 'heroicon-o-shield-check'),
|
||||
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();
|
||||
}
|
||||
}
|
||||
@ -71,7 +71,7 @@ final class OperatorOutcomeTaxonomy
|
||||
],
|
||||
'partial' => [
|
||||
'axis' => 'data_coverage',
|
||||
'label' => 'Partial',
|
||||
'label' => 'Partially complete',
|
||||
'color' => 'warning',
|
||||
'classification' => 'primary',
|
||||
'next_action_policy' => 'required',
|
||||
@ -136,7 +136,7 @@ final class OperatorOutcomeTaxonomy
|
||||
],
|
||||
'stale' => [
|
||||
'axis' => 'data_freshness',
|
||||
'label' => 'Stale',
|
||||
'label' => 'Refresh recommended',
|
||||
'color' => 'warning',
|
||||
'classification' => 'primary',
|
||||
'next_action_policy' => 'optional',
|
||||
@ -183,7 +183,7 @@ final class OperatorOutcomeTaxonomy
|
||||
],
|
||||
'blocked' => [
|
||||
'axis' => 'publication_readiness',
|
||||
'label' => 'Blocked',
|
||||
'label' => 'Publication blocked',
|
||||
'color' => 'warning',
|
||||
'classification' => 'primary',
|
||||
'next_action_policy' => 'required',
|
||||
@ -220,6 +220,100 @@ final class OperatorOutcomeTaxonomy
|
||||
'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',
|
||||
|
||||
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;
|
||||
|
||||
use App\Support\Ui\OperatorExplanation\ExplanationFamily;
|
||||
use App\Support\Ui\OperatorExplanation\TrustworthinessLevel;
|
||||
|
||||
enum BaselineCompareReasonCode: string
|
||||
{
|
||||
case NoSubjectsInScope = 'no_subjects_in_scope';
|
||||
@ -11,6 +14,9 @@ enum BaselineCompareReasonCode: string
|
||||
case EvidenceCaptureIncomplete = 'evidence_capture_incomplete';
|
||||
case RolloutDisabled = 'rollout_disabled';
|
||||
case NoDriftDetected = 'no_drift_detected';
|
||||
case OverdueFindingsRemain = 'overdue_findings_remain';
|
||||
case GovernanceExpiring = 'governance_expiring';
|
||||
case GovernanceLapsed = 'governance_lapsed';
|
||||
|
||||
public function message(): string
|
||||
{
|
||||
@ -20,6 +26,56 @@ public function message(): string
|
||||
self::EvidenceCaptureIncomplete => 'Evidence capture was incomplete, so some drift evaluation may have been suppressed.',
|
||||
self::RolloutDisabled => 'Full-content baseline compare is currently disabled by rollout configuration.',
|
||||
self::NoDriftDetected => 'No drift was detected for in-scope subjects.',
|
||||
self::OverdueFindingsRemain => 'Overdue findings still need action even though the latest compare did not produce new drift.',
|
||||
self::GovernanceExpiring => 'Accepted-risk governance is nearing expiry and needs review.',
|
||||
self::GovernanceLapsed => 'Accepted-risk governance has lapsed and needs follow-up.',
|
||||
};
|
||||
}
|
||||
|
||||
public function explanationFamily(): ExplanationFamily
|
||||
{
|
||||
return match ($this) {
|
||||
self::NoDriftDetected => ExplanationFamily::NoIssuesDetected,
|
||||
self::CoverageUnproven,
|
||||
self::EvidenceCaptureIncomplete,
|
||||
self::RolloutDisabled,
|
||||
self::OverdueFindingsRemain,
|
||||
self::GovernanceExpiring,
|
||||
self::GovernanceLapsed => ExplanationFamily::CompletedButLimited,
|
||||
self::NoSubjectsInScope => ExplanationFamily::MissingInput,
|
||||
};
|
||||
}
|
||||
|
||||
public function trustworthinessLevel(): TrustworthinessLevel
|
||||
{
|
||||
return match ($this) {
|
||||
self::NoDriftDetected => TrustworthinessLevel::Trustworthy,
|
||||
self::CoverageUnproven,
|
||||
self::EvidenceCaptureIncomplete,
|
||||
self::OverdueFindingsRemain,
|
||||
self::GovernanceExpiring,
|
||||
self::GovernanceLapsed => TrustworthinessLevel::LimitedConfidence,
|
||||
self::RolloutDisabled,
|
||||
self::NoSubjectsInScope => TrustworthinessLevel::Unusable,
|
||||
};
|
||||
}
|
||||
|
||||
public function absencePattern(): ?string
|
||||
{
|
||||
return match ($this) {
|
||||
self::NoDriftDetected => 'true_no_result',
|
||||
self::CoverageUnproven,
|
||||
self::EvidenceCaptureIncomplete,
|
||||
self::OverdueFindingsRemain,
|
||||
self::GovernanceExpiring,
|
||||
self::GovernanceLapsed => 'suppressed_output',
|
||||
self::RolloutDisabled => 'blocked_prerequisite',
|
||||
self::NoSubjectsInScope => 'missing_input',
|
||||
};
|
||||
}
|
||||
|
||||
public function supportsPositiveClaim(): bool
|
||||
{
|
||||
return $this === self::NoDriftDetected;
|
||||
}
|
||||
}
|
||||
|
||||
@ -14,6 +14,8 @@
|
||||
use App\Services\Baselines\BaselineSnapshotTruthResolver;
|
||||
use App\Support\OperationRunStatus;
|
||||
use App\Support\OperationRunType;
|
||||
use App\Support\Ui\OperatorExplanation\CountDescriptor;
|
||||
use App\Support\Ui\OperatorExplanation\OperatorExplanationPattern;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
|
||||
final class BaselineCompareStats
|
||||
@ -22,6 +24,32 @@ final class BaselineCompareStats
|
||||
* @param array<string, int> $severityCounts
|
||||
* @param list<string> $uncoveredTypes
|
||||
* @param array<string, int> $evidenceGapsTopReasons
|
||||
* @param array{
|
||||
* summary: array{
|
||||
* count: int,
|
||||
* by_reason: array<string, int>,
|
||||
* detail_state: string,
|
||||
* recorded_subjects_total: int,
|
||||
* missing_detail_count: int
|
||||
* },
|
||||
* buckets: list<array{
|
||||
* reason_code: string,
|
||||
* reason_label: string,
|
||||
* count: int,
|
||||
* recorded_count: int,
|
||||
* missing_detail_count: int,
|
||||
* detail_state: string,
|
||||
* search_text: string,
|
||||
* rows: list<array{
|
||||
* reason_code: string,
|
||||
* reason_label: string,
|
||||
* policy_type: string,
|
||||
* subject_key: string,
|
||||
* search_text: string
|
||||
* }>
|
||||
* }>
|
||||
* } $evidenceGapDetails
|
||||
* @param array<string, mixed> $baselineCompareDiagnostics
|
||||
*/
|
||||
private function __construct(
|
||||
public readonly string $state,
|
||||
@ -30,6 +58,7 @@ private function __construct(
|
||||
public readonly ?int $profileId,
|
||||
public readonly ?int $snapshotId,
|
||||
public readonly ?int $duplicateNamePoliciesCount,
|
||||
public readonly ?int $duplicateNameSubjectsCount,
|
||||
public readonly ?int $operationRunId,
|
||||
public readonly ?int $findingsCount,
|
||||
public readonly array $severityCounts,
|
||||
@ -45,6 +74,17 @@ private function __construct(
|
||||
public readonly ?int $evidenceGapsCount = null,
|
||||
public readonly array $evidenceGapsTopReasons = [],
|
||||
public readonly ?array $rbacRoleDefinitionSummary = null,
|
||||
public readonly array $evidenceGapDetails = [],
|
||||
public readonly array $baselineCompareDiagnostics = [],
|
||||
public readonly ?int $evidenceGapStructuralCount = null,
|
||||
public readonly ?int $evidenceGapOperationalCount = null,
|
||||
public readonly ?int $evidenceGapTransientCount = null,
|
||||
public readonly ?bool $evidenceGapLegacyMode = null,
|
||||
public readonly int $overdueOpenFindingsCount = 0,
|
||||
public readonly int $expiringGovernanceCount = 0,
|
||||
public readonly int $lapsedGovernanceCount = 0,
|
||||
public readonly int $activeNonNewFindingsCount = 0,
|
||||
public readonly int $highSeverityActiveFindingsCount = 0,
|
||||
) {}
|
||||
|
||||
public static function forTenant(?Tenant $tenant): self
|
||||
@ -89,7 +129,9 @@ public static function forTenant(?Tenant $tenant): self
|
||||
: null;
|
||||
$effectiveScope = BaselineScope::effective($profileScope, $overrideScope);
|
||||
|
||||
$duplicateNamePoliciesCount = self::duplicateNamePoliciesCount($tenant, $effectiveScope);
|
||||
$duplicateNameStats = self::duplicateNameStats($tenant, $effectiveScope);
|
||||
$duplicateNamePoliciesCount = $duplicateNameStats['policy_count'];
|
||||
$duplicateNameSubjectsCount = $duplicateNameStats['subject_count'];
|
||||
|
||||
if ($snapshotId === null) {
|
||||
return new self(
|
||||
@ -99,6 +141,7 @@ public static function forTenant(?Tenant $tenant): self
|
||||
profileId: $profileId,
|
||||
snapshotId: null,
|
||||
duplicateNamePoliciesCount: $duplicateNamePoliciesCount,
|
||||
duplicateNameSubjectsCount: $duplicateNameSubjectsCount,
|
||||
operationRunId: null,
|
||||
findingsCount: null,
|
||||
severityCounts: [],
|
||||
@ -120,6 +163,22 @@ public static function forTenant(?Tenant $tenant): self
|
||||
[$evidenceGapsCount, $evidenceGapsTopReasons] = self::evidenceGapSummaryForRun($latestRun);
|
||||
[$reasonCode, $reasonMessage] = self::reasonInfoForRun($latestRun);
|
||||
$rbacRoleDefinitionSummary = self::rbacRoleDefinitionSummaryForRun($latestRun);
|
||||
$evidenceGapDetails = self::evidenceGapDetailsForRun($latestRun);
|
||||
$baselineCompareDiagnostics = self::baselineCompareDiagnosticsForRun($latestRun);
|
||||
$findingAttentionCounts = self::findingAttentionCounts($tenant);
|
||||
$evidenceGapSummary = is_array($evidenceGapDetails['summary'] ?? null) ? $evidenceGapDetails['summary'] : [];
|
||||
$evidenceGapStructuralCount = is_numeric($evidenceGapSummary['structural_count'] ?? null)
|
||||
? (int) $evidenceGapSummary['structural_count']
|
||||
: null;
|
||||
$evidenceGapOperationalCount = is_numeric($evidenceGapSummary['operational_count'] ?? null)
|
||||
? (int) $evidenceGapSummary['operational_count']
|
||||
: null;
|
||||
$evidenceGapTransientCount = is_numeric($evidenceGapSummary['transient_count'] ?? null)
|
||||
? (int) $evidenceGapSummary['transient_count']
|
||||
: null;
|
||||
$evidenceGapLegacyMode = is_bool($evidenceGapSummary['legacy_mode'] ?? null)
|
||||
? (bool) $evidenceGapSummary['legacy_mode']
|
||||
: null;
|
||||
|
||||
// Active run (queued/running)
|
||||
if ($latestRun instanceof OperationRun && in_array($latestRun->status, ['queued', 'running'], true)) {
|
||||
@ -130,6 +189,7 @@ public static function forTenant(?Tenant $tenant): self
|
||||
profileId: $profileId,
|
||||
snapshotId: $snapshotId,
|
||||
duplicateNamePoliciesCount: $duplicateNamePoliciesCount,
|
||||
duplicateNameSubjectsCount: $duplicateNameSubjectsCount,
|
||||
operationRunId: (int) $latestRun->getKey(),
|
||||
findingsCount: null,
|
||||
severityCounts: [],
|
||||
@ -145,6 +205,17 @@ public static function forTenant(?Tenant $tenant): self
|
||||
evidenceGapsCount: $evidenceGapsCount,
|
||||
evidenceGapsTopReasons: $evidenceGapsTopReasons,
|
||||
rbacRoleDefinitionSummary: $rbacRoleDefinitionSummary,
|
||||
evidenceGapDetails: $evidenceGapDetails,
|
||||
baselineCompareDiagnostics: $baselineCompareDiagnostics,
|
||||
evidenceGapStructuralCount: $evidenceGapStructuralCount,
|
||||
evidenceGapOperationalCount: $evidenceGapOperationalCount,
|
||||
evidenceGapTransientCount: $evidenceGapTransientCount,
|
||||
evidenceGapLegacyMode: $evidenceGapLegacyMode,
|
||||
overdueOpenFindingsCount: $findingAttentionCounts['overdue_open_findings_count'],
|
||||
expiringGovernanceCount: $findingAttentionCounts['expiring_governance_count'],
|
||||
lapsedGovernanceCount: $findingAttentionCounts['lapsed_governance_count'],
|
||||
activeNonNewFindingsCount: $findingAttentionCounts['active_non_new_findings_count'],
|
||||
highSeverityActiveFindingsCount: $findingAttentionCounts['high_severity_active_findings_count'],
|
||||
);
|
||||
}
|
||||
|
||||
@ -162,6 +233,7 @@ public static function forTenant(?Tenant $tenant): self
|
||||
profileId: $profileId,
|
||||
snapshotId: $snapshotId,
|
||||
duplicateNamePoliciesCount: $duplicateNamePoliciesCount,
|
||||
duplicateNameSubjectsCount: $duplicateNameSubjectsCount,
|
||||
operationRunId: (int) $latestRun->getKey(),
|
||||
findingsCount: null,
|
||||
severityCounts: [],
|
||||
@ -177,6 +249,17 @@ public static function forTenant(?Tenant $tenant): self
|
||||
evidenceGapsCount: $evidenceGapsCount,
|
||||
evidenceGapsTopReasons: $evidenceGapsTopReasons,
|
||||
rbacRoleDefinitionSummary: $rbacRoleDefinitionSummary,
|
||||
evidenceGapDetails: $evidenceGapDetails,
|
||||
baselineCompareDiagnostics: $baselineCompareDiagnostics,
|
||||
evidenceGapStructuralCount: $evidenceGapStructuralCount,
|
||||
evidenceGapOperationalCount: $evidenceGapOperationalCount,
|
||||
evidenceGapTransientCount: $evidenceGapTransientCount,
|
||||
evidenceGapLegacyMode: $evidenceGapLegacyMode,
|
||||
overdueOpenFindingsCount: $findingAttentionCounts['overdue_open_findings_count'],
|
||||
expiringGovernanceCount: $findingAttentionCounts['expiring_governance_count'],
|
||||
lapsedGovernanceCount: $findingAttentionCounts['lapsed_governance_count'],
|
||||
activeNonNewFindingsCount: $findingAttentionCounts['active_non_new_findings_count'],
|
||||
highSeverityActiveFindingsCount: $findingAttentionCounts['high_severity_active_findings_count'],
|
||||
);
|
||||
}
|
||||
|
||||
@ -216,6 +299,7 @@ public static function forTenant(?Tenant $tenant): self
|
||||
profileId: $profileId,
|
||||
snapshotId: $snapshotId,
|
||||
duplicateNamePoliciesCount: $duplicateNamePoliciesCount,
|
||||
duplicateNameSubjectsCount: $duplicateNameSubjectsCount,
|
||||
operationRunId: $latestRun instanceof OperationRun ? (int) $latestRun->getKey() : null,
|
||||
findingsCount: $totalFindings,
|
||||
severityCounts: $severityCounts,
|
||||
@ -231,6 +315,17 @@ public static function forTenant(?Tenant $tenant): self
|
||||
evidenceGapsCount: $evidenceGapsCount,
|
||||
evidenceGapsTopReasons: $evidenceGapsTopReasons,
|
||||
rbacRoleDefinitionSummary: $rbacRoleDefinitionSummary,
|
||||
evidenceGapDetails: $evidenceGapDetails,
|
||||
baselineCompareDiagnostics: $baselineCompareDiagnostics,
|
||||
evidenceGapStructuralCount: $evidenceGapStructuralCount,
|
||||
evidenceGapOperationalCount: $evidenceGapOperationalCount,
|
||||
evidenceGapTransientCount: $evidenceGapTransientCount,
|
||||
evidenceGapLegacyMode: $evidenceGapLegacyMode,
|
||||
overdueOpenFindingsCount: $findingAttentionCounts['overdue_open_findings_count'],
|
||||
expiringGovernanceCount: $findingAttentionCounts['expiring_governance_count'],
|
||||
lapsedGovernanceCount: $findingAttentionCounts['lapsed_governance_count'],
|
||||
activeNonNewFindingsCount: $findingAttentionCounts['active_non_new_findings_count'],
|
||||
highSeverityActiveFindingsCount: $findingAttentionCounts['high_severity_active_findings_count'],
|
||||
);
|
||||
}
|
||||
|
||||
@ -244,6 +339,7 @@ public static function forTenant(?Tenant $tenant): self
|
||||
profileId: $profileId,
|
||||
snapshotId: $snapshotId,
|
||||
duplicateNamePoliciesCount: $duplicateNamePoliciesCount,
|
||||
duplicateNameSubjectsCount: $duplicateNameSubjectsCount,
|
||||
operationRunId: (int) $latestRun->getKey(),
|
||||
findingsCount: 0,
|
||||
severityCounts: $severityCounts,
|
||||
@ -259,6 +355,17 @@ public static function forTenant(?Tenant $tenant): self
|
||||
evidenceGapsCount: $evidenceGapsCount,
|
||||
evidenceGapsTopReasons: $evidenceGapsTopReasons,
|
||||
rbacRoleDefinitionSummary: $rbacRoleDefinitionSummary,
|
||||
evidenceGapDetails: $evidenceGapDetails,
|
||||
baselineCompareDiagnostics: $baselineCompareDiagnostics,
|
||||
evidenceGapStructuralCount: $evidenceGapStructuralCount,
|
||||
evidenceGapOperationalCount: $evidenceGapOperationalCount,
|
||||
evidenceGapTransientCount: $evidenceGapTransientCount,
|
||||
evidenceGapLegacyMode: $evidenceGapLegacyMode,
|
||||
overdueOpenFindingsCount: $findingAttentionCounts['overdue_open_findings_count'],
|
||||
expiringGovernanceCount: $findingAttentionCounts['expiring_governance_count'],
|
||||
lapsedGovernanceCount: $findingAttentionCounts['lapsed_governance_count'],
|
||||
activeNonNewFindingsCount: $findingAttentionCounts['active_non_new_findings_count'],
|
||||
highSeverityActiveFindingsCount: $findingAttentionCounts['high_severity_active_findings_count'],
|
||||
);
|
||||
}
|
||||
|
||||
@ -269,6 +376,7 @@ public static function forTenant(?Tenant $tenant): self
|
||||
profileId: $profileId,
|
||||
snapshotId: $snapshotId,
|
||||
duplicateNamePoliciesCount: $duplicateNamePoliciesCount,
|
||||
duplicateNameSubjectsCount: $duplicateNameSubjectsCount,
|
||||
operationRunId: null,
|
||||
findingsCount: null,
|
||||
severityCounts: $severityCounts,
|
||||
@ -284,6 +392,17 @@ public static function forTenant(?Tenant $tenant): self
|
||||
evidenceGapsCount: $evidenceGapsCount,
|
||||
evidenceGapsTopReasons: $evidenceGapsTopReasons,
|
||||
rbacRoleDefinitionSummary: $rbacRoleDefinitionSummary,
|
||||
evidenceGapDetails: $evidenceGapDetails,
|
||||
baselineCompareDiagnostics: $baselineCompareDiagnostics,
|
||||
evidenceGapStructuralCount: $evidenceGapStructuralCount,
|
||||
evidenceGapOperationalCount: $evidenceGapOperationalCount,
|
||||
evidenceGapTransientCount: $evidenceGapTransientCount,
|
||||
evidenceGapLegacyMode: $evidenceGapLegacyMode,
|
||||
overdueOpenFindingsCount: $findingAttentionCounts['overdue_open_findings_count'],
|
||||
expiringGovernanceCount: $findingAttentionCounts['expiring_governance_count'],
|
||||
lapsedGovernanceCount: $findingAttentionCounts['lapsed_governance_count'],
|
||||
activeNonNewFindingsCount: $findingAttentionCounts['active_non_new_findings_count'],
|
||||
highSeverityActiveFindingsCount: $findingAttentionCounts['high_severity_active_findings_count'],
|
||||
);
|
||||
}
|
||||
|
||||
@ -340,6 +459,7 @@ public static function forWidget(?Tenant $tenant): self
|
||||
profileId: (int) $profile->getKey(),
|
||||
snapshotId: $snapshotId,
|
||||
duplicateNamePoliciesCount: null,
|
||||
duplicateNameSubjectsCount: null,
|
||||
operationRunId: $latestRun instanceof OperationRun ? (int) $latestRun->getKey() : null,
|
||||
findingsCount: $totalFindings,
|
||||
severityCounts: [
|
||||
@ -355,17 +475,23 @@ public static function forWidget(?Tenant $tenant): self
|
||||
);
|
||||
}
|
||||
|
||||
private static function duplicateNamePoliciesCount(Tenant $tenant, BaselineScope $effectiveScope): int
|
||||
/**
|
||||
* @return array{policy_count: int, subject_count: int}
|
||||
*/
|
||||
private static function duplicateNameStats(Tenant $tenant, BaselineScope $effectiveScope): array
|
||||
{
|
||||
$policyTypes = $effectiveScope->allTypes();
|
||||
|
||||
if ($policyTypes === []) {
|
||||
return 0;
|
||||
return [
|
||||
'policy_count' => 0,
|
||||
'subject_count' => 0,
|
||||
];
|
||||
}
|
||||
|
||||
$latestInventorySyncRunId = self::latestInventorySyncRunId($tenant);
|
||||
|
||||
$compute = static function () use ($tenant, $policyTypes, $latestInventorySyncRunId): int {
|
||||
$compute = static function () use ($tenant, $policyTypes, $latestInventorySyncRunId): array {
|
||||
/**
|
||||
* @var array<string, int> $countsByKey
|
||||
*/
|
||||
@ -398,14 +524,19 @@ private static function duplicateNamePoliciesCount(Tenant $tenant, BaselineScope
|
||||
});
|
||||
|
||||
$duplicatePolicies = 0;
|
||||
$duplicateSubjects = 0;
|
||||
|
||||
foreach ($countsByKey as $count) {
|
||||
if ($count > 1) {
|
||||
$duplicateSubjects++;
|
||||
$duplicatePolicies += $count;
|
||||
}
|
||||
}
|
||||
|
||||
return $duplicatePolicies;
|
||||
return [
|
||||
'policy_count' => $duplicatePolicies,
|
||||
'subject_count' => $duplicateSubjects,
|
||||
];
|
||||
};
|
||||
|
||||
if (app()->environment('testing')) {
|
||||
@ -419,7 +550,10 @@ private static function duplicateNamePoliciesCount(Tenant $tenant, BaselineScope
|
||||
$latestInventorySyncRunId ?? 'all',
|
||||
);
|
||||
|
||||
return (int) Cache::remember($cacheKey, now()->addSeconds(60), $compute);
|
||||
/** @var array{policy_count: int, subject_count: int} $stats */
|
||||
$stats = Cache::remember($cacheKey, now()->addSeconds(60), $compute);
|
||||
|
||||
return $stats;
|
||||
}
|
||||
|
||||
private static function latestInventorySyncRunId(Tenant $tenant): ?int
|
||||
@ -513,48 +647,67 @@ private static function evidenceGapSummaryForRun(?OperationRun $run): array
|
||||
return [null, []];
|
||||
}
|
||||
|
||||
$details = self::evidenceGapDetailsForRun($run);
|
||||
$summary = is_array($details['summary'] ?? null) ? $details['summary'] : [];
|
||||
$count = is_numeric($summary['count'] ?? null) ? (int) $summary['count'] : null;
|
||||
$byReason = is_array($summary['by_reason'] ?? null) ? $summary['by_reason'] : [];
|
||||
|
||||
return [$count, array_slice($byReason, 0, 6, true)];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{
|
||||
* summary: array{
|
||||
* count: int,
|
||||
* by_reason: array<string, int>,
|
||||
* detail_state: string,
|
||||
* recorded_subjects_total: int,
|
||||
* missing_detail_count: int
|
||||
* },
|
||||
* buckets: list<array{
|
||||
* reason_code: string,
|
||||
* reason_label: string,
|
||||
* count: int,
|
||||
* recorded_count: int,
|
||||
* missing_detail_count: int,
|
||||
* detail_state: string,
|
||||
* search_text: string,
|
||||
* rows: list<array{
|
||||
* reason_code: string,
|
||||
* reason_label: string,
|
||||
* policy_type: string,
|
||||
* subject_key: string,
|
||||
* search_text: string
|
||||
* }>
|
||||
* }>
|
||||
* }
|
||||
*/
|
||||
private static function evidenceGapDetailsForRun(?OperationRun $run): array
|
||||
{
|
||||
if (! $run instanceof OperationRun) {
|
||||
return BaselineCompareEvidenceGapDetails::fromContext([]);
|
||||
}
|
||||
|
||||
return BaselineCompareEvidenceGapDetails::fromOperationRun($run);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private static function baselineCompareDiagnosticsForRun(?OperationRun $run): array
|
||||
{
|
||||
if (! $run instanceof OperationRun) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$context = is_array($run->context) ? $run->context : [];
|
||||
$baselineCompare = $context['baseline_compare'] ?? null;
|
||||
|
||||
if (! is_array($baselineCompare)) {
|
||||
return [null, []];
|
||||
return [];
|
||||
}
|
||||
|
||||
$gaps = $baselineCompare['evidence_gaps'] ?? null;
|
||||
|
||||
if (! is_array($gaps)) {
|
||||
return [null, []];
|
||||
}
|
||||
|
||||
$count = $gaps['count'] ?? null;
|
||||
$count = is_numeric($count) ? (int) $count : null;
|
||||
|
||||
$byReason = $gaps['by_reason'] ?? null;
|
||||
$byReason = is_array($byReason) ? $byReason : [];
|
||||
|
||||
$normalized = [];
|
||||
|
||||
foreach ($byReason as $reason => $value) {
|
||||
if (! is_string($reason) || trim($reason) === '' || ! is_numeric($value)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$intValue = (int) $value;
|
||||
|
||||
if ($intValue <= 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$normalized[trim($reason)] = $intValue;
|
||||
}
|
||||
|
||||
if ($count === null) {
|
||||
$count = array_sum($normalized);
|
||||
}
|
||||
|
||||
arsort($normalized);
|
||||
|
||||
return [$count, array_slice($normalized, 0, 6, true)];
|
||||
return BaselineCompareEvidenceGapDetails::diagnosticsPayload($baselineCompare);
|
||||
}
|
||||
|
||||
/**
|
||||
@ -583,12 +736,119 @@ private static function rbacRoleDefinitionSummaryForRun(?OperationRun $run): ?ar
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{
|
||||
* overdue_open_findings_count: int,
|
||||
* expiring_governance_count: int,
|
||||
* lapsed_governance_count: int,
|
||||
* active_non_new_findings_count: int,
|
||||
* high_severity_active_findings_count: int
|
||||
* }
|
||||
*/
|
||||
private static function findingAttentionCounts(Tenant $tenant): array
|
||||
{
|
||||
$tenantId = (int) $tenant->getKey();
|
||||
|
||||
$overdueOpenFindingsCount = Finding::query()
|
||||
->where('tenant_id', $tenantId)
|
||||
->whereIn('status', Finding::openStatusesForQuery())
|
||||
->whereNotNull('due_at')
|
||||
->where('due_at', '<', now())
|
||||
->count();
|
||||
|
||||
$expiringGovernanceCount = Finding::query()
|
||||
->where('tenant_id', $tenantId)
|
||||
->where('status', Finding::STATUS_RISK_ACCEPTED)
|
||||
->whereHas('findingException', function ($query): void {
|
||||
$query->where('current_validity_state', \App\Models\FindingException::VALIDITY_EXPIRING);
|
||||
})
|
||||
->count();
|
||||
|
||||
$lapsedGovernanceCount = Finding::query()
|
||||
->where('tenant_id', $tenantId)
|
||||
->where('status', Finding::STATUS_RISK_ACCEPTED)
|
||||
->where(function ($query): void {
|
||||
$query
|
||||
->whereDoesntHave('findingException')
|
||||
->orWhereHas('findingException', function ($exceptionQuery): void {
|
||||
$exceptionQuery->whereIn('current_validity_state', [
|
||||
\App\Models\FindingException::VALIDITY_EXPIRED,
|
||||
\App\Models\FindingException::VALIDITY_REVOKED,
|
||||
\App\Models\FindingException::VALIDITY_REJECTED,
|
||||
\App\Models\FindingException::VALIDITY_MISSING_SUPPORT,
|
||||
]);
|
||||
});
|
||||
})
|
||||
->count();
|
||||
|
||||
$activeNonNewFindingsCount = Finding::query()
|
||||
->where('tenant_id', $tenantId)
|
||||
->whereIn('status', [
|
||||
Finding::STATUS_ACKNOWLEDGED,
|
||||
Finding::STATUS_TRIAGED,
|
||||
Finding::STATUS_IN_PROGRESS,
|
||||
Finding::STATUS_REOPENED,
|
||||
])
|
||||
->count();
|
||||
|
||||
$highSeverityActiveFindingsCount = Finding::query()
|
||||
->where('tenant_id', $tenantId)
|
||||
->whereIn('status', Finding::openStatusesForQuery())
|
||||
->whereIn('severity', [
|
||||
Finding::SEVERITY_HIGH,
|
||||
Finding::SEVERITY_CRITICAL,
|
||||
])
|
||||
->count();
|
||||
|
||||
return [
|
||||
'overdue_open_findings_count' => $overdueOpenFindingsCount,
|
||||
'expiring_governance_count' => $expiringGovernanceCount,
|
||||
'lapsed_governance_count' => $lapsedGovernanceCount,
|
||||
'active_non_new_findings_count' => $activeNonNewFindingsCount,
|
||||
'high_severity_active_findings_count' => $highSeverityActiveFindingsCount,
|
||||
];
|
||||
}
|
||||
|
||||
public function operatorExplanation(): OperatorExplanationPattern
|
||||
{
|
||||
/** @var BaselineCompareExplanationRegistry $registry */
|
||||
$registry = app(BaselineCompareExplanationRegistry::class);
|
||||
|
||||
return $registry->forStats($this);
|
||||
}
|
||||
|
||||
public function summaryAssessment(): BaselineCompareSummaryAssessment
|
||||
{
|
||||
/** @var BaselineCompareSummaryAssessor $assessor */
|
||||
$assessor = app(BaselineCompareSummaryAssessor::class);
|
||||
|
||||
return $assessor->assess($this);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, array{
|
||||
* label: string,
|
||||
* value: int,
|
||||
* role: string,
|
||||
* qualifier: ?string,
|
||||
* visibilityTier: string
|
||||
* }>
|
||||
*/
|
||||
public function explanationCountDescriptors(): array
|
||||
{
|
||||
return array_map(
|
||||
static fn (CountDescriptor $descriptor): array => $descriptor->toArray(),
|
||||
$this->operatorExplanation()->countDescriptors,
|
||||
);
|
||||
}
|
||||
|
||||
private static function empty(
|
||||
string $state,
|
||||
?string $message,
|
||||
?string $profileName = null,
|
||||
?int $profileId = null,
|
||||
?int $duplicateNamePoliciesCount = null,
|
||||
?int $duplicateNameSubjectsCount = null,
|
||||
): self {
|
||||
return new self(
|
||||
state: $state,
|
||||
@ -597,6 +857,7 @@ private static function empty(
|
||||
profileId: $profileId,
|
||||
snapshotId: null,
|
||||
duplicateNamePoliciesCount: $duplicateNamePoliciesCount,
|
||||
duplicateNameSubjectsCount: $duplicateNameSubjectsCount,
|
||||
operationRunId: null,
|
||||
findingsCount: null,
|
||||
severityCounts: [],
|
||||
|
||||
159
app/Support/Baselines/BaselineCompareSummaryAssessment.php
Normal file
159
app/Support/Baselines/BaselineCompareSummaryAssessment.php
Normal file
@ -0,0 +1,159 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Support\Baselines;
|
||||
|
||||
use InvalidArgumentException;
|
||||
|
||||
final readonly class BaselineCompareSummaryAssessment
|
||||
{
|
||||
public const string STATE_POSITIVE = 'positive';
|
||||
|
||||
public const string STATE_CAUTION = 'caution';
|
||||
|
||||
public const string STATE_STALE = 'stale';
|
||||
|
||||
public const string STATE_ACTION_REQUIRED = 'action_required';
|
||||
|
||||
public const string STATE_UNAVAILABLE = 'unavailable';
|
||||
|
||||
public const string STATE_IN_PROGRESS = 'in_progress';
|
||||
|
||||
public const string EVIDENCE_NONE = 'none';
|
||||
|
||||
public const string EVIDENCE_COVERAGE_WARNING = 'coverage_warning';
|
||||
|
||||
public const string EVIDENCE_EVIDENCE_GAP = 'evidence_gap';
|
||||
|
||||
public const string EVIDENCE_STALE_RESULT = 'stale_result';
|
||||
|
||||
public const string EVIDENCE_SUPPRESSED_OUTPUT = 'suppressed_output';
|
||||
|
||||
public const string EVIDENCE_UNAVAILABLE = 'unavailable';
|
||||
|
||||
public const string NEXT_TARGET_LANDING = 'landing';
|
||||
|
||||
public const string NEXT_TARGET_FINDINGS = 'findings';
|
||||
|
||||
public const string NEXT_TARGET_RUN = 'run';
|
||||
|
||||
public const string NEXT_TARGET_NONE = 'none';
|
||||
|
||||
/**
|
||||
* @param array{label: string, target: string} $nextAction
|
||||
*/
|
||||
public function __construct(
|
||||
public string $stateFamily,
|
||||
public string $headline,
|
||||
public ?string $supportingMessage,
|
||||
public string $tone,
|
||||
public bool $positiveClaimAllowed,
|
||||
public string $trustworthinessLevel,
|
||||
public string $evaluationResult,
|
||||
public string $evidenceImpact,
|
||||
public int $findingsVisibleCount,
|
||||
public int $highSeverityCount,
|
||||
public array $nextAction,
|
||||
public ?string $lastComparedLabel = null,
|
||||
public ?string $reasonCode = null,
|
||||
public int $overdueOpenFindingsCount = 0,
|
||||
public int $expiringGovernanceCount = 0,
|
||||
public int $lapsedGovernanceCount = 0,
|
||||
) {
|
||||
if (! in_array($this->stateFamily, [
|
||||
self::STATE_POSITIVE,
|
||||
self::STATE_CAUTION,
|
||||
self::STATE_STALE,
|
||||
self::STATE_ACTION_REQUIRED,
|
||||
self::STATE_UNAVAILABLE,
|
||||
self::STATE_IN_PROGRESS,
|
||||
], true)) {
|
||||
throw new InvalidArgumentException('Unsupported baseline summary state family: '.$this->stateFamily);
|
||||
}
|
||||
|
||||
if (trim($this->headline) === '') {
|
||||
throw new InvalidArgumentException('Baseline summary assessments require a headline.');
|
||||
}
|
||||
|
||||
if (! in_array($this->evidenceImpact, [
|
||||
self::EVIDENCE_NONE,
|
||||
self::EVIDENCE_COVERAGE_WARNING,
|
||||
self::EVIDENCE_EVIDENCE_GAP,
|
||||
self::EVIDENCE_STALE_RESULT,
|
||||
self::EVIDENCE_SUPPRESSED_OUTPUT,
|
||||
self::EVIDENCE_UNAVAILABLE,
|
||||
], true)) {
|
||||
throw new InvalidArgumentException('Unsupported baseline summary evidence impact: '.$this->evidenceImpact);
|
||||
}
|
||||
|
||||
if (! in_array($this->nextAction['target'] ?? null, [
|
||||
self::NEXT_TARGET_LANDING,
|
||||
self::NEXT_TARGET_FINDINGS,
|
||||
self::NEXT_TARGET_RUN,
|
||||
self::NEXT_TARGET_NONE,
|
||||
], true)) {
|
||||
throw new InvalidArgumentException('Unsupported baseline summary next-action target.');
|
||||
}
|
||||
|
||||
if (trim((string) ($this->nextAction['label'] ?? '')) === '') {
|
||||
throw new InvalidArgumentException('Baseline summary assessments require a next-action label.');
|
||||
}
|
||||
|
||||
if ($this->positiveClaimAllowed && $this->stateFamily !== self::STATE_POSITIVE) {
|
||||
throw new InvalidArgumentException('Positive claim eligibility must resolve to the positive summary state.');
|
||||
}
|
||||
}
|
||||
|
||||
public function nextActionLabel(): string
|
||||
{
|
||||
return $this->nextAction['label'];
|
||||
}
|
||||
|
||||
public function nextActionTarget(): string
|
||||
{
|
||||
return $this->nextAction['target'];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{
|
||||
* stateFamily: string,
|
||||
* headline: string,
|
||||
* supportingMessage: ?string,
|
||||
* tone: string,
|
||||
* positiveClaimAllowed: bool,
|
||||
* trustworthinessLevel: string,
|
||||
* evaluationResult: string,
|
||||
* evidenceImpact: string,
|
||||
* findingsVisibleCount: int,
|
||||
* highSeverityCount: int,
|
||||
* nextAction: array{label: string, target: string},
|
||||
* lastComparedLabel: ?string,
|
||||
* reasonCode: ?string,
|
||||
* overdueOpenFindingsCount: int,
|
||||
* expiringGovernanceCount: int,
|
||||
* lapsedGovernanceCount: int
|
||||
* }
|
||||
*/
|
||||
public function toArray(): array
|
||||
{
|
||||
return [
|
||||
'stateFamily' => $this->stateFamily,
|
||||
'headline' => $this->headline,
|
||||
'supportingMessage' => $this->supportingMessage,
|
||||
'tone' => $this->tone,
|
||||
'positiveClaimAllowed' => $this->positiveClaimAllowed,
|
||||
'trustworthinessLevel' => $this->trustworthinessLevel,
|
||||
'evaluationResult' => $this->evaluationResult,
|
||||
'evidenceImpact' => $this->evidenceImpact,
|
||||
'findingsVisibleCount' => $this->findingsVisibleCount,
|
||||
'highSeverityCount' => $this->highSeverityCount,
|
||||
'nextAction' => $this->nextAction,
|
||||
'lastComparedLabel' => $this->lastComparedLabel,
|
||||
'reasonCode' => $this->reasonCode,
|
||||
'overdueOpenFindingsCount' => $this->overdueOpenFindingsCount,
|
||||
'expiringGovernanceCount' => $this->expiringGovernanceCount,
|
||||
'lapsedGovernanceCount' => $this->lapsedGovernanceCount,
|
||||
];
|
||||
}
|
||||
}
|
||||
387
app/Support/Baselines/BaselineCompareSummaryAssessor.php
Normal file
387
app/Support/Baselines/BaselineCompareSummaryAssessor.php
Normal file
@ -0,0 +1,387 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Support\Baselines;
|
||||
|
||||
use App\Support\Ui\OperatorExplanation\OperatorExplanationPattern;
|
||||
use App\Support\Ui\OperatorExplanation\TrustworthinessLevel;
|
||||
use Carbon\CarbonImmutable;
|
||||
|
||||
final class BaselineCompareSummaryAssessor
|
||||
{
|
||||
private const int STALE_AFTER_DAYS = 7;
|
||||
|
||||
public function assess(BaselineCompareStats $stats): BaselineCompareSummaryAssessment
|
||||
{
|
||||
$explanation = $stats->operatorExplanation();
|
||||
$findingsVisibleCount = (int) ($stats->findingsCount ?? 0);
|
||||
$highSeverityCount = (int) ($stats->severityCounts['high'] ?? 0);
|
||||
$overdueOpenFindingsCount = $stats->overdueOpenFindingsCount;
|
||||
$expiringGovernanceCount = $stats->expiringGovernanceCount;
|
||||
$lapsedGovernanceCount = $stats->lapsedGovernanceCount;
|
||||
$reasonCode = is_string($stats->reasonCode) ? BaselineCompareReasonCode::tryFrom($stats->reasonCode) : null;
|
||||
$evaluationResult = $stats->state === 'failed'
|
||||
? 'failed_result'
|
||||
: $explanation->evaluationResult;
|
||||
$positiveClaimAllowed = $this->positiveClaimAllowed(
|
||||
$stats,
|
||||
$explanation,
|
||||
$reasonCode,
|
||||
$evaluationResult,
|
||||
$overdueOpenFindingsCount,
|
||||
$expiringGovernanceCount,
|
||||
$lapsedGovernanceCount,
|
||||
);
|
||||
$isStale = $this->hasStaleResult($stats, $evaluationResult);
|
||||
$stateFamily = $this->stateFamily(
|
||||
$stats,
|
||||
$findingsVisibleCount,
|
||||
$positiveClaimAllowed,
|
||||
$isStale,
|
||||
$overdueOpenFindingsCount,
|
||||
$expiringGovernanceCount,
|
||||
$lapsedGovernanceCount,
|
||||
);
|
||||
$summaryReasonCode = $this->summaryReasonCode(
|
||||
$stats,
|
||||
$overdueOpenFindingsCount,
|
||||
$expiringGovernanceCount,
|
||||
$lapsedGovernanceCount,
|
||||
);
|
||||
|
||||
return new BaselineCompareSummaryAssessment(
|
||||
stateFamily: $stateFamily,
|
||||
headline: $this->headline(
|
||||
$stats,
|
||||
$stateFamily,
|
||||
$findingsVisibleCount,
|
||||
$highSeverityCount,
|
||||
$evaluationResult,
|
||||
$overdueOpenFindingsCount,
|
||||
$expiringGovernanceCount,
|
||||
$lapsedGovernanceCount,
|
||||
),
|
||||
supportingMessage: $this->supportingMessage(
|
||||
$stats,
|
||||
$stateFamily,
|
||||
$findingsVisibleCount,
|
||||
$evaluationResult,
|
||||
$overdueOpenFindingsCount,
|
||||
$expiringGovernanceCount,
|
||||
$lapsedGovernanceCount,
|
||||
),
|
||||
tone: $this->tone($stats, $stateFamily),
|
||||
positiveClaimAllowed: $positiveClaimAllowed,
|
||||
trustworthinessLevel: $explanation->trustworthinessLevel->value,
|
||||
evaluationResult: $evaluationResult,
|
||||
evidenceImpact: $this->evidenceImpact($stats, $evaluationResult, $isStale),
|
||||
findingsVisibleCount: $findingsVisibleCount,
|
||||
highSeverityCount: $highSeverityCount,
|
||||
nextAction: $this->nextAction(
|
||||
$stats,
|
||||
$stateFamily,
|
||||
$findingsVisibleCount,
|
||||
$evaluationResult,
|
||||
$overdueOpenFindingsCount,
|
||||
$expiringGovernanceCount,
|
||||
$lapsedGovernanceCount,
|
||||
),
|
||||
lastComparedLabel: $stats->lastComparedHuman,
|
||||
reasonCode: $summaryReasonCode,
|
||||
overdueOpenFindingsCount: $overdueOpenFindingsCount,
|
||||
expiringGovernanceCount: $expiringGovernanceCount,
|
||||
lapsedGovernanceCount: $lapsedGovernanceCount,
|
||||
);
|
||||
}
|
||||
|
||||
private function positiveClaimAllowed(
|
||||
BaselineCompareStats $stats,
|
||||
OperatorExplanationPattern $explanation,
|
||||
?BaselineCompareReasonCode $reasonCode,
|
||||
string $evaluationResult,
|
||||
int $overdueOpenFindingsCount,
|
||||
int $expiringGovernanceCount,
|
||||
int $lapsedGovernanceCount,
|
||||
): bool {
|
||||
if ($stats->state !== 'ready') {
|
||||
return false;
|
||||
}
|
||||
|
||||
if ((int) ($stats->findingsCount ?? 0) > 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if ($evaluationResult !== 'no_result') {
|
||||
return false;
|
||||
}
|
||||
|
||||
if ($explanation->trustworthinessLevel !== TrustworthinessLevel::Trustworthy) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (in_array($stats->coverageStatus, ['warning', 'unproven'], true)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if ((int) ($stats->evidenceGapsCount ?? 0) > 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if ($overdueOpenFindingsCount > 0 || $expiringGovernanceCount > 0 || $lapsedGovernanceCount > 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if ($this->hasStaleResult($stats, $evaluationResult)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if ($stats->reasonCode === null) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return $reasonCode?->supportsPositiveClaim() ?? false;
|
||||
}
|
||||
|
||||
private function stateFamily(
|
||||
BaselineCompareStats $stats,
|
||||
int $findingsVisibleCount,
|
||||
bool $positiveClaimAllowed,
|
||||
bool $isStale,
|
||||
int $overdueOpenFindingsCount,
|
||||
int $expiringGovernanceCount,
|
||||
int $lapsedGovernanceCount,
|
||||
): string {
|
||||
return match (true) {
|
||||
$stats->state === 'comparing' => BaselineCompareSummaryAssessment::STATE_IN_PROGRESS,
|
||||
$stats->state === 'failed',
|
||||
$findingsVisibleCount > 0,
|
||||
$overdueOpenFindingsCount > 0,
|
||||
$expiringGovernanceCount > 0,
|
||||
$lapsedGovernanceCount > 0 => BaselineCompareSummaryAssessment::STATE_ACTION_REQUIRED,
|
||||
in_array($stats->state, ['no_tenant', 'no_assignment', 'no_snapshot', 'idle'], true) => BaselineCompareSummaryAssessment::STATE_UNAVAILABLE,
|
||||
$isStale => BaselineCompareSummaryAssessment::STATE_STALE,
|
||||
$positiveClaimAllowed => BaselineCompareSummaryAssessment::STATE_POSITIVE,
|
||||
default => BaselineCompareSummaryAssessment::STATE_CAUTION,
|
||||
};
|
||||
}
|
||||
|
||||
private function evidenceImpact(BaselineCompareStats $stats, string $evaluationResult, bool $isStale): string
|
||||
{
|
||||
if (in_array($stats->state, ['no_tenant', 'no_assignment', 'no_snapshot', 'idle', 'failed'], true)) {
|
||||
return BaselineCompareSummaryAssessment::EVIDENCE_UNAVAILABLE;
|
||||
}
|
||||
|
||||
if ($isStale) {
|
||||
return BaselineCompareSummaryAssessment::EVIDENCE_STALE_RESULT;
|
||||
}
|
||||
|
||||
if ($evaluationResult === 'suppressed_result') {
|
||||
return BaselineCompareSummaryAssessment::EVIDENCE_SUPPRESSED_OUTPUT;
|
||||
}
|
||||
|
||||
if ((int) ($stats->evidenceGapsCount ?? 0) > 0) {
|
||||
return BaselineCompareSummaryAssessment::EVIDENCE_EVIDENCE_GAP;
|
||||
}
|
||||
|
||||
if (in_array($stats->coverageStatus, ['warning', 'unproven'], true)) {
|
||||
return BaselineCompareSummaryAssessment::EVIDENCE_COVERAGE_WARNING;
|
||||
}
|
||||
|
||||
return BaselineCompareSummaryAssessment::EVIDENCE_NONE;
|
||||
}
|
||||
|
||||
private function headline(
|
||||
BaselineCompareStats $stats,
|
||||
string $stateFamily,
|
||||
int $findingsVisibleCount,
|
||||
int $highSeverityCount,
|
||||
string $evaluationResult,
|
||||
int $overdueOpenFindingsCount,
|
||||
int $expiringGovernanceCount,
|
||||
int $lapsedGovernanceCount,
|
||||
): string {
|
||||
return match ($stateFamily) {
|
||||
BaselineCompareSummaryAssessment::STATE_POSITIVE => 'No confirmed drift in the latest baseline compare.',
|
||||
BaselineCompareSummaryAssessment::STATE_CAUTION => match (true) {
|
||||
$evaluationResult === 'suppressed_result' => 'The last compare finished, but normal result output was suppressed.',
|
||||
(int) ($stats->evidenceGapsCount ?? 0) > 0 => 'No confirmed drift is visible, but evidence gaps still limit this result.',
|
||||
in_array($stats->coverageStatus, ['warning', 'unproven'], true) => 'No confirmed drift is visible, but coverage limits this compare.',
|
||||
default => 'The latest compare result needs caution before you treat it as an all-clear.',
|
||||
},
|
||||
BaselineCompareSummaryAssessment::STATE_STALE => 'The latest baseline compare result is stale.',
|
||||
BaselineCompareSummaryAssessment::STATE_ACTION_REQUIRED => match (true) {
|
||||
$stats->state === 'failed' || $evaluationResult === 'failed_result' => 'The latest baseline compare failed before it produced a usable result.',
|
||||
$lapsedGovernanceCount > 0 => sprintf('Accepted-risk governance has lapsed on %d finding%s.', $lapsedGovernanceCount, $lapsedGovernanceCount === 1 ? '' : 's'),
|
||||
$overdueOpenFindingsCount > 0 => sprintf('%d overdue finding%s still need review.', $overdueOpenFindingsCount, $overdueOpenFindingsCount === 1 ? '' : 's'),
|
||||
$expiringGovernanceCount > 0 => sprintf('Accepted-risk governance is nearing expiry on %d finding%s.', $expiringGovernanceCount, $expiringGovernanceCount === 1 ? '' : 's'),
|
||||
$highSeverityCount > 0 => sprintf('%d high-severity drift finding%s need review.', $highSeverityCount, $highSeverityCount === 1 ? '' : 's'),
|
||||
default => sprintf('%d open drift finding%s need review.', $findingsVisibleCount, $findingsVisibleCount === 1 ? '' : 's'),
|
||||
},
|
||||
BaselineCompareSummaryAssessment::STATE_IN_PROGRESS => 'Baseline compare is in progress.',
|
||||
default => match ($stats->state) {
|
||||
'no_assignment' => 'This tenant does not have an assigned baseline yet.',
|
||||
'no_snapshot' => 'The current baseline snapshot is not available for compare.',
|
||||
'idle' => 'A current baseline compare result is not available yet.',
|
||||
default => 'A usable baseline compare result is not currently available.',
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
private function supportingMessage(
|
||||
BaselineCompareStats $stats,
|
||||
string $stateFamily,
|
||||
int $findingsVisibleCount,
|
||||
string $evaluationResult,
|
||||
int $overdueOpenFindingsCount,
|
||||
int $expiringGovernanceCount,
|
||||
int $lapsedGovernanceCount,
|
||||
): ?string {
|
||||
return match ($stateFamily) {
|
||||
BaselineCompareSummaryAssessment::STATE_POSITIVE => $stats->lastComparedHuman !== null
|
||||
? 'Last compared '.$stats->lastComparedHuman.'.'
|
||||
: 'The latest compare result is trustworthy enough to treat zero findings as current.',
|
||||
BaselineCompareSummaryAssessment::STATE_CAUTION => match (true) {
|
||||
$evaluationResult === 'suppressed_result' => 'Review the run detail before treating zero visible findings as complete.',
|
||||
(int) ($stats->evidenceGapsCount ?? 0) > 0 => 'Review the compare detail to see which evidence gaps still limit trust.',
|
||||
in_array($stats->coverageStatus, ['warning', 'unproven'], true) => 'Coverage warnings mean zero visible findings are not an all-clear on their own.',
|
||||
default => $stats->reasonMessage ?? $stats->message,
|
||||
},
|
||||
BaselineCompareSummaryAssessment::STATE_STALE => $stats->lastComparedHuman !== null
|
||||
? 'Last compared '.$stats->lastComparedHuman.'. Refresh compare before relying on this posture.'
|
||||
: 'Refresh compare before relying on this posture.',
|
||||
BaselineCompareSummaryAssessment::STATE_ACTION_REQUIRED => match (true) {
|
||||
$stats->state === 'failed' => $stats->failureReason,
|
||||
$lapsedGovernanceCount > 0 => 'Restore valid governance or move those findings back into active remediation before relying on accepted risk.',
|
||||
$overdueOpenFindingsCount > 0 => 'Overdue workflow items remain even if the latest compare did not introduce new drift findings.',
|
||||
$expiringGovernanceCount > 0 => 'Current governance is still valid, but review or renewal is due soon.',
|
||||
$findingsVisibleCount > 0 => 'Open findings remain on this tenant and need review.',
|
||||
default => $stats->message,
|
||||
},
|
||||
BaselineCompareSummaryAssessment::STATE_IN_PROGRESS => 'Current counts are diagnostic only until the compare run finishes.',
|
||||
default => $stats->message,
|
||||
};
|
||||
}
|
||||
|
||||
private function tone(BaselineCompareStats $stats, string $stateFamily): string
|
||||
{
|
||||
return match ($stateFamily) {
|
||||
BaselineCompareSummaryAssessment::STATE_POSITIVE => 'success',
|
||||
BaselineCompareSummaryAssessment::STATE_ACTION_REQUIRED => 'danger',
|
||||
BaselineCompareSummaryAssessment::STATE_IN_PROGRESS => 'info',
|
||||
BaselineCompareSummaryAssessment::STATE_UNAVAILABLE => $stats->state === 'no_snapshot' ? 'warning' : 'gray',
|
||||
default => 'warning',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{label: string, target: string}
|
||||
*/
|
||||
private function nextAction(
|
||||
BaselineCompareStats $stats,
|
||||
string $stateFamily,
|
||||
int $findingsVisibleCount,
|
||||
string $evaluationResult,
|
||||
int $overdueOpenFindingsCount,
|
||||
int $expiringGovernanceCount,
|
||||
int $lapsedGovernanceCount,
|
||||
): array {
|
||||
if ($findingsVisibleCount > 0 || $overdueOpenFindingsCount > 0 || $expiringGovernanceCount > 0 || $lapsedGovernanceCount > 0) {
|
||||
return [
|
||||
'label' => 'Open findings',
|
||||
'target' => BaselineCompareSummaryAssessment::NEXT_TARGET_FINDINGS,
|
||||
];
|
||||
}
|
||||
|
||||
return match ($stateFamily) {
|
||||
BaselineCompareSummaryAssessment::STATE_ACTION_REQUIRED => [
|
||||
'label' => $evaluationResult === 'failed_result' ? 'Review the failed run' : 'Review compare detail',
|
||||
'target' => $stats->operationRunId !== null
|
||||
? BaselineCompareSummaryAssessment::NEXT_TARGET_RUN
|
||||
: BaselineCompareSummaryAssessment::NEXT_TARGET_LANDING,
|
||||
],
|
||||
BaselineCompareSummaryAssessment::STATE_CAUTION => [
|
||||
'label' => 'Review compare detail',
|
||||
'target' => $stats->operationRunId !== null
|
||||
? BaselineCompareSummaryAssessment::NEXT_TARGET_RUN
|
||||
: BaselineCompareSummaryAssessment::NEXT_TARGET_LANDING,
|
||||
],
|
||||
BaselineCompareSummaryAssessment::STATE_STALE => [
|
||||
'label' => 'Open Baseline Compare',
|
||||
'target' => BaselineCompareSummaryAssessment::NEXT_TARGET_LANDING,
|
||||
],
|
||||
BaselineCompareSummaryAssessment::STATE_IN_PROGRESS => [
|
||||
'label' => $stats->operationRunId !== null ? 'View run' : 'Open Baseline Compare',
|
||||
'target' => $stats->operationRunId !== null
|
||||
? BaselineCompareSummaryAssessment::NEXT_TARGET_RUN
|
||||
: BaselineCompareSummaryAssessment::NEXT_TARGET_LANDING,
|
||||
],
|
||||
BaselineCompareSummaryAssessment::STATE_UNAVAILABLE => match ($stats->state) {
|
||||
'no_assignment' => [
|
||||
'label' => 'Assign a baseline first',
|
||||
'target' => BaselineCompareSummaryAssessment::NEXT_TARGET_NONE,
|
||||
],
|
||||
'no_snapshot' => [
|
||||
'label' => 'Review baseline prerequisites',
|
||||
'target' => BaselineCompareSummaryAssessment::NEXT_TARGET_LANDING,
|
||||
],
|
||||
'idle' => [
|
||||
'label' => 'Open Baseline Compare',
|
||||
'target' => BaselineCompareSummaryAssessment::NEXT_TARGET_LANDING,
|
||||
],
|
||||
default => [
|
||||
'label' => 'Review compare availability',
|
||||
'target' => BaselineCompareSummaryAssessment::NEXT_TARGET_NONE,
|
||||
],
|
||||
},
|
||||
default => [
|
||||
'label' => 'No action needed',
|
||||
'target' => BaselineCompareSummaryAssessment::NEXT_TARGET_NONE,
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
private function summaryReasonCode(
|
||||
BaselineCompareStats $stats,
|
||||
int $overdueOpenFindingsCount,
|
||||
int $expiringGovernanceCount,
|
||||
int $lapsedGovernanceCount,
|
||||
): ?string {
|
||||
if ($lapsedGovernanceCount > 0) {
|
||||
return BaselineCompareReasonCode::GovernanceLapsed->value;
|
||||
}
|
||||
|
||||
if ($overdueOpenFindingsCount > 0) {
|
||||
return BaselineCompareReasonCode::OverdueFindingsRemain->value;
|
||||
}
|
||||
|
||||
if ($expiringGovernanceCount > 0) {
|
||||
return BaselineCompareReasonCode::GovernanceExpiring->value;
|
||||
}
|
||||
|
||||
return $stats->reasonCode;
|
||||
}
|
||||
|
||||
private function hasStaleResult(BaselineCompareStats $stats, string $evaluationResult): bool
|
||||
{
|
||||
if ($stats->state !== 'ready') {
|
||||
return false;
|
||||
}
|
||||
|
||||
if ($stats->lastComparedIso === null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (! in_array($evaluationResult, ['full_result', 'no_result', 'incomplete_result', 'suppressed_result'], true)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
$lastComparedAt = CarbonImmutable::parse($stats->lastComparedIso);
|
||||
} catch (\Throwable) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return $lastComparedAt->lt(now()->subDays(self::STALE_AFTER_DAYS));
|
||||
}
|
||||
}
|
||||
@ -85,4 +85,58 @@ public static function isKnown(?string $reasonCode): bool
|
||||
{
|
||||
return is_string($reasonCode) && in_array(trim($reasonCode), self::all(), true);
|
||||
}
|
||||
|
||||
public static function trustImpact(?string $reasonCode): ?string
|
||||
{
|
||||
return match (trim((string) $reasonCode)) {
|
||||
self::SNAPSHOT_CAPTURE_FAILED => 'limited_confidence',
|
||||
self::COMPARE_ROLLOUT_DISABLED,
|
||||
self::CAPTURE_ROLLOUT_DISABLED,
|
||||
self::SNAPSHOT_BUILDING,
|
||||
self::SNAPSHOT_INCOMPLETE,
|
||||
self::SNAPSHOT_SUPERSEDED,
|
||||
self::SNAPSHOT_COMPLETION_PROOF_FAILED,
|
||||
self::SNAPSHOT_LEGACY_NO_PROOF,
|
||||
self::SNAPSHOT_LEGACY_CONTRADICTORY,
|
||||
self::COMPARE_NO_ASSIGNMENT,
|
||||
self::COMPARE_PROFILE_NOT_ACTIVE,
|
||||
self::COMPARE_NO_ACTIVE_SNAPSHOT,
|
||||
self::COMPARE_NO_CONSUMABLE_SNAPSHOT,
|
||||
self::COMPARE_NO_ELIGIBLE_TARGET,
|
||||
self::COMPARE_INVALID_SNAPSHOT,
|
||||
self::COMPARE_SNAPSHOT_BUILDING,
|
||||
self::COMPARE_SNAPSHOT_INCOMPLETE,
|
||||
self::COMPARE_SNAPSHOT_SUPERSEDED,
|
||||
self::CAPTURE_MISSING_SOURCE_TENANT,
|
||||
self::CAPTURE_PROFILE_NOT_ACTIVE => 'unusable',
|
||||
default => null,
|
||||
};
|
||||
}
|
||||
|
||||
public static function absencePattern(?string $reasonCode): ?string
|
||||
{
|
||||
return match (trim((string) $reasonCode)) {
|
||||
self::SNAPSHOT_BUILDING,
|
||||
self::SNAPSHOT_INCOMPLETE,
|
||||
self::SNAPSHOT_COMPLETION_PROOF_FAILED,
|
||||
self::SNAPSHOT_LEGACY_NO_PROOF,
|
||||
self::SNAPSHOT_LEGACY_CONTRADICTORY,
|
||||
self::COMPARE_NO_ACTIVE_SNAPSHOT,
|
||||
self::COMPARE_NO_CONSUMABLE_SNAPSHOT,
|
||||
self::COMPARE_SNAPSHOT_BUILDING,
|
||||
self::COMPARE_SNAPSHOT_INCOMPLETE => 'missing_input',
|
||||
self::CAPTURE_MISSING_SOURCE_TENANT,
|
||||
self::CAPTURE_PROFILE_NOT_ACTIVE,
|
||||
self::CAPTURE_ROLLOUT_DISABLED,
|
||||
self::COMPARE_NO_ASSIGNMENT,
|
||||
self::COMPARE_PROFILE_NOT_ACTIVE,
|
||||
self::COMPARE_NO_ELIGIBLE_TARGET,
|
||||
self::COMPARE_INVALID_SNAPSHOT,
|
||||
self::COMPARE_ROLLOUT_DISABLED,
|
||||
self::SNAPSHOT_SUPERSEDED,
|
||||
self::COMPARE_SNAPSHOT_SUPERSEDED => 'blocked_prerequisite',
|
||||
self::SNAPSHOT_CAPTURE_FAILED => 'unavailable',
|
||||
default => null,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@ -118,6 +118,17 @@ public function allTypes(): array
|
||||
));
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<string>
|
||||
*/
|
||||
public function truthfulTypes(string $operation, ?BaselineSupportCapabilityGuard $guard = null): array
|
||||
{
|
||||
$guard ??= app(BaselineSupportCapabilityGuard::class);
|
||||
$guardResult = $guard->guardTypes($this->allTypes(), $operation);
|
||||
|
||||
return $guardResult['allowed_types'];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
@ -134,17 +145,32 @@ public function toJsonb(): array
|
||||
*
|
||||
* @return array{policy_types: list<string>, foundation_types: list<string>, all_types: list<string>, foundations_included: bool}
|
||||
*/
|
||||
public function toEffectiveScopeContext(): array
|
||||
public function toEffectiveScopeContext(?BaselineSupportCapabilityGuard $guard = null, ?string $operation = null): array
|
||||
{
|
||||
$expanded = $this->expandDefaults();
|
||||
$allTypes = self::uniqueSorted(array_merge($expanded->policyTypes, $expanded->foundationTypes));
|
||||
|
||||
return [
|
||||
$context = [
|
||||
'policy_types' => $expanded->policyTypes,
|
||||
'foundation_types' => $expanded->foundationTypes,
|
||||
'all_types' => $allTypes,
|
||||
'foundations_included' => $expanded->foundationTypes !== [],
|
||||
];
|
||||
|
||||
if (! is_string($operation) || $operation === '') {
|
||||
return $context;
|
||||
}
|
||||
|
||||
$guard ??= app(BaselineSupportCapabilityGuard::class);
|
||||
$guardResult = $guard->guardTypes($allTypes, $operation);
|
||||
|
||||
return array_merge($context, [
|
||||
'truthful_types' => $guardResult['allowed_types'],
|
||||
'limited_types' => $guardResult['limited_types'],
|
||||
'unsupported_types' => $guardResult['unsupported_types'],
|
||||
'invalid_support_types' => $guardResult['invalid_support_types'],
|
||||
'capabilities' => $guardResult['capabilities'],
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
79
app/Support/Baselines/BaselineSupportCapabilityGuard.php
Normal file
79
app/Support/Baselines/BaselineSupportCapabilityGuard.php
Normal file
@ -0,0 +1,79 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Support\Baselines;
|
||||
|
||||
final class BaselineSupportCapabilityGuard
|
||||
{
|
||||
public function __construct(
|
||||
private readonly SubjectResolver $resolver,
|
||||
) {}
|
||||
|
||||
public function inspectType(string $policyType): SupportCapabilityRecord
|
||||
{
|
||||
return $this->resolver->capability($policyType);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<string> $policyTypes
|
||||
* @return array{
|
||||
* allowed_types: list<string>,
|
||||
* limited_types: list<string>,
|
||||
* unsupported_types: list<string>,
|
||||
* invalid_support_types: list<string>,
|
||||
* capabilities: array<string, array<string, mixed>>
|
||||
* }
|
||||
*/
|
||||
public function guardTypes(array $policyTypes, string $operation): array
|
||||
{
|
||||
$allowedTypes = [];
|
||||
$limitedTypes = [];
|
||||
$unsupportedTypes = [];
|
||||
$invalidSupportTypes = [];
|
||||
$capabilities = [];
|
||||
|
||||
foreach (array_values(array_unique(array_filter($policyTypes, 'is_string'))) as $policyType) {
|
||||
$record = $this->inspectType($policyType);
|
||||
$mode = $record->supportModeFor($operation);
|
||||
|
||||
$capabilities[$policyType] = array_merge(
|
||||
$record->toArray(),
|
||||
['support_mode' => $mode],
|
||||
);
|
||||
|
||||
if ($mode === 'invalid_support_config') {
|
||||
$invalidSupportTypes[] = $policyType;
|
||||
$unsupportedTypes[] = $policyType;
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($record->allows($operation)) {
|
||||
$allowedTypes[] = $policyType;
|
||||
|
||||
if ($mode === 'limited') {
|
||||
$limitedTypes[] = $policyType;
|
||||
}
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$unsupportedTypes[] = $policyType;
|
||||
}
|
||||
|
||||
sort($allowedTypes, SORT_STRING);
|
||||
sort($limitedTypes, SORT_STRING);
|
||||
sort($unsupportedTypes, SORT_STRING);
|
||||
sort($invalidSupportTypes, SORT_STRING);
|
||||
ksort($capabilities);
|
||||
|
||||
return [
|
||||
'allowed_types' => $allowedTypes,
|
||||
'limited_types' => $limitedTypes,
|
||||
'unsupported_types' => $unsupportedTypes,
|
||||
'invalid_support_types' => $invalidSupportTypes,
|
||||
'capabilities' => $capabilities,
|
||||
];
|
||||
}
|
||||
}
|
||||
16
app/Support/Baselines/OperatorActionCategory.php
Normal file
16
app/Support/Baselines/OperatorActionCategory.php
Normal file
@ -0,0 +1,16 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Support\Baselines;
|
||||
|
||||
enum OperatorActionCategory: string
|
||||
{
|
||||
case None = 'none';
|
||||
case Retry = 'retry';
|
||||
case RunInventorySync = 'run_inventory_sync';
|
||||
case RunPolicySyncOrBackup = 'run_policy_sync_or_backup';
|
||||
case ReviewPermissions = 'review_permissions';
|
||||
case InspectSubjectMapping = 'inspect_subject_mapping';
|
||||
case ProductFollowUp = 'product_follow_up';
|
||||
}
|
||||
25
app/Support/Baselines/ResolutionOutcome.php
Normal file
25
app/Support/Baselines/ResolutionOutcome.php
Normal file
@ -0,0 +1,25 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Support\Baselines;
|
||||
|
||||
enum ResolutionOutcome: string
|
||||
{
|
||||
case ResolvedPolicy = 'resolved_policy';
|
||||
case ResolvedInventory = 'resolved_inventory';
|
||||
case PolicyRecordMissing = 'policy_record_missing';
|
||||
case InventoryRecordMissing = 'inventory_record_missing';
|
||||
case FoundationInventoryOnly = 'foundation_inventory_only';
|
||||
case ResolutionTypeMismatch = 'resolution_type_mismatch';
|
||||
case UnresolvableSubject = 'unresolvable_subject';
|
||||
case InvalidSupportConfig = 'invalid_support_config';
|
||||
case PermissionOrScopeBlocked = 'permission_or_scope_blocked';
|
||||
case AmbiguousMatch = 'ambiguous_match';
|
||||
case InvalidSubject = 'invalid_subject';
|
||||
case DuplicateSubject = 'duplicate_subject';
|
||||
case RetryableCaptureFailure = 'retryable_capture_failure';
|
||||
case CaptureFailed = 'capture_failed';
|
||||
case Throttled = 'throttled';
|
||||
case BudgetExhausted = 'budget_exhausted';
|
||||
}
|
||||
36
app/Support/Baselines/ResolutionOutcomeRecord.php
Normal file
36
app/Support/Baselines/ResolutionOutcomeRecord.php
Normal file
@ -0,0 +1,36 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Support\Baselines;
|
||||
|
||||
final class ResolutionOutcomeRecord
|
||||
{
|
||||
/**
|
||||
* @param non-empty-string $reasonCode
|
||||
* @param 'policy'|'inventory'|'derived'|null $sourceModelExpected
|
||||
* @param 'policy'|'inventory'|'derived'|null $sourceModelFound
|
||||
*/
|
||||
public function __construct(
|
||||
public readonly ResolutionOutcome $resolutionOutcome,
|
||||
public readonly string $reasonCode,
|
||||
public readonly OperatorActionCategory $operatorActionCategory,
|
||||
public readonly bool $structural,
|
||||
public readonly bool $retryable,
|
||||
public readonly ?string $sourceModelExpected = null,
|
||||
public readonly ?string $sourceModelFound = null,
|
||||
) {}
|
||||
|
||||
public function toArray(): array
|
||||
{
|
||||
return [
|
||||
'resolution_outcome' => $this->resolutionOutcome->value,
|
||||
'reason_code' => $this->reasonCode,
|
||||
'operator_action_category' => $this->operatorActionCategory->value,
|
||||
'structural' => $this->structural,
|
||||
'retryable' => $this->retryable,
|
||||
'source_model_expected' => $this->sourceModelExpected,
|
||||
'source_model_found' => $this->sourceModelFound,
|
||||
];
|
||||
}
|
||||
}
|
||||
14
app/Support/Baselines/ResolutionPath.php
Normal file
14
app/Support/Baselines/ResolutionPath.php
Normal file
@ -0,0 +1,14 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Support\Baselines;
|
||||
|
||||
enum ResolutionPath: string
|
||||
{
|
||||
case Policy = 'policy';
|
||||
case Inventory = 'inventory';
|
||||
case FoundationPolicy = 'foundation_policy';
|
||||
case FoundationInventory = 'foundation_inventory';
|
||||
case Derived = 'derived';
|
||||
}
|
||||
13
app/Support/Baselines/SubjectClass.php
Normal file
13
app/Support/Baselines/SubjectClass.php
Normal file
@ -0,0 +1,13 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Support\Baselines;
|
||||
|
||||
enum SubjectClass: string
|
||||
{
|
||||
case PolicyBacked = 'policy_backed';
|
||||
case InventoryBacked = 'inventory_backed';
|
||||
case FoundationBacked = 'foundation_backed';
|
||||
case Derived = 'derived';
|
||||
}
|
||||
47
app/Support/Baselines/SubjectDescriptor.php
Normal file
47
app/Support/Baselines/SubjectDescriptor.php
Normal file
@ -0,0 +1,47 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Support\Baselines;
|
||||
|
||||
final class SubjectDescriptor
|
||||
{
|
||||
/**
|
||||
* @param non-empty-string $policyType
|
||||
* @param non-empty-string $subjectKey
|
||||
* @param 'supported'|'limited'|'excluded'|'invalid_support_config' $supportMode
|
||||
* @param 'policy'|'inventory'|'derived'|null $sourceModelExpected
|
||||
*/
|
||||
public function __construct(
|
||||
public readonly string $policyType,
|
||||
public readonly ?string $subjectExternalId,
|
||||
public readonly string $subjectKey,
|
||||
public readonly SubjectClass $subjectClass,
|
||||
public readonly ResolutionPath $resolutionPath,
|
||||
public readonly string $supportMode,
|
||||
public readonly ?string $sourceModelExpected,
|
||||
) {}
|
||||
|
||||
public function expectsPolicy(): bool
|
||||
{
|
||||
return $this->sourceModelExpected === 'policy';
|
||||
}
|
||||
|
||||
public function expectsInventory(): bool
|
||||
{
|
||||
return $this->sourceModelExpected === 'inventory';
|
||||
}
|
||||
|
||||
public function toArray(): array
|
||||
{
|
||||
return [
|
||||
'policy_type' => $this->policyType,
|
||||
'subject_external_id' => $this->subjectExternalId,
|
||||
'subject_key' => $this->subjectKey,
|
||||
'subject_class' => $this->subjectClass->value,
|
||||
'resolution_path' => $this->resolutionPath->value,
|
||||
'support_mode' => $this->supportMode,
|
||||
'source_model_expected' => $this->sourceModelExpected,
|
||||
];
|
||||
}
|
||||
}
|
||||
201
app/Support/Baselines/SubjectResolver.php
Normal file
201
app/Support/Baselines/SubjectResolver.php
Normal file
@ -0,0 +1,201 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Support\Baselines;
|
||||
|
||||
use App\Support\Inventory\InventoryPolicyTypeMeta;
|
||||
|
||||
final class SubjectResolver
|
||||
{
|
||||
public function capability(string $policyType): SupportCapabilityRecord
|
||||
{
|
||||
$contract = InventoryPolicyTypeMeta::baselineSupportContract($policyType);
|
||||
|
||||
return new SupportCapabilityRecord(
|
||||
policyType: $policyType,
|
||||
subjectClass: SubjectClass::from($contract['subject_class']),
|
||||
compareCapability: $contract['compare_capability'],
|
||||
captureCapability: $contract['capture_capability'],
|
||||
resolutionPath: ResolutionPath::from($contract['resolution_path']),
|
||||
configSupported: (bool) $contract['config_supported'],
|
||||
runtimeValid: (bool) $contract['runtime_valid'],
|
||||
sourceModelExpected: $contract['source_model_expected'],
|
||||
);
|
||||
}
|
||||
|
||||
public function describeForCompare(string $policyType, ?string $subjectExternalId = null, ?string $subjectKey = null): SubjectDescriptor
|
||||
{
|
||||
return $this->describe(operation: 'compare', policyType: $policyType, subjectExternalId: $subjectExternalId, subjectKey: $subjectKey);
|
||||
}
|
||||
|
||||
public function describeForCapture(string $policyType, ?string $subjectExternalId = null, ?string $subjectKey = null): SubjectDescriptor
|
||||
{
|
||||
return $this->describe(operation: 'capture', policyType: $policyType, subjectExternalId: $subjectExternalId, subjectKey: $subjectKey);
|
||||
}
|
||||
|
||||
public function resolved(SubjectDescriptor $descriptor, ?string $sourceModelFound = null): ResolutionOutcomeRecord
|
||||
{
|
||||
$outcome = $descriptor->expectsPolicy()
|
||||
? ResolutionOutcome::ResolvedPolicy
|
||||
: ResolutionOutcome::ResolvedInventory;
|
||||
|
||||
return new ResolutionOutcomeRecord(
|
||||
resolutionOutcome: $outcome,
|
||||
reasonCode: $outcome->value,
|
||||
operatorActionCategory: OperatorActionCategory::None,
|
||||
structural: false,
|
||||
retryable: false,
|
||||
sourceModelExpected: $descriptor->sourceModelExpected,
|
||||
sourceModelFound: $sourceModelFound ?? $descriptor->sourceModelExpected,
|
||||
);
|
||||
}
|
||||
|
||||
public function missingExpectedRecord(SubjectDescriptor $descriptor): ResolutionOutcomeRecord
|
||||
{
|
||||
$expectsPolicy = $descriptor->expectsPolicy();
|
||||
|
||||
return new ResolutionOutcomeRecord(
|
||||
resolutionOutcome: $expectsPolicy ? ResolutionOutcome::PolicyRecordMissing : ResolutionOutcome::InventoryRecordMissing,
|
||||
reasonCode: $expectsPolicy ? 'policy_record_missing' : 'inventory_record_missing',
|
||||
operatorActionCategory: $expectsPolicy ? OperatorActionCategory::RunPolicySyncOrBackup : OperatorActionCategory::RunInventorySync,
|
||||
structural: false,
|
||||
retryable: false,
|
||||
sourceModelExpected: $descriptor->sourceModelExpected,
|
||||
);
|
||||
}
|
||||
|
||||
public function structuralInventoryOnly(SubjectDescriptor $descriptor): ResolutionOutcomeRecord
|
||||
{
|
||||
return new ResolutionOutcomeRecord(
|
||||
resolutionOutcome: ResolutionOutcome::FoundationInventoryOnly,
|
||||
reasonCode: 'foundation_not_policy_backed',
|
||||
operatorActionCategory: OperatorActionCategory::ProductFollowUp,
|
||||
structural: true,
|
||||
retryable: false,
|
||||
sourceModelExpected: $descriptor->sourceModelExpected,
|
||||
sourceModelFound: 'inventory',
|
||||
);
|
||||
}
|
||||
|
||||
public function invalidSubject(SubjectDescriptor $descriptor): ResolutionOutcomeRecord
|
||||
{
|
||||
return new ResolutionOutcomeRecord(
|
||||
resolutionOutcome: ResolutionOutcome::InvalidSubject,
|
||||
reasonCode: 'invalid_subject',
|
||||
operatorActionCategory: OperatorActionCategory::InspectSubjectMapping,
|
||||
structural: false,
|
||||
retryable: false,
|
||||
sourceModelExpected: $descriptor->sourceModelExpected,
|
||||
);
|
||||
}
|
||||
|
||||
public function duplicateSubject(SubjectDescriptor $descriptor): ResolutionOutcomeRecord
|
||||
{
|
||||
return new ResolutionOutcomeRecord(
|
||||
resolutionOutcome: ResolutionOutcome::DuplicateSubject,
|
||||
reasonCode: 'duplicate_subject',
|
||||
operatorActionCategory: OperatorActionCategory::InspectSubjectMapping,
|
||||
structural: false,
|
||||
retryable: false,
|
||||
sourceModelExpected: $descriptor->sourceModelExpected,
|
||||
);
|
||||
}
|
||||
|
||||
public function ambiguousMatch(SubjectDescriptor $descriptor): ResolutionOutcomeRecord
|
||||
{
|
||||
return new ResolutionOutcomeRecord(
|
||||
resolutionOutcome: ResolutionOutcome::AmbiguousMatch,
|
||||
reasonCode: 'ambiguous_match',
|
||||
operatorActionCategory: OperatorActionCategory::InspectSubjectMapping,
|
||||
structural: false,
|
||||
retryable: false,
|
||||
sourceModelExpected: $descriptor->sourceModelExpected,
|
||||
);
|
||||
}
|
||||
|
||||
public function invalidSupportConfiguration(SupportCapabilityRecord $capability): ResolutionOutcomeRecord
|
||||
{
|
||||
return new ResolutionOutcomeRecord(
|
||||
resolutionOutcome: ResolutionOutcome::InvalidSupportConfig,
|
||||
reasonCode: 'invalid_support_config',
|
||||
operatorActionCategory: OperatorActionCategory::ProductFollowUp,
|
||||
structural: true,
|
||||
retryable: false,
|
||||
sourceModelExpected: $capability->sourceModelExpected,
|
||||
);
|
||||
}
|
||||
|
||||
public function throttled(SubjectDescriptor $descriptor): ResolutionOutcomeRecord
|
||||
{
|
||||
return new ResolutionOutcomeRecord(
|
||||
resolutionOutcome: ResolutionOutcome::Throttled,
|
||||
reasonCode: 'throttled',
|
||||
operatorActionCategory: OperatorActionCategory::Retry,
|
||||
structural: false,
|
||||
retryable: true,
|
||||
sourceModelExpected: $descriptor->sourceModelExpected,
|
||||
);
|
||||
}
|
||||
|
||||
public function captureFailed(SubjectDescriptor $descriptor, bool $retryable = false): ResolutionOutcomeRecord
|
||||
{
|
||||
return new ResolutionOutcomeRecord(
|
||||
resolutionOutcome: $retryable ? ResolutionOutcome::RetryableCaptureFailure : ResolutionOutcome::CaptureFailed,
|
||||
reasonCode: $retryable ? 'retryable_capture_failure' : 'capture_failed',
|
||||
operatorActionCategory: OperatorActionCategory::Retry,
|
||||
structural: false,
|
||||
retryable: $retryable,
|
||||
sourceModelExpected: $descriptor->sourceModelExpected,
|
||||
);
|
||||
}
|
||||
|
||||
public function budgetExhausted(SubjectDescriptor $descriptor): ResolutionOutcomeRecord
|
||||
{
|
||||
return new ResolutionOutcomeRecord(
|
||||
resolutionOutcome: ResolutionOutcome::BudgetExhausted,
|
||||
reasonCode: 'budget_exhausted',
|
||||
operatorActionCategory: OperatorActionCategory::Retry,
|
||||
structural: false,
|
||||
retryable: true,
|
||||
sourceModelExpected: $descriptor->sourceModelExpected,
|
||||
);
|
||||
}
|
||||
|
||||
private function describe(string $operation, string $policyType, ?string $subjectExternalId = null, ?string $subjectKey = null): SubjectDescriptor
|
||||
{
|
||||
$capability = $this->capability($policyType);
|
||||
$resolvedSubjectKey = $this->normalizeSubjectKey($policyType, $subjectExternalId, $subjectKey);
|
||||
|
||||
return new SubjectDescriptor(
|
||||
policyType: $policyType,
|
||||
subjectExternalId: $subjectExternalId !== null && trim($subjectExternalId) !== '' ? trim($subjectExternalId) : null,
|
||||
subjectKey: $resolvedSubjectKey,
|
||||
subjectClass: $capability->subjectClass,
|
||||
resolutionPath: $capability->resolutionPath,
|
||||
supportMode: $capability->supportModeFor($operation),
|
||||
sourceModelExpected: $capability->sourceModelExpected,
|
||||
);
|
||||
}
|
||||
|
||||
private function normalizeSubjectKey(string $policyType, ?string $subjectExternalId, ?string $subjectKey): string
|
||||
{
|
||||
$trimmedSubjectKey = is_string($subjectKey) ? trim($subjectKey) : '';
|
||||
|
||||
if ($trimmedSubjectKey !== '') {
|
||||
return $trimmedSubjectKey;
|
||||
}
|
||||
|
||||
$generated = BaselineSubjectKey::forPolicy($policyType, subjectExternalId: $subjectExternalId);
|
||||
|
||||
if (is_string($generated) && $generated !== '') {
|
||||
return $generated;
|
||||
}
|
||||
|
||||
$fallbackExternalId = is_string($subjectExternalId) && trim($subjectExternalId) !== ''
|
||||
? trim($subjectExternalId)
|
||||
: 'unknown';
|
||||
|
||||
return trim($policyType).'|'.$fallbackExternalId;
|
||||
}
|
||||
}
|
||||
67
app/Support/Baselines/SupportCapabilityRecord.php
Normal file
67
app/Support/Baselines/SupportCapabilityRecord.php
Normal file
@ -0,0 +1,67 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Support\Baselines;
|
||||
|
||||
use InvalidArgumentException;
|
||||
|
||||
final class SupportCapabilityRecord
|
||||
{
|
||||
/**
|
||||
* @param non-empty-string $policyType
|
||||
* @param 'supported'|'limited'|'unsupported' $compareCapability
|
||||
* @param 'supported'|'limited'|'unsupported' $captureCapability
|
||||
* @param 'policy'|'inventory'|'derived'|null $sourceModelExpected
|
||||
*/
|
||||
public function __construct(
|
||||
public readonly string $policyType,
|
||||
public readonly SubjectClass $subjectClass,
|
||||
public readonly string $compareCapability,
|
||||
public readonly string $captureCapability,
|
||||
public readonly ResolutionPath $resolutionPath,
|
||||
public readonly bool $configSupported,
|
||||
public readonly bool $runtimeValid,
|
||||
public readonly ?string $sourceModelExpected = null,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* @return 'supported'|'limited'|'excluded'|'invalid_support_config'
|
||||
*/
|
||||
public function supportModeFor(string $operation): string
|
||||
{
|
||||
$capability = match ($operation) {
|
||||
'compare' => $this->compareCapability,
|
||||
'capture' => $this->captureCapability,
|
||||
default => throw new InvalidArgumentException('Unsupported operation ['.$operation.'].'),
|
||||
};
|
||||
|
||||
if ($this->configSupported && ! $this->runtimeValid) {
|
||||
return 'invalid_support_config';
|
||||
}
|
||||
|
||||
return match ($capability) {
|
||||
'supported', 'limited' => $capability,
|
||||
default => 'excluded',
|
||||
};
|
||||
}
|
||||
|
||||
public function allows(string $operation): bool
|
||||
{
|
||||
return in_array($this->supportModeFor($operation), ['supported', 'limited'], true);
|
||||
}
|
||||
|
||||
public function toArray(): array
|
||||
{
|
||||
return [
|
||||
'policy_type' => $this->policyType,
|
||||
'subject_class' => $this->subjectClass->value,
|
||||
'compare_capability' => $this->compareCapability,
|
||||
'capture_capability' => $this->captureCapability,
|
||||
'resolution_path' => $this->resolutionPath->value,
|
||||
'config_supported' => $this->configSupported,
|
||||
'runtime_valid' => $this->runtimeValid,
|
||||
'source_model_expected' => $this->sourceModelExpected,
|
||||
];
|
||||
}
|
||||
}
|
||||
@ -130,6 +130,31 @@ public static function findingStatuses(bool $includeLegacyAcknowledged = true):
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, string>
|
||||
*/
|
||||
public static function findingWorkflowFamilies(): array
|
||||
{
|
||||
return [
|
||||
'active' => 'Active workflow',
|
||||
'accepted_risk' => 'Accepted risk',
|
||||
'historical' => 'Historical',
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, string>
|
||||
*/
|
||||
public static function findingSeverities(): array
|
||||
{
|
||||
return self::badgeOptions(BadgeDomain::FindingSeverity, [
|
||||
Finding::SEVERITY_LOW,
|
||||
Finding::SEVERITY_MEDIUM,
|
||||
Finding::SEVERITY_HIGH,
|
||||
Finding::SEVERITY_CRITICAL,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, string>
|
||||
*/
|
||||
@ -161,6 +186,17 @@ public static function findingExceptionValidityStates(): array
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, string>
|
||||
*/
|
||||
public static function findingGovernanceAttentionStates(): array
|
||||
{
|
||||
return [
|
||||
'healthy' => 'Healthy governance',
|
||||
'attention_needed' => 'Governance attention needed',
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param iterable<mixed>|null $types
|
||||
* @return array<string, string>
|
||||
|
||||
@ -4,6 +4,9 @@
|
||||
|
||||
namespace App\Support\Inventory;
|
||||
|
||||
use App\Support\Baselines\ResolutionPath;
|
||||
use App\Support\Baselines\SubjectClass;
|
||||
|
||||
class InventoryPolicyTypeMeta
|
||||
{
|
||||
/**
|
||||
@ -175,4 +178,141 @@ public static function baselineCompareLabel(?string $type): ?string
|
||||
|
||||
return static::label($type);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{
|
||||
* config_supported: bool,
|
||||
* runtime_valid: bool,
|
||||
* subject_class: string,
|
||||
* resolution_path: string,
|
||||
* compare_capability: string,
|
||||
* capture_capability: string,
|
||||
* source_model_expected: 'policy'|'inventory'|'derived'|null
|
||||
* }
|
||||
*/
|
||||
public static function baselineSupportContract(?string $type): array
|
||||
{
|
||||
$contract = static::defaultBaselineSupportContract($type);
|
||||
$resolution = static::baselineCompareMeta($type)['resolution'] ?? null;
|
||||
|
||||
if (is_array($resolution)) {
|
||||
$contract = array_replace($contract, array_filter([
|
||||
'subject_class' => is_string($resolution['subject_class'] ?? null) ? $resolution['subject_class'] : null,
|
||||
'resolution_path' => is_string($resolution['resolution_path'] ?? null) ? $resolution['resolution_path'] : null,
|
||||
'compare_capability' => is_string($resolution['compare_capability'] ?? null) ? $resolution['compare_capability'] : null,
|
||||
'capture_capability' => is_string($resolution['capture_capability'] ?? null) ? $resolution['capture_capability'] : null,
|
||||
'source_model_expected' => is_string($resolution['source_model_expected'] ?? null) ? $resolution['source_model_expected'] : null,
|
||||
], static fn (mixed $value): bool => $value !== null));
|
||||
}
|
||||
|
||||
$subjectClass = SubjectClass::tryFrom((string) ($contract['subject_class'] ?? ''));
|
||||
$resolutionPath = ResolutionPath::tryFrom((string) ($contract['resolution_path'] ?? ''));
|
||||
$compareCapability = in_array($contract['compare_capability'] ?? null, ['supported', 'limited', 'unsupported'], true)
|
||||
? (string) $contract['compare_capability']
|
||||
: 'unsupported';
|
||||
$captureCapability = in_array($contract['capture_capability'] ?? null, ['supported', 'limited', 'unsupported'], true)
|
||||
? (string) $contract['capture_capability']
|
||||
: 'unsupported';
|
||||
$sourceModelExpected = in_array($contract['source_model_expected'] ?? null, ['policy', 'inventory', 'derived'], true)
|
||||
? (string) $contract['source_model_expected']
|
||||
: null;
|
||||
|
||||
$runtimeValid = $subjectClass instanceof SubjectClass
|
||||
&& $resolutionPath instanceof ResolutionPath
|
||||
&& static::pathMatchesSubjectClass($subjectClass, $resolutionPath)
|
||||
&& static::pathMatchesExpectedSource($resolutionPath, $sourceModelExpected);
|
||||
|
||||
if (! $runtimeValid) {
|
||||
$compareCapability = 'unsupported';
|
||||
$captureCapability = 'unsupported';
|
||||
}
|
||||
|
||||
return [
|
||||
'config_supported' => (bool) ($contract['config_supported'] ?? false),
|
||||
'runtime_valid' => $runtimeValid,
|
||||
'subject_class' => ($subjectClass ?? SubjectClass::Derived)->value,
|
||||
'resolution_path' => ($resolutionPath ?? ResolutionPath::Derived)->value,
|
||||
'compare_capability' => $compareCapability,
|
||||
'capture_capability' => $captureCapability,
|
||||
'source_model_expected' => $sourceModelExpected,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{
|
||||
* config_supported: bool,
|
||||
* subject_class: string,
|
||||
* resolution_path: string,
|
||||
* compare_capability: string,
|
||||
* capture_capability: string,
|
||||
* source_model_expected: 'policy'|'inventory'|'derived'|null
|
||||
* }
|
||||
*/
|
||||
private static function defaultBaselineSupportContract(?string $type): array
|
||||
{
|
||||
if (filled($type) && ! static::isFoundation($type) && static::metaFor($type) !== []) {
|
||||
return [
|
||||
'config_supported' => true,
|
||||
'subject_class' => SubjectClass::PolicyBacked->value,
|
||||
'resolution_path' => ResolutionPath::Policy->value,
|
||||
'compare_capability' => 'supported',
|
||||
'capture_capability' => 'supported',
|
||||
'source_model_expected' => 'policy',
|
||||
];
|
||||
}
|
||||
|
||||
if (static::isFoundation($type)) {
|
||||
$supported = (bool) (static::baselineCompareMeta($type)['supported'] ?? false);
|
||||
$identityStrategy = static::baselineCompareIdentityStrategy($type);
|
||||
$usesPolicyPath = $identityStrategy === 'external_id';
|
||||
|
||||
return [
|
||||
'config_supported' => $supported,
|
||||
'subject_class' => SubjectClass::FoundationBacked->value,
|
||||
'resolution_path' => $usesPolicyPath
|
||||
? ResolutionPath::FoundationPolicy->value
|
||||
: ResolutionPath::FoundationInventory->value,
|
||||
'compare_capability' => ! $supported
|
||||
? 'unsupported'
|
||||
: ($usesPolicyPath ? 'supported' : 'limited'),
|
||||
'capture_capability' => ! $supported
|
||||
? 'unsupported'
|
||||
: ($usesPolicyPath ? 'supported' : 'limited'),
|
||||
'source_model_expected' => $usesPolicyPath ? 'policy' : 'inventory',
|
||||
];
|
||||
}
|
||||
|
||||
return [
|
||||
'config_supported' => false,
|
||||
'subject_class' => SubjectClass::Derived->value,
|
||||
'resolution_path' => ResolutionPath::Derived->value,
|
||||
'compare_capability' => 'unsupported',
|
||||
'capture_capability' => 'unsupported',
|
||||
'source_model_expected' => 'derived',
|
||||
];
|
||||
}
|
||||
|
||||
private static function pathMatchesSubjectClass(SubjectClass $subjectClass, ResolutionPath $resolutionPath): bool
|
||||
{
|
||||
return match ($subjectClass) {
|
||||
SubjectClass::PolicyBacked => $resolutionPath === ResolutionPath::Policy,
|
||||
SubjectClass::InventoryBacked => $resolutionPath === ResolutionPath::Inventory,
|
||||
SubjectClass::FoundationBacked => in_array($resolutionPath, [
|
||||
ResolutionPath::FoundationInventory,
|
||||
ResolutionPath::FoundationPolicy,
|
||||
], true),
|
||||
SubjectClass::Derived => $resolutionPath === ResolutionPath::Derived,
|
||||
};
|
||||
}
|
||||
|
||||
private static function pathMatchesExpectedSource(ResolutionPath $resolutionPath, ?string $sourceModelExpected): bool
|
||||
{
|
||||
return match ($resolutionPath) {
|
||||
ResolutionPath::Policy,
|
||||
ResolutionPath::FoundationPolicy => $sourceModelExpected === 'policy',
|
||||
ResolutionPath::Inventory,
|
||||
ResolutionPath::FoundationInventory => $sourceModelExpected === 'inventory',
|
||||
ResolutionPath::Derived => $sourceModelExpected === 'derived',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@ -35,6 +35,9 @@
|
||||
use App\Support\References\ReferenceDescriptor;
|
||||
use App\Support\References\ReferenceResolverRegistry;
|
||||
use App\Support\References\RelatedContextReferenceAdapter;
|
||||
use App\Support\Ui\DerivedState\DerivedStateFamily;
|
||||
use App\Support\Ui\DerivedState\DerivedStateKey;
|
||||
use App\Support\Ui\DerivedState\RequestScopedDerivedStateStore;
|
||||
use Filament\Facades\Filament;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Support\Arr;
|
||||
@ -48,6 +51,7 @@ public function __construct(
|
||||
private readonly WorkspaceCapabilityResolver $workspaceCapabilityResolver,
|
||||
private readonly ReferenceResolverRegistry $referenceResolverRegistry,
|
||||
private readonly RelatedContextReferenceAdapter $relatedContextReferenceAdapter,
|
||||
private readonly RequestScopedDerivedStateStore $derivedStateStore,
|
||||
) {}
|
||||
|
||||
/**
|
||||
@ -69,18 +73,41 @@ public function detailEntries(string $sourceType, Model $record): array
|
||||
{
|
||||
return array_map(
|
||||
static fn (RelatedContextEntry $entry): array => $entry->toArray(),
|
||||
$this->resolveEntries($sourceType, CrossResourceNavigationMatrix::SURFACE_DETAIL_SECTION, $record),
|
||||
$this->detailEntryObjects($sourceType, $record),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<array{
|
||||
* key: string,
|
||||
* label: string,
|
||||
* value: string,
|
||||
* secondaryValue: ?string,
|
||||
* targetUrl: ?string,
|
||||
* targetKind: string,
|
||||
* availability: string,
|
||||
* unavailableReason: ?string,
|
||||
* contextBadge: ?string,
|
||||
* priority: int,
|
||||
* actionLabel: string
|
||||
* }>
|
||||
*/
|
||||
public function detailEntriesFresh(string $sourceType, Model $record): array
|
||||
{
|
||||
return array_map(
|
||||
static fn (RelatedContextEntry $entry): array => $entry->toArray(),
|
||||
$this->detailEntryObjects($sourceType, $record, fresh: true),
|
||||
);
|
||||
}
|
||||
|
||||
public function primaryListAction(string $sourceType, Model $record): ?RelatedContextEntry
|
||||
{
|
||||
$entries = array_values(array_filter(
|
||||
$this->resolveEntries($sourceType, CrossResourceNavigationMatrix::SURFACE_LIST_ROW, $record),
|
||||
static fn (RelatedContextEntry $entry): bool => $entry->isAvailable(),
|
||||
));
|
||||
return $this->resolvePrimaryListAction($sourceType, $record);
|
||||
}
|
||||
|
||||
return $entries[0] ?? null;
|
||||
public function primaryListActionFresh(string $sourceType, Model $record): ?RelatedContextEntry
|
||||
{
|
||||
return $this->resolvePrimaryListAction($sourceType, $record, fresh: true);
|
||||
}
|
||||
|
||||
/**
|
||||
@ -89,7 +116,7 @@ public function primaryListAction(string $sourceType, Model $record): ?RelatedCo
|
||||
public function operationLinks(OperationRun $run, ?Tenant $tenant): array
|
||||
{
|
||||
$entries = array_filter(
|
||||
$this->resolveEntries(CrossResourceNavigationMatrix::SOURCE_OPERATION_RUN, CrossResourceNavigationMatrix::SURFACE_DETAIL_SECTION, $run),
|
||||
$this->detailEntryObjects(CrossResourceNavigationMatrix::SOURCE_OPERATION_RUN, $run),
|
||||
static fn (RelatedContextEntry $entry): bool => $entry->isAvailable(),
|
||||
);
|
||||
|
||||
@ -100,20 +127,51 @@ public function operationLinks(OperationRun $run, ?Tenant $tenant): array
|
||||
}
|
||||
|
||||
if ($tenant instanceof Tenant) {
|
||||
$links = ['Open operations' => OperationRunLinks::index($tenant)] + $links;
|
||||
$links = ['Operations' => OperationRunLinks::index($tenant)] + $links;
|
||||
} else {
|
||||
$links = ['Open operations' => OperationRunLinks::index()] + $links;
|
||||
$links = ['Operations' => OperationRunLinks::index()] + $links;
|
||||
}
|
||||
|
||||
return $links;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, string>
|
||||
*/
|
||||
public function operationLinksFresh(OperationRun $run, ?Tenant $tenant): array
|
||||
{
|
||||
$entries = array_filter(
|
||||
$this->detailEntryObjects(CrossResourceNavigationMatrix::SOURCE_OPERATION_RUN, $run, fresh: true),
|
||||
static fn (RelatedContextEntry $entry): bool => $entry->isAvailable(),
|
||||
);
|
||||
|
||||
$links = [];
|
||||
|
||||
foreach ($entries as $entry) {
|
||||
$links[$entry->actionLabel] = (string) $entry->targetUrl;
|
||||
}
|
||||
|
||||
if ($tenant instanceof Tenant) {
|
||||
return ['Operations' => OperationRunLinks::index($tenant)] + $links;
|
||||
}
|
||||
|
||||
return ['Operations' => OperationRunLinks::index()] + $links;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<RelatedContextEntry>
|
||||
*/
|
||||
public function headerEntries(string $sourceType, Model $record): array
|
||||
{
|
||||
return $this->resolveEntries($sourceType, CrossResourceNavigationMatrix::SURFACE_DETAIL_HEADER, $record);
|
||||
return $this->headerEntryObjects($sourceType, $record);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<RelatedContextEntry>
|
||||
*/
|
||||
public function headerEntriesFresh(string $sourceType, Model $record): array
|
||||
{
|
||||
return $this->headerEntryObjects($sourceType, $record, fresh: true);
|
||||
}
|
||||
|
||||
/**
|
||||
@ -247,6 +305,91 @@ private function resolveEntries(string $sourceType, string $surface, Model $reco
|
||||
return $entries;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<RelatedContextEntry>
|
||||
*/
|
||||
private function detailEntryObjects(string $sourceType, Model $record, bool $fresh = false): array
|
||||
{
|
||||
return $this->memoizedEntries(
|
||||
family: DerivedStateFamily::RelatedNavigationDetail,
|
||||
sourceType: $sourceType,
|
||||
record: $record,
|
||||
surface: CrossResourceNavigationMatrix::SURFACE_DETAIL_SECTION,
|
||||
fresh: $fresh,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<RelatedContextEntry>
|
||||
*/
|
||||
private function headerEntryObjects(string $sourceType, Model $record, bool $fresh = false): array
|
||||
{
|
||||
return $this->memoizedEntries(
|
||||
family: DerivedStateFamily::RelatedNavigationHeader,
|
||||
sourceType: $sourceType,
|
||||
record: $record,
|
||||
surface: CrossResourceNavigationMatrix::SURFACE_DETAIL_HEADER,
|
||||
fresh: $fresh,
|
||||
);
|
||||
}
|
||||
|
||||
private function resolvePrimaryListAction(string $sourceType, Model $record, bool $fresh = false): ?RelatedContextEntry
|
||||
{
|
||||
$entries = array_values(array_filter(
|
||||
$this->memoizedEntries(
|
||||
family: DerivedStateFamily::RelatedNavigationPrimary,
|
||||
sourceType: $sourceType,
|
||||
record: $record,
|
||||
surface: CrossResourceNavigationMatrix::SURFACE_LIST_ROW,
|
||||
fresh: $fresh,
|
||||
),
|
||||
static fn (RelatedContextEntry $entry): bool => $entry->isAvailable(),
|
||||
));
|
||||
|
||||
return $entries[0] ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<RelatedContextEntry>
|
||||
*/
|
||||
private function memoizedEntries(
|
||||
DerivedStateFamily $family,
|
||||
string $sourceType,
|
||||
Model $record,
|
||||
string $surface,
|
||||
bool $fresh = false,
|
||||
): array {
|
||||
$key = DerivedStateKey::fromModel(
|
||||
family: $family,
|
||||
record: $record,
|
||||
variant: $sourceType,
|
||||
context: [
|
||||
'source_type' => $sourceType,
|
||||
'surface' => $surface,
|
||||
'active_tenant_id' => $this->activeTenantId(),
|
||||
'route_name' => request()?->route()?->getName(),
|
||||
'user_id' => auth()->id(),
|
||||
],
|
||||
);
|
||||
|
||||
/** @var list<RelatedContextEntry> $entries */
|
||||
$entries = $fresh
|
||||
? $this->derivedStateStore->resolveFresh(
|
||||
$key,
|
||||
fn (): array => $this->resolveEntries($sourceType, $surface, $record),
|
||||
$family->defaultFreshnessPolicy(),
|
||||
$family->allowsNegativeResultCache(),
|
||||
)
|
||||
: $this->derivedStateStore->resolve(
|
||||
$key,
|
||||
fn (): array => $this->resolveEntries($sourceType, $surface, $record),
|
||||
$family->defaultFreshnessPolicy(),
|
||||
$family->allowsNegativeResultCache(),
|
||||
);
|
||||
|
||||
return $entries;
|
||||
}
|
||||
|
||||
private function resolveRule(NavigationMatrixRule $rule, Model $record): ?RelatedContextEntry
|
||||
{
|
||||
return match ($rule->sourceType) {
|
||||
|
||||
@ -121,4 +121,12 @@ public static function isGovernanceArtifactOperation(string $operationType): boo
|
||||
{
|
||||
return self::governanceArtifactFamily($operationType) !== null;
|
||||
}
|
||||
|
||||
public static function supportsOperatorExplanation(string $operationType): bool
|
||||
{
|
||||
$operationType = trim($operationType);
|
||||
|
||||
return self::isGovernanceArtifactOperation($operationType)
|
||||
|| $operationType === 'baseline_compare';
|
||||
}
|
||||
}
|
||||
|
||||
@ -9,7 +9,13 @@
|
||||
use App\Support\OperationCatalog;
|
||||
use App\Support\Operations\OperationRunFreshnessState;
|
||||
use App\Support\ReasonTranslation\ReasonPresenter;
|
||||
use App\Support\ReasonTranslation\ReasonResolutionEnvelope;
|
||||
use App\Support\RedactionIntegrity;
|
||||
use App\Support\Ui\DerivedState\DerivedStateFamily;
|
||||
use App\Support\Ui\DerivedState\DerivedStateKey;
|
||||
use App\Support\Ui\DerivedState\RequestScopedDerivedStateStore;
|
||||
use App\Support\Ui\GovernanceArtifactTruth\ArtifactTruthPresenter;
|
||||
use App\Support\Ui\OperatorExplanation\OperatorExplanationPattern;
|
||||
use Filament\Notifications\Notification as FilamentNotification;
|
||||
|
||||
final class OperationUxPresenter
|
||||
@ -95,10 +101,30 @@ public static function terminalDatabaseNotification(OperationRun $run, ?Tenant $
|
||||
}
|
||||
|
||||
public static function surfaceGuidance(OperationRun $run): ?string
|
||||
{
|
||||
return self::memoizeGuidance(
|
||||
run: $run,
|
||||
variant: 'surface_guidance',
|
||||
resolver: fn (): ?string => self::buildSurfaceGuidance($run),
|
||||
);
|
||||
}
|
||||
|
||||
public static function surfaceGuidanceFresh(OperationRun $run): ?string
|
||||
{
|
||||
return self::memoizeGuidance(
|
||||
run: $run,
|
||||
variant: 'surface_guidance',
|
||||
resolver: fn (): ?string => self::buildSurfaceGuidance($run),
|
||||
fresh: true,
|
||||
);
|
||||
}
|
||||
|
||||
private static function buildSurfaceGuidance(OperationRun $run): ?string
|
||||
{
|
||||
$uxStatus = OperationStatusNormalizer::toUxStatus($run->status, $run->outcome);
|
||||
$reasonEnvelope = self::reasonEnvelope($run);
|
||||
$reasonGuidance = app(ReasonPresenter::class)->guidance($reasonEnvelope);
|
||||
$operatorExplanationGuidance = self::operatorExplanationGuidance($run);
|
||||
$nextStepLabel = self::firstNextStepLabel($run);
|
||||
$freshnessState = self::freshnessState($run);
|
||||
|
||||
@ -107,11 +133,23 @@ public static function surfaceGuidance(OperationRun $run): ?string
|
||||
}
|
||||
|
||||
if ($freshnessState->isReconciledFailed()) {
|
||||
return $reasonGuidance ?? 'TenantPilot reconciled this run after lifecycle truth was lost. Review the recorded evidence before retrying.';
|
||||
return $operatorExplanationGuidance
|
||||
?? $reasonGuidance
|
||||
?? 'TenantPilot reconciled this run after lifecycle truth was lost. Review the recorded evidence before retrying.';
|
||||
}
|
||||
|
||||
if (in_array($uxStatus, ['blocked', 'failed', 'partial'], true) && $reasonGuidance !== null) {
|
||||
return $reasonGuidance;
|
||||
if (in_array($uxStatus, ['blocked', 'failed', 'partial'], true)) {
|
||||
if ($operatorExplanationGuidance !== null) {
|
||||
return $operatorExplanationGuidance;
|
||||
}
|
||||
|
||||
if ($reasonGuidance !== null) {
|
||||
return $reasonGuidance;
|
||||
}
|
||||
}
|
||||
|
||||
if ($uxStatus === 'succeeded' && $operatorExplanationGuidance !== null) {
|
||||
return $operatorExplanationGuidance;
|
||||
}
|
||||
|
||||
return match ($uxStatus) {
|
||||
@ -134,6 +172,38 @@ public static function surfaceGuidance(OperationRun $run): ?string
|
||||
|
||||
public static function surfaceFailureDetail(OperationRun $run): ?string
|
||||
{
|
||||
return self::memoizeExplanation(
|
||||
run: $run,
|
||||
variant: 'surface_failure_detail',
|
||||
resolver: fn (): ?string => self::buildSurfaceFailureDetail($run),
|
||||
);
|
||||
}
|
||||
|
||||
public static function surfaceFailureDetailFresh(OperationRun $run): ?string
|
||||
{
|
||||
return self::memoizeExplanation(
|
||||
run: $run,
|
||||
variant: 'surface_failure_detail',
|
||||
resolver: fn (): ?string => self::buildSurfaceFailureDetail($run),
|
||||
fresh: true,
|
||||
);
|
||||
}
|
||||
|
||||
private static function buildSurfaceFailureDetail(OperationRun $run): ?string
|
||||
{
|
||||
$operatorExplanation = self::governanceOperatorExplanation($run);
|
||||
|
||||
if (is_string($operatorExplanation?->dominantCauseExplanation) && trim($operatorExplanation->dominantCauseExplanation) !== '') {
|
||||
return trim($operatorExplanation->dominantCauseExplanation);
|
||||
}
|
||||
|
||||
$failureMessage = (string) (($run->failure_summary[0]['message'] ?? '') ?? '');
|
||||
$sanitizedFailureMessage = self::sanitizeFailureMessage($failureMessage);
|
||||
|
||||
if ($sanitizedFailureMessage !== null) {
|
||||
return $sanitizedFailureMessage;
|
||||
}
|
||||
|
||||
$reasonEnvelope = self::reasonEnvelope($run);
|
||||
|
||||
if ($reasonEnvelope !== null) {
|
||||
@ -144,9 +214,7 @@ public static function surfaceFailureDetail(OperationRun $run): ?string
|
||||
return 'This run is no longer within its normal lifecycle window and may no longer be progressing.';
|
||||
}
|
||||
|
||||
$failureMessage = (string) (($run->failure_summary[0]['message'] ?? '') ?? '');
|
||||
|
||||
return self::sanitizeFailureMessage($failureMessage);
|
||||
return null;
|
||||
}
|
||||
|
||||
public static function freshnessState(OperationRun $run): OperationRunFreshnessState
|
||||
@ -155,6 +223,25 @@ public static function freshnessState(OperationRun $run): OperationRunFreshnessS
|
||||
}
|
||||
|
||||
public static function lifecycleAttentionSummary(OperationRun $run): ?string
|
||||
{
|
||||
return self::memoizeExplanation(
|
||||
run: $run,
|
||||
variant: 'lifecycle_attention_summary',
|
||||
resolver: fn (): ?string => self::buildLifecycleAttentionSummary($run),
|
||||
);
|
||||
}
|
||||
|
||||
public static function lifecycleAttentionSummaryFresh(OperationRun $run): ?string
|
||||
{
|
||||
return self::memoizeExplanation(
|
||||
run: $run,
|
||||
variant: 'lifecycle_attention_summary',
|
||||
resolver: fn (): ?string => self::buildLifecycleAttentionSummary($run),
|
||||
fresh: true,
|
||||
);
|
||||
}
|
||||
|
||||
private static function buildLifecycleAttentionSummary(OperationRun $run): ?string
|
||||
{
|
||||
return match (self::freshnessState($run)) {
|
||||
OperationRunFreshnessState::LikelyStale => 'Likely stale',
|
||||
@ -163,6 +250,16 @@ public static function lifecycleAttentionSummary(OperationRun $run): ?string
|
||||
};
|
||||
}
|
||||
|
||||
public static function governanceOperatorExplanation(OperationRun $run): ?OperatorExplanationPattern
|
||||
{
|
||||
return self::resolveGovernanceOperatorExplanation($run);
|
||||
}
|
||||
|
||||
public static function governanceOperatorExplanationFresh(OperationRun $run): ?OperatorExplanationPattern
|
||||
{
|
||||
return self::resolveGovernanceOperatorExplanation($run, fresh: true);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{titleSuffix: string, body: string, status: string}
|
||||
*/
|
||||
@ -256,8 +353,101 @@ private static function sanitizeFailureMessage(string $failureMessage): ?string
|
||||
return $failureMessage !== '' ? $failureMessage : null;
|
||||
}
|
||||
|
||||
private static function reasonEnvelope(OperationRun $run): ?\App\Support\ReasonTranslation\ReasonResolutionEnvelope
|
||||
private static function reasonEnvelope(OperationRun $run): ?ReasonResolutionEnvelope
|
||||
{
|
||||
return app(ReasonPresenter::class)->forOperationRun($run, 'notification');
|
||||
return self::memoizeExplanation(
|
||||
run: $run,
|
||||
variant: 'reason_envelope_notification',
|
||||
resolver: fn (): ?ReasonResolutionEnvelope => app(ReasonPresenter::class)->forOperationRun($run, 'notification'),
|
||||
);
|
||||
}
|
||||
|
||||
private static function operatorExplanationGuidance(OperationRun $run): ?string
|
||||
{
|
||||
$operatorExplanation = self::resolveGovernanceOperatorExplanation($run);
|
||||
|
||||
if (! is_string($operatorExplanation?->nextActionText) || trim($operatorExplanation->nextActionText) === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
$text = trim($operatorExplanation->nextActionText);
|
||||
|
||||
if (str_ends_with($text, '.')) {
|
||||
return $text;
|
||||
}
|
||||
|
||||
return $text === 'No action needed'
|
||||
? 'No action needed.'
|
||||
: 'Next step: '.$text.'.';
|
||||
}
|
||||
|
||||
private static function resolveGovernanceOperatorExplanation(OperationRun $run, bool $fresh = false): ?OperatorExplanationPattern
|
||||
{
|
||||
if (! $run->supportsOperatorExplanation()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return self::memoizeExplanation(
|
||||
run: $run,
|
||||
variant: 'governance_operator_explanation',
|
||||
resolver: fn (): ?OperatorExplanationPattern => $fresh
|
||||
? app(ArtifactTruthPresenter::class)->forOperationRunFresh($run)?->operatorExplanation
|
||||
: app(ArtifactTruthPresenter::class)->forOperationRun($run)?->operatorExplanation,
|
||||
fresh: $fresh,
|
||||
);
|
||||
}
|
||||
|
||||
private static function memoizeGuidance(
|
||||
OperationRun $run,
|
||||
string $variant,
|
||||
callable $resolver,
|
||||
bool $fresh = false,
|
||||
): ?string {
|
||||
$key = DerivedStateKey::fromModel(DerivedStateFamily::OperationUxGuidance, $run, $variant);
|
||||
|
||||
/** @var ?string $value */
|
||||
$value = $fresh
|
||||
? self::derivedStateStore()->resolveFresh(
|
||||
$key,
|
||||
$resolver,
|
||||
DerivedStateFamily::OperationUxGuidance->defaultFreshnessPolicy(),
|
||||
DerivedStateFamily::OperationUxGuidance->allowsNegativeResultCache(),
|
||||
)
|
||||
: self::derivedStateStore()->resolve(
|
||||
$key,
|
||||
$resolver,
|
||||
DerivedStateFamily::OperationUxGuidance->defaultFreshnessPolicy(),
|
||||
DerivedStateFamily::OperationUxGuidance->allowsNegativeResultCache(),
|
||||
);
|
||||
|
||||
return $value;
|
||||
}
|
||||
|
||||
private static function memoizeExplanation(
|
||||
OperationRun $run,
|
||||
string $variant,
|
||||
callable $resolver,
|
||||
bool $fresh = false,
|
||||
): mixed {
|
||||
$key = DerivedStateKey::fromModel(DerivedStateFamily::OperationUxExplanation, $run, $variant);
|
||||
|
||||
return $fresh
|
||||
? self::derivedStateStore()->resolveFresh(
|
||||
$key,
|
||||
$resolver,
|
||||
DerivedStateFamily::OperationUxExplanation->defaultFreshnessPolicy(),
|
||||
DerivedStateFamily::OperationUxExplanation->allowsNegativeResultCache(),
|
||||
)
|
||||
: self::derivedStateStore()->resolve(
|
||||
$key,
|
||||
$resolver,
|
||||
DerivedStateFamily::OperationUxExplanation->defaultFreshnessPolicy(),
|
||||
DerivedStateFamily::OperationUxExplanation->allowsNegativeResultCache(),
|
||||
);
|
||||
}
|
||||
|
||||
private static function derivedStateStore(): RequestScopedDerivedStateStore
|
||||
{
|
||||
return app(RequestScopedDerivedStateStore::class);
|
||||
}
|
||||
}
|
||||
|
||||
@ -5,6 +5,7 @@
|
||||
namespace App\Support\ReasonTranslation;
|
||||
|
||||
use App\Support\ReasonTranslation\Contracts\TranslatesReasonCode;
|
||||
use App\Support\Ui\OperatorExplanation\TrustworthinessLevel;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
final class FallbackReasonTranslator implements TranslatesReasonCode
|
||||
@ -43,6 +44,8 @@ public function translate(string $reasonCode, string $surface = 'detail', array
|
||||
nextSteps: $nextSteps,
|
||||
showNoActionNeeded: $actionability === 'non_actionable',
|
||||
diagnosticCodeLabel: $normalizedCode,
|
||||
trustImpact: $this->trustImpactFor($actionability),
|
||||
absencePattern: $this->absencePatternFor($normalizedCode, $actionability),
|
||||
);
|
||||
}
|
||||
|
||||
@ -109,4 +112,36 @@ private function fallbackNextStepsFor(string $actionability): array
|
||||
default => [NextStepOption::instruction('Review access and configuration before retrying.')],
|
||||
};
|
||||
}
|
||||
|
||||
private function trustImpactFor(string $actionability): string
|
||||
{
|
||||
return match ($actionability) {
|
||||
'non_actionable' => TrustworthinessLevel::Trustworthy->value,
|
||||
'retryable_transient' => TrustworthinessLevel::LimitedConfidence->value,
|
||||
default => TrustworthinessLevel::Unusable->value,
|
||||
};
|
||||
}
|
||||
|
||||
private function absencePatternFor(string $reasonCode, string $actionability): ?string
|
||||
{
|
||||
$normalizedCode = strtolower($reasonCode);
|
||||
|
||||
if (str_contains($normalizedCode, 'suppressed')) {
|
||||
return 'suppressed_output';
|
||||
}
|
||||
|
||||
if (str_contains($normalizedCode, 'missing') || str_contains($normalizedCode, 'stale')) {
|
||||
return 'missing_input';
|
||||
}
|
||||
|
||||
if ($actionability === 'prerequisite_missing') {
|
||||
return 'blocked_prerequisite';
|
||||
}
|
||||
|
||||
if ($actionability === 'non_actionable') {
|
||||
return 'true_no_result';
|
||||
}
|
||||
|
||||
return 'unavailable';
|
||||
}
|
||||
}
|
||||
|
||||
@ -25,14 +25,16 @@ public function __construct(
|
||||
public function forOperationRun(OperationRun $run, string $surface = 'detail'): ?ReasonResolutionEnvelope
|
||||
{
|
||||
$context = is_array($run->context) ? $run->context : [];
|
||||
$storedTranslation = is_array($context['reason_translation'] ?? null) ? $context['reason_translation'] : null;
|
||||
$storedTranslation = $this->storedOperationRunTranslation($context);
|
||||
|
||||
if ($storedTranslation !== null) {
|
||||
$storedEnvelope = ReasonResolutionEnvelope::fromArray($storedTranslation);
|
||||
|
||||
if ($storedEnvelope instanceof ReasonResolutionEnvelope) {
|
||||
if ($storedEnvelope->nextSteps === [] && is_array($context['next_steps'] ?? null)) {
|
||||
return $storedEnvelope->withNextSteps(NextStepOption::collect($context['next_steps']));
|
||||
$nextSteps = $this->operationRunNextSteps($context);
|
||||
|
||||
if ($storedEnvelope->nextSteps === [] && $nextSteps !== []) {
|
||||
return $storedEnvelope->withNextSteps($nextSteps);
|
||||
}
|
||||
|
||||
return $storedEnvelope;
|
||||
@ -40,7 +42,8 @@ public function forOperationRun(OperationRun $run, string $surface = 'detail'):
|
||||
}
|
||||
|
||||
$contextReasonCode = data_get($context, 'execution_legitimacy.reason_code')
|
||||
?? data_get($context, 'reason_code');
|
||||
?? data_get($context, 'reason_code')
|
||||
?? data_get($context, 'baseline_compare.reason_code');
|
||||
|
||||
if (is_string($contextReasonCode) && trim($contextReasonCode) !== '') {
|
||||
return $this->translateOperationRunReason(trim($contextReasonCode), $surface, $context);
|
||||
@ -68,11 +71,33 @@ public function forOperationRun(OperationRun $run, string $surface = 'detail'):
|
||||
return $envelope;
|
||||
}
|
||||
|
||||
$legacyNextSteps = is_array($context['next_steps'] ?? null) ? NextStepOption::collect($context['next_steps']) : [];
|
||||
$legacyNextSteps = $this->operationRunNextSteps($context);
|
||||
|
||||
return $legacyNextSteps !== [] ? $envelope->withNextSteps($legacyNextSteps) : $envelope;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $context
|
||||
* @return array<string, mixed>|null
|
||||
*/
|
||||
private function storedOperationRunTranslation(array $context): ?array
|
||||
{
|
||||
$storedTranslation = $context['reason_translation'] ?? data_get($context, 'baseline_compare.reason_translation');
|
||||
|
||||
return is_array($storedTranslation) ? $storedTranslation : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $context
|
||||
* @return array<int, NextStepOption>
|
||||
*/
|
||||
private function operationRunNextSteps(array $context): array
|
||||
{
|
||||
$nextSteps = $context['next_steps'] ?? data_get($context, 'baseline_compare.next_steps');
|
||||
|
||||
return is_array($nextSteps) ? NextStepOption::collect($nextSteps) : [];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $context
|
||||
*/
|
||||
@ -169,6 +194,26 @@ public function shortExplanation(?ReasonResolutionEnvelope $envelope): ?string
|
||||
return $envelope?->shortExplanation;
|
||||
}
|
||||
|
||||
public function dominantCauseLabel(?ReasonResolutionEnvelope $envelope): ?string
|
||||
{
|
||||
return $envelope?->operatorLabel;
|
||||
}
|
||||
|
||||
public function dominantCauseExplanation(?ReasonResolutionEnvelope $envelope): ?string
|
||||
{
|
||||
return $envelope?->shortExplanation;
|
||||
}
|
||||
|
||||
public function trustImpact(?ReasonResolutionEnvelope $envelope): ?string
|
||||
{
|
||||
return $envelope?->trustImpact;
|
||||
}
|
||||
|
||||
public function absencePattern(?ReasonResolutionEnvelope $envelope): ?string
|
||||
{
|
||||
return $envelope?->absencePattern;
|
||||
}
|
||||
|
||||
public function guidance(?ReasonResolutionEnvelope $envelope): ?string
|
||||
{
|
||||
return $envelope?->guidanceText();
|
||||
|
||||
@ -4,6 +4,7 @@
|
||||
|
||||
namespace App\Support\ReasonTranslation;
|
||||
|
||||
use App\Support\Ui\OperatorExplanation\TrustworthinessLevel;
|
||||
use InvalidArgumentException;
|
||||
|
||||
final readonly class ReasonResolutionEnvelope
|
||||
@ -19,6 +20,8 @@ public function __construct(
|
||||
public array $nextSteps = [],
|
||||
public bool $showNoActionNeeded = false,
|
||||
public ?string $diagnosticCodeLabel = null,
|
||||
public string $trustImpact = TrustworthinessLevel::LimitedConfidence->value,
|
||||
public ?string $absencePattern = null,
|
||||
) {
|
||||
if (trim($this->internalCode) === '') {
|
||||
throw new InvalidArgumentException('Reason envelopes must preserve an internal code.');
|
||||
@ -41,6 +44,24 @@ public function __construct(
|
||||
throw new InvalidArgumentException('Unsupported reason actionability: '.$this->actionability);
|
||||
}
|
||||
|
||||
if (! in_array($this->trustImpact, array_map(
|
||||
static fn (TrustworthinessLevel $level): string => $level->value,
|
||||
TrustworthinessLevel::cases(),
|
||||
), true)) {
|
||||
throw new InvalidArgumentException('Unsupported reason trust impact: '.$this->trustImpact);
|
||||
}
|
||||
|
||||
if ($this->absencePattern !== null && ! in_array($this->absencePattern, [
|
||||
'none',
|
||||
'true_no_result',
|
||||
'missing_input',
|
||||
'blocked_prerequisite',
|
||||
'suppressed_output',
|
||||
'unavailable',
|
||||
], true)) {
|
||||
throw new InvalidArgumentException('Unsupported reason absence pattern: '.$this->absencePattern);
|
||||
}
|
||||
|
||||
foreach ($this->nextSteps as $nextStep) {
|
||||
if (! $nextStep instanceof NextStepOption) {
|
||||
throw new InvalidArgumentException('Reason envelopes only support NextStepOption instances.');
|
||||
@ -70,6 +91,12 @@ public static function fromArray(array $data): ?self
|
||||
$diagnosticCodeLabel = is_string($data['diagnostic_code_label'] ?? null)
|
||||
? trim((string) $data['diagnostic_code_label'])
|
||||
: (is_string($data['diagnosticCodeLabel'] ?? null) ? trim((string) $data['diagnosticCodeLabel']) : null);
|
||||
$trustImpact = is_string($data['trust_impact'] ?? null)
|
||||
? trim((string) $data['trust_impact'])
|
||||
: (is_string($data['trustImpact'] ?? null) ? trim((string) $data['trustImpact']) : TrustworthinessLevel::LimitedConfidence->value);
|
||||
$absencePattern = is_string($data['absence_pattern'] ?? null)
|
||||
? trim((string) $data['absence_pattern'])
|
||||
: (is_string($data['absencePattern'] ?? null) ? trim((string) $data['absencePattern']) : null);
|
||||
|
||||
if ($internalCode === '' || $operatorLabel === '' || $shortExplanation === '' || $actionability === '') {
|
||||
return null;
|
||||
@ -83,6 +110,8 @@ public static function fromArray(array $data): ?self
|
||||
nextSteps: $nextSteps,
|
||||
showNoActionNeeded: $showNoActionNeeded,
|
||||
diagnosticCodeLabel: $diagnosticCodeLabel !== '' ? $diagnosticCodeLabel : null,
|
||||
trustImpact: $trustImpact !== '' ? $trustImpact : TrustworthinessLevel::LimitedConfidence->value,
|
||||
absencePattern: $absencePattern !== '' ? $absencePattern : null,
|
||||
);
|
||||
}
|
||||
|
||||
@ -99,6 +128,8 @@ public function withNextSteps(array $nextSteps): self
|
||||
nextSteps: $nextSteps,
|
||||
showNoActionNeeded: $this->showNoActionNeeded,
|
||||
diagnosticCodeLabel: $this->diagnosticCodeLabel,
|
||||
trustImpact: $this->trustImpact,
|
||||
absencePattern: $this->absencePattern,
|
||||
);
|
||||
}
|
||||
|
||||
@ -179,6 +210,8 @@ public function toLegacyNextSteps(): array
|
||||
* }>,
|
||||
* show_no_action_needed: bool,
|
||||
* diagnostic_code_label: string
|
||||
* trust_impact: string,
|
||||
* absence_pattern: ?string
|
||||
* }
|
||||
*/
|
||||
public function toArray(): array
|
||||
@ -194,6 +227,8 @@ public function toArray(): array
|
||||
),
|
||||
'show_no_action_needed' => $this->showNoActionNeeded,
|
||||
'diagnostic_code_label' => $this->diagnosticCode(),
|
||||
'trust_impact' => $this->trustImpact,
|
||||
'absence_pattern' => $this->absencePattern,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@ -4,6 +4,7 @@
|
||||
|
||||
namespace App\Support\ReasonTranslation;
|
||||
|
||||
use App\Support\Baselines\BaselineCompareReasonCode;
|
||||
use App\Support\Baselines\BaselineReasonCodes;
|
||||
use App\Support\Operations\ExecutionDenialReasonCode;
|
||||
use App\Support\Operations\LifecycleReconciliationReason;
|
||||
@ -11,6 +12,7 @@
|
||||
use App\Support\Providers\ProviderReasonTranslator;
|
||||
use App\Support\RbacReason;
|
||||
use App\Support\Tenants\TenantOperabilityReasonCode;
|
||||
use App\Support\Ui\OperatorExplanation\TrustworthinessLevel;
|
||||
|
||||
final class ReasonTranslator
|
||||
{
|
||||
@ -45,6 +47,8 @@ public function translate(
|
||||
return match (true) {
|
||||
$artifactKey === ProviderReasonTranslator::ARTIFACT_KEY,
|
||||
$artifactKey === null && $this->providerReasonTranslator->canTranslate($reasonCode) => $this->providerReasonTranslator->translate($reasonCode, $surface, $context),
|
||||
$artifactKey === self::GOVERNANCE_ARTIFACT_TRUTH_ARTIFACT && BaselineCompareReasonCode::tryFrom($reasonCode) instanceof BaselineCompareReasonCode => $this->translateBaselineCompareReason($reasonCode),
|
||||
$artifactKey === null && BaselineCompareReasonCode::tryFrom($reasonCode) instanceof BaselineCompareReasonCode => $this->translateBaselineCompareReason($reasonCode),
|
||||
$artifactKey === self::GOVERNANCE_ARTIFACT_TRUTH_ARTIFACT && BaselineReasonCodes::isKnown($reasonCode) => $this->translateBaselineReason($reasonCode),
|
||||
$artifactKey === null && BaselineReasonCodes::isKnown($reasonCode) => $this->translateBaselineReason($reasonCode),
|
||||
$artifactKey === self::EXECUTION_DENIAL_ARTIFACT,
|
||||
@ -195,6 +199,68 @@ private function translateBaselineReason(string $reasonCode): ReasonResolutionEn
|
||||
NextStepOption::instruction($nextStep),
|
||||
],
|
||||
diagnosticCodeLabel: $reasonCode,
|
||||
trustImpact: BaselineReasonCodes::trustImpact($reasonCode) ?? TrustworthinessLevel::Unusable->value,
|
||||
absencePattern: BaselineReasonCodes::absencePattern($reasonCode),
|
||||
);
|
||||
}
|
||||
|
||||
private function translateBaselineCompareReason(string $reasonCode): ReasonResolutionEnvelope
|
||||
{
|
||||
$enum = BaselineCompareReasonCode::tryFrom($reasonCode);
|
||||
|
||||
if (! $enum instanceof BaselineCompareReasonCode) {
|
||||
return $this->fallbackReasonTranslator->translate($reasonCode) ?? new ReasonResolutionEnvelope(
|
||||
internalCode: $reasonCode,
|
||||
operatorLabel: 'Baseline compare needs review',
|
||||
shortExplanation: 'TenantPilot recorded a baseline-compare state that needs operator review.',
|
||||
actionability: 'permanent_configuration',
|
||||
);
|
||||
}
|
||||
|
||||
[$operatorLabel, $shortExplanation, $actionability, $nextStep] = match ($enum) {
|
||||
BaselineCompareReasonCode::NoDriftDetected => [
|
||||
'No drift detected',
|
||||
'The comparison completed with enough coverage to treat the absence of drift findings as trustworthy.',
|
||||
'non_actionable',
|
||||
'No action needed unless you expected a newer compare result.',
|
||||
],
|
||||
BaselineCompareReasonCode::CoverageUnproven => [
|
||||
'Coverage proof missing',
|
||||
'The comparison finished, but missing coverage proof means some findings may have been suppressed for safety.',
|
||||
'prerequisite_missing',
|
||||
'Run inventory sync and compare again before treating this as complete.',
|
||||
],
|
||||
BaselineCompareReasonCode::EvidenceCaptureIncomplete => [
|
||||
'Evidence capture incomplete',
|
||||
'The comparison finished, but incomplete evidence capture limits how much confidence you should place in the visible result.',
|
||||
'prerequisite_missing',
|
||||
'Resume or rerun evidence capture before relying on this compare result.',
|
||||
],
|
||||
BaselineCompareReasonCode::RolloutDisabled => [
|
||||
'Compare rollout disabled',
|
||||
'The comparison path was limited by rollout configuration, so the result is not decision-grade.',
|
||||
'prerequisite_missing',
|
||||
'Enable the rollout or use the supported compare mode before retrying.',
|
||||
],
|
||||
BaselineCompareReasonCode::NoSubjectsInScope => [
|
||||
'Nothing was eligible to compare',
|
||||
'No in-scope subjects were available for evaluation, so the compare could not produce a normal result.',
|
||||
'prerequisite_missing',
|
||||
'Review scope selection and baseline inputs before comparing again.',
|
||||
],
|
||||
};
|
||||
|
||||
return new ReasonResolutionEnvelope(
|
||||
internalCode: $reasonCode,
|
||||
operatorLabel: $operatorLabel,
|
||||
shortExplanation: $shortExplanation,
|
||||
actionability: $actionability,
|
||||
nextSteps: [
|
||||
NextStepOption::instruction($nextStep),
|
||||
],
|
||||
diagnosticCodeLabel: $reasonCode,
|
||||
trustImpact: $enum->trustworthinessLevel()->value,
|
||||
absencePattern: $enum->absencePattern(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
25
app/Support/Ui/DerivedState/DerivedStateFamily.php
Normal file
25
app/Support/Ui/DerivedState/DerivedStateFamily.php
Normal file
@ -0,0 +1,25 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Support\Ui\DerivedState;
|
||||
|
||||
enum DerivedStateFamily: string
|
||||
{
|
||||
case ArtifactTruth = 'artifact_truth';
|
||||
case OperationUxGuidance = 'operation_ux_guidance';
|
||||
case OperationUxExplanation = 'operation_ux_explanation';
|
||||
case RelatedNavigationPrimary = 'related_navigation_primary';
|
||||
case RelatedNavigationDetail = 'related_navigation_detail';
|
||||
case RelatedNavigationHeader = 'related_navigation_header';
|
||||
|
||||
public function allowsNegativeResultCache(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
public function defaultFreshnessPolicy(): string
|
||||
{
|
||||
return RequestScopedDerivedStateStore::FRESHNESS_INVALIDATE_AFTER_MUTATION;
|
||||
}
|
||||
}
|
||||
190
app/Support/Ui/DerivedState/DerivedStateKey.php
Normal file
190
app/Support/Ui/DerivedState/DerivedStateKey.php
Normal file
@ -0,0 +1,190 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Support\Ui\DerivedState;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use JsonException;
|
||||
|
||||
final class DerivedStateKey
|
||||
{
|
||||
public function __construct(
|
||||
public readonly DerivedStateFamily $family,
|
||||
public readonly string $recordClass,
|
||||
public readonly string $recordKey,
|
||||
public readonly string $variant,
|
||||
public readonly ?int $workspaceId = null,
|
||||
public readonly ?int $tenantId = null,
|
||||
public readonly ?string $contextHash = null,
|
||||
) {
|
||||
if (trim($this->recordClass) === '') {
|
||||
throw new \InvalidArgumentException('Derived state keys require a non-empty record class.');
|
||||
}
|
||||
|
||||
if (trim($this->recordKey) === '') {
|
||||
throw new \InvalidArgumentException('Derived state keys require a non-empty record key.');
|
||||
}
|
||||
|
||||
if (trim($this->variant) === '') {
|
||||
throw new \InvalidArgumentException('Derived state keys require a non-empty variant.');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed>|string|null $context
|
||||
*/
|
||||
public static function fromModel(
|
||||
DerivedStateFamily $family,
|
||||
Model $record,
|
||||
string $variant,
|
||||
array|string|null $context = null,
|
||||
?int $workspaceId = null,
|
||||
?int $tenantId = null,
|
||||
): self {
|
||||
return new self(
|
||||
family: $family,
|
||||
recordClass: $record::class,
|
||||
recordKey: (string) $record->getKey(),
|
||||
variant: $variant,
|
||||
workspaceId: $workspaceId ?? self::normalizeScopeId($record->getAttribute('workspace_id')),
|
||||
tenantId: $tenantId ?? self::normalizeScopeId($record->getAttribute('tenant_id')),
|
||||
contextHash: self::hashContext($context),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{
|
||||
* family: string,
|
||||
* record_class: string,
|
||||
* record_key: string,
|
||||
* variant: string,
|
||||
* workspace_id: ?int,
|
||||
* tenant_id: ?int,
|
||||
* context_hash: ?string
|
||||
* }
|
||||
*/
|
||||
public function toArray(): array
|
||||
{
|
||||
return [
|
||||
'family' => $this->family->value,
|
||||
'record_class' => $this->recordClass,
|
||||
'record_key' => $this->recordKey,
|
||||
'variant' => $this->variant,
|
||||
'workspace_id' => $this->workspaceId,
|
||||
'tenant_id' => $this->tenantId,
|
||||
'context_hash' => $this->contextHash,
|
||||
];
|
||||
}
|
||||
|
||||
public function fingerprint(): string
|
||||
{
|
||||
try {
|
||||
/** @var string $json */
|
||||
$json = json_encode($this->toArray(), JSON_THROW_ON_ERROR);
|
||||
} catch (JsonException $exception) {
|
||||
throw new \RuntimeException('Unable to encode derived state key fingerprint.', previous: $exception);
|
||||
}
|
||||
|
||||
return $json;
|
||||
}
|
||||
|
||||
public function matches(
|
||||
DerivedStateFamily $family,
|
||||
?string $recordClass = null,
|
||||
string|int|null $recordKey = null,
|
||||
?string $variant = null,
|
||||
?int $workspaceId = null,
|
||||
?int $tenantId = null,
|
||||
): bool {
|
||||
if ($this->family !== $family) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if ($recordClass !== null && $this->recordClass !== $recordClass) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if ($recordKey !== null && $this->recordKey !== (string) $recordKey) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if ($variant !== null && $this->variant !== $variant) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if ($workspaceId !== null && $this->workspaceId !== $workspaceId) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if ($tenantId !== null && $this->tenantId !== $tenantId) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed>|string|null $context
|
||||
*/
|
||||
public static function hashContext(array|string|null $context): ?string
|
||||
{
|
||||
if ($context === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (is_string($context)) {
|
||||
$context = trim($context);
|
||||
|
||||
return $context === '' ? null : sha1($context);
|
||||
}
|
||||
|
||||
if ($context === []) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$normalized = self::normalizeContext($context);
|
||||
|
||||
try {
|
||||
/** @var string $json */
|
||||
$json = json_encode($normalized, JSON_THROW_ON_ERROR);
|
||||
} catch (JsonException $exception) {
|
||||
throw new \RuntimeException('Unable to encode derived state context.', previous: $exception);
|
||||
}
|
||||
|
||||
return sha1($json);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $context
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private static function normalizeContext(array $context): array
|
||||
{
|
||||
ksort($context);
|
||||
|
||||
foreach ($context as $key => $value) {
|
||||
if (is_array($value)) {
|
||||
/** @var mixed $normalized */
|
||||
$normalized = array_is_list($value)
|
||||
? array_map(static fn (mixed $item): mixed => is_array($item) ? self::normalizeContext($item) : $item, $value)
|
||||
: self::normalizeContext($value);
|
||||
|
||||
$context[$key] = $normalized;
|
||||
}
|
||||
}
|
||||
|
||||
return $context;
|
||||
}
|
||||
|
||||
private static function normalizeScopeId(mixed $value): ?int
|
||||
{
|
||||
if (! is_numeric($value)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$normalized = (int) $value;
|
||||
|
||||
return $normalized > 0 ? $normalized : null;
|
||||
}
|
||||
}
|
||||
186
app/Support/Ui/DerivedState/RequestScopedDerivedStateStore.php
Normal file
186
app/Support/Ui/DerivedState/RequestScopedDerivedStateStore.php
Normal file
@ -0,0 +1,186 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Support\Ui\DerivedState;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
final class RequestScopedDerivedStateStore
|
||||
{
|
||||
public const string FRESHNESS_REQUEST_STABLE = 'request_stable';
|
||||
|
||||
public const string FRESHNESS_INVALIDATE_AFTER_MUTATION = 'invalidate_after_mutation';
|
||||
|
||||
public const string FRESHNESS_NO_REUSE = 'no_reuse';
|
||||
|
||||
private string $requestScopeId;
|
||||
|
||||
/**
|
||||
* @var array<string, array{
|
||||
* key: DerivedStateKey,
|
||||
* value: mixed,
|
||||
* negative_result: bool,
|
||||
* freshness_policy: string,
|
||||
* resolved_at: int
|
||||
* }>
|
||||
*/
|
||||
private array $entries = [];
|
||||
|
||||
/**
|
||||
* @var list<string>
|
||||
*/
|
||||
private array $invalidations = [];
|
||||
|
||||
private int $resolutionSequence = 0;
|
||||
|
||||
public function __construct(?string $requestScopeId = null)
|
||||
{
|
||||
$this->requestScopeId = $requestScopeId ?? (string) Str::uuid();
|
||||
}
|
||||
|
||||
public function requestScopeId(): string
|
||||
{
|
||||
return $this->requestScopeId;
|
||||
}
|
||||
|
||||
public function resolve(
|
||||
DerivedStateKey $key,
|
||||
callable $resolver,
|
||||
?string $freshnessPolicy = null,
|
||||
?bool $allowNegativeResultCache = null,
|
||||
): mixed {
|
||||
$freshnessPolicy ??= $key->family->defaultFreshnessPolicy();
|
||||
|
||||
if ($freshnessPolicy === self::FRESHNESS_NO_REUSE) {
|
||||
return $resolver();
|
||||
}
|
||||
|
||||
$fingerprint = $key->fingerprint();
|
||||
|
||||
if (array_key_exists($fingerprint, $this->entries)) {
|
||||
return $this->entries[$fingerprint]['value'];
|
||||
}
|
||||
|
||||
$value = $resolver();
|
||||
$negativeResult = $this->isNegativeResult($value);
|
||||
$allowNegativeResultCache ??= $key->family->allowsNegativeResultCache();
|
||||
|
||||
if ($negativeResult && ! $allowNegativeResultCache) {
|
||||
return $value;
|
||||
}
|
||||
|
||||
$this->entries[$fingerprint] = [
|
||||
'key' => $key,
|
||||
'value' => $value,
|
||||
'negative_result' => $negativeResult,
|
||||
'freshness_policy' => $freshnessPolicy,
|
||||
'resolved_at' => ++$this->resolutionSequence,
|
||||
];
|
||||
|
||||
return $value;
|
||||
}
|
||||
|
||||
public function resolveFresh(
|
||||
DerivedStateKey $key,
|
||||
callable $resolver,
|
||||
?string $freshnessPolicy = null,
|
||||
?bool $allowNegativeResultCache = null,
|
||||
): mixed {
|
||||
$this->invalidateKey($key);
|
||||
|
||||
return $this->resolve($key, $resolver, $freshnessPolicy, $allowNegativeResultCache);
|
||||
}
|
||||
|
||||
public function invalidateKey(DerivedStateKey $key): int
|
||||
{
|
||||
$fingerprint = $key->fingerprint();
|
||||
|
||||
if (! array_key_exists($fingerprint, $this->entries)) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
unset($this->entries[$fingerprint]);
|
||||
$this->invalidations[] = $fingerprint;
|
||||
|
||||
return 1;
|
||||
}
|
||||
|
||||
public function invalidateFamily(
|
||||
DerivedStateFamily $family,
|
||||
?string $recordClass = null,
|
||||
string|int|null $recordKey = null,
|
||||
?string $variant = null,
|
||||
?int $workspaceId = null,
|
||||
?int $tenantId = null,
|
||||
): int {
|
||||
$invalidated = 0;
|
||||
|
||||
foreach ($this->entries as $fingerprint => $record) {
|
||||
if (! $record['key']->matches($family, $recordClass, $recordKey, $variant, $workspaceId, $tenantId)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
unset($this->entries[$fingerprint]);
|
||||
$this->invalidations[] = $fingerprint;
|
||||
$invalidated++;
|
||||
}
|
||||
|
||||
return $invalidated;
|
||||
}
|
||||
|
||||
public function invalidateModel(DerivedStateFamily $family, Model $record, ?string $variant = null): int
|
||||
{
|
||||
return $this->invalidateFamily(
|
||||
family: $family,
|
||||
recordClass: $record::class,
|
||||
recordKey: $record->getKey(),
|
||||
variant: $variant,
|
||||
);
|
||||
}
|
||||
|
||||
public function entryCount(): int
|
||||
{
|
||||
return count($this->entries);
|
||||
}
|
||||
|
||||
public function countStored(
|
||||
DerivedStateFamily $family,
|
||||
?string $recordClass = null,
|
||||
string|int|null $recordKey = null,
|
||||
?string $variant = null,
|
||||
): int {
|
||||
return count(array_filter(
|
||||
$this->entries,
|
||||
static fn (array $record): bool => $record['key']->matches($family, $recordClass, $recordKey, $variant),
|
||||
));
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{
|
||||
* key: DerivedStateKey,
|
||||
* value: mixed,
|
||||
* negative_result: bool,
|
||||
* freshness_policy: string,
|
||||
* resolved_at: int
|
||||
* }|null
|
||||
*/
|
||||
public function resolutionRecord(DerivedStateKey $key): ?array
|
||||
{
|
||||
return $this->entries[$key->fingerprint()] ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<string>
|
||||
*/
|
||||
public function invalidations(): array
|
||||
{
|
||||
return $this->invalidations;
|
||||
}
|
||||
|
||||
private function isNegativeResult(mixed $value): bool
|
||||
{
|
||||
return $value === null || $value === [];
|
||||
}
|
||||
}
|
||||
@ -10,6 +10,11 @@ final class EnterpriseDetailBuilder
|
||||
{
|
||||
private ?SummaryHeaderData $header = null;
|
||||
|
||||
/**
|
||||
* @var array<string, mixed>|null
|
||||
*/
|
||||
private ?array $decisionZone = null;
|
||||
|
||||
/**
|
||||
* @var list<DetailSectionData>
|
||||
*/
|
||||
@ -18,7 +23,7 @@ final class EnterpriseDetailBuilder
|
||||
/**
|
||||
* @var list<SupportingCardData>
|
||||
*/
|
||||
private array $supportingCards = [];
|
||||
private array $supportingGroups = [];
|
||||
|
||||
/**
|
||||
* @var list<TechnicalDetailData>
|
||||
@ -47,6 +52,16 @@ public function header(SummaryHeaderData $header): self
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $decisionZone
|
||||
*/
|
||||
public function decisionZone(array $decisionZone): self
|
||||
{
|
||||
$this->decisionZone = $decisionZone;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function addSection(DetailSectionData ...$sections): self
|
||||
{
|
||||
foreach ($sections as $section) {
|
||||
@ -58,8 +73,13 @@ public function addSection(DetailSectionData ...$sections): self
|
||||
|
||||
public function addSupportingCard(SupportingCardData ...$cards): self
|
||||
{
|
||||
foreach ($cards as $card) {
|
||||
$this->supportingCards[] = $card;
|
||||
return $this->addSupportingGroup(...$cards);
|
||||
}
|
||||
|
||||
public function addSupportingGroup(SupportingCardData ...$groups): self
|
||||
{
|
||||
foreach ($groups as $group) {
|
||||
$this->supportingGroups[] = $group;
|
||||
}
|
||||
|
||||
return $this;
|
||||
@ -94,13 +114,16 @@ public function build(): EnterpriseDetailPageData
|
||||
resourceType: $this->resourceType,
|
||||
scope: $this->scope,
|
||||
header: $this->header,
|
||||
decisionZone: is_array($this->decisionZone) && $this->decisionZone !== []
|
||||
? $this->decisionZone
|
||||
: null,
|
||||
mainSections: array_values(array_filter(
|
||||
$this->mainSections,
|
||||
static fn (DetailSectionData $section): bool => $section->shouldRender(),
|
||||
)),
|
||||
supportingCards: array_values(array_filter(
|
||||
$this->supportingCards,
|
||||
static fn (SupportingCardData $card): bool => $card->shouldRender(),
|
||||
supportingGroups: array_values(array_filter(
|
||||
$this->supportingGroups,
|
||||
static fn (SupportingCardData $group): bool => $group->shouldRender(),
|
||||
)),
|
||||
technicalSections: array_values(array_filter(
|
||||
$this->technicalSections,
|
||||
|
||||
@ -7,8 +7,25 @@
|
||||
final readonly class EnterpriseDetailPageData
|
||||
{
|
||||
/**
|
||||
* @param array{
|
||||
* title?: string,
|
||||
* description?: ?string,
|
||||
* facts?: list<array<string, mixed>>,
|
||||
* primaryNextStep?: array{
|
||||
* label?: string,
|
||||
* text: string,
|
||||
* source: string,
|
||||
* secondaryGuidance?: list<array{label: string, text: string, source: string}>
|
||||
* },
|
||||
* compactCounts?: array{
|
||||
* summaryLine?: ?string,
|
||||
* primaryFacts?: list<array<string, mixed>>,
|
||||
* diagnosticFacts?: list<array<string, mixed>>
|
||||
* },
|
||||
* attentionNote?: ?string
|
||||
* }|null $decisionZone
|
||||
* @param list<DetailSectionData> $mainSections
|
||||
* @param list<SupportingCardData> $supportingCards
|
||||
* @param list<SupportingCardData> $supportingGroups
|
||||
* @param list<TechnicalDetailData> $technicalSections
|
||||
* @param list<array{title: string, description?: ?string, icon?: ?string}> $emptyStateNotes
|
||||
*/
|
||||
@ -16,8 +33,9 @@ public function __construct(
|
||||
public string $resourceType,
|
||||
public string $scope,
|
||||
public SummaryHeaderData $header,
|
||||
public ?array $decisionZone = null,
|
||||
public array $mainSections = [],
|
||||
public array $supportingCards = [],
|
||||
public array $supportingGroups = [],
|
||||
public array $technicalSections = [],
|
||||
public array $emptyStateNotes = [],
|
||||
) {}
|
||||
@ -34,8 +52,9 @@ public function __construct(
|
||||
* primaryActions: list<array{label: string, placement: string, url: ?string, actionName: ?string, destructive: bool, requiresConfirmation: bool, visible: bool, icon: ?string, openInNewTab: bool}>,
|
||||
* descriptionHint: ?string
|
||||
* },
|
||||
* decisionZone: array<string, mixed>|null,
|
||||
* mainSections: list<array<string, mixed>>,
|
||||
* supportingCards: list<array<string, mixed>>,
|
||||
* supportingGroups: list<array<string, mixed>>,
|
||||
* technicalSections: list<array<string, mixed>>,
|
||||
* emptyStateNotes: list<array{title: string, description?: ?string, icon?: ?string}>
|
||||
* }
|
||||
@ -46,13 +65,14 @@ public function toArray(): array
|
||||
'resourceType' => $this->resourceType,
|
||||
'scope' => $this->scope,
|
||||
'header' => $this->header->toArray(),
|
||||
'decisionZone' => $this->decisionZone,
|
||||
'mainSections' => array_values(array_map(
|
||||
static fn (DetailSectionData $section): array => $section->toArray(),
|
||||
$this->mainSections,
|
||||
)),
|
||||
'supportingCards' => array_values(array_map(
|
||||
static fn (SupportingCardData $card): array => $card->toArray(),
|
||||
$this->supportingCards,
|
||||
'supportingGroups' => array_values(array_map(
|
||||
static fn (SupportingCardData $group): array => $group->toArray(),
|
||||
$this->supportingGroups,
|
||||
)),
|
||||
'technicalSections' => array_values(array_map(
|
||||
static fn (TechnicalDetailData $section): array => $section->toArray(),
|
||||
|
||||
@ -8,9 +8,11 @@ final class EnterpriseDetailSectionFactory
|
||||
{
|
||||
/**
|
||||
* @param array{label: string, color?: string, icon?: ?string, iconColor?: ?string}|null $badge
|
||||
* @return array{label: string, value: string, hint?: ?string, badge?: ?array{label: string, color?: string, icon?: ?string, iconColor?: ?string}}
|
||||
* @param 'default'|'danger'|'success'|'warning'|null $tone Optional color tone for the card border/value
|
||||
* @param bool $mono Whether the value should be rendered in monospace font (e.g. hashes, IDs)
|
||||
* @return array{label: string, value: string, hint?: ?string, badge?: ?array{label: string, color?: string, icon?: ?string, iconColor?: ?string}, tone?: string, mono?: bool}
|
||||
*/
|
||||
public function keyFact(string $label, mixed $value, ?string $hint = null, ?array $badge = null): array
|
||||
public function keyFact(string $label, mixed $value, ?string $hint = null, ?array $badge = null, ?string $tone = null, bool $mono = false): array
|
||||
{
|
||||
$displayValue = match (true) {
|
||||
is_bool($value) => $value ? 'Yes' : 'No',
|
||||
@ -24,6 +26,8 @@ public function keyFact(string $label, mixed $value, ?string $hint = null, ?arra
|
||||
'value' => $displayValue,
|
||||
'hint' => $hint,
|
||||
'badge' => $badge,
|
||||
'tone' => $tone,
|
||||
'mono' => $mono ?: null,
|
||||
], static fn (mixed $item): bool => $item !== null);
|
||||
}
|
||||
|
||||
@ -52,6 +56,92 @@ public function emptyState(string $title, ?string $description = null, ?string $
|
||||
], static fn (mixed $item): bool => $item !== null);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<array<string, mixed>> $facts
|
||||
* @param array{
|
||||
* label?: string,
|
||||
* text: string,
|
||||
* source: string,
|
||||
* secondaryGuidance?: list<array{label: string, text: string, source: string}>
|
||||
* } $primaryNextStep
|
||||
* @param array{
|
||||
* summaryLine?: ?string,
|
||||
* primaryFacts?: list<array<string, mixed>>,
|
||||
* diagnosticFacts?: list<array<string, mixed>>
|
||||
* }|null $compactCounts
|
||||
* @return array{
|
||||
* title: string,
|
||||
* description?: ?string,
|
||||
* facts: list<array<string, mixed>>,
|
||||
* primaryNextStep: array{
|
||||
* label?: string,
|
||||
* text: string,
|
||||
* source: string,
|
||||
* secondaryGuidance?: list<array{label: string, text: string, source: string}>
|
||||
* },
|
||||
* compactCounts?: array{
|
||||
* summaryLine?: ?string,
|
||||
* primaryFacts?: list<array<string, mixed>>,
|
||||
* diagnosticFacts?: list<array<string, mixed>>
|
||||
* },
|
||||
* attentionNote?: ?string
|
||||
* }
|
||||
*/
|
||||
public function decisionZone(
|
||||
array $facts,
|
||||
array $primaryNextStep,
|
||||
?string $description = null,
|
||||
?array $compactCounts = null,
|
||||
?string $attentionNote = null,
|
||||
string $title = 'Decision',
|
||||
): array {
|
||||
return array_filter([
|
||||
'title' => $title,
|
||||
'description' => $description,
|
||||
'facts' => array_values($facts),
|
||||
'primaryNextStep' => $primaryNextStep,
|
||||
'compactCounts' => $compactCounts,
|
||||
'attentionNote' => $attentionNote,
|
||||
], static fn (mixed $item): bool => $item !== null);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<array{label: string, text: string, source: string}> $secondaryGuidance
|
||||
* @return array{
|
||||
* label: string,
|
||||
* text: string,
|
||||
* source: string,
|
||||
* secondaryGuidance: list<array{label: string, text: string, source: string}>
|
||||
* }
|
||||
*/
|
||||
public function primaryNextStep(string $text, string $source, array $secondaryGuidance = [], string $label = 'Primary next step'): array
|
||||
{
|
||||
return [
|
||||
'label' => $label,
|
||||
'text' => $text,
|
||||
'source' => $source,
|
||||
'secondaryGuidance' => array_values($secondaryGuidance),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<array<string, mixed>> $primaryFacts
|
||||
* @param list<array<string, mixed>> $diagnosticFacts
|
||||
* @return array{
|
||||
* summaryLine?: ?string,
|
||||
* primaryFacts: list<array<string, mixed>>,
|
||||
* diagnosticFacts: list<array<string, mixed>>
|
||||
* }
|
||||
*/
|
||||
public function countPresentation(?string $summaryLine = null, array $primaryFacts = [], array $diagnosticFacts = []): array
|
||||
{
|
||||
return [
|
||||
'summaryLine' => $summaryLine,
|
||||
'primaryFacts' => array_values($primaryFacts),
|
||||
'diagnosticFacts' => array_values($diagnosticFacts),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<array<string, mixed>> $items
|
||||
*/
|
||||
@ -174,6 +264,7 @@ public function technicalDetail(
|
||||
bool $visible = true,
|
||||
bool $collapsible = true,
|
||||
bool $collapsed = true,
|
||||
string $variant = 'technical',
|
||||
): TechnicalDetailData {
|
||||
return new TechnicalDetailData(
|
||||
title: $title,
|
||||
@ -185,6 +276,7 @@ public function technicalDetail(
|
||||
view: $view,
|
||||
viewData: $viewData,
|
||||
emptyState: $emptyState,
|
||||
variant: $variant,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -21,6 +21,7 @@ public function __construct(
|
||||
public ?string $view = null,
|
||||
public array $viewData = [],
|
||||
public ?array $emptyState = null,
|
||||
public string $variant = 'technical',
|
||||
) {}
|
||||
|
||||
public function shouldRender(): bool
|
||||
@ -59,6 +60,7 @@ public function toArray(): array
|
||||
'view' => $this->view,
|
||||
'viewData' => $this->viewData,
|
||||
'emptyState' => $this->emptyState,
|
||||
'variant' => $this->variant,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@ -6,6 +6,7 @@
|
||||
|
||||
use App\Support\ReasonTranslation\NextStepOption;
|
||||
use App\Support\ReasonTranslation\ReasonResolutionEnvelope;
|
||||
use App\Support\Ui\OperatorExplanation\TrustworthinessLevel;
|
||||
|
||||
final readonly class ArtifactTruthCause
|
||||
{
|
||||
@ -18,6 +19,8 @@ public function __construct(
|
||||
public ?string $operatorLabel,
|
||||
public ?string $shortExplanation,
|
||||
public ?string $diagnosticCode,
|
||||
public string $trustImpact,
|
||||
public ?string $absencePattern,
|
||||
public array $nextSteps = [],
|
||||
) {}
|
||||
|
||||
@ -35,6 +38,8 @@ public static function fromReasonResolutionEnvelope(
|
||||
operatorLabel: $reason->operatorLabel,
|
||||
shortExplanation: $reason->shortExplanation,
|
||||
diagnosticCode: $reason->diagnosticCode(),
|
||||
trustImpact: $reason->trustImpact,
|
||||
absencePattern: $reason->absencePattern,
|
||||
nextSteps: array_values(array_map(
|
||||
static fn (NextStepOption $nextStep): string => $nextStep->label,
|
||||
$reason->nextSteps,
|
||||
@ -42,6 +47,23 @@ public static function fromReasonResolutionEnvelope(
|
||||
);
|
||||
}
|
||||
|
||||
public function toReasonResolutionEnvelope(): ReasonResolutionEnvelope
|
||||
{
|
||||
return new ReasonResolutionEnvelope(
|
||||
internalCode: $this->reasonCode ?? 'artifact_truth_reason',
|
||||
operatorLabel: $this->operatorLabel ?? 'Operator attention required',
|
||||
shortExplanation: $this->shortExplanation ?? 'Technical diagnostics are available for this result.',
|
||||
actionability: $this->absencePattern === 'true_no_result' ? 'non_actionable' : 'prerequisite_missing',
|
||||
nextSteps: array_map(
|
||||
static fn (string $label): NextStepOption => NextStepOption::instruction($label),
|
||||
$this->nextSteps,
|
||||
),
|
||||
diagnosticCodeLabel: $this->diagnosticCode,
|
||||
trustImpact: $this->trustImpact !== '' ? $this->trustImpact : TrustworthinessLevel::LimitedConfidence->value,
|
||||
absencePattern: $this->absencePattern,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{
|
||||
* reasonCode: ?string,
|
||||
@ -49,6 +71,8 @@ public static function fromReasonResolutionEnvelope(
|
||||
* operatorLabel: ?string,
|
||||
* shortExplanation: ?string,
|
||||
* diagnosticCode: ?string,
|
||||
* trustImpact: string,
|
||||
* absencePattern: ?string,
|
||||
* nextSteps: array<int, string>
|
||||
* }
|
||||
*/
|
||||
@ -60,6 +84,8 @@ public function toArray(): array
|
||||
'operatorLabel' => $this->operatorLabel,
|
||||
'shortExplanation' => $this->shortExplanation,
|
||||
'diagnosticCode' => $this->diagnosticCode,
|
||||
'trustImpact' => $this->trustImpact,
|
||||
'absencePattern' => $this->absencePattern,
|
||||
'nextSteps' => $this->nextSteps,
|
||||
];
|
||||
}
|
||||
|
||||
@ -5,6 +5,7 @@
|
||||
namespace App\Support\Ui\GovernanceArtifactTruth;
|
||||
|
||||
use App\Support\Badges\BadgeSpec;
|
||||
use App\Support\Ui\OperatorExplanation\OperatorExplanationPattern;
|
||||
|
||||
final readonly class ArtifactTruthEnvelope
|
||||
{
|
||||
@ -32,6 +33,7 @@ public function __construct(
|
||||
public ?string $relatedArtifactUrl,
|
||||
public array $dimensions = [],
|
||||
public ?ArtifactTruthCause $reason = null,
|
||||
public ?OperatorExplanationPattern $operatorExplanation = null,
|
||||
) {}
|
||||
|
||||
public function primaryDimension(): ?ArtifactTruthDimension
|
||||
@ -99,8 +101,11 @@ public function nextStepText(): string
|
||||
* operatorLabel: ?string,
|
||||
* shortExplanation: ?string,
|
||||
* diagnosticCode: ?string,
|
||||
* trustImpact: string,
|
||||
* absencePattern: ?string,
|
||||
* nextSteps: array<int, string>
|
||||
* }
|
||||
* },
|
||||
* operatorExplanation: ?array<string, mixed>
|
||||
* }
|
||||
*/
|
||||
public function toArray(): array
|
||||
@ -132,6 +137,7 @@ public function toArray(): array
|
||||
),
|
||||
)),
|
||||
'reason' => $this->reason?->toArray(),
|
||||
'operatorExplanation' => $this->operatorExplanation?->toArray(),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@ -21,11 +21,18 @@
|
||||
use App\Support\OperationRunLinks;
|
||||
use App\Support\OperationRunOutcome;
|
||||
use App\Support\OperationRunStatus;
|
||||
use App\Support\OpsUx\SummaryCountsNormalizer;
|
||||
use App\Support\ReasonTranslation\ReasonPresenter;
|
||||
use App\Support\ReasonTranslation\ReasonResolutionEnvelope;
|
||||
use App\Support\ReviewPackStatus;
|
||||
use App\Support\TenantReviewCompletenessState;
|
||||
use App\Support\TenantReviewStatus;
|
||||
use App\Support\Ui\DerivedState\DerivedStateFamily;
|
||||
use App\Support\Ui\DerivedState\DerivedStateKey;
|
||||
use App\Support\Ui\DerivedState\RequestScopedDerivedStateStore;
|
||||
use App\Support\Ui\OperatorExplanation\CountDescriptor;
|
||||
use App\Support\Ui\OperatorExplanation\OperatorExplanationBuilder;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Support\Arr;
|
||||
|
||||
final class ArtifactTruthPresenter
|
||||
@ -33,6 +40,8 @@ final class ArtifactTruthPresenter
|
||||
public function __construct(
|
||||
private readonly ReasonPresenter $reasonPresenter,
|
||||
private readonly BaselineSnapshotTruthResolver $snapshotTruthResolver,
|
||||
private readonly OperatorExplanationBuilder $operatorExplanationBuilder,
|
||||
private readonly RequestScopedDerivedStateStore $derivedStateStore,
|
||||
) {}
|
||||
|
||||
public function for(mixed $record): ?ArtifactTruthEnvelope
|
||||
@ -47,7 +56,38 @@ public function for(mixed $record): ?ArtifactTruthEnvelope
|
||||
};
|
||||
}
|
||||
|
||||
public function forFresh(mixed $record): ?ArtifactTruthEnvelope
|
||||
{
|
||||
return match (true) {
|
||||
$record instanceof BaselineSnapshot => $this->forBaselineSnapshotFresh($record),
|
||||
$record instanceof EvidenceSnapshot => $this->forEvidenceSnapshotFresh($record),
|
||||
$record instanceof TenantReview => $this->forTenantReviewFresh($record),
|
||||
$record instanceof ReviewPack => $this->forReviewPackFresh($record),
|
||||
$record instanceof OperationRun => $this->forOperationRunFresh($record),
|
||||
default => null,
|
||||
};
|
||||
}
|
||||
|
||||
public function forBaselineSnapshot(BaselineSnapshot $snapshot): ArtifactTruthEnvelope
|
||||
{
|
||||
return $this->resolveEnvelope(
|
||||
record: $snapshot,
|
||||
variant: 'baseline_snapshot',
|
||||
resolver: fn (): ArtifactTruthEnvelope => $this->buildBaselineSnapshotEnvelope($snapshot),
|
||||
);
|
||||
}
|
||||
|
||||
public function forBaselineSnapshotFresh(BaselineSnapshot $snapshot): ArtifactTruthEnvelope
|
||||
{
|
||||
return $this->resolveEnvelope(
|
||||
record: $snapshot,
|
||||
variant: 'baseline_snapshot',
|
||||
resolver: fn (): ArtifactTruthEnvelope => $this->buildBaselineSnapshotEnvelope($snapshot),
|
||||
fresh: true,
|
||||
);
|
||||
}
|
||||
|
||||
private function buildBaselineSnapshotEnvelope(BaselineSnapshot $snapshot): ArtifactTruthEnvelope
|
||||
{
|
||||
$snapshot->loadMissing('baselineProfile');
|
||||
|
||||
@ -164,10 +204,42 @@ public function forBaselineSnapshot(BaselineSnapshot $snapshot): ArtifactTruthEn
|
||||
relatedRunId: null,
|
||||
relatedArtifactUrl: BaselineSnapshotResource::getUrl('view', ['record' => $snapshot], panel: 'admin'),
|
||||
includePublicationDimension: false,
|
||||
countDescriptors: [
|
||||
new CountDescriptor(
|
||||
label: 'Captured items',
|
||||
value: (int) ($summary['total_items'] ?? 0),
|
||||
role: CountDescriptor::ROLE_EVALUATION_OUTPUT,
|
||||
),
|
||||
new CountDescriptor(
|
||||
label: 'Evidence gaps',
|
||||
value: (int) (Arr::get($summary, 'gaps.count', 0)),
|
||||
role: CountDescriptor::ROLE_COVERAGE,
|
||||
qualifier: (int) (Arr::get($summary, 'gaps.count', 0)) > 0 ? 'review needed' : null,
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
public function forEvidenceSnapshot(EvidenceSnapshot $snapshot): ArtifactTruthEnvelope
|
||||
{
|
||||
return $this->resolveEnvelope(
|
||||
record: $snapshot,
|
||||
variant: 'evidence_snapshot',
|
||||
resolver: fn (): ArtifactTruthEnvelope => $this->buildEvidenceSnapshotEnvelope($snapshot),
|
||||
);
|
||||
}
|
||||
|
||||
public function forEvidenceSnapshotFresh(EvidenceSnapshot $snapshot): ArtifactTruthEnvelope
|
||||
{
|
||||
return $this->resolveEnvelope(
|
||||
record: $snapshot,
|
||||
variant: 'evidence_snapshot',
|
||||
resolver: fn (): ArtifactTruthEnvelope => $this->buildEvidenceSnapshotEnvelope($snapshot),
|
||||
fresh: true,
|
||||
);
|
||||
}
|
||||
|
||||
private function buildEvidenceSnapshotEnvelope(EvidenceSnapshot $snapshot): ArtifactTruthEnvelope
|
||||
{
|
||||
$snapshot->loadMissing('tenant');
|
||||
|
||||
@ -287,10 +359,48 @@ public function forEvidenceSnapshot(EvidenceSnapshot $snapshot): ArtifactTruthEn
|
||||
? EvidenceSnapshotResource::getUrl('view', ['record' => $snapshot], tenant: $snapshot->tenant)
|
||||
: null,
|
||||
includePublicationDimension: false,
|
||||
countDescriptors: [
|
||||
new CountDescriptor(
|
||||
label: 'Evidence dimensions',
|
||||
value: (int) ($summary['dimension_count'] ?? 0),
|
||||
role: CountDescriptor::ROLE_EVALUATION_OUTPUT,
|
||||
),
|
||||
new CountDescriptor(
|
||||
label: 'Missing dimensions',
|
||||
value: $missingDimensions,
|
||||
role: CountDescriptor::ROLE_COVERAGE,
|
||||
qualifier: $missingDimensions > 0 ? 'partial' : null,
|
||||
),
|
||||
new CountDescriptor(
|
||||
label: 'Stale dimensions',
|
||||
value: $staleDimensions,
|
||||
role: CountDescriptor::ROLE_RELIABILITY_SIGNAL,
|
||||
qualifier: $staleDimensions > 0 ? 'refresh recommended' : null,
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
public function forTenantReview(TenantReview $review): ArtifactTruthEnvelope
|
||||
{
|
||||
return $this->resolveEnvelope(
|
||||
record: $review,
|
||||
variant: 'tenant_review',
|
||||
resolver: fn (): ArtifactTruthEnvelope => $this->buildTenantReviewEnvelope($review),
|
||||
);
|
||||
}
|
||||
|
||||
public function forTenantReviewFresh(TenantReview $review): ArtifactTruthEnvelope
|
||||
{
|
||||
return $this->resolveEnvelope(
|
||||
record: $review,
|
||||
variant: 'tenant_review',
|
||||
resolver: fn (): ArtifactTruthEnvelope => $this->buildTenantReviewEnvelope($review),
|
||||
fresh: true,
|
||||
);
|
||||
}
|
||||
|
||||
private function buildTenantReviewEnvelope(TenantReview $review): ArtifactTruthEnvelope
|
||||
{
|
||||
$review->loadMissing(['tenant', 'currentExportReviewPack']);
|
||||
|
||||
@ -416,10 +526,47 @@ public function forTenantReview(TenantReview $review): ArtifactTruthEnvelope
|
||||
? TenantReviewResource::tenantScopedUrl('view', ['record' => $review], $review->tenant)
|
||||
: null,
|
||||
includePublicationDimension: true,
|
||||
countDescriptors: [
|
||||
new CountDescriptor(
|
||||
label: 'Findings',
|
||||
value: (int) ($summary['finding_count'] ?? 0),
|
||||
role: CountDescriptor::ROLE_EVALUATION_OUTPUT,
|
||||
),
|
||||
new CountDescriptor(
|
||||
label: 'Sections',
|
||||
value: (int) ($summary['section_count'] ?? 0),
|
||||
role: CountDescriptor::ROLE_EXECUTION,
|
||||
),
|
||||
new CountDescriptor(
|
||||
label: 'Publish blockers',
|
||||
value: count($publishBlockers),
|
||||
role: CountDescriptor::ROLE_RELIABILITY_SIGNAL,
|
||||
qualifier: $publishBlockers !== [] ? 'resolve before publish' : null,
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
public function forReviewPack(ReviewPack $pack): ArtifactTruthEnvelope
|
||||
{
|
||||
return $this->resolveEnvelope(
|
||||
record: $pack,
|
||||
variant: 'review_pack',
|
||||
resolver: fn (): ArtifactTruthEnvelope => $this->buildReviewPackEnvelope($pack),
|
||||
);
|
||||
}
|
||||
|
||||
public function forReviewPackFresh(ReviewPack $pack): ArtifactTruthEnvelope
|
||||
{
|
||||
return $this->resolveEnvelope(
|
||||
record: $pack,
|
||||
variant: 'review_pack',
|
||||
resolver: fn (): ArtifactTruthEnvelope => $this->buildReviewPackEnvelope($pack),
|
||||
fresh: true,
|
||||
);
|
||||
}
|
||||
|
||||
private function buildReviewPackEnvelope(ReviewPack $pack): ArtifactTruthEnvelope
|
||||
{
|
||||
$pack->loadMissing(['tenant', 'tenantReview']);
|
||||
|
||||
@ -536,10 +683,47 @@ public function forReviewPack(ReviewPack $pack): ArtifactTruthEnvelope
|
||||
? ReviewPackResource::getUrl('view', ['record' => $pack], tenant: $pack->tenant)
|
||||
: null,
|
||||
includePublicationDimension: true,
|
||||
countDescriptors: [
|
||||
new CountDescriptor(
|
||||
label: 'Findings',
|
||||
value: (int) ($summary['finding_count'] ?? 0),
|
||||
role: CountDescriptor::ROLE_EVALUATION_OUTPUT,
|
||||
),
|
||||
new CountDescriptor(
|
||||
label: 'Reports',
|
||||
value: (int) ($summary['report_count'] ?? 0),
|
||||
role: CountDescriptor::ROLE_EXECUTION,
|
||||
),
|
||||
new CountDescriptor(
|
||||
label: 'Operations',
|
||||
value: (int) ($summary['operation_count'] ?? 0),
|
||||
role: CountDescriptor::ROLE_EXECUTION,
|
||||
visibilityTier: CountDescriptor::VISIBILITY_DIAGNOSTIC,
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
public function forOperationRun(OperationRun $run): ArtifactTruthEnvelope
|
||||
{
|
||||
return $this->resolveEnvelope(
|
||||
record: $run,
|
||||
variant: 'operation_run',
|
||||
resolver: fn (): ArtifactTruthEnvelope => $this->buildOperationRunEnvelope($run),
|
||||
);
|
||||
}
|
||||
|
||||
public function forOperationRunFresh(OperationRun $run): ArtifactTruthEnvelope
|
||||
{
|
||||
return $this->resolveEnvelope(
|
||||
record: $run,
|
||||
variant: 'operation_run',
|
||||
resolver: fn (): ArtifactTruthEnvelope => $this->buildOperationRunEnvelope($run),
|
||||
fresh: true,
|
||||
);
|
||||
}
|
||||
|
||||
private function buildOperationRunEnvelope(OperationRun $run): ArtifactTruthEnvelope
|
||||
{
|
||||
$artifact = $this->resolveArtifactForRun($run);
|
||||
$reason = $this->reasonPresenter->forOperationRun($run, 'run_detail');
|
||||
@ -577,6 +761,10 @@ public function forOperationRun(OperationRun $run): ArtifactTruthEnvelope
|
||||
relatedRunId: (int) $run->getKey(),
|
||||
relatedArtifactUrl: $artifactEnvelope->relatedArtifactUrl,
|
||||
includePublicationDimension: $artifactEnvelope->publicationReadiness !== null,
|
||||
countDescriptors: array_merge(
|
||||
$artifactEnvelope->operatorExplanation?->countDescriptors ?? [],
|
||||
$this->runCountDescriptors($run),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -618,18 +806,16 @@ public function forOperationRun(OperationRun $run): ArtifactTruthEnvelope
|
||||
},
|
||||
diagnosticLabel: BadgeCatalog::spec(BadgeDomain::OperationRunOutcome, $run->outcome)->label,
|
||||
reason: ArtifactTruthCause::fromReasonResolutionEnvelope($reason, ReasonPresenter::GOVERNANCE_ARTIFACT_TRUTH_ARTIFACT),
|
||||
nextActionLabel: $this->nextActionLabel(
|
||||
$actionability,
|
||||
$reason,
|
||||
$actionability === 'required'
|
||||
nextActionLabel: $reason?->firstNextStep()?->label
|
||||
?? ($actionability === 'required'
|
||||
? 'Inspect the blocked run details before retrying'
|
||||
: 'Wait for the artifact-producing run to finish',
|
||||
),
|
||||
: 'Wait for the artifact-producing run to finish'),
|
||||
nextActionUrl: null,
|
||||
relatedRunId: (int) $run->getKey(),
|
||||
relatedArtifactUrl: null,
|
||||
includePublicationDimension: OperationCatalog::governanceArtifactFamily((string) $run->type) === 'review_pack'
|
||||
|| OperationCatalog::governanceArtifactFamily((string) $run->type) === 'tenant_review',
|
||||
countDescriptors: $this->runCountDescriptors($run),
|
||||
);
|
||||
}
|
||||
|
||||
@ -646,6 +832,32 @@ private function resolveArtifactForRun(OperationRun $run): BaselineSnapshot|Evid
|
||||
};
|
||||
}
|
||||
|
||||
private function resolveEnvelope(
|
||||
Model $record,
|
||||
string $variant,
|
||||
callable $resolver,
|
||||
bool $fresh = false,
|
||||
): ArtifactTruthEnvelope {
|
||||
$key = DerivedStateKey::fromModel(DerivedStateFamily::ArtifactTruth, $record, $variant);
|
||||
|
||||
/** @var ArtifactTruthEnvelope $envelope */
|
||||
$envelope = $fresh
|
||||
? $this->derivedStateStore->resolveFresh(
|
||||
$key,
|
||||
$resolver,
|
||||
DerivedStateFamily::ArtifactTruth->defaultFreshnessPolicy(),
|
||||
DerivedStateFamily::ArtifactTruth->allowsNegativeResultCache(),
|
||||
)
|
||||
: $this->derivedStateStore->resolve(
|
||||
$key,
|
||||
$resolver,
|
||||
DerivedStateFamily::ArtifactTruth->defaultFreshnessPolicy(),
|
||||
DerivedStateFamily::ArtifactTruth->allowsNegativeResultCache(),
|
||||
);
|
||||
|
||||
return $envelope;
|
||||
}
|
||||
|
||||
private function contentExplanation(string $contentState): string
|
||||
{
|
||||
return match ($contentState) {
|
||||
@ -715,6 +927,7 @@ private function makeEnvelope(
|
||||
?int $relatedRunId,
|
||||
?string $relatedArtifactUrl,
|
||||
bool $includePublicationDimension,
|
||||
array $countDescriptors = [],
|
||||
): ArtifactTruthEnvelope {
|
||||
$primarySpec = BadgeCatalog::spec($primaryDomain, $primaryState);
|
||||
$dimensions = [
|
||||
@ -748,7 +961,7 @@ classification: 'diagnostic',
|
||||
);
|
||||
}
|
||||
|
||||
return new ArtifactTruthEnvelope(
|
||||
$draftEnvelope = new ArtifactTruthEnvelope(
|
||||
artifactFamily: $artifactFamily,
|
||||
artifactKey: $artifactKey,
|
||||
workspaceId: $workspaceId,
|
||||
@ -770,6 +983,30 @@ classification: 'diagnostic',
|
||||
dimensions: array_values($dimensions),
|
||||
reason: $reason,
|
||||
);
|
||||
|
||||
return new ArtifactTruthEnvelope(
|
||||
artifactFamily: $draftEnvelope->artifactFamily,
|
||||
artifactKey: $draftEnvelope->artifactKey,
|
||||
workspaceId: $draftEnvelope->workspaceId,
|
||||
tenantId: $draftEnvelope->tenantId,
|
||||
executionOutcome: $draftEnvelope->executionOutcome,
|
||||
artifactExistence: $draftEnvelope->artifactExistence,
|
||||
contentState: $draftEnvelope->contentState,
|
||||
freshnessState: $draftEnvelope->freshnessState,
|
||||
publicationReadiness: $draftEnvelope->publicationReadiness,
|
||||
supportState: $draftEnvelope->supportState,
|
||||
actionability: $draftEnvelope->actionability,
|
||||
primaryLabel: $draftEnvelope->primaryLabel,
|
||||
primaryExplanation: $draftEnvelope->primaryExplanation,
|
||||
diagnosticLabel: $draftEnvelope->diagnosticLabel,
|
||||
nextActionLabel: $draftEnvelope->nextActionLabel,
|
||||
nextActionUrl: $draftEnvelope->nextActionUrl,
|
||||
relatedRunId: $draftEnvelope->relatedRunId,
|
||||
relatedArtifactUrl: $draftEnvelope->relatedArtifactUrl,
|
||||
dimensions: $draftEnvelope->dimensions,
|
||||
reason: $draftEnvelope->reason,
|
||||
operatorExplanation: $this->operatorExplanationBuilder->fromArtifactTruthEnvelope($draftEnvelope, $countDescriptors),
|
||||
);
|
||||
}
|
||||
|
||||
private function dimension(
|
||||
@ -787,4 +1024,31 @@ classification: $classification,
|
||||
badgeState: $state,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, CountDescriptor>
|
||||
*/
|
||||
private function runCountDescriptors(OperationRun $run): array
|
||||
{
|
||||
$descriptors = [];
|
||||
|
||||
foreach (SummaryCountsNormalizer::normalize(is_array($run->summary_counts) ? $run->summary_counts : []) as $key => $value) {
|
||||
$role = match (true) {
|
||||
in_array($key, ['total', 'processed'], true) => CountDescriptor::ROLE_EXECUTION,
|
||||
str_contains($key, 'failed') || str_contains($key, 'warning') || str_contains($key, 'blocked') => CountDescriptor::ROLE_RELIABILITY_SIGNAL,
|
||||
default => CountDescriptor::ROLE_EVALUATION_OUTPUT,
|
||||
};
|
||||
|
||||
$descriptors[] = new CountDescriptor(
|
||||
label: SummaryCountsNormalizer::label($key),
|
||||
value: (int) $value,
|
||||
role: $role,
|
||||
visibilityTier: in_array($key, ['total', 'processed'], true)
|
||||
? CountDescriptor::VISIBILITY_PRIMARY
|
||||
: CountDescriptor::VISIBILITY_DIAGNOSTIC,
|
||||
);
|
||||
}
|
||||
|
||||
return $descriptors;
|
||||
}
|
||||
}
|
||||
|
||||
67
app/Support/Ui/OperatorExplanation/CountDescriptor.php
Normal file
67
app/Support/Ui/OperatorExplanation/CountDescriptor.php
Normal file
@ -0,0 +1,67 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Support\Ui\OperatorExplanation;
|
||||
|
||||
use InvalidArgumentException;
|
||||
|
||||
final readonly class CountDescriptor
|
||||
{
|
||||
public const string ROLE_EXECUTION = 'execution';
|
||||
|
||||
public const string ROLE_EVALUATION_OUTPUT = 'evaluation_output';
|
||||
|
||||
public const string ROLE_COVERAGE = 'coverage';
|
||||
|
||||
public const string ROLE_RELIABILITY_SIGNAL = 'reliability_signal';
|
||||
|
||||
public const string VISIBILITY_PRIMARY = 'primary';
|
||||
|
||||
public const string VISIBILITY_DIAGNOSTIC = 'diagnostic';
|
||||
|
||||
public function __construct(
|
||||
public string $label,
|
||||
public int $value,
|
||||
public string $role,
|
||||
public ?string $qualifier = null,
|
||||
public string $visibilityTier = self::VISIBILITY_PRIMARY,
|
||||
) {
|
||||
if (trim($this->label) === '') {
|
||||
throw new InvalidArgumentException('Count descriptors require a label.');
|
||||
}
|
||||
|
||||
if (! in_array($this->role, [
|
||||
self::ROLE_EXECUTION,
|
||||
self::ROLE_EVALUATION_OUTPUT,
|
||||
self::ROLE_COVERAGE,
|
||||
self::ROLE_RELIABILITY_SIGNAL,
|
||||
], true)) {
|
||||
throw new InvalidArgumentException('Unsupported count descriptor role: '.$this->role);
|
||||
}
|
||||
|
||||
if (! in_array($this->visibilityTier, [self::VISIBILITY_PRIMARY, self::VISIBILITY_DIAGNOSTIC], true)) {
|
||||
throw new InvalidArgumentException('Unsupported count descriptor visibility tier: '.$this->visibilityTier);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{
|
||||
* label: string,
|
||||
* value: int,
|
||||
* role: string,
|
||||
* qualifier: ?string,
|
||||
* visibilityTier: string
|
||||
* }
|
||||
*/
|
||||
public function toArray(): array
|
||||
{
|
||||
return [
|
||||
'label' => $this->label,
|
||||
'value' => $this->value,
|
||||
'role' => $this->role,
|
||||
'qualifier' => $this->qualifier,
|
||||
'visibilityTier' => $this->visibilityTier,
|
||||
];
|
||||
}
|
||||
}
|
||||
17
app/Support/Ui/OperatorExplanation/ExplanationFamily.php
Normal file
17
app/Support/Ui/OperatorExplanation/ExplanationFamily.php
Normal file
@ -0,0 +1,17 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Support\Ui\OperatorExplanation;
|
||||
|
||||
enum ExplanationFamily: string
|
||||
{
|
||||
case TrustworthyResult = 'trustworthy_result';
|
||||
case NoIssuesDetected = 'no_issues_detected';
|
||||
case CompletedButLimited = 'completed_but_limited';
|
||||
case SuppressedOutput = 'suppressed_output';
|
||||
case MissingInput = 'missing_input';
|
||||
case BlockedPrerequisite = 'blocked_prerequisite';
|
||||
case Unavailable = 'unavailable';
|
||||
case InProgress = 'in_progress';
|
||||
}
|
||||
@ -0,0 +1,245 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Support\Ui\OperatorExplanation;
|
||||
|
||||
use App\Support\Badges\BadgeCatalog;
|
||||
use App\Support\Badges\BadgeDomain;
|
||||
use App\Support\ReasonTranslation\ReasonResolutionEnvelope;
|
||||
use App\Support\Ui\GovernanceArtifactTruth\ArtifactTruthEnvelope;
|
||||
|
||||
final class OperatorExplanationBuilder
|
||||
{
|
||||
/**
|
||||
* @param array<int, CountDescriptor> $countDescriptors
|
||||
*/
|
||||
public function build(
|
||||
ExplanationFamily $family,
|
||||
string $headline,
|
||||
string $executionOutcome,
|
||||
string $executionOutcomeLabel,
|
||||
string $evaluationResult,
|
||||
TrustworthinessLevel $trustworthinessLevel,
|
||||
string $reliabilityStatement,
|
||||
?string $coverageStatement,
|
||||
?string $dominantCauseCode,
|
||||
?string $dominantCauseLabel,
|
||||
?string $dominantCauseExplanation,
|
||||
string $nextActionCategory,
|
||||
string $nextActionText,
|
||||
array $countDescriptors = [],
|
||||
bool $diagnosticsAvailable = false,
|
||||
?string $diagnosticsSummary = null,
|
||||
): OperatorExplanationPattern {
|
||||
return new OperatorExplanationPattern(
|
||||
family: $family,
|
||||
headline: $headline,
|
||||
executionOutcome: $executionOutcome,
|
||||
executionOutcomeLabel: $executionOutcomeLabel,
|
||||
evaluationResult: $evaluationResult,
|
||||
trustworthinessLevel: $trustworthinessLevel,
|
||||
reliabilityStatement: $reliabilityStatement,
|
||||
coverageStatement: $coverageStatement,
|
||||
dominantCauseCode: $dominantCauseCode,
|
||||
dominantCauseLabel: $dominantCauseLabel,
|
||||
dominantCauseExplanation: $dominantCauseExplanation,
|
||||
nextActionCategory: $nextActionCategory,
|
||||
nextActionText: $nextActionText,
|
||||
countDescriptors: $countDescriptors,
|
||||
diagnosticsAvailable: $diagnosticsAvailable,
|
||||
diagnosticsSummary: $diagnosticsSummary,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, CountDescriptor> $countDescriptors
|
||||
*/
|
||||
public function fromArtifactTruthEnvelope(
|
||||
ArtifactTruthEnvelope $truth,
|
||||
array $countDescriptors = [],
|
||||
): OperatorExplanationPattern {
|
||||
$reason = $truth->reason?->toReasonResolutionEnvelope();
|
||||
$family = $this->familyForTruth($truth, $reason);
|
||||
$trustworthiness = $this->trustworthinessForTruth($truth, $reason);
|
||||
$evaluationResult = $this->evaluationResultForTruth($truth, $family);
|
||||
$executionOutcome = $this->executionOutcomeKey($truth->executionOutcome);
|
||||
$executionOutcomeLabel = $this->executionOutcomeLabel($truth->executionOutcome);
|
||||
$dominantCauseCode = $reason?->internalCode;
|
||||
$dominantCauseLabel = $reason?->operatorLabel ?? $truth->primaryLabel;
|
||||
$dominantCauseExplanation = $reason?->shortExplanation ?? $truth->primaryExplanation;
|
||||
$headline = $this->headlineForTruth($truth, $family, $trustworthiness);
|
||||
$reliabilityStatement = $this->reliabilityStatementForTruth($truth, $trustworthiness);
|
||||
$coverageStatement = $this->coverageStatementForTruth($truth, $reason);
|
||||
$nextActionText = $truth->nextStepText();
|
||||
$nextActionCategory = $this->nextActionCategory($truth->actionability, $reason);
|
||||
$diagnosticsAvailable = $truth->reason !== null
|
||||
|| $truth->diagnosticLabel !== null
|
||||
|| $countDescriptors !== [];
|
||||
|
||||
return $this->build(
|
||||
family: $family,
|
||||
headline: $headline,
|
||||
executionOutcome: $executionOutcome,
|
||||
executionOutcomeLabel: $executionOutcomeLabel,
|
||||
evaluationResult: $evaluationResult,
|
||||
trustworthinessLevel: $trustworthiness,
|
||||
reliabilityStatement: $reliabilityStatement,
|
||||
coverageStatement: $coverageStatement,
|
||||
dominantCauseCode: $dominantCauseCode,
|
||||
dominantCauseLabel: $dominantCauseLabel,
|
||||
dominantCauseExplanation: $dominantCauseExplanation,
|
||||
nextActionCategory: $nextActionCategory,
|
||||
nextActionText: $nextActionText,
|
||||
countDescriptors: $countDescriptors,
|
||||
diagnosticsAvailable: $diagnosticsAvailable,
|
||||
diagnosticsSummary: $diagnosticsAvailable
|
||||
? 'Technical truth detail remains available below the primary explanation.'
|
||||
: null,
|
||||
);
|
||||
}
|
||||
|
||||
private function familyForTruth(
|
||||
ArtifactTruthEnvelope $truth,
|
||||
?ReasonResolutionEnvelope $reason,
|
||||
): ExplanationFamily {
|
||||
return match (true) {
|
||||
$reason?->absencePattern === 'suppressed_output' => ExplanationFamily::SuppressedOutput,
|
||||
$reason?->absencePattern === 'blocked_prerequisite' => ExplanationFamily::BlockedPrerequisite,
|
||||
$truth->executionOutcome === 'pending' || $truth->artifactExistence === 'not_created' && $truth->actionability !== 'required' => ExplanationFamily::InProgress,
|
||||
$truth->executionOutcome === 'failed' || $truth->executionOutcome === 'blocked' => ExplanationFamily::BlockedPrerequisite,
|
||||
$truth->artifactExistence === 'created_but_not_usable' || $truth->contentState === 'missing_input' => ExplanationFamily::MissingInput,
|
||||
$truth->contentState === 'trusted' && $truth->freshnessState === 'current' && $truth->actionability === 'none' && $truth->primaryLabel === 'Trustworthy artifact' => ExplanationFamily::TrustworthyResult,
|
||||
$truth->contentState === 'trusted' && $truth->freshnessState === 'current' && $truth->actionability === 'none' => ExplanationFamily::NoIssuesDetected,
|
||||
$truth->artifactExistence === 'historical_only' => ExplanationFamily::Unavailable,
|
||||
default => ExplanationFamily::CompletedButLimited,
|
||||
};
|
||||
}
|
||||
|
||||
private function trustworthinessForTruth(
|
||||
ArtifactTruthEnvelope $truth,
|
||||
?ReasonResolutionEnvelope $reason,
|
||||
): TrustworthinessLevel {
|
||||
if ($reason?->trustImpact !== null) {
|
||||
return TrustworthinessLevel::tryFrom($reason->trustImpact) ?? TrustworthinessLevel::LimitedConfidence;
|
||||
}
|
||||
|
||||
return match (true) {
|
||||
$truth->artifactExistence === 'created_but_not_usable',
|
||||
$truth->contentState === 'missing_input',
|
||||
$truth->executionOutcome === 'failed',
|
||||
$truth->executionOutcome === 'blocked' => TrustworthinessLevel::Unusable,
|
||||
$truth->supportState === 'limited_support',
|
||||
in_array($truth->contentState, ['reference_only', 'unsupported'], true) => TrustworthinessLevel::DiagnosticOnly,
|
||||
$truth->contentState === 'trusted' && $truth->freshnessState === 'current' && $truth->actionability === 'none' => TrustworthinessLevel::Trustworthy,
|
||||
default => TrustworthinessLevel::LimitedConfidence,
|
||||
};
|
||||
}
|
||||
|
||||
private function evaluationResultForTruth(
|
||||
ArtifactTruthEnvelope $truth,
|
||||
ExplanationFamily $family,
|
||||
): string {
|
||||
return match ($family) {
|
||||
ExplanationFamily::TrustworthyResult => 'full_result',
|
||||
ExplanationFamily::NoIssuesDetected => 'no_result',
|
||||
ExplanationFamily::SuppressedOutput => 'suppressed_result',
|
||||
ExplanationFamily::MissingInput,
|
||||
ExplanationFamily::BlockedPrerequisite,
|
||||
ExplanationFamily::Unavailable => 'unavailable',
|
||||
ExplanationFamily::InProgress => 'unavailable',
|
||||
ExplanationFamily::CompletedButLimited => 'incomplete_result',
|
||||
};
|
||||
}
|
||||
|
||||
private function executionOutcomeKey(?string $executionOutcome): string
|
||||
{
|
||||
$normalized = BadgeCatalog::normalizeState($executionOutcome);
|
||||
|
||||
return match ($normalized) {
|
||||
'queued', 'running', 'pending' => 'in_progress',
|
||||
'partially_succeeded' => 'completed_with_follow_up',
|
||||
'blocked' => 'blocked',
|
||||
'failed' => 'failed',
|
||||
default => 'completed',
|
||||
};
|
||||
}
|
||||
|
||||
private function executionOutcomeLabel(?string $executionOutcome): string
|
||||
{
|
||||
if (! is_string($executionOutcome) || trim($executionOutcome) === '') {
|
||||
return 'Completed';
|
||||
}
|
||||
|
||||
$spec = BadgeCatalog::spec(BadgeDomain::OperationRunOutcome, $executionOutcome);
|
||||
|
||||
return $spec->label !== 'Unknown' ? $spec->label : ucfirst(str_replace('_', ' ', trim($executionOutcome)));
|
||||
}
|
||||
|
||||
private function headlineForTruth(
|
||||
ArtifactTruthEnvelope $truth,
|
||||
ExplanationFamily $family,
|
||||
TrustworthinessLevel $trustworthiness,
|
||||
): string {
|
||||
return match ($family) {
|
||||
ExplanationFamily::TrustworthyResult => 'The result is ready to use.',
|
||||
ExplanationFamily::NoIssuesDetected => 'No follow-up was detected from this result.',
|
||||
ExplanationFamily::SuppressedOutput => 'The run completed, but normal output was intentionally suppressed.',
|
||||
ExplanationFamily::MissingInput => 'The result exists, but missing inputs keep it from being decision-grade.',
|
||||
ExplanationFamily::BlockedPrerequisite => 'The workflow did not produce a usable result because a prerequisite blocked it.',
|
||||
ExplanationFamily::InProgress => 'The result is still being prepared.',
|
||||
ExplanationFamily::Unavailable => 'A result is not currently available for this surface.',
|
||||
ExplanationFamily::CompletedButLimited => match ($trustworthiness) {
|
||||
TrustworthinessLevel::DiagnosticOnly => 'The result is available for diagnostics, not for a final decision.',
|
||||
TrustworthinessLevel::LimitedConfidence => 'The result is available, but it should be read with caution.',
|
||||
TrustworthinessLevel::Unusable => 'The result is not reliable enough to use as-is.',
|
||||
default => 'The result completed with operator follow-up.',
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
private function reliabilityStatementForTruth(
|
||||
ArtifactTruthEnvelope $truth,
|
||||
TrustworthinessLevel $trustworthiness,
|
||||
): string {
|
||||
return match ($trustworthiness) {
|
||||
TrustworthinessLevel::Trustworthy => 'Trustworthiness is high for the intended operator task.',
|
||||
TrustworthinessLevel::LimitedConfidence => $truth->primaryExplanation
|
||||
?? 'Trustworthiness is limited because coverage, freshness, or publication readiness still need review.',
|
||||
TrustworthinessLevel::DiagnosticOnly => 'This output is suitable for diagnostics only and should not be treated as the final answer.',
|
||||
TrustworthinessLevel::Unusable => 'This output is not reliable enough to support the intended operator action yet.',
|
||||
};
|
||||
}
|
||||
|
||||
private function coverageStatementForTruth(
|
||||
ArtifactTruthEnvelope $truth,
|
||||
?ReasonResolutionEnvelope $reason,
|
||||
): ?string {
|
||||
return match (true) {
|
||||
$truth->contentState === 'trusted' && $truth->freshnessState === 'current' => 'Coverage and artifact quality are sufficient for the default reading path.',
|
||||
$truth->freshnessState === 'stale' => 'The artifact exists, but freshness limits how confidently it should be used.',
|
||||
$truth->contentState === 'partial' => 'Coverage is incomplete, so the visible output should be treated as partial.',
|
||||
$truth->contentState === 'missing_input' => $reason?->shortExplanation ?? 'Required inputs were missing or unusable when this result was assembled.',
|
||||
in_array($truth->contentState, ['reference_only', 'unsupported'], true) => 'Only reduced-fidelity support is available for this result.',
|
||||
$truth->publicationReadiness === 'blocked' => 'The artifact exists, but it is still blocked from the intended downstream use.',
|
||||
default => null,
|
||||
};
|
||||
}
|
||||
|
||||
private function nextActionCategory(
|
||||
string $actionability,
|
||||
?ReasonResolutionEnvelope $reason,
|
||||
): string {
|
||||
if ($reason?->actionability === 'retryable_transient') {
|
||||
return 'retry_later';
|
||||
}
|
||||
|
||||
return match ($actionability) {
|
||||
'none' => 'none',
|
||||
'optional' => 'review_evidence_gaps',
|
||||
default => $reason?->actionability === 'prerequisite_missing'
|
||||
? 'fix_prerequisite'
|
||||
: 'manual_validate',
|
||||
};
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,135 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Support\Ui\OperatorExplanation;
|
||||
|
||||
use App\Support\Badges\BadgeCatalog;
|
||||
use App\Support\Badges\BadgeDomain;
|
||||
use InvalidArgumentException;
|
||||
|
||||
final readonly class OperatorExplanationPattern
|
||||
{
|
||||
/**
|
||||
* @param array<int, CountDescriptor> $countDescriptors
|
||||
*/
|
||||
public function __construct(
|
||||
public ExplanationFamily $family,
|
||||
public string $headline,
|
||||
public string $executionOutcome,
|
||||
public string $executionOutcomeLabel,
|
||||
public string $evaluationResult,
|
||||
public TrustworthinessLevel $trustworthinessLevel,
|
||||
public string $reliabilityStatement,
|
||||
public ?string $coverageStatement,
|
||||
public ?string $dominantCauseCode,
|
||||
public ?string $dominantCauseLabel,
|
||||
public ?string $dominantCauseExplanation,
|
||||
public string $nextActionCategory,
|
||||
public string $nextActionText,
|
||||
public array $countDescriptors = [],
|
||||
public bool $diagnosticsAvailable = false,
|
||||
public ?string $diagnosticsSummary = null,
|
||||
) {
|
||||
if (trim($this->headline) === '') {
|
||||
throw new InvalidArgumentException('Operator explanation patterns require a headline.');
|
||||
}
|
||||
|
||||
if (trim($this->executionOutcome) === '' || trim($this->executionOutcomeLabel) === '') {
|
||||
throw new InvalidArgumentException('Operator explanation patterns require an execution outcome and label.');
|
||||
}
|
||||
|
||||
if (trim($this->evaluationResult) === '') {
|
||||
throw new InvalidArgumentException('Operator explanation patterns require an evaluation result state.');
|
||||
}
|
||||
|
||||
if (trim($this->reliabilityStatement) === '') {
|
||||
throw new InvalidArgumentException('Operator explanation patterns require a reliability statement.');
|
||||
}
|
||||
|
||||
if (trim($this->nextActionCategory) === '' || trim($this->nextActionText) === '') {
|
||||
throw new InvalidArgumentException('Operator explanation patterns require a next action category and text.');
|
||||
}
|
||||
|
||||
foreach ($this->countDescriptors as $descriptor) {
|
||||
if (! $descriptor instanceof CountDescriptor) {
|
||||
throw new InvalidArgumentException('Operator explanation count descriptors must contain CountDescriptor instances.');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public function evaluationResultLabel(): string
|
||||
{
|
||||
return BadgeCatalog::spec(BadgeDomain::OperatorExplanationEvaluationResult, $this->evaluationResult)->label;
|
||||
}
|
||||
|
||||
public function trustworthinessLabel(): string
|
||||
{
|
||||
return BadgeCatalog::spec(BadgeDomain::OperatorExplanationTrustworthiness, $this->trustworthinessLevel)->label;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{
|
||||
* family: string,
|
||||
* headline: string,
|
||||
* executionOutcome: string,
|
||||
* executionOutcomeLabel: string,
|
||||
* evaluationResult: string,
|
||||
* evaluationResultLabel: string,
|
||||
* trustworthinessLevel: string,
|
||||
* reliabilityLevel: string,
|
||||
* trustworthinessLabel: string,
|
||||
* reliabilityStatement: string,
|
||||
* coverageStatement: ?string,
|
||||
* dominantCause: array{
|
||||
* code: ?string,
|
||||
* label: ?string,
|
||||
* explanation: ?string
|
||||
* },
|
||||
* nextAction: array{
|
||||
* category: string,
|
||||
* text: string
|
||||
* },
|
||||
* countDescriptors: array<int, array{
|
||||
* label: string,
|
||||
* value: int,
|
||||
* role: string,
|
||||
* qualifier: ?string,
|
||||
* visibilityTier: string
|
||||
* }>,
|
||||
* diagnosticsAvailable: bool,
|
||||
* diagnosticsSummary: ?string
|
||||
* }
|
||||
*/
|
||||
public function toArray(): array
|
||||
{
|
||||
return [
|
||||
'family' => $this->family->value,
|
||||
'headline' => $this->headline,
|
||||
'executionOutcome' => $this->executionOutcome,
|
||||
'executionOutcomeLabel' => $this->executionOutcomeLabel,
|
||||
'evaluationResult' => $this->evaluationResult,
|
||||
'evaluationResultLabel' => $this->evaluationResultLabel(),
|
||||
'trustworthinessLevel' => $this->trustworthinessLevel->value,
|
||||
'reliabilityLevel' => $this->trustworthinessLevel->value,
|
||||
'trustworthinessLabel' => $this->trustworthinessLabel(),
|
||||
'reliabilityStatement' => $this->reliabilityStatement,
|
||||
'coverageStatement' => $this->coverageStatement,
|
||||
'dominantCause' => [
|
||||
'code' => $this->dominantCauseCode,
|
||||
'label' => $this->dominantCauseLabel,
|
||||
'explanation' => $this->dominantCauseExplanation,
|
||||
],
|
||||
'nextAction' => [
|
||||
'category' => $this->nextActionCategory,
|
||||
'text' => $this->nextActionText,
|
||||
],
|
||||
'countDescriptors' => array_map(
|
||||
static fn (CountDescriptor $descriptor): array => $descriptor->toArray(),
|
||||
$this->countDescriptors,
|
||||
),
|
||||
'diagnosticsAvailable' => $this->diagnosticsAvailable,
|
||||
'diagnosticsSummary' => $this->diagnosticsSummary,
|
||||
];
|
||||
}
|
||||
}
|
||||
13
app/Support/Ui/OperatorExplanation/TrustworthinessLevel.php
Normal file
13
app/Support/Ui/OperatorExplanation/TrustworthinessLevel.php
Normal file
@ -0,0 +1,13 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Support\Ui\OperatorExplanation;
|
||||
|
||||
enum TrustworthinessLevel: string
|
||||
{
|
||||
case Trustworthy = 'trustworthy';
|
||||
case LimitedConfidence = 'limited_confidence';
|
||||
case DiagnosticOnly = 'diagnostic_only';
|
||||
case Unusable = 'unusable';
|
||||
}
|
||||
@ -35,6 +35,7 @@ public static function firstSlice(): array
|
||||
'evidence_snapshots',
|
||||
'inventory_items',
|
||||
'entra_groups',
|
||||
'tenant_reviews',
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
@ -412,6 +412,13 @@
|
||||
'baseline_compare' => [
|
||||
'supported' => true,
|
||||
'identity_strategy' => 'display_name',
|
||||
'resolution' => [
|
||||
'subject_class' => 'foundation_backed',
|
||||
'resolution_path' => 'foundation_inventory',
|
||||
'compare_capability' => 'limited',
|
||||
'capture_capability' => 'limited',
|
||||
'source_model_expected' => 'inventory',
|
||||
],
|
||||
],
|
||||
],
|
||||
[
|
||||
@ -426,6 +433,13 @@
|
||||
'baseline_compare' => [
|
||||
'supported' => true,
|
||||
'identity_strategy' => 'display_name',
|
||||
'resolution' => [
|
||||
'subject_class' => 'foundation_backed',
|
||||
'resolution_path' => 'foundation_inventory',
|
||||
'compare_capability' => 'limited',
|
||||
'capture_capability' => 'limited',
|
||||
'source_model_expected' => 'inventory',
|
||||
],
|
||||
],
|
||||
],
|
||||
[
|
||||
@ -440,6 +454,13 @@
|
||||
'baseline_compare' => [
|
||||
'supported' => true,
|
||||
'identity_strategy' => 'external_id',
|
||||
'resolution' => [
|
||||
'subject_class' => 'foundation_backed',
|
||||
'resolution_path' => 'foundation_policy',
|
||||
'compare_capability' => 'supported',
|
||||
'capture_capability' => 'supported',
|
||||
'source_model_expected' => 'policy',
|
||||
],
|
||||
],
|
||||
],
|
||||
[
|
||||
@ -454,6 +475,13 @@
|
||||
'baseline_compare' => [
|
||||
'supported' => false,
|
||||
'identity_strategy' => 'external_id',
|
||||
'resolution' => [
|
||||
'subject_class' => 'foundation_backed',
|
||||
'resolution_path' => 'foundation_policy',
|
||||
'compare_capability' => 'unsupported',
|
||||
'capture_capability' => 'unsupported',
|
||||
'source_model_expected' => 'policy',
|
||||
],
|
||||
],
|
||||
],
|
||||
[
|
||||
@ -468,6 +496,13 @@
|
||||
'baseline_compare' => [
|
||||
'supported' => true,
|
||||
'identity_strategy' => 'display_name',
|
||||
'resolution' => [
|
||||
'subject_class' => 'foundation_backed',
|
||||
'resolution_path' => 'foundation_inventory',
|
||||
'compare_capability' => 'limited',
|
||||
'capture_capability' => 'limited',
|
||||
'source_model_expected' => 'inventory',
|
||||
],
|
||||
],
|
||||
],
|
||||
],
|
||||
|
||||
@ -120,7 +120,7 @@ ### Missing (no code, no spec beyond brainstorming)
|
||||
|
||||
## Architecture & Principles (Non-Negotiables)
|
||||
|
||||
Source: [.specify/memory/constitution.md](.specify/memory/constitution.md) (v1.12.0)
|
||||
Source: [.specify/memory/constitution.md](.specify/memory/constitution.md) (v1.14.0)
|
||||
|
||||
### Core Principles
|
||||
|
||||
@ -131,6 +131,9 @@ ### Core Principles
|
||||
5. **Workspace Isolation** — Non-member → 404; workspace is primary session context. Enforced via `DenyNonMemberTenantAccess` middleware + `EnsureFilamentTenantSelected`.
|
||||
6. **Tenant Isolation** — Every read/write must be tenant-scoped; `DerivesWorkspaceIdFromTenant` concern auto-fills `workspace_id` from tenant.
|
||||
7. **Operator Surface Principles** — `/admin` defaults are operator-first, diagnostics are progressively disclosed, status dimensions stay distinct, mutation scope is explicit before execution, and every materially changed operator page carries an explicit page contract.
|
||||
8. **Filament-native first / no ad-hoc styling** — Admin and operator UI must use Filament-native components or shared primitives before any local Blade/Tailwind assembly; page-local status styling is not an acceptable substitute.
|
||||
9. **Proportionality first** — New structure, layers, persistence, and semantic machinery must be justified by current release truth, current operator workflow, and why a narrower solution is insufficient.
|
||||
10. **Anti-bloat guardrails** — No premature abstraction, no new persisted truth without independent source-of-truth need, no new domain state without behavioral consequence, and specs that add structural complexity must carry an explicit proportionality review.
|
||||
|
||||
### RBAC-UX Rules
|
||||
|
||||
@ -158,6 +161,7 @@ ### Filament Standards
|
||||
- **Action Surface Contract**: List/View/Create/Edit pages each have defined required surfaces (Spec 082, 090).
|
||||
- **Layout**: Main/Aside layout, sections required, view pages use Infolists.
|
||||
- **Badge Semantics**: Centralized via `BadgeCatalog`/`BadgeRenderer` (Specs 059, 060).
|
||||
- **Filament-native UI**: Native Filament components and shared primitives come before any local styling or replacement markup for semantic UI elements.
|
||||
- **No naked forms**: Everything in sections/cards with proper enterprise IA.
|
||||
|
||||
### Provider Gateway
|
||||
|
||||
@ -3,7 +3,7 @@ # Product Principles
|
||||
> Permanent product principles that govern every spec, every UI decision, and every architectural choice.
|
||||
> New specs must align with these. If a principle needs to change, update this file first.
|
||||
|
||||
**Last reviewed**: 2026-03-21
|
||||
**Last reviewed**: 2026-03-27
|
||||
|
||||
---
|
||||
|
||||
@ -60,6 +60,26 @@ ### Enterprise-grade auditability
|
||||
|
||||
## Data & Architecture
|
||||
|
||||
### Proportionality first
|
||||
New structure, layers, persistence, and semantic machinery must be justified by current release truth and current operator workflow.
|
||||
If a narrower implementation can solve the current problem safely, it wins.
|
||||
|
||||
### No premature abstraction
|
||||
No new registries, resolvers, strategy systems, orchestration layers, interfaces, or extension frameworks before at least two real concrete cases exist.
|
||||
Exceptions are allowed only when security, isolation, auditability, compliance evidence, or queue correctness require them now.
|
||||
|
||||
### Persist only real truth
|
||||
New tables or stored artifacts exist only for independent truth, lifecycle, audit, retention, compliance, routing, or durable operator workflow needs.
|
||||
Convenience projections, UI helpers, and speculative artifacts stay derived.
|
||||
|
||||
### New state requires new behavior
|
||||
Statuses, reason codes, and lifecycle labels are domain truth only when they change operator action, routing, permissioning, lifecycle, retention, audit, or retry behavior.
|
||||
Otherwise they remain derived presentation.
|
||||
|
||||
### One truth, few layers
|
||||
Avoid re-modeling the same domain truth across models, result DTOs, presenters, summaries, wrappers, and persisted mirrors.
|
||||
New layers should replace old ones or prove why the old ones cannot serve.
|
||||
|
||||
### Inventory-first, Snapshots-second
|
||||
- `InventoryItem` = last observed metadata
|
||||
- `PolicyVersion.snapshot` = explicit immutable JSONB capture
|
||||
@ -93,6 +113,15 @@ ### Action Surface Contract (non-negotiable)
|
||||
### Badge semantics centralized
|
||||
All status badges via `BadgeCatalog` / `BadgeRenderer`. No ad-hoc badge mappings.
|
||||
|
||||
### Filament-native first, no ad-hoc styling
|
||||
Admin and operator UI uses native Filament components or shared primitives first.
|
||||
No hand-built status chips, alert cards, or local semantic color/border styling when Filament or a central primitive already expresses the meaning.
|
||||
Any exception must be justified explicitly and stay minimal.
|
||||
|
||||
### UI semantics stay lightweight
|
||||
Badges, explanation text, trust/confidence labels, and status summaries are presentation helpers until they prove they are durable product contracts.
|
||||
Avoid building interpretive stacks that require multiple semantic wrapper layers before one domain truth can be shown.
|
||||
|
||||
### Canonical navigation and terminology
|
||||
Consistent naming, consistent routing, consistent mental model.
|
||||
No competing terms for the same concept.
|
||||
@ -117,6 +146,9 @@ ### Spec-first workflow
|
||||
Runtime behavior changes require spec update first.
|
||||
Every spec must declare: scope, primary routes, data ownership, RBAC requirements (SCOPE-002).
|
||||
|
||||
### Mandatory proportionality review for structural additions
|
||||
Any spec that adds a new enum/status family, DTO/presenter layer, persisted entity, interface/registry/resolver, or taxonomy must explain the operator problem, why existing structure is insufficient, why the implementation is the narrowest correct one, its ownership cost, the rejected simpler alternative, and whether it serves current-release truth.
|
||||
|
||||
### Regression guards mandatory
|
||||
RBAC regression tests per role. Ops-UX regression guards prevent direct status writes and ad-hoc notifications.
|
||||
Architectural guard tests enforce code-level contracts.
|
||||
|
||||
@ -47,6 +47,25 @@ ### Baseline Drift Engine (Cutover)
|
||||
|
||||
**Active specs**: 119 (cutover)
|
||||
|
||||
### R1.9 Platform Localization v1 (DE/EN)
|
||||
UI-Sprache umschaltbar (`de`, `en`) mit sauberem Locale-Foundation-Layer.
|
||||
Goal: Konsistente, durchgängige Lokalisierung aller Governance-Oberflächen — ohne Brüche in Export, Audit oder Maschinenformaten.
|
||||
|
||||
- Locale-Priorität: expliziter Override → User Preference → Workspace Default → System Default
|
||||
- Workspace Default Language für neue Nutzer, User kann persönliche Sprache überschreiben
|
||||
- Core-Surfaces zuerst: Navigation, Dashboard, Tenant Views, Findings, Baseline Compare, Risk Exceptions, Alerts, Operations, Audit-nahe Grundtexte
|
||||
- Canonical Glossary für Governance-Begriffe (Finding, Baseline, Drift, Risk Accepted, Evidence Gap, Run) — konsistente Terminologie über alle Views
|
||||
- Locale-aware Anzeigeformate für Datum, Uhrzeit, Zahlen und relative Zeiten
|
||||
- Maschinen- und Exportformate bleiben invariant/stabil (keine lokalisierte Semantik in CSV/JSON/Audit-Artefakten)
|
||||
- Notifications, E-Mails und operatorseitige Systemtexte nutzen die aufgelöste Locale des Empfängers
|
||||
- Fallback-Regel: fehlende Übersetzungen fallen kontrolliert auf Englisch zurück; keine leeren/rohen Keys im UI
|
||||
- Translation-Key Governance für Labels, Actions, Statuswerte, Empty States, Table Filters, Notifications und Validation-/Systemtexte
|
||||
- HTML/UI i18n-Foundation: korrektes `lang`/Locale-Setup, keine hartcodierten kritischen UI-Strings, layouts sprachrobust
|
||||
- Search/Sort/Filter auf kritischen Listen für locale-sensitives Verhalten prüfen
|
||||
- QA/Foundation: Missing-Key Detection, Locale Regression Tests, Pseudolocalization Smoke Tests für kritische Flows
|
||||
|
||||
**Active specs**: — (not yet specced)
|
||||
|
||||
---
|
||||
|
||||
## Planned (Next Quarter)
|
||||
|
||||
@ -5,7 +5,7 @@ # Spec Candidates
|
||||
>
|
||||
> **Flow**: Inbox → Qualified → Planned → Spec created → moved to `Promoted to Spec`
|
||||
|
||||
**Last reviewed**: 2026-03-23 (added Operator Explanation Layer candidate; added governance operator outcome compression follow-up; promoted Spec 158 into ledger)
|
||||
**Last reviewed**: 2026-03-28 (added request-scoped performance foundation candidates for derived state, governance aggregates, and workspace access context)
|
||||
|
||||
---
|
||||
|
||||
@ -44,6 +44,89 @@ ## Qualified
|
||||
|
||||
> Problem + Nutzen klar. Scope noch offen. Braucht noch Priorisierung.
|
||||
|
||||
### Request-Scoped Derived State and Resolver Memoization
|
||||
- **Type**: foundation
|
||||
- **Source**: cross-cutting Filament render-path performance analysis 2026-03-28 — repeated derived-state resolution across `ArtifactTruthPresenter`, `OperationUxPresenter`, and `RelatedNavigationResolver`
|
||||
- **Problem**: TenantPilot's presenter / resolver layer is architecturally correct but render-path chatty. The same derived truth, guidance, or related-navigation state is recomputed multiple times per record, per surface, and per request: list row badges, descriptions, tooltips, visibility checks, detail entries, and widgets ask for the same deterministic answer through separate closures. This is broader than one local N+1 query; it is a repeated-cost shape that now spans baseline, evidence, review, operation, and navigation surfaces.
|
||||
- **Why it matters**: If each page is locally optimized in isolation, the underlying cost pattern remains and spreads into tenant reviews, review packs, evidence surfaces, baseline snapshots, and future portfolio/MSP views. The product needs a shared contract for deterministic request-local reuse, not another generation of ad hoc static caches and page-specific memoization helpers.
|
||||
- **Proposed direction**:
|
||||
- Introduce a canonical request-scoped derived-state store for deterministic presenter / resolver outputs
|
||||
- Define explicit keying rules around presenter family, record type, record identity, variant / surface mode, and any relevant view-policy context
|
||||
- Route `ArtifactTruthPresenter::for*()` paths, `OperationUxPresenter` surface guidance paths, and `RelatedNavigationResolver` primary/detail-entry paths through the shared store
|
||||
- Separate raw domain state, derived surface state, and navigation/context state so memoization boundaries are explicit instead of accidental
|
||||
- Provide a row-safe consumption pattern for Filament tables so label / tooltip / description / visible / url closures share the same derived state instead of recomputing it independently
|
||||
- Define an explicit freshness rule for mutating action flows so request-local reuse never masks state that must be recomputed within the same action request
|
||||
- **Scope boundaries**:
|
||||
- **In scope**: request-scoped memoization contract, derived-state store infrastructure, keying contract, presenter/resolver adoption for at least Artifact Truth, Operation UX, and Related Navigation, and Filament list/detail/widget guardrails for row-safe consumption
|
||||
- **Out of scope**: Redis or cross-request caching, aggressive query redesign, semantic changes to truth/guidance/navigation rules, widget redesign, and one-off page caches that bypass the shared contract
|
||||
- **Acceptance points**:
|
||||
- The same artifact truth for the same scope is fully derived at most once per request
|
||||
- The same operation guidance for the same run/surface scope is fully derived at most once per request
|
||||
- The same related primary/detail navigation entry for the same record is fully resolved at most once per request
|
||||
- At least three currently affected surface families adopt the same shared contract
|
||||
- Existing truth / guidance / navigation semantics stay unchanged from the operator's perspective
|
||||
- Regression tests prove request-local reuse and mutation-path freshness
|
||||
- **Risks / open questions**:
|
||||
- Incorrect cache keys could cause invalid reuse across surfaces or variants
|
||||
- Static ad hoc caches would hide the problem rather than solve it
|
||||
- Mutation flows need an explicit invalidation or fresh-recompute rule where state can legitimately change mid-request
|
||||
- **Suggested order**: first. This is the shared performance foundation the two follow-up candidates build on.
|
||||
- **Priority**: high
|
||||
|
||||
### Tenant Governance Aggregate Contract
|
||||
- **Type**: foundation
|
||||
- **Source**: tenant governance surface overlap analysis 2026-03-28 — shared summary state duplicated across Baseline Compare Landing, Needs Attention, Coverage Banner, Compare Now, and related tenant-governance cards
|
||||
- **Problem**: TenantPilot currently recomposes overlapping tenant-governance summaries on multiple surfaces in parallel instead of treating them as one tenant-scoped aggregate. Baseline compare freshness, compare outcome, open finding counts, overdue findings, expiring governance, lapsed governance, and related drift/coverage signals are recalculated or re-queried per widget/page, leaving ownership fragmented and making each new governance card more expensive than it needs to be.
|
||||
- **Why it matters**: Release 1 and 2 continue expanding governance, review, evidence, and dashboard surfaces. If these tenant-level summaries keep growing surface-by-surface, new widgets will multiply redundant count queries, presentation-specific helper code, and semantic drift around what exactly qualifies as an attention count or governance posture.
|
||||
- **Proposed direction**:
|
||||
- Promote the current baseline/governance summary path into an explicit tenant-scoped aggregate contract, either by hardening `BaselineCompareStats` into that role or by introducing a clearly named `TenantGovernanceAggregate`
|
||||
- Define one shared tenant summary that covers compare freshness, compare outcome, confidence/coverage/suppression where relevant, open findings summary, overdue findings, expiring/lapsed governance counts, and compare-related drift posture
|
||||
- Make dashboard widgets and attention cards consume the aggregate and keep only presentation mapping local to the surface
|
||||
- Prohibit parallel count queries for states that are already part of the shared aggregate contract
|
||||
- Ensure repeated reads of the same tenant aggregate on one page are request-scoped and reusable instead of recomputed per widget
|
||||
- **Scope boundaries**:
|
||||
- **In scope**: tenant-scoped governance aggregate contract, shared ownership of attention counts, dashboard/landing/banner/card consumption for the same aggregate family, and request-local reuse across those surfaces
|
||||
- **Out of scope**: cross-tenant portfolio aggregation, cross-request persistence caching, new drift semantics, new findings workflow semantics, full dashboard redesign, and broader evidence/review aggregation beyond the current tenant-governance summary family
|
||||
- **Acceptance points**:
|
||||
- At least three tenant-governance widgets/pages consume the same aggregate contract
|
||||
- No widget re-queries overdue, lapsed, or expiring counts that are already part of the shared aggregate
|
||||
- Dashboard, landing, and banner surfaces present semantically consistent values for the same tenant state
|
||||
- Request-local reuse for the tenant aggregate is demonstrably testable
|
||||
- No visible business-semantics regression is introduced while consolidating the summary source
|
||||
- **Risks / open questions**:
|
||||
- An overly broad aggregate could become a single oversized payload instead of a crisp contract
|
||||
- If attention semantics are not clearly separated from presentation mapping, widgets will continue to smuggle business logic back into the UI layer
|
||||
- If `BaselineCompareStats` stays helper-shaped rather than becoming an explicit contract, ownership ambiguity will persist even after partial consolidation
|
||||
- **Suggested order**: second, ideally immediately after or alongside the request-scoped derived-state foundation.
|
||||
- **Priority**: high
|
||||
|
||||
### Workspace Access Context and Navigation Cost Hardening
|
||||
- **Type**: hardening
|
||||
- **Source**: admin/workspace access-path analysis 2026-03-28 — repeated current-workspace, membership, navigation-visibility, and policy-adjacent access resolution across admin requests
|
||||
- **Problem**: TenantPilot already has request-local caching in some capability resolvers, but the wider workspace/admin access path still pays a repeated request tax. Current workspace resolution, workspace membership lookups, navigation visibility checks, page access checks, and policy-adjacent access helpers can rebuild overlapping context multiple times before the actual screen content has even rendered. The issue is not one slow page; it is a hidden cost shape spread across many admin requests.
|
||||
- **Why it matters**: As admin, monitoring, review, evidence, and future portfolio/workspace surfaces grow, this hidden context tax will compound across almost every workspace-scoped request. Left unbounded, it also increases the risk of access logic drifting into scattered local helpers instead of one explicit request-level contract.
|
||||
- **Proposed direction**:
|
||||
- Introduce an explicit request-scoped workspace access context that carries the current workspace ID/model, the membership decision, and any capability-access snapshot needed for repeated checks
|
||||
- Harden `currentWorkspace()` or equivalent paths so the active workspace model is request-stable instead of repeatedly reloaded
|
||||
- Make navigation visibility, resource visibility, page access helpers, and similar admin-panel checks consume the shared access context rather than rebuilding workspace/membership state locally
|
||||
- Reuse the same context in policy-side or policy-adjacent workspace access decisions where repeated lookup is currently common
|
||||
- Keep cross-panel workspace-aware transitions aligned with the same context contract rather than introducing special-case handoff helpers
|
||||
- **Scope boundaries**:
|
||||
- **In scope**: request-scoped workspace access context, current-workspace reuse, membership reuse, navigation/page-access context reuse, and migration of at least a subset of admin-sensitive helpers to the shared path
|
||||
- **Out of scope**: RBAC redesign, capability-semantic changes, navigation IA restructuring, tenant-panel RBAC rewrite, or product-model changes to workspace-first behavior
|
||||
- **Acceptance points**:
|
||||
- The current workspace is not separately loaded multiple times within the same request path
|
||||
- Repeated workspace-scoped access checks reuse the same membership/access context instead of rebuilding it
|
||||
- At least two admin-sensitive request paths are migrated to the shared access context
|
||||
- Navigation visibility uses request-wide reusable workspace context rather than repeated local lookups
|
||||
- Access semantics remain unchanged while the request-path cost is hardened
|
||||
- **Risks / open questions**:
|
||||
- A wrong or overly broad shared context could create subtle access bugs
|
||||
- The boundary between session-persisted workspace choice and request-scoped access context must stay explicit
|
||||
- Over-centralization could hide legitimate special cases if exceptions are not consciously modeled
|
||||
- **Suggested order**: third, after the derived-state and tenant-aggregate foundation work has clarified the shared request-scoped patterns.
|
||||
- **Priority**: medium
|
||||
|
||||
### Tenant Draft Discard Lifecycle and Orphaned Draft Visibility
|
||||
- **Type**: hardening
|
||||
- **Source**: domain architecture analysis 2026-03-16 — tenant lifecycle vs onboarding workflow lifecycle review
|
||||
@ -293,6 +376,62 @@ ### Operator Explanation Layer for Degraded / Partial / Suppressed Results
|
||||
>
|
||||
> **Why these are not one spec:** Each candidate has a different implementation surface, different stakeholders, and different shippability boundary. The taxonomy is a cross-cutting decision document. Reason code translation touches reason-code artifacts and notification builders. Spec 158 defines the richer artifact truth engine. The Operator Explanation Layer defines the shared interpretation semantics and explanation patterns. Governance operator outcome compression is a UI-information-architecture adoption slice across governance artifact surfaces. Humanized diagnostic summaries are an adoption slice for governance run-detail pages. Gate unification touches provider dispatch and notification plumbing across ~20 services. Merging them would create an unshippable monolith. Keeping them sequenced preserves independent delivery while still converging on one operator language.
|
||||
|
||||
### Operation Run Active-State Visibility & Stale Escalation
|
||||
- **Type**: hardening
|
||||
- **Source**: product/operator visibility analysis 2026-03-24; operation-run lifecycle and stale-state communication review
|
||||
- **Vehicle**: new standalone candidate
|
||||
- **Problem**: TenantPilot already has the core lifecycle foundations for `OperationRun` records: canonical run modelling, workspace-level run viewing, per-type lifecycle policies, freshness and stale detection, overdue-run reconciliation, terminal notifications, and tenant-local active-run hints. The gap is no longer primarily lifecycle logic. The gap is that the same lifecycle truth is not communicated with enough consistency and urgency across the operator surfaces that matter. A run can be past its expected lifecycle or likely stuck while still looking like normal active work on tenant-local cards or dashboard attention surfaces. Operators then have to drill into the canonical run viewer to learn that the run is no longer healthy, which weakens monitoring trust and makes hanging work look deceptively normal.
|
||||
- **Why it matters**: This is an observability and operator-trust problem in a core platform layer, not visual polish. If `queued` or `running` remains visually neutral after lifecycle expectations have been exceeded, operators receive false reassurance, support burden rises, queue or worker issues are discovered later, and the product trains users that active-state surfaces are not trustworthy without manual drill-down. As TenantPilot pushes more governance, review, drift, and evidence workflows through `OperationRun`, stale active work must never read as healthy progress anywhere in the product.
|
||||
- **Proposed direction**:
|
||||
- Reuse the existing lifecycle, freshness, and reconciliation truth to define one **cross-surface active-state presentation contract** that distinguishes at least: `active / normal`, `active / past expected lifecycle`, `stale / likely stuck`, and `terminal / no longer active`
|
||||
- Upgrade **tenant-local active-run and progress cards** so stale or past-lifecycle runs are visibly and linguistically different from healthy active work instead of reading as neutral `Queued • 1d` or `Running • 45m`
|
||||
- Upgrade **tenant dashboard and attention surfaces** so they distinguish between healthy activity, activity that needs attention, and activity that is likely stale or hanging
|
||||
- Upgrade the **workspace operations list / monitoring views** so problematic active runs become scanable at row level instead of being discoverable only through subtle secondary text or by opening each run
|
||||
- Preserve the **workspace-level canonical run viewer** as the authoritative diagnostic surface, while ensuring compact and summary surfaces do not contradict it
|
||||
- Apply a **same meaning, different density** rule: tenant cards, dashboard signals, list rows, and run detail may vary in information density, but not in lifecycle meaning or operator implication
|
||||
- **Core product principles**:
|
||||
- Execution lifecycle, freshness, and operator attention are related but not identical dimensions
|
||||
- Compact surfaces may compress information, but must not downplay stale or hanging work
|
||||
- The workspace-level run viewer remains canonical; this candidate improves visibility, not source-of-truth ownership
|
||||
- Stale or past-lifecycle work must not look like healthy progress anywhere
|
||||
- **Candidate requirements**:
|
||||
- **R1 Cross-surface lifecycle visibility**: all relevant active-run surfaces can distinguish at least normal active, past-lifecycle active, stale/likely stuck, and terminal states
|
||||
- **R2 Tenant active-run escalation**: tenant-local active-run and progress cards visibly and linguistically escalate stale or past-lifecycle work
|
||||
- **R3 Dashboard attention separation**: dashboard and attention surfaces distinguish healthy activity from concerning active work
|
||||
- **R4 Operations-list scanability**: the workspace operations list makes problematic active runs quickly identifiable without requiring row-by-row interpretation or drill-in
|
||||
- **R5 Canonical viewer preservation**: the workspace-level run viewer remains the detailed and authoritative truth surface
|
||||
- **R6 No hidden contradiction**: a run that is clearly stale or lifecycle-problematic on the detail page must not appear as ordinary active work on tenant or monitoring surfaces
|
||||
- **R7 Existing lifecycle logic reuse**: the candidate reuses current freshness, lifecycle, and reconciliation semantics instead of introducing parallel UI-only heuristics
|
||||
- **R8 No new backend lifecycle semantics unless necessary**: new status values or model-level lifecycle semantics are out unless the current semantics cannot carry the presentation contract cleanly
|
||||
- **Scope boundaries**:
|
||||
- **In scope**: tenant-local active-run cards, tenant dashboard activity and attention surfaces, workspace operations list and monitoring surfaces, shared lifecycle presentation contract for active-state visibility, copy and visual semantics needed to distinguish healthy active work from stale active work
|
||||
- **Out of scope**: retry, cancel, force-fail, or reconcile-now operator actions; queue or worker architecture changes; new scheduler or timeout engines; new notification channels; a full operations-hub redesign; cross-workspace fleet monitoring; introducing new `OperationRun` status values unless existing semantics are proven insufficient
|
||||
- **Acceptance points**:
|
||||
- An active run outside its lifecycle expectation is visibly distinct from healthy active work on tenant-local progress cards
|
||||
- Tenant dashboard and attention surfaces clearly represent the difference between healthy activity and active work that needs attention
|
||||
- The workspace operations list makes stale or problematic active runs quickly scanable
|
||||
- No surface shows a run as stale/problematic while another still presents it as normal active work
|
||||
- The canonical workspace-level run viewer remains the most detailed lifecycle and diagnosis surface
|
||||
- Existing lifecycle and freshness logic is reused rather than duplicated into local UI-only state rules
|
||||
- No retry, cancel, or force-fail intervention actions are introduced by this candidate
|
||||
- Fresh active runs do not regress into false escalation
|
||||
- Tenant and workspace scoping remain correct; no cross-tenant leakage appears in cards or monitoring views
|
||||
- Regression coverage includes fresh and stale active runs across tenant and workspace surfaces
|
||||
- **Suggested test matrix**:
|
||||
- queued run within expected lifecycle
|
||||
- queued run well past expected lifecycle
|
||||
- running run within expected lifecycle
|
||||
- running run well past expected lifecycle
|
||||
- run becomes terminal while an operator navigates between tenant and run-detail surfaces
|
||||
- stale state on detail surface remains semantically stale on tenant and monitoring surfaces
|
||||
- fresh active runs do not escalate falsely
|
||||
- tenant-scoped surfaces never show another tenant's runs
|
||||
- operations list clearly surfaces problematic active runs for fast scan
|
||||
- **Dependencies**: existing operation-run lifecycle policy and stale detection foundations, canonical run viewer work, tenant-local active-run surfaces, operations monitoring list surfaces
|
||||
- **Related specs / candidates**: Spec 114 (system console / operations monitoring foundations), Spec 144 (canonical operation viewer context decoupling), Spec 149 (queued execution reauthorization and lifecycle continuity), Operator Explanation Layer for Degraded / Partial / Suppressed Results (adjacent but broader interpretation layer), Provider-Backed Action Preflight and Dispatch Gate Unification (neighboring operational hardening lane)
|
||||
- **Strategic sequencing**: Best treated before any broader operator intervention-actions spec. First make stale or hanging work visibly truthful across all existing surfaces; only then add retry / force-fail / reconcile-now UX on top of a coherent active-state language.
|
||||
- **Priority**: high
|
||||
|
||||
### Baseline Snapshot Fidelity Semantics
|
||||
- **Type**: hardening
|
||||
- **Source**: semantic clarity & operator-language audit 2026-03-21
|
||||
@ -303,6 +442,50 @@ ### Baseline Snapshot Fidelity Semantics
|
||||
- **Dependencies**: Operator Outcome Taxonomy and Cross-Domain State Separation, Structured Snapshot Rendering (Spec 130)
|
||||
- **Priority**: high
|
||||
|
||||
### Baseline Compare Scope Guardrails & Ambiguity Guidance
|
||||
- **Type**: hardening
|
||||
- **Source**: product/operator-trust analysis 2026-03-24 — baseline compare ambiguity and scope communication review
|
||||
- **Vehicle**: new standalone candidate
|
||||
- **Problem**: Baseline Compare currently produces confusing results when the baseline snapshot contains generic Microsoft/Intune default objects or subjects with non-unique display names. Not-uniquely-matchable subjects are surfaced as "duplicates" in the UI, implying operator error even when the root cause is provider-side generic naming. Separate truth dimensions — identity confidence (could subjects be matched uniquely?), evidence fidelity (how deep was the compare?), and result trust (how reliable is the overall outcome?) — are collapsed into ambiguous operator signals such as `No Drift Detected` + `Limited confidence` + `Fidelity: Meta` without explaining whether the issue is baseline scope, generic names, limited compare capability, or actual tenant drift.
|
||||
- **Why it matters**: Operators reading compare results cannot distinguish between "everything is fine" and "we couldn't compare reliably." False reassurance (`No Drift Detected` at limited confidence) and false blame ("rename your duplicates" when subjects are provider-managed defaults) erode trust in the product's core governance promise. MSP operators managing baselines for multiple tenants need clear signals about what they can rely on and what requires scope curation — not academic-sounding fidelity labels next to misleading all-clear verdicts.
|
||||
- **Product decision**: Baseline Compare in V1 is designed for uniquely identifiable, intentionally curated governance policies — not for arbitrary tenant-wide default/enrollment/generic standard objects. When compare subjects cannot be reliably matched due to generic names or weak identity, TenantPilot treats this primarily as a scope/suitability problem of the current baseline content and a transparency/guidance topic in the product — not as an occasion for building a large identity classification engine.
|
||||
- **Proposed direction**:
|
||||
- **Compare wording correction**: replace pausal "rename your duplicates" messaging with neutral, scope-aware language explaining that some subjects cannot be matched uniquely by the current compare strategy, that this can happen with generic or provider-managed display names, and that the visible result is therefore only partially reliable
|
||||
- **Scope guidance on compare surfaces**: make explicit that Baseline Compare is for curated governance-scope policies, not for every tenant content. Baseline/capture surfaces must frame Golden Master as a deliberate governance scope, not an unfiltered tenant full-extract
|
||||
- **Actionable next-step guidance**: when ambiguity is detected, direct operators to review baseline profile scope, remove non-uniquely-identifiable subjects from governance scope, and re-run compare after scope cleanup — not to pauschal rename everything
|
||||
- **Meta-fidelity and limited-confidence separation**: separate identity-matchability, evidence/compare-depth, and overall result trustworthiness in the communication so operators can tell which dimension is limited and why
|
||||
- **Conservative framing for problematic V1 domains**: for known generically-named compare domains, allow conservative copy such as "not ideal for baseline compare," "limited compare confidence," "review scope before relying on result" — without introducing deep system-managed default detection
|
||||
- **Evidence/snapshot surface consistency**: terms like `Missing input`, `Not collected yet`, `Limited confidence` must not read as runtime errors when the actual issue is scope suitability
|
||||
- **Scope boundaries**:
|
||||
- **In scope**: compare result warning copy, limited-confidence explanation, next-step guidance, baseline profile/capture scope framing, conservative guardrail copy for problematic domains, evidence/snapshot surface term consistency
|
||||
- **Out of scope**: comprehensive Microsoft default policy detection, new global identity strategy engine, object-class-based system-managed vs user-managed classification, new deep fidelity matrix for all policy types, automatic exclusion or repair of problematic baseline items, compare engine architecture redesign
|
||||
- **UX direction**:
|
||||
- **Bad (current)**: "32 policies share the same display name" / "Please rename the duplicates" / `No Drift Detected` despite `Limited confidence`
|
||||
- **Good (target)**: neutral, honest, operator-actionable — e.g. "Some policies in the current baseline scope cannot be matched uniquely by the current compare strategy. This often happens with generic or provider-managed display names. Review your baseline scope and keep only uniquely identifiable governance policies before relying on this result."
|
||||
- **Acceptance criteria**:
|
||||
- AC1: ambiguous-match UI no longer pauschal blames operators for duplicates without explaining scope/generic-name context
|
||||
- AC2: limited-trust compare results are visually and linguistically distinguishable from fully reliable results; operators can tell the result is technically complete but content-wise only partially reliable
|
||||
- AC3: primary V1 guidance directs operators to baseline-scope review/cleanup and re-compare — not to pauschal rename or assume tenant misconfiguration
|
||||
- AC4: baseline/compare surfaces convey that Golden Master is a curated governance scope
|
||||
- AC5: `No Drift Detected` at `Limited confidence` is understandable as not-fully-trustworthy, not as definitive all-clear
|
||||
- **Tests / validation**:
|
||||
- Warning text for ambiguous matches uses neutral scope/matchability wording
|
||||
- Next-step guidance points to baseline scope review, not pauschal rename
|
||||
- `Limited confidence` + `No Drift Detected` is not presented as unambiguous all-clear
|
||||
- Baseline/compare surfaces include governance-scope hint
|
||||
- Known compare gaps do not produce misleading "user named everything wrong" messaging
|
||||
- Existing compare status/outcome logic remains intact
|
||||
- No new provider-specific special classification logic required for consistent UI
|
||||
- **Risks**:
|
||||
- R1: pure copy changes alone might address the symptom too weakly → mitigation: include scope/guidance framing, not just single-sentence edits
|
||||
- R2: too much guidance without technical guardrails might let operators keep building bad baselines → mitigation: conservative framing and later evolution via real usage data
|
||||
- R3: team reads this spec as a starting point for large identity architecture → mitigation: non-goals are explicitly and strictly scoped
|
||||
- **Roadmap fit**: Aligns directly with Release 1 — Golden Master Governance (R1.1 BaselineProfile, R1.3 baseline.compare, R1.4 Drift UI: Soll vs Ist). Improves V1 sellability without domain-model expansion: less confusing drift communication, clearer Golden Master story, no false operator blame, better trust basis for compare results.
|
||||
- **Dependencies**: Baseline drift engine stable (Specs 116–119), Baseline Snapshot Fidelity Semantics candidate (complementary — snapshot-level fidelity clarity), Operator Explanation Layer candidate (consumes explanation patterns), Governance Operator Outcome Compression candidate (complementary — governance artifact presentation)
|
||||
- **Related specs / candidates**: Specs 116–119 (baseline drift engine), Spec 101 (baseline governance), Baseline Capture Truthful Outcomes candidate, Baseline Snapshot Fidelity Semantics candidate, Operator Explanation Layer candidate, Governance Operator Outcome Compression candidate
|
||||
- **Recommendation**: Treat before any large matching/identity extension. Small enough for V1, reduces real operator confusion, protects against scope creep, and sharpens the product message: TenantPilot compares curated governance baselines — not blindly every generic tenant default content.
|
||||
- **Priority**: high
|
||||
|
||||
### Restore Lifecycle Semantic Clarity
|
||||
- **Type**: hardening
|
||||
- **Source**: semantic clarity & operator-language audit 2026-03-21
|
||||
@ -323,6 +506,70 @@ ### Inventory, Provider & Operability Semantics
|
||||
- **Dependencies**: Operator Outcome Taxonomy and Cross-Domain State Separation, provider connection vocabulary/cutover work, onboarding and verification spec family
|
||||
- **Priority**: medium
|
||||
|
||||
### Tenant Operational Readiness & Status Truth Hierarchy
|
||||
- **Type**: hardening
|
||||
- **Source**: product/operator-trust analysis 2026-03-24 — tenant-facing status presentation and source-of-truth hierarchy review
|
||||
- **Vehicle**: new standalone candidate
|
||||
- **Problem**: Tenant-facing surfaces expose multiple parallel status domains — lifecycle, legacy app status, provider connection state, provider health, verification report availability, RBAC readiness, and recent run evidence — without a clear hierarchy. Some domains are valid but poorly explained; at least one (`Tenant.app_status`) is legacy/orphaned truth still presented as if authoritative. The combined presentation does not answer the operator's actual question: "Can I trust this tenant right now, and is any action required?" Instead, operators must mentally reconcile six semi-related status fragments with no clear precedence, creating three distinct risks: legacy truth leakage (dead fields displayed as current truth), state collision without hierarchy (valid domains answering different questions but appearing to compete), and support/trust burden (operators asking why a tenant is "active" yet also "unknown," or provider is "connected" but health is "unknown," even when operational evidence proves usability).
|
||||
- **Why it matters**: TenantPilot is moving further into governance, evidence, reviews, drift, and portfolio visibility. As the product becomes more compliance- and operations-centric, source-of-truth quality on core tenant surfaces becomes more important, not less. If left unresolved: support load scales with tenant count, MSP operators learn to distrust or ignore status surfaces, future governance views inherit ambiguous foundations, and headline truth across baselines, evidence, findings, and reviews remains semantically inconsistent. For an enterprise governance platform, this is a product-truth and operator-confidence issue, not just a wording problem.
|
||||
- **Core insight**: Not every status belongs at the same level. The product currently exposes multiple truths belonging to different semantic layers:
|
||||
- **Layer 1 — Headline operator truth**: "Can I work with this tenant, and is action required?"
|
||||
- **Layer 2 — Domain truth**: lifecycle, provider consent/access, verification, RBAC, recent operational evidence
|
||||
- **Layer 3 — Diagnostic truth**: low-level or specialized states useful for investigation, not competing with headline summary
|
||||
- **Layer 4 — Legacy/orphaned truth**: stale, weakly maintained, deprecated, or no longer authoritative fields
|
||||
- **Proposed direction**:
|
||||
- **Headline readiness model**: define a single tenant-facing readiness summary answering whether the tenant is usable and whether action is needed. Concise operator-facing states such as: Ready, Ready with follow-up, Limited, Action required, Not ready.
|
||||
- **Source-of-truth hierarchy**: every tenant-facing status shown on primary surfaces classified as authoritative, derived, diagnostic, or legacy. Authoritative sources: lifecycle, canonical provider consent/access state, canonical verification state, RBAC readiness, recent operational evidence as supporting evidence.
|
||||
- **Domain breakdown beneath headline**: each supporting domain exists in a clearly subordinate role — lifecycle, provider access/consent, verification state, RBAC readiness, recent operational evidence.
|
||||
- **Action semantics clarity**: primary surfaces must distinguish between no action needed, recommended follow-up, required action, and informational only.
|
||||
- **Verification semantics**: UI must distinguish between never verified, verification unavailable, verification stale, verification failed, and verified but follow-up recommended. These must not collapse into ambiguous "unknown" messaging.
|
||||
- **Provider truth clarity**: provider access state must clearly differentiate access configured/consented, access verified, access usable but not freshly verified, access blocked or failed.
|
||||
- **RBAC semantics clarity**: RBAC readiness must clearly state whether write actions are blocked, without implying that all tenant operations are unavailable when read-only operations still function.
|
||||
- **Operational evidence handling**: recent successful operations may contribute supporting confidence, but must not silently overwrite or replace distinct provider verification truth.
|
||||
- **Legacy truth removal/demotion**: fields that are legacy, orphaned, or too weak to serve as source of truth must not remain prominent on tenant overview surfaces. Explicit disposition for orphaned fields like `Tenant.app_status`.
|
||||
- **Reusable semantics model**: the resulting truth hierarchy and readiness model must be reusable across tenant list/detail and future higher-level governance surfaces.
|
||||
- **Functional requirements**:
|
||||
- FR1 — Single tenant-facing readiness summary answering operability and action-needed
|
||||
- FR2 — Every primary-surface status classified as authoritative, derived, diagnostic, or legacy
|
||||
- FR3 — Legacy/orphaned fields not displayed as current operational truth on primary surfaces
|
||||
- FR4 — No peer-level contradiction on primary surfaces
|
||||
- FR5 — Verification semantics explicitly distinguishing not yet verified / unavailable / stale / failed / verified with follow-up
|
||||
- FR6 — Provider access state clearly differentiating configured, verified, usable-but-not-fresh, blocked
|
||||
- FR7 — RBAC readiness clarifying write-block vs full-block
|
||||
- FR8 — Operational evidence supportive but not substitutive for verification truth
|
||||
- FR9 — Actionability clarity on primary surfaces
|
||||
- FR10 — Reusable semantics for future governance surfaces
|
||||
- **UX/product rules**:
|
||||
- Same question, one answer: if several states contribute to the same operator decision, present one synthesized answer first
|
||||
- Summary before diagnostics: operator summary belongs first, domain detail underneath or behind expansion
|
||||
- "Unknown" is not enough: must not substitute for not checked, no report stored, stale result, legacy field, or unavailable artifact
|
||||
- Evidence is supportive, not substitutive: successful operations reinforce confidence but do not replace explicit verification
|
||||
- Lifecycle is not health: active does not mean provider access is verified or write operations are ready
|
||||
- Health is not onboarding history: historical onboarding verification is not automatically current operational truth
|
||||
- **Likely surfaces affected**:
|
||||
- Primary: tenant detail/overview page, tenant list presentation, tenant widgets/cards related to verification and recent operations, provider-related status presentation within tenant views, helper text/badge semantics on primary tenant surfaces
|
||||
- Secondary follow-up: provider connection detail pages, onboarding completion/follow-up states, future portfolio rollup views
|
||||
- **Scope boundaries**:
|
||||
- **In scope**: truth hierarchy definition, headline readiness model, tenant detail/overview presentation rules, provider state presentation rules on tenant surfaces, verification semantics on tenant surfaces, RBAC relationship to readiness, role of recent operational evidence, legacy truth cleanup on primary tenant surfaces
|
||||
- **Out of scope**: redesigning OperationRun result semantics in general, revisiting every badge/helper in the product, evidence/reporting semantics outside tenant readiness, changing onboarding lifecycle requirements unless directly necessary for truth consistency, provider architecture overhaul, full data-model cleanup beyond what is needed to remove legacy truth from primary surfaces, full badge taxonomy standardization everywhere, color palette / visual design overhaul, findings severity or workflow semantics, broad IA/navigation redesign, portfolio-level rollup semantics beyond stating compatibility goals
|
||||
- **Acceptance criteria**:
|
||||
- AC1: Primary tenant surfaces present a single operator-facing readiness truth rather than several equal-weight raw statuses
|
||||
- AC2: Lifecycle, provider access, verification, RBAC, and operational evidence shown with explicit semantic roles and no ambiguous precedence
|
||||
- AC3: Legacy/orphaned status fields no longer presented as live operational truth on primary surfaces
|
||||
- AC4: System clearly differentiates not yet verified / verification unavailable / stale / failed
|
||||
- AC5: Operator can tell within seconds whether tenant is usable / usable with follow-up / limited / blocked / in need of action
|
||||
- AC6: Recent successful operations reinforce confidence where appropriate but do not silently overwrite explicit verification truth
|
||||
- AC7: Primary tenant status communication suitable for MSP/enterprise use without requiring tribal knowledge to interpret contradictions
|
||||
- **Boundary with Tenant App Status False-Truth Removal**: That candidate is a quick, bounded removal of the single most obvious legacy truth field (`Tenant.app_status`). This candidate defines the broader truth hierarchy and presentation model that decides how all tenant status domains interrelate. The false-truth removal is a subset action that can proceed independently as a quick win; this candidate provides the architectural direction that prevents future truth leakage.
|
||||
- **Boundary with Provider Connection Status Vocabulary Cutover**: Provider vocabulary cutover owns the `ProviderConnection` model-level status field cleanup (canonical enum sources replacing legacy varchar fields). This candidate owns how provider truth appears on tenant-level surfaces in relation to other readiness domains. The vocabulary cutover provides cleaner provider truth; this candidate defines where and how that truth is presented alongside lifecycle, verification, RBAC, and evidence.
|
||||
- **Boundary with Inventory, Provider & Operability Semantics**: That candidate normalizes how inventory capture mode, provider health/prerequisites, and verification results are presented on their own surfaces. This candidate defines the tenant-level integration layer that synthesizes those domain truths into a single headline readiness answer. Complementary: domain-level semantic cleanup feeds into the readiness model.
|
||||
- **Boundary with Operator Explanation Layer**: The explanation layer defines cross-cutting interpretation patterns for degraded/partial/suppressed results. This candidate defines the tenant-specific readiness truth hierarchy. The explanation layer may inform how degraded readiness states are explained, but the readiness model itself is tenant-domain-specific.
|
||||
- **Boundary with Governance Operator Outcome Compression**: Governance compression improves governance artifact scan surfaces. This candidate improves tenant operational readiness surfaces. Both reduce operator cognitive load but on different product domains.
|
||||
- **Dependencies**: Operator Outcome Taxonomy and Cross-Domain State Separation (shared vocabulary foundation), Tenant App Status False-Truth Removal (quick win that removes the most obvious legacy truth — can proceed independently before or during this spec), Provider Connection Status Vocabulary Cutover (cleaner provider truth sources — soft dependency), existing tenant lifecycle semantics (Spec 143)
|
||||
- **Related specs / candidates**: Spec 143 (tenant lifecycle operability context semantics), Tenant App Status False-Truth Removal, Provider Connection Status Vocabulary Cutover, Provider Connection UX Clarity, Inventory Provider & Operability Semantics, Operator Outcome Taxonomy (Spec 156), Operation Run Active-State Visibility & Stale Escalation
|
||||
- **Strategic sequencing**: Best tackled after the Operator Outcome Taxonomy provides shared vocabulary and after Tenant App Status False-Truth Removal removes the most obvious legacy truth as a quick win. Should land before major portfolio, MSP rollup, or compliance readiness surfaces are built, since those surfaces will inherit the tenant readiness model as a foundational input.
|
||||
- **Priority**: high
|
||||
|
||||
### Exception / Risk-Acceptance Workflow for Findings
|
||||
- **Type**: feature
|
||||
- **Source**: HANDOVER gap analysis, Spec 111 follow-up
|
||||
|
||||
@ -4,7 +4,7 @@ # Product Standards
|
||||
> Specs reference these standards; they do not redefine them.
|
||||
> Guard tests enforce critical constraints automatically.
|
||||
|
||||
**Last reviewed**: 2026-03-21
|
||||
**Last reviewed**: 2026-03-27
|
||||
|
||||
---
|
||||
|
||||
@ -21,7 +21,7 @@ ## Standards Index
|
||||
|
||||
## How Standards Are Enforced
|
||||
|
||||
1. **Constitution** — Principles in `.specify/memory/constitution.md` govern why we build this way.
|
||||
1. **Constitution** — Principles in `.specify/memory/constitution.md` govern why we build this way and whether new persistence, abstractions, or semantic frameworks are justified at all.
|
||||
2. **Standards** (this directory) — Concrete rules for how every surface must behave.
|
||||
3. **Guard tests** — Automated Pest tests that fail CI when critical standards are violated.
|
||||
4. **PR review** — The [review checklist](list-surface-review-checklist.md) is checked for every spec or PR that touches a list surface.
|
||||
@ -42,7 +42,7 @@ ## Related Docs
|
||||
|
||||
| Document | Location | Purpose |
|
||||
|---|---|---|
|
||||
| Constitution | `.specify/memory/constitution.md` | Permanent principles (OPSURF-001, UX-001, Action Surface Contract, RBAC-UX) |
|
||||
| Constitution | `.specify/memory/constitution.md` | Permanent principles (PROP-001, BLOAT-001, OPSURF-001, UI-FIL-001, UX-001, Action Surface Contract, RBAC-UX) |
|
||||
| Product Principles | `docs/product/principles.md` | High-level product decisions |
|
||||
| Table Rollout Audit | `docs/ui/filament-table-standard.md` | Rollout inventory and implementation state from Spec 125 |
|
||||
| Action Surface Contract | `docs/ui/action-surface-contract.md` | Original action surface reference (now governed by this standard) |
|
||||
|
||||
@ -12,8 +12,8 @@
|
||||
|
||||
// Duplicate-name warning banner
|
||||
'duplicate_warning_title' => 'Warning',
|
||||
'duplicate_warning_body_plural' => ':count policies in this tenant share the same display name. :app cannot match them to the baseline. Please rename the duplicates in the Microsoft Intune portal.',
|
||||
'duplicate_warning_body_singular' => ':count policy in this tenant shares the same display name. :app cannot match it to the baseline. Please rename the duplicate in the Microsoft Intune portal.',
|
||||
'duplicate_warning_body_plural' => ':count policies in this tenant share generic display names, resulting in :ambiguous_count ambiguous subjects. :app cannot match them safely to the baseline.',
|
||||
'duplicate_warning_body_singular' => ':count policy in this tenant shares a generic display name, resulting in :ambiguous_count ambiguous subject. :app cannot match it safely to the baseline.',
|
||||
|
||||
// Stats card labels
|
||||
'stat_assigned_baseline' => 'Assigned Baseline',
|
||||
@ -29,15 +29,50 @@
|
||||
'badge_fidelity' => 'Fidelity: :level',
|
||||
'badge_evidence_gaps' => 'Evidence gaps: :count',
|
||||
'evidence_gaps_tooltip' => 'Top gaps: :summary',
|
||||
'evidence_gap_details_heading' => 'Evidence gap details',
|
||||
'evidence_gap_details_description' => 'Search recorded gap subjects by reason, policy type, subject class, outcome, next action, or subject key before falling back to raw diagnostics.',
|
||||
'evidence_gap_search_label' => 'Search gap details',
|
||||
'evidence_gap_search_placeholder' => 'Search by reason, type, class, outcome, action, or subject key',
|
||||
'evidence_gap_search_help' => 'Filter matches across reason, policy type, subject class, outcome, next action, and subject key.',
|
||||
'evidence_gap_bucket_help_ambiguous_match' => 'Multiple inventory records matched the same policy subject — inspect the mapping to confirm the correct pairing.',
|
||||
'evidence_gap_bucket_help_policy_record_missing' => 'The expected policy record was not found in the baseline snapshot — verify the policy still exists in the tenant.',
|
||||
'evidence_gap_bucket_help_inventory_record_missing' => 'No inventory record could be matched for these subjects — confirm the inventory sync is current.',
|
||||
'evidence_gap_bucket_help_foundation_not_policy_backed' => 'These subjects exist in the foundation layer but are not backed by a managed policy — review whether a policy should be created.',
|
||||
'evidence_gap_bucket_help_capture_failed' => 'Evidence capture failed for these subjects — retry the comparison or check Graph connectivity.',
|
||||
'evidence_gap_bucket_help_default' => 'These subjects were flagged during comparison — review the affected rows below for details.',
|
||||
'evidence_gap_reason' => 'Reason',
|
||||
'evidence_gap_reason_affected' => ':count affected',
|
||||
'evidence_gap_reason_recorded' => ':count recorded',
|
||||
'evidence_gap_reason_missing_detail' => ':count missing detail',
|
||||
'evidence_gap_structural' => 'Structural: :count',
|
||||
'evidence_gap_operational' => 'Operational: :count',
|
||||
'evidence_gap_transient' => 'Transient: :count',
|
||||
'evidence_gap_bucket_structural' => ':count structural',
|
||||
'evidence_gap_bucket_operational' => ':count operational',
|
||||
'evidence_gap_bucket_transient' => ':count transient',
|
||||
'evidence_gap_missing_details_title' => 'Detailed rows were not recorded for this run',
|
||||
'evidence_gap_missing_details_body' => 'Evidence gaps were counted for this compare run, but subject-level detail was not stored. Review the raw diagnostics below or rerun the comparison for fresh detail.',
|
||||
'evidence_gap_missing_reason_body' => ':count affected subjects were counted for this reason, but detailed rows were not recorded for this run.',
|
||||
'evidence_gap_legacy_title' => 'Legacy development gap payload detected',
|
||||
'evidence_gap_legacy_body' => 'This run still uses the retired broad reason shape. Regenerate the run or purge stale local development payloads before treating the gap details as operator-safe.',
|
||||
'evidence_gap_diagnostics_heading' => 'Baseline compare evidence',
|
||||
'evidence_gap_diagnostics_description' => 'Raw diagnostics remain available for support and deep troubleshooting after the operator summary and searchable detail.',
|
||||
'evidence_gap_policy_type' => 'Policy type',
|
||||
'evidence_gap_subject_class' => 'Subject class',
|
||||
'evidence_gap_outcome' => 'Outcome',
|
||||
'evidence_gap_next_action' => 'Next action',
|
||||
'evidence_gap_subject_key' => 'Subject key',
|
||||
'evidence_gap_table_empty_heading' => 'No recorded gap rows match this view',
|
||||
'evidence_gap_table_empty_description' => 'Adjust the current search or filters to review other affected subjects.',
|
||||
|
||||
// Comparing state
|
||||
'comparing_indicator' => 'Comparing…',
|
||||
|
||||
// Why-no-findings explanations
|
||||
'no_findings_all_clear' => 'All clear',
|
||||
'no_findings_coverage_warnings' => 'Coverage warnings',
|
||||
'no_findings_evidence_gaps' => 'Evidence gaps',
|
||||
'no_findings_default' => 'No findings',
|
||||
'no_findings_all_clear' => 'No confirmed drift in the latest compare',
|
||||
'no_findings_coverage_warnings' => 'No drift is shown, but coverage limits this compare',
|
||||
'no_findings_evidence_gaps' => 'No drift is shown, but evidence gaps still need review',
|
||||
'no_findings_default' => 'No drift findings are currently visible',
|
||||
|
||||
// Coverage warning banner
|
||||
'coverage_warning_title' => 'Comparison completed with warnings',
|
||||
@ -70,11 +105,11 @@
|
||||
|
||||
// No drift
|
||||
'no_drift_title' => 'No Drift Detected',
|
||||
'no_drift_body' => 'The tenant configuration matches the baseline profile. Everything looks good.',
|
||||
'no_drift_body' => 'The latest compare recorded no confirmed drift for the assigned baseline profile.',
|
||||
|
||||
// Coverage warnings (no findings)
|
||||
'coverage_warnings_title' => 'Coverage Warnings',
|
||||
'coverage_warnings_body' => 'The last comparison completed with warnings and produced no drift findings. Run Inventory Sync again to establish full coverage before interpreting results.',
|
||||
'coverage_warnings_body' => 'The last comparison completed with warnings and produced no confirmed drift findings. Refresh evidence before treating the result as an all-clear.',
|
||||
|
||||
// Idle
|
||||
'idle_title' => 'Ready to Compare',
|
||||
|
||||
118
public/js/tenantpilot/unhandled-rejection-logger.js
Normal file
118
public/js/tenantpilot/unhandled-rejection-logger.js
Normal file
@ -0,0 +1,118 @@
|
||||
(() => {
|
||||
if (typeof window === 'undefined') {
|
||||
return;
|
||||
}
|
||||
|
||||
if (window.__tenantpilotUnhandledRejectionLoggerApplied) {
|
||||
return;
|
||||
}
|
||||
|
||||
window.__tenantpilotUnhandledRejectionLoggerApplied = true;
|
||||
|
||||
const recentKeys = new Map();
|
||||
|
||||
const cleanupRecentKeys = (nowMs) => {
|
||||
for (const [key, timestampMs] of recentKeys.entries()) {
|
||||
if (nowMs - timestampMs > 5_000) {
|
||||
recentKeys.delete(key);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const normalizeReason = (value, depth = 0) => {
|
||||
if (depth > 3) {
|
||||
return '[max-depth-reached]';
|
||||
}
|
||||
|
||||
if (value instanceof Error) {
|
||||
return {
|
||||
type: 'Error',
|
||||
name: value.name,
|
||||
message: value.message,
|
||||
stack: value.stack,
|
||||
};
|
||||
}
|
||||
|
||||
if (value === null || value === undefined) {
|
||||
return value;
|
||||
}
|
||||
|
||||
if (typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean') {
|
||||
return value;
|
||||
}
|
||||
|
||||
if (Array.isArray(value)) {
|
||||
return value.slice(0, 10).map((item) => normalizeReason(item, depth + 1));
|
||||
}
|
||||
|
||||
if (typeof value === 'object') {
|
||||
const result = {};
|
||||
const allowedKeys = [
|
||||
'message',
|
||||
'stack',
|
||||
'name',
|
||||
'type',
|
||||
'status',
|
||||
'body',
|
||||
'json',
|
||||
'errors',
|
||||
'reason',
|
||||
'code',
|
||||
];
|
||||
|
||||
for (const key of allowedKeys) {
|
||||
if (Object.prototype.hasOwnProperty.call(value, key)) {
|
||||
result[key] = normalizeReason(value[key], depth + 1);
|
||||
}
|
||||
}
|
||||
|
||||
if (Object.keys(result).length > 0) {
|
||||
return result;
|
||||
}
|
||||
|
||||
const stringTag = Object.prototype.toString.call(value);
|
||||
|
||||
return {
|
||||
type: stringTag,
|
||||
value: String(value),
|
||||
};
|
||||
}
|
||||
|
||||
return String(value);
|
||||
};
|
||||
|
||||
const toStableJson = (payload) => {
|
||||
try {
|
||||
return JSON.stringify(payload);
|
||||
} catch {
|
||||
return JSON.stringify({
|
||||
source: payload.source,
|
||||
href: payload.href,
|
||||
timestamp: payload.timestamp,
|
||||
reason: '[unserializable]',
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('unhandledrejection', (event) => {
|
||||
const payload = {
|
||||
source: 'window.unhandledrejection',
|
||||
href: window.location.href,
|
||||
timestamp: new Date().toISOString(),
|
||||
reason: normalizeReason(event.reason),
|
||||
};
|
||||
|
||||
const payloadJson = toStableJson(payload);
|
||||
const nowMs = Date.now();
|
||||
|
||||
cleanupRecentKeys(nowMs);
|
||||
|
||||
if (recentKeys.has(payloadJson)) {
|
||||
return;
|
||||
}
|
||||
|
||||
recentKeys.set(payloadJson, nowMs);
|
||||
|
||||
console.error(`TenantPilot unhandled promise rejection ${payloadJson}`);
|
||||
});
|
||||
})();
|
||||
@ -0,0 +1,69 @@
|
||||
@php
|
||||
$decisionZone = $decisionZone ?? [];
|
||||
$decisionZone = is_array($decisionZone) ? $decisionZone : [];
|
||||
|
||||
$facts = array_values(array_filter($decisionZone['facts'] ?? [], 'is_array'));
|
||||
$primaryNextStep = is_array($decisionZone['primaryNextStep'] ?? null) ? $decisionZone['primaryNextStep'] : null;
|
||||
$compactCounts = is_array($decisionZone['compactCounts'] ?? null) ? $decisionZone['compactCounts'] : null;
|
||||
$countFacts = array_values(array_filter($compactCounts['primaryFacts'] ?? [], 'is_array'));
|
||||
$attentionNote = is_string($decisionZone['attentionNote'] ?? null) && trim($decisionZone['attentionNote']) !== ''
|
||||
? trim($decisionZone['attentionNote'])
|
||||
: null;
|
||||
@endphp
|
||||
|
||||
<x-filament::section
|
||||
:heading="$decisionZone['title'] ?? 'Decision'"
|
||||
:description="$decisionZone['description'] ?? 'Start here to see how the run ended, whether the result is usable, and what to do next.'"
|
||||
>
|
||||
<div class="grid gap-6 xl:grid-cols-[minmax(0,2fr)_minmax(18rem,1fr)]">
|
||||
<div class="space-y-4">
|
||||
@if ($attentionNote !== null)
|
||||
<div class="rounded-2xl border border-amber-200 bg-amber-50 px-4 py-3 text-sm text-amber-900 dark:border-amber-500/30 dark:bg-amber-500/10 dark:text-amber-100">
|
||||
{{ $attentionNote }}
|
||||
</div>
|
||||
@endif
|
||||
|
||||
@if ($facts !== [])
|
||||
@include('filament.infolists.entries.enterprise-detail.section-items', [
|
||||
'items' => $facts,
|
||||
'variant' => 'summary',
|
||||
])
|
||||
@endif
|
||||
</div>
|
||||
|
||||
<div class="space-y-4">
|
||||
@if ($primaryNextStep !== null)
|
||||
<div class="rounded-xl border-l-4 border-primary-500 bg-primary-50 px-4 py-4 dark:bg-primary-500/10">
|
||||
<div class="text-xs font-semibold uppercase tracking-[0.16em] text-primary-600 dark:text-primary-400">
|
||||
{{ $primaryNextStep['label'] ?? 'Primary next step' }}
|
||||
</div>
|
||||
<div class="mt-2 text-base font-semibold text-gray-950 dark:text-white">
|
||||
{{ $primaryNextStep['text'] ?? 'No action needed.' }}
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
@if (filled($compactCounts['summaryLine'] ?? null) || $countFacts !== [])
|
||||
<div class="rounded-xl border border-gray-200 bg-gray-50/70 px-4 py-4 dark:border-gray-700 dark:bg-gray-800/50">
|
||||
<div class="text-xs font-semibold uppercase tracking-[0.16em] text-gray-500 dark:text-gray-400">
|
||||
Counts
|
||||
</div>
|
||||
<div class="mt-2 space-y-4">
|
||||
@if (filled($compactCounts['summaryLine'] ?? null))
|
||||
<div class="text-sm font-medium text-gray-900 dark:text-white">
|
||||
{{ $compactCounts['summaryLine'] }}
|
||||
</div>
|
||||
@endif
|
||||
|
||||
@if ($countFacts !== [])
|
||||
@include('filament.infolists.entries.enterprise-detail.section-items', [
|
||||
'items' => $countFacts,
|
||||
'variant' => 'supporting',
|
||||
])
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
</x-filament::section>
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user