Compare commits
5 Commits
162-baseli
...
dev
| Author | SHA1 | Date | |
|---|---|---|---|
| 55aef627aa | |||
| 02e75e1cda | |||
| 20b6aa6a32 | |||
| c17255f854 | |||
| 7d4d607475 |
15
.github/agents/copilot-instructions.md
vendored
15
.github/agents/copilot-instructions.md
vendored
@ -103,6 +103,15 @@ ## Active Technologies
|
|||||||
- 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)
|
- 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 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)
|
- 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 (feat/005-bulk-operations)
|
- PHP 8.4.15 (feat/005-bulk-operations)
|
||||||
|
|
||||||
@ -122,8 +131,8 @@ ## Code Style
|
|||||||
PHP 8.4.15: Follow standard conventions
|
PHP 8.4.15: Follow standard conventions
|
||||||
|
|
||||||
## Recent Changes
|
## Recent Changes
|
||||||
- 161-operator-explanation-layer: Added PHP 8.4 + Laravel 12, Filament v5, Livewire v4
|
- 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
|
||||||
- 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
|
- 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`
|
||||||
- 159-baseline-snapshot-truth: Added PHP 8.4 + Laravel 12, Filament v5, Livewire v4
|
- 164-run-detail-hardening: Added PHP 8.4, Laravel 12, Blade views, Alpine via Filament v5 / Livewire v4 + Filament v5, Livewire v4, Pest v4, Laravel Sail, existing `OperationRunResource`, `TenantlessOperationRunViewer`, `EnterpriseDetailBuilder`, `ArtifactTruthPresenter`, `OperationUxPresenter`, and `SummaryCountsNormalizer`
|
||||||
<!-- MANUAL ADDITIONS START -->
|
<!-- MANUAL ADDITIONS START -->
|
||||||
<!-- MANUAL ADDITIONS END -->
|
<!-- MANUAL ADDITIONS END -->
|
||||||
|
|||||||
@ -1,19 +1,34 @@
|
|||||||
<!--
|
<!--
|
||||||
Sync Impact Report
|
Sync Impact Report
|
||||||
|
|
||||||
- Version change: 1.11.0 → 1.12.0
|
- Version change: 1.13.0 → 1.14.0
|
||||||
- Modified principles:
|
- Modified principles:
|
||||||
- None
|
- Governance / Scope & Compliance → Governance / Scope, Compliance, and Review Expectations
|
||||||
- Added sections:
|
- 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
|
- Removed sections: None
|
||||||
- Templates requiring updates:
|
- Templates requiring updates:
|
||||||
|
- ✅ .specify/memory/constitution.md
|
||||||
- ✅ .specify/templates/spec-template.md
|
- ✅ .specify/templates/spec-template.md
|
||||||
- ✅ .specify/templates/plan-template.md
|
- ✅ .specify/templates/plan-template.md
|
||||||
- ✅ .specify/templates/tasks-template.md
|
- ✅ .specify/templates/tasks-template.md
|
||||||
- ✅ docs/product/principles.md
|
|
||||||
- ✅ docs/product/standards/README.md
|
- ✅ docs/product/standards/README.md
|
||||||
- ✅ docs/HANDOVER.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:
|
- Follow-up TODOs:
|
||||||
- None.
|
- None.
|
||||||
-->
|
-->
|
||||||
@ -42,6 +57,73 @@ ### Deterministic Capabilities
|
|||||||
- Backup/restore/risk/support flags MUST be derived deterministically from config/contracts via a Capabilities Resolver.
|
- 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.
|
- 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 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
|
- 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).
|
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.
|
- Introducing or changing a status-like value MUST include updating the relevant badge mapper and adding/updating tests for the mapping.
|
||||||
- Tag/category chips (e.g., type/platform/environment) are not status-like and are not governed by BADGE-001.
|
- Tag/category chips (e.g., type/platform/environment) are not status-like and are not governed by BADGE-001.
|
||||||
|
|
||||||
|
### Filament Native First / No Ad-hoc Styling (UI-FIL-001)
|
||||||
|
- Admin and operator-facing surfaces MUST use native Filament components, existing shared UI primitives, and centralized design patterns first.
|
||||||
|
- If Filament already provides the required semantic element, feature code MUST use the Filament-native component instead of a locally assembled replacement.
|
||||||
|
- Preferred native elements include `x-filament::badge`, `x-filament::button`, `x-filament::icon`, and Filament Forms, Infolists, Tables, Sections, Tabs, Grids, and Actions.
|
||||||
|
|
||||||
|
Forbidden local replacements
|
||||||
|
- Feature code MUST NOT hand-build badges, pills, status chips, alert cards, or action buttons from raw `<span>`, `<div>`, or `<button>` markup plus Tailwind classes when Filament-native or shared project primitives can express the same meaning.
|
||||||
|
- Feature code MUST NOT introduce page-local visual status languages for status, risk, outcome, drift, trust, importance, or severity.
|
||||||
|
- Feature code MUST NOT make local color, border, rounding, or emphasis decisions for semantic UI states using ad-hoc classes such as `bg-danger-100`, `text-warning-900`, `border-dashed`, or `rounded-full` when the same state can be expressed through Filament props or shared primitives.
|
||||||
|
|
||||||
|
Shared primitive before local override
|
||||||
|
- If the same UI pattern can recur, it MUST use an existing shared primitive or introduce a new central primitive instead of reassembling the pattern inside a Blade view.
|
||||||
|
- Central badge and status catalogs remain the canonical source for status semantics; local views MUST consume them rather than re-map them.
|
||||||
|
|
||||||
|
Upgrade-safe preference
|
||||||
|
- Update-safe, framework-native implementations take priority over page-local styling shortcuts.
|
||||||
|
- Filament props such as `color`, `icon`, and standard component composition are the default mechanism for semantic emphasis.
|
||||||
|
- Publishing or emulating Filament internals for cosmetic speed is not acceptable when native composition or shared primitives are sufficient.
|
||||||
|
|
||||||
|
Exception rule
|
||||||
|
- Ad-hoc markup or styling is allowed only when all of the following are true:
|
||||||
|
- native Filament components cannot express the required semantics,
|
||||||
|
- no suitable shared primitive exists,
|
||||||
|
- and the deviation is justified briefly in code and in the governing spec or PR.
|
||||||
|
- Approved exceptions MUST stay layout-neutral, use the minimum local classes necessary, and MUST NOT invent a new page-local status language.
|
||||||
|
|
||||||
|
Review and enforcement
|
||||||
|
- Every UI review MUST answer:
|
||||||
|
- which native Filament element or shared primitive was used,
|
||||||
|
- why an existing component was insufficient if an exception was taken,
|
||||||
|
- and whether any ad-hoc status or emphasis styling was introduced.
|
||||||
|
- UI work is not Done if it introduces ad-hoc status styling or framework-foreign replacement components where a native Filament or shared UI solution was viable.
|
||||||
|
|
||||||
### Incremental UI Standards Enforcement (UI-STD-001)
|
### Incremental UI Standards Enforcement (UI-STD-001)
|
||||||
- UI consistency is enforced incrementally, not by recurring cleanup passes.
|
- UI consistency is enforced incrementally, not by recurring cleanup passes.
|
||||||
- New tables, filters, and list surfaces MUST follow established Filament-native standards from the first implementation.
|
- New tables, filters, and list surfaces MUST follow established Filament-native standards from the first implementation.
|
||||||
@ -437,9 +552,12 @@ ## Quality Gates
|
|||||||
|
|
||||||
## Governance
|
## Governance
|
||||||
|
|
||||||
### Scope & Compliance
|
### Scope, Compliance, and Review Expectations
|
||||||
- This constitution applies across the repo. Feature specs may add stricter constraints but not weaker ones.
|
- 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.
|
- 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
|
### Amendment Procedure
|
||||||
- Propose changes as a PR that updates `.specify/memory/constitution.md`.
|
- 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.
|
- **MINOR**: new principle/section or materially expanded guidance.
|
||||||
- **MAJOR**: removing/redefining principles in a backward-incompatible way.
|
- **MAJOR**: removing/redefining principles in a backward-incompatible way.
|
||||||
|
|
||||||
**Version**: 1.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)
|
- 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
|
- Automation: queued/scheduled ops use locks + idempotency; handle 429/503 with backoff+jitter
|
||||||
- Data minimization: Inventory stores metadata + whitelisted meta; logs contain no secrets/tokens
|
- Data minimization: Inventory stores metadata + whitelisted meta; logs contain no secrets/tokens
|
||||||
|
- 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
|
- Badge semantics (BADGE-001): status-like badges use `BadgeCatalog` / `BadgeRenderer`; no ad-hoc mappings; new values include tests
|
||||||
|
- Filament-native UI (UI-FIL-001): admin/operator surfaces use native Filament components or shared primitives first; no ad-hoc status UI, local semantic color/border decisions, or hand-built replacements when native/shared semantics exist; any exception is explicitly justified
|
||||||
- UI naming (UI-NAMING-001): operator-facing labels use `Verb + Object`; scope (`Workspace`, `Tenant`) is never the primary action label; source/domain is secondary unless disambiguation is required; runs/toasts/audit prose use the same domain vocabulary; implementation-first terms do not appear in primary operator UI
|
- UI naming (UI-NAMING-001): operator-facing labels use `Verb + Object`; scope (`Workspace`, `Tenant`) is never the primary action label; source/domain is secondary unless disambiguation is required; runs/toasts/audit prose use the same domain vocabulary; implementation-first terms do not appear in primary operator UI
|
||||||
- Operator surfaces (OPSURF-001): `/admin` defaults are operator-first; default-visible content avoids raw implementation detail; diagnostics are explicitly revealed secondarily
|
- Operator surfaces (OPSURF-001): `/admin` defaults are operator-first; default-visible content avoids raw implementation detail; diagnostics are explicitly revealed secondarily
|
||||||
- Operator surfaces (OPSURF-001): execution outcome, data completeness, governance result, and lifecycle/readiness are modeled as distinct status dimensions when all apply; they are not collapsed into one ambiguous status
|
- Operator surfaces (OPSURF-001): execution outcome, data completeness, governance result, and lifecycle/readiness are modeled as distinct status dimensions when all apply; they are not collapsed into one ambiguous status
|
||||||
@ -121,9 +129,20 @@ # [REMOVE IF UNUSED] Option 3: Mobile + API (when "iOS/Android" detected)
|
|||||||
|
|
||||||
## Complexity Tracking
|
## 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 |
|
| Violation | Why Needed | Simpler Alternative Rejected Because |
|
||||||
|-----------|------------|-------------------------------------|
|
|-----------|------------|-------------------------------------|
|
||||||
| [e.g., 4th project] | [current need] | [why 3 projects insufficient] |
|
| [e.g., 4th project] | [current need] | [why 3 projects insufficient] |
|
||||||
| [e.g., Repository pattern] | [specific problem] | [why direct DB access 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 |
|
| 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)*
|
## User Scenarios & Testing *(mandatory)*
|
||||||
|
|
||||||
<!--
|
<!--
|
||||||
@ -102,6 +123,16 @@ ## Requirements *(mandatory)*
|
|||||||
(preview/confirmation/audit), tenant isolation, run observability (`OperationRun` type/identity/visibility), and tests.
|
(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.
|
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:
|
**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),
|
- 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`),
|
- 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),
|
**Constitution alignment (BADGE-001):** If this feature changes status-like badges (status/outcome/severity/risk/availability/boolean),
|
||||||
the spec MUST describe how badge semantics stay centralized (no ad-hoc mappings) and which tests cover any new/changed values.
|
the spec MUST describe how badge semantics stay centralized (no ad-hoc mappings) and which tests cover any new/changed values.
|
||||||
|
|
||||||
|
**Constitution alignment (UI-FIL-001):** If this feature adds or changes Filament or Blade UI for admin/operator surfaces, the spec MUST describe:
|
||||||
|
- which native Filament components or shared UI primitives are used,
|
||||||
|
- whether any local replacement markup was avoided for badges, alerts, buttons, or status surfaces,
|
||||||
|
- how semantic emphasis is expressed through Filament props or central primitives rather than page-local color/border classes,
|
||||||
|
- and any exception where Filament or a shared primitive was insufficient, including why the exception is necessary and how it avoids introducing a new local status language.
|
||||||
|
|
||||||
**Constitution alignment (UI-NAMING-001):** If this feature adds or changes operator-facing buttons, header actions, run titles,
|
**Constitution alignment (UI-NAMING-001):** If this feature adds or changes operator-facing buttons, header actions, run titles,
|
||||||
notifications, audit prose, or related helper copy, the spec MUST describe:
|
notifications, audit prose, or related helper copy, the spec MUST describe:
|
||||||
- the target object,
|
- the target object,
|
||||||
@ -144,9 +181,17 @@ ## Requirements *(mandatory)*
|
|||||||
- how workspace and tenant context remain explicit in navigation, action copy, and page semantics,
|
- 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.
|
- 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,
|
**Constitution alignment (Filament Action Surfaces):** If this feature adds or modifies any Filament Resource / RelationManager / Page,
|
||||||
the spec MUST include a “UI Action Matrix” (see below) and explicitly state whether the Action Surface Contract is satisfied.
|
the spec MUST include a “UI Action Matrix” (see below) and explicitly state whether the Action Surface Contract is satisfied.
|
||||||
If the contract is not satisfied, the spec MUST include an explicit exemption with rationale.
|
If the contract is not satisfied, the spec MUST include an explicit exemption with rationale.
|
||||||
|
The same section MUST also state whether UI-FIL-001 is satisfied and identify any approved exception.
|
||||||
**Constitution alignment (UX-001 — Layout & Information Architecture):** If this feature adds or modifies any Filament screen,
|
**Constitution alignment (UX-001 — Layout & Information Architecture):** If this feature adds or modifies any Filament screen,
|
||||||
the spec MUST describe compliance with UX-001: Create/Edit uses Main/Aside layout (3-col grid), all fields inside Sections/Cards
|
the spec MUST describe compliance with UX-001: Create/Edit uses Main/Aside layout (3-col grid), all fields inside Sections/Cards
|
||||||
(no naked inputs), View pages use Infolists (not disabled edit forms), status badges use BADGE-001, empty states have a specific
|
(no naked inputs), View pages use Infolists (not disabled edit forms), status badges use BADGE-001, empty states have a specific
|
||||||
|
|||||||
@ -53,6 +53,9 @@ # Tasks: [FEATURE NAME]
|
|||||||
- grouping bulk actions via BulkActionGroup,
|
- grouping bulk actions via BulkActionGroup,
|
||||||
- adding confirmations for destructive actions (and typed confirmation where required by scale),
|
- adding confirmations for destructive actions (and typed confirmation where required by scale),
|
||||||
- adding `AuditLog` entries for relevant mutations,
|
- adding `AuditLog` entries for relevant mutations,
|
||||||
|
- using native Filament components or shared UI primitives before any local Blade/Tailwind assembly for badges, alerts, buttons, and semantic status surfaces,
|
||||||
|
- avoiding page-local semantic color, border, rounding, or highlight styling when Filament props or shared primitives can express the same state,
|
||||||
|
- documenting any UI-FIL-001 exception with rationale in the spec/PR,
|
||||||
- adding/updated tests that enforce the contract and block merge on violations, OR documenting an explicit exemption with rationale.
|
- adding/updated tests that enforce the contract and block merge on violations, OR documenting an explicit exemption with rationale.
|
||||||
**Filament UI UX-001 (Layout & IA)**: If this feature adds/modifies any Filament screen, tasks MUST include:
|
**Filament UI UX-001 (Layout & IA)**: If this feature adds/modifies any Filament screen, tasks MUST include:
|
||||||
- ensuring Create/Edit pages use Main/Aside layout (3-col grid, Main=columnSpan(2), Aside=columnSpan(1)),
|
- ensuring Create/Edit pages use Main/Aside layout (3-col grid, Main=columnSpan(2), Aside=columnSpan(1)),
|
||||||
@ -64,6 +67,13 @@ # Tasks: [FEATURE NAME]
|
|||||||
- OR documenting an explicit exemption with rationale if UX-001 is not fully satisfied.
|
- 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),
|
**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.
|
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.
|
**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 Performance optimization across all stories
|
||||||
- [ ] TXXX [P] Additional unit tests (if requested) in tests/unit/
|
- [ ] TXXX [P] Additional unit tests (if requested) in tests/unit/
|
||||||
- [ ] TXXX Security hardening
|
- [ ] TXXX Security hardening
|
||||||
|
- [ ] TXXX Proportionality cleanup: remove or collapse superseded layers introduced during implementation
|
||||||
- [ ] TXXX Run quickstart.md validation
|
- [ ] TXXX Run quickstart.md validation
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|||||||
@ -25,12 +25,14 @@ ## Scope Reference
|
|||||||
- Tenant-scoped RBAC and audit logs
|
- Tenant-scoped RBAC and audit logs
|
||||||
|
|
||||||
## Workflow (Spec Kit)
|
## 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`
|
2. For new work: create/update `specs/<NNN>-<slug>/spec.md`
|
||||||
3. Produce `specs/<NNN>-<slug>/plan.md`
|
3. Produce `specs/<NNN>-<slug>/plan.md`
|
||||||
4. Break into `specs/<NNN>-<slug>/tasks.md`
|
4. Break into `specs/<NNN>-<slug>/tasks.md`
|
||||||
5. Implement changes in small PRs
|
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.
|
If requirements change during implementation, update spec/plan before continuing.
|
||||||
|
|
||||||
## Workflow (SDD in diesem Repo)
|
## 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\Services\Baselines\BaselineCompareService;
|
||||||
use App\Support\Auth\Capabilities;
|
use App\Support\Auth\Capabilities;
|
||||||
use App\Support\Baselines\BaselineCaptureMode;
|
use App\Support\Baselines\BaselineCaptureMode;
|
||||||
|
use App\Support\Baselines\BaselineCompareEvidenceGapDetails;
|
||||||
use App\Support\Baselines\BaselineCompareStats;
|
use App\Support\Baselines\BaselineCompareStats;
|
||||||
use App\Support\OperationRunLinks;
|
use App\Support\OperationRunLinks;
|
||||||
use App\Support\OpsUx\OperationUxPresenter;
|
use App\Support\OpsUx\OperationUxPresenter;
|
||||||
@ -59,6 +60,8 @@ class BaselineCompareLanding extends Page
|
|||||||
|
|
||||||
public ?int $duplicateNamePoliciesCount = null;
|
public ?int $duplicateNamePoliciesCount = null;
|
||||||
|
|
||||||
|
public ?int $duplicateNameSubjectsCount = null;
|
||||||
|
|
||||||
public ?int $operationRunId = null;
|
public ?int $operationRunId = null;
|
||||||
|
|
||||||
public ?int $findingsCount = null;
|
public ?int $findingsCount = null;
|
||||||
@ -86,12 +89,24 @@ class BaselineCompareLanding extends Page
|
|||||||
/** @var array<string, int>|null */
|
/** @var array<string, int>|null */
|
||||||
public ?array $evidenceGapsTopReasons = null;
|
public ?array $evidenceGapsTopReasons = null;
|
||||||
|
|
||||||
|
/** @var array<string, mixed>|null */
|
||||||
|
public ?array $evidenceGapSummary = null;
|
||||||
|
|
||||||
|
/** @var list<array<string, mixed>>|null */
|
||||||
|
public ?array $evidenceGapBuckets = null;
|
||||||
|
|
||||||
|
/** @var array<string, mixed>|null */
|
||||||
|
public ?array $baselineCompareDiagnostics = null;
|
||||||
|
|
||||||
/** @var array<string, int>|null */
|
/** @var array<string, int>|null */
|
||||||
public ?array $rbacRoleDefinitionSummary = null;
|
public ?array $rbacRoleDefinitionSummary = null;
|
||||||
|
|
||||||
/** @var array<string, mixed>|null */
|
/** @var array<string, mixed>|null */
|
||||||
public ?array $operatorExplanation = null;
|
public ?array $operatorExplanation = null;
|
||||||
|
|
||||||
|
/** @var array<string, mixed>|null */
|
||||||
|
public ?array $summaryAssessment = null;
|
||||||
|
|
||||||
public static function canAccess(): bool
|
public static function canAccess(): bool
|
||||||
{
|
{
|
||||||
$user = auth()->user();
|
$user = auth()->user();
|
||||||
@ -126,6 +141,7 @@ public function refreshStats(): void
|
|||||||
$this->profileId = $stats->profileId;
|
$this->profileId = $stats->profileId;
|
||||||
$this->snapshotId = $stats->snapshotId;
|
$this->snapshotId = $stats->snapshotId;
|
||||||
$this->duplicateNamePoliciesCount = $stats->duplicateNamePoliciesCount;
|
$this->duplicateNamePoliciesCount = $stats->duplicateNamePoliciesCount;
|
||||||
|
$this->duplicateNameSubjectsCount = $stats->duplicateNameSubjectsCount;
|
||||||
$this->operationRunId = $stats->operationRunId;
|
$this->operationRunId = $stats->operationRunId;
|
||||||
$this->findingsCount = $stats->findingsCount;
|
$this->findingsCount = $stats->findingsCount;
|
||||||
$this->severityCounts = $stats->severityCounts !== [] ? $stats->severityCounts : null;
|
$this->severityCounts = $stats->severityCounts !== [] ? $stats->severityCounts : null;
|
||||||
@ -142,8 +158,18 @@ public function refreshStats(): void
|
|||||||
|
|
||||||
$this->evidenceGapsCount = $stats->evidenceGapsCount;
|
$this->evidenceGapsCount = $stats->evidenceGapsCount;
|
||||||
$this->evidenceGapsTopReasons = $stats->evidenceGapsTopReasons !== [] ? $stats->evidenceGapsTopReasons : null;
|
$this->evidenceGapsTopReasons = $stats->evidenceGapsTopReasons !== [] ? $stats->evidenceGapsTopReasons : null;
|
||||||
|
$this->evidenceGapSummary = is_array($stats->evidenceGapDetails['summary'] ?? null)
|
||||||
|
? $stats->evidenceGapDetails['summary']
|
||||||
|
: null;
|
||||||
|
$this->evidenceGapBuckets = is_array($stats->evidenceGapDetails['buckets'] ?? null) && $stats->evidenceGapDetails['buckets'] !== []
|
||||||
|
? $stats->evidenceGapDetails['buckets']
|
||||||
|
: null;
|
||||||
|
$this->baselineCompareDiagnostics = $stats->baselineCompareDiagnostics !== []
|
||||||
|
? $stats->baselineCompareDiagnostics
|
||||||
|
: null;
|
||||||
$this->rbacRoleDefinitionSummary = $stats->rbacRoleDefinitionSummary !== [] ? $stats->rbacRoleDefinitionSummary : null;
|
$this->rbacRoleDefinitionSummary = $stats->rbacRoleDefinitionSummary !== [] ? $stats->rbacRoleDefinitionSummary : null;
|
||||||
$this->operatorExplanation = $stats->operatorExplanation()->toArray();
|
$this->operatorExplanation = $stats->operatorExplanation()->toArray();
|
||||||
|
$this->summaryAssessment = $stats->summaryAssessment()->toArray();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -156,26 +182,32 @@ public function refreshStats(): void
|
|||||||
*/
|
*/
|
||||||
protected function getViewData(): array
|
protected function getViewData(): array
|
||||||
{
|
{
|
||||||
|
$evidenceGapSummary = is_array($this->evidenceGapSummary) ? $this->evidenceGapSummary : [];
|
||||||
$hasCoverageWarnings = in_array($this->coverageStatus, ['warning', 'unproven'], true);
|
$hasCoverageWarnings = in_array($this->coverageStatus, ['warning', 'unproven'], true);
|
||||||
$evidenceGapsCountValue = (int) ($this->evidenceGapsCount ?? 0);
|
$evidenceGapsCountValue = is_numeric($evidenceGapSummary['count'] ?? null)
|
||||||
|
? (int) $evidenceGapSummary['count']
|
||||||
|
: (int) ($this->evidenceGapsCount ?? 0);
|
||||||
$hasEvidenceGaps = $evidenceGapsCountValue > 0;
|
$hasEvidenceGaps = $evidenceGapsCountValue > 0;
|
||||||
$hasWarnings = $hasCoverageWarnings || $hasEvidenceGaps;
|
$hasWarnings = $hasCoverageWarnings || $hasEvidenceGaps;
|
||||||
$hasRbacRoleDefinitionSummary = is_array($this->rbacRoleDefinitionSummary)
|
$hasRbacRoleDefinitionSummary = is_array($this->rbacRoleDefinitionSummary)
|
||||||
&& array_sum($this->rbacRoleDefinitionSummary) > 0;
|
&& array_sum($this->rbacRoleDefinitionSummary) > 0;
|
||||||
|
$evidenceGapDetailState = is_string($evidenceGapSummary['detail_state'] ?? null)
|
||||||
|
? (string) $evidenceGapSummary['detail_state']
|
||||||
|
: 'no_gaps';
|
||||||
|
$hasEvidenceGapDetailSection = $evidenceGapDetailState !== 'no_gaps';
|
||||||
|
$hasEvidenceGapDiagnostics = is_array($this->baselineCompareDiagnostics) && $this->baselineCompareDiagnostics !== [];
|
||||||
|
|
||||||
$evidenceGapsSummary = null;
|
$evidenceGapsSummary = null;
|
||||||
$evidenceGapsTooltip = null;
|
$evidenceGapsTooltip = null;
|
||||||
|
|
||||||
if ($hasEvidenceGaps && is_array($this->evidenceGapsTopReasons) && $this->evidenceGapsTopReasons !== []) {
|
if ($hasEvidenceGaps) {
|
||||||
$parts = [];
|
$parts = array_map(
|
||||||
|
static fn (array $reason): string => $reason['reason_label'].' ('.$reason['count'].')',
|
||||||
foreach (array_slice($this->evidenceGapsTopReasons, 0, 5, true) as $reason => $count) {
|
BaselineCompareEvidenceGapDetails::topReasons(
|
||||||
if (! is_string($reason) || $reason === '' || ! is_numeric($count)) {
|
is_array($evidenceGapSummary['by_reason'] ?? null) ? $evidenceGapSummary['by_reason'] : [],
|
||||||
continue;
|
5,
|
||||||
}
|
),
|
||||||
|
);
|
||||||
$parts[] = $reason.' ('.((int) $count).')';
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($parts !== []) {
|
if ($parts !== []) {
|
||||||
$evidenceGapsSummary = implode(', ', $parts);
|
$evidenceGapsSummary = implode(', ', $parts);
|
||||||
@ -211,12 +243,16 @@ protected function getViewData(): array
|
|||||||
'hasEvidenceGaps' => $hasEvidenceGaps,
|
'hasEvidenceGaps' => $hasEvidenceGaps,
|
||||||
'hasWarnings' => $hasWarnings,
|
'hasWarnings' => $hasWarnings,
|
||||||
'hasRbacRoleDefinitionSummary' => $hasRbacRoleDefinitionSummary,
|
'hasRbacRoleDefinitionSummary' => $hasRbacRoleDefinitionSummary,
|
||||||
|
'evidenceGapDetailState' => $evidenceGapDetailState,
|
||||||
|
'hasEvidenceGapDetailSection' => $hasEvidenceGapDetailSection,
|
||||||
|
'hasEvidenceGapDiagnostics' => $hasEvidenceGapDiagnostics,
|
||||||
'evidenceGapsSummary' => $evidenceGapsSummary,
|
'evidenceGapsSummary' => $evidenceGapsSummary,
|
||||||
'evidenceGapsTooltip' => $evidenceGapsTooltip,
|
'evidenceGapsTooltip' => $evidenceGapsTooltip,
|
||||||
'findingsColorClass' => $findingsColorClass,
|
'findingsColorClass' => $findingsColorClass,
|
||||||
'whyNoFindingsMessage' => $whyNoFindingsMessage,
|
'whyNoFindingsMessage' => $whyNoFindingsMessage,
|
||||||
'whyNoFindingsFallback' => $whyNoFindingsFallback,
|
'whyNoFindingsFallback' => $whyNoFindingsFallback,
|
||||||
'whyNoFindingsColor' => $whyNoFindingsColor,
|
'whyNoFindingsColor' => $whyNoFindingsColor,
|
||||||
|
'summaryAssessment' => is_array($this->summaryAssessment) ? $this->summaryAssessment : null,
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -12,6 +12,7 @@
|
|||||||
use App\Models\Workspace;
|
use App\Models\Workspace;
|
||||||
use App\Services\Auth\WorkspaceCapabilityResolver;
|
use App\Services\Auth\WorkspaceCapabilityResolver;
|
||||||
use App\Services\Findings\FindingExceptionService;
|
use App\Services\Findings\FindingExceptionService;
|
||||||
|
use App\Services\Findings\FindingRiskGovernanceResolver;
|
||||||
use App\Support\Auth\Capabilities;
|
use App\Support\Auth\Capabilities;
|
||||||
use App\Support\Badges\BadgeDomain;
|
use App\Support\Badges\BadgeDomain;
|
||||||
use App\Support\Badges\BadgeRenderer;
|
use App\Support\Badges\BadgeRenderer;
|
||||||
@ -281,6 +282,11 @@ public function table(Table $table): Table
|
|||||||
->label('Finding')
|
->label('Finding')
|
||||||
->state(fn (FindingException $record): string => $record->finding?->resolvedSubjectDisplayName() ?: 'Finding #'.$record->finding_id)
|
->state(fn (FindingException $record): string => $record->finding?->resolvedSubjectDisplayName() ?: 'Finding #'.$record->finding_id)
|
||||||
->searchable(),
|
->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')
|
TextColumn::make('requester.name')
|
||||||
->label('Requested by')
|
->label('Requested by')
|
||||||
->placeholder('—'),
|
->placeholder('—'),
|
||||||
@ -500,4 +506,34 @@ private function hasActiveQueueFilters(): bool
|
|||||||
|| is_string(data_get($this->tableFilters, 'status.value'))
|
|| is_string(data_get($this->tableFilters, 'status.value'))
|
||||||
|| is_string(data_get($this->tableFilters, 'current_validity_state.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';
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -54,8 +54,6 @@ class TenantlessOperationRunViewer extends Page
|
|||||||
*/
|
*/
|
||||||
public ?array $navigationContextPayload = null;
|
public ?array $navigationContextPayload = null;
|
||||||
|
|
||||||
public bool $opsUxIsTabHidden = false;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @return array<Action|ActionGroup>
|
* @return array<Action|ActionGroup>
|
||||||
*/
|
*/
|
||||||
@ -178,17 +176,15 @@ public function blockedExecutionBanner(): ?array
|
|||||||
? array_values(array_filter([
|
? array_values(array_filter([
|
||||||
$operatorExplanation->headline,
|
$operatorExplanation->headline,
|
||||||
$operatorExplanation->dominantCauseExplanation,
|
$operatorExplanation->dominantCauseExplanation,
|
||||||
OperationUxPresenter::surfaceGuidance($this->run),
|
|
||||||
]))
|
]))
|
||||||
: ($reasonEnvelope?->toBodyLines() ?? [
|
: ($reasonEnvelope?->toBodyLines(false) ?? [
|
||||||
OperationUxPresenter::surfaceFailureDetail($this->run) ?? 'The queued run was refused before side effects could begin.',
|
OperationUxPresenter::surfaceFailureDetail($this->run) ?? 'The queued run was refused before side effects could begin.',
|
||||||
OperationUxPresenter::surfaceGuidance($this->run) ?? 'Review the blocked prerequisite before retrying.',
|
|
||||||
]);
|
]);
|
||||||
|
|
||||||
return [
|
return [
|
||||||
'tone' => 'amber',
|
'tone' => 'amber',
|
||||||
'title' => 'Blocked by prerequisite',
|
'title' => 'Blocked by prerequisite',
|
||||||
'body' => implode(' ', $lines),
|
'body' => implode(' ', array_values(array_unique($lines))),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -208,19 +204,17 @@ public function lifecycleBanner(): ?array
|
|||||||
}
|
}
|
||||||
|
|
||||||
$detail = OperationUxPresenter::surfaceFailureDetail($this->run) ?? 'Lifecycle truth needs operator review.';
|
$detail = OperationUxPresenter::surfaceFailureDetail($this->run) ?? 'Lifecycle truth needs operator review.';
|
||||||
$guidance = OperationUxPresenter::surfaceGuidance($this->run);
|
|
||||||
$body = $guidance !== null ? $detail.' '.$guidance : $detail;
|
|
||||||
|
|
||||||
return match ($this->run->freshnessState()->value) {
|
return match ($this->run->freshnessState()->value) {
|
||||||
'likely_stale' => [
|
'likely_stale' => [
|
||||||
'tone' => 'amber',
|
'tone' => 'amber',
|
||||||
'title' => 'Likely stale run',
|
'title' => 'Likely stale run',
|
||||||
'body' => $body,
|
'body' => $detail,
|
||||||
],
|
],
|
||||||
'reconciled_failed' => [
|
'reconciled_failed' => [
|
||||||
'tone' => 'rose',
|
'tone' => 'rose',
|
||||||
'title' => 'Automatically reconciled',
|
'title' => 'Automatically reconciled',
|
||||||
'body' => $body,
|
'body' => $detail,
|
||||||
],
|
],
|
||||||
default => null,
|
default => null,
|
||||||
};
|
};
|
||||||
@ -290,10 +284,6 @@ public function pollInterval(): ?string
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($this->opsUxIsTabHidden === true) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (filled($this->mountedActions ?? null)) {
|
if (filled($this->mountedActions ?? null)) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -371,9 +371,9 @@ private static function applyLifecycleFilter(Builder $query, mixed $value): Buil
|
|||||||
private static function gapCountExpression(Builder $query): string
|
private static function gapCountExpression(Builder $query): string
|
||||||
{
|
{
|
||||||
return match ($query->getConnection()->getDriverName()) {
|
return match ($query->getConnection()->getDriverName()) {
|
||||||
'sqlite' => "COALESCE(CAST(json_extract(summary_jsonb, '$.gaps.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)",
|
'sqlite' => "MAX(0, COALESCE(CAST(json_extract(summary_jsonb, '$.gaps.count') AS INTEGER), 0) - COALESCE(CAST(json_extract(summary_jsonb, '$.gaps.by_reason.meta_fallback') AS INTEGER), 0))",
|
||||||
'pgsql' => "COALESCE((summary_jsonb #>> '{gaps,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)",
|
'pgsql' => "GREATEST(0, COALESCE((summary_jsonb #>> '{gaps,count}')::int, 0) - COALESCE((summary_jsonb #>> '{gaps,by_reason,meta_fallback}')::int, 0))",
|
||||||
default => "COALESCE(CAST(JSON_EXTRACT(summary_jsonb, '$.gaps.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)",
|
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))",
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -11,6 +11,8 @@
|
|||||||
use App\Models\FindingExceptionEvidenceReference;
|
use App\Models\FindingExceptionEvidenceReference;
|
||||||
use App\Models\Tenant;
|
use App\Models\Tenant;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
|
use App\Models\Workspace;
|
||||||
|
use App\Services\Auth\WorkspaceCapabilityResolver;
|
||||||
use App\Services\Findings\FindingExceptionService;
|
use App\Services\Findings\FindingExceptionService;
|
||||||
use App\Services\Findings\FindingRiskGovernanceResolver;
|
use App\Services\Findings\FindingRiskGovernanceResolver;
|
||||||
use App\Support\Auth\Capabilities;
|
use App\Support\Auth\Capabilities;
|
||||||
@ -122,6 +124,36 @@ public static function getEloquentQuery(): Builder
|
|||||||
->with(static::relationshipsForView());
|
->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
|
public static function resolveScopedRecordOrFail(int|string|null $record): Model
|
||||||
{
|
{
|
||||||
return static::resolveTenantOwnedRecordOrFail($record, parent::getEloquentQuery()->with(static::relationshipsForView()));
|
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))
|
->icon(BadgeRenderer::icon(BadgeDomain::FindingRiskGovernanceValidity))
|
||||||
->iconColor(BadgeRenderer::iconColor(BadgeDomain::FindingRiskGovernanceValidity))
|
->iconColor(BadgeRenderer::iconColor(BadgeDomain::FindingRiskGovernanceValidity))
|
||||||
->sortable(),
|
->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')
|
Tables\Columns\TextColumn::make('finding_summary')
|
||||||
->label('Finding')
|
->label('Finding')
|
||||||
->state(fn (FindingException $record): string => static::findingSummary($record))
|
->state(fn (FindingException $record): string => static::findingSummary($record))
|
||||||
->searchable(),
|
->searchable()
|
||||||
|
->wrap()
|
||||||
|
->limit(60),
|
||||||
Tables\Columns\TextColumn::make('governance_warning')
|
Tables\Columns\TextColumn::make('governance_warning')
|
||||||
->label('Governance warning')
|
->label('Governance warning')
|
||||||
->state(fn (FindingException $record): ?string => static::governanceWarning($record))
|
->state(fn (FindingException $record): ?string => static::governanceWarning($record))
|
||||||
@ -257,7 +300,14 @@ public static function table(Table $table): Table
|
|||||||
->label('Review due')
|
->label('Review due')
|
||||||
->dateTime()
|
->dateTime()
|
||||||
->placeholder('—')
|
->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')
|
Tables\Columns\TextColumn::make('requested_at')
|
||||||
->label('Requested')
|
->label('Requested')
|
||||||
->dateTime()
|
->dateTime()
|
||||||
@ -269,6 +319,12 @@ public static function table(Table $table): Table
|
|||||||
SelectFilter::make('current_validity_state')
|
SelectFilter::make('current_validity_state')
|
||||||
->label('Validity')
|
->label('Validity')
|
||||||
->options(FilterOptionCatalog::findingExceptionValidityStates()),
|
->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([
|
->actions([
|
||||||
Action::make('renew_exception')
|
Action::make('renew_exception')
|
||||||
@ -441,15 +497,62 @@ private static function tenantMemberOptions(): array
|
|||||||
|
|
||||||
private static function findingSummary(FindingException $record): string
|
private static function findingSummary(FindingException $record): string
|
||||||
{
|
{
|
||||||
$summary = $record->finding?->resolvedSubjectDisplayName();
|
$finding = $record->finding;
|
||||||
|
|
||||||
if (is_string($summary) && trim($summary) !== '') {
|
if (! $finding instanceof \App\Models\Finding) {
|
||||||
return trim($summary);
|
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;
|
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
|
private static function canManageRecord(FindingException $record): bool
|
||||||
{
|
{
|
||||||
$user = auth()->user();
|
$user = auth()->user();
|
||||||
@ -479,10 +582,49 @@ private static function governanceWarningColor(FindingException $record): string
|
|||||||
? $record->finding
|
? $record->finding
|
||||||
: $record->finding()->withSubjectDisplayName()->first();
|
: $record->finding()->withSubjectDisplayName()->first();
|
||||||
|
|
||||||
|
if ((string) $record->current_validity_state === FindingException::VALIDITY_EXPIRING) {
|
||||||
|
return 'warning';
|
||||||
|
}
|
||||||
|
|
||||||
if ($finding instanceof \App\Models\Finding && $record->requiresFreshDecisionForFinding($finding)) {
|
if ($finding instanceof \App\Models\Finding && $record->requiresFreshDecisionForFinding($finding)) {
|
||||||
return 'warning';
|
return 'warning';
|
||||||
}
|
}
|
||||||
|
|
||||||
return 'danger';
|
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\FindingExceptionResource;
|
||||||
use App\Filament\Resources\FindingResource;
|
use App\Filament\Resources\FindingResource;
|
||||||
|
use App\Filament\Widgets\Tenant\FindingExceptionStatsOverview;
|
||||||
|
use App\Models\FindingException;
|
||||||
use Filament\Actions\Action;
|
use Filament\Actions\Action;
|
||||||
use Filament\Resources\Pages\ListRecords;
|
use Filament\Resources\Pages\ListRecords;
|
||||||
|
use Filament\Schemas\Components\Tabs\Tab;
|
||||||
|
use Illuminate\Database\Eloquent\Builder;
|
||||||
|
|
||||||
class ListFindingExceptions extends ListRecords
|
class ListFindingExceptions extends ListRecords
|
||||||
{
|
{
|
||||||
protected static string $resource = FindingExceptionResource::class;
|
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
|
protected function getHeaderActions(): array
|
||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
@ -21,6 +65,12 @@ protected function getHeaderActions(): array
|
|||||||
->icon('heroicon-o-arrow-top-right-on-square')
|
->icon('heroicon-o-arrow-top-right-on-square')
|
||||||
->color('gray')
|
->color('gray')
|
||||||
->url(FindingResource::getUrl('index')),
|
->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);
|
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')
|
Action::make('renew_exception')
|
||||||
->label('Renew exception')
|
->label('Renew exception')
|
||||||
->icon('heroicon-o-arrow-path')
|
->icon('heroicon-o-arrow-path')
|
||||||
|
|||||||
@ -64,6 +64,11 @@ class FindingResource extends Resource
|
|||||||
use InteractsWithTenantOwnedRecords;
|
use InteractsWithTenantOwnedRecords;
|
||||||
use ResolvesPanelTenantContext;
|
use ResolvesPanelTenantContext;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @var array<string, RelatedContextEntry|null>
|
||||||
|
*/
|
||||||
|
private static array $primaryRelatedEntryCache = [];
|
||||||
|
|
||||||
protected static ?string $model = Finding::class;
|
protected static ?string $model = Finding::class;
|
||||||
|
|
||||||
protected static ?string $tenantOwnershipRelationshipName = 'tenant';
|
protected static ?string $tenantOwnershipRelationshipName = 'tenant';
|
||||||
@ -146,6 +151,65 @@ public static function infolist(Schema $schema): Schema
|
|||||||
{
|
{
|
||||||
return $schema
|
return $schema
|
||||||
->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')
|
Section::make('Finding')
|
||||||
->schema([
|
->schema([
|
||||||
TextEntry::make('finding_type')->badge()->label('Type'),
|
TextEntry::make('finding_type')->badge()->label('Type'),
|
||||||
@ -628,19 +692,45 @@ public static function table(Table $table): Table
|
|||||||
->persistSearchInSession()
|
->persistSearchInSession()
|
||||||
->persistSortInSession()
|
->persistSortInSession()
|
||||||
->columns([
|
->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')
|
Tables\Columns\TextColumn::make('status')
|
||||||
->badge()
|
->badge()
|
||||||
->formatStateUsing(BadgeRenderer::label(BadgeDomain::FindingStatus))
|
->formatStateUsing(BadgeRenderer::label(BadgeDomain::FindingStatus))
|
||||||
->color(BadgeRenderer::color(BadgeDomain::FindingStatus))
|
->color(BadgeRenderer::color(BadgeDomain::FindingStatus))
|
||||||
->icon(BadgeRenderer::icon(BadgeDomain::FindingStatus))
|
->icon(BadgeRenderer::icon(BadgeDomain::FindingStatus))
|
||||||
->iconColor(BadgeRenderer::iconColor(BadgeDomain::FindingStatus)),
|
->iconColor(BadgeRenderer::iconColor(BadgeDomain::FindingStatus))
|
||||||
Tables\Columns\TextColumn::make('severity')
|
->description(fn (Finding $record): string => static::primaryNarrative($record)),
|
||||||
|
Tables\Columns\TextColumn::make('governance_validity')
|
||||||
|
->label('Governance')
|
||||||
->badge()
|
->badge()
|
||||||
->formatStateUsing(BadgeRenderer::label(BadgeDomain::FindingSeverity))
|
->state(fn (Finding $record): ?string => static::governanceValidityState($record))
|
||||||
->color(BadgeRenderer::color(BadgeDomain::FindingSeverity))
|
->formatStateUsing(BadgeRenderer::label(BadgeDomain::FindingRiskGovernanceValidity))
|
||||||
->icon(BadgeRenderer::icon(BadgeDomain::FindingSeverity))
|
->color(BadgeRenderer::color(BadgeDomain::FindingRiskGovernanceValidity))
|
||||||
->iconColor(BadgeRenderer::iconColor(BadgeDomain::FindingSeverity)),
|
->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')
|
Tables\Columns\TextColumn::make('evidence_fidelity')
|
||||||
->label('Fidelity')
|
->label('Fidelity')
|
||||||
->badge()
|
->badge()
|
||||||
@ -652,12 +742,6 @@ public static function table(Table $table): Table
|
|||||||
})
|
})
|
||||||
->sortable()
|
->sortable()
|
||||||
->toggleable(isToggledHiddenByDefault: true),
|
->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')
|
Tables\Columns\TextColumn::make('subject_type')
|
||||||
->label('Subject type')
|
->label('Subject type')
|
||||||
->searchable()
|
->searchable()
|
||||||
@ -667,18 +751,19 @@ public static function table(Table $table): Table
|
|||||||
->label('Due')
|
->label('Due')
|
||||||
->dateTime()
|
->dateTime()
|
||||||
->sortable()
|
->sortable()
|
||||||
->placeholder('—'),
|
->placeholder('—')
|
||||||
|
->description(fn (Finding $record): ?string => FindingExceptionResource::relativeTimeDescription($record->due_at) ?? static::dueAttentionLabel($record)),
|
||||||
Tables\Columns\TextColumn::make('assigneeUser.name')
|
Tables\Columns\TextColumn::make('assigneeUser.name')
|
||||||
->label('Assignee')
|
->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('subject_external_id')->label('External ID')->toggleable(isToggledHiddenByDefault: true),
|
||||||
Tables\Columns\TextColumn::make('scope_key')->label('Scope')->toggleable(isToggledHiddenByDefault: true),
|
Tables\Columns\TextColumn::make('scope_key')->label('Scope')->toggleable(isToggledHiddenByDefault: true),
|
||||||
Tables\Columns\TextColumn::make('created_at')->since()->label('Created'),
|
Tables\Columns\TextColumn::make('created_at')->since()->label('Created'),
|
||||||
])
|
])
|
||||||
->filters([
|
->filters([
|
||||||
Tables\Filters\Filter::make('open')
|
Tables\Filters\Filter::make('open')
|
||||||
->label('Open')
|
->label('Active workflow')
|
||||||
->default()
|
|
||||||
->query(fn (Builder $query): Builder => $query->whereIn('status', Finding::openStatusesForQuery())),
|
->query(fn (Builder $query): Builder => $query->whereIn('status', Finding::openStatusesForQuery())),
|
||||||
Tables\Filters\Filter::make('overdue')
|
Tables\Filters\Filter::make('overdue')
|
||||||
->label('Overdue')
|
->label('Overdue')
|
||||||
@ -706,6 +791,45 @@ public static function table(Table $table): Table
|
|||||||
Tables\Filters\SelectFilter::make('status')
|
Tables\Filters\SelectFilter::make('status')
|
||||||
->options(FilterOptionCatalog::findingStatuses())
|
->options(FilterOptionCatalog::findingStatuses())
|
||||||
->label('Status'),
|
->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')
|
Tables\Filters\SelectFilter::make('finding_type')
|
||||||
->options([
|
->options([
|
||||||
Finding::FINDING_TYPE_DRIFT => 'Drift',
|
Finding::FINDING_TYPE_DRIFT => 'Drift',
|
||||||
@ -760,9 +884,7 @@ public static function table(Table $table): Table
|
|||||||
}),
|
}),
|
||||||
FilterPresets::dateRange('created_at', 'Created', 'created_at'),
|
FilterPresets::dateRange('created_at', 'Created', 'created_at'),
|
||||||
])
|
])
|
||||||
->recordUrl(static fn (Finding $record): ?string => static::canView($record)
|
->recordUrl(static fn (Finding $record): string => static::getUrl('view', ['record' => $record]))
|
||||||
? static::getUrl('view', ['record' => $record])
|
|
||||||
: null)
|
|
||||||
->actions([
|
->actions([
|
||||||
static::primaryRelatedAction(),
|
static::primaryRelatedAction(),
|
||||||
Actions\ActionGroup::make([
|
Actions\ActionGroup::make([
|
||||||
@ -1078,7 +1200,7 @@ public static function table(Table $table): Table
|
|||||||
])->label('More'),
|
])->label('More'),
|
||||||
])
|
])
|
||||||
->emptyStateHeading('No findings match this view')
|
->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');
|
->emptyStateIcon('heroicon-o-exclamation-triangle');
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -1131,7 +1253,15 @@ private static function primaryRelatedAction(): Actions\Action
|
|||||||
|
|
||||||
private static function primaryRelatedEntry(Finding $record): ?RelatedContextEntry
|
private static function primaryRelatedEntry(Finding $record): ?RelatedContextEntry
|
||||||
{
|
{
|
||||||
return app(RelatedNavigationResolver::class)
|
$cacheKey = is_numeric($record->getKey())
|
||||||
|
? (string) $record->getKey()
|
||||||
|
: spl_object_hash($record);
|
||||||
|
|
||||||
|
if (array_key_exists($cacheKey, static::$primaryRelatedEntryCache)) {
|
||||||
|
return static::$primaryRelatedEntryCache[$cacheKey];
|
||||||
|
}
|
||||||
|
|
||||||
|
return static::$primaryRelatedEntryCache[$cacheKey] = app(RelatedNavigationResolver::class)
|
||||||
->primaryListAction(CrossResourceNavigationMatrix::SOURCE_FINDING, $record);
|
->primaryListAction(CrossResourceNavigationMatrix::SOURCE_FINDING, $record);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -1186,7 +1316,7 @@ public static function triageAction(): Actions\Action
|
|||||||
->label('Triage')
|
->label('Triage')
|
||||||
->icon('heroicon-o-check')
|
->icon('heroicon-o-check')
|
||||||
->color('gray')
|
->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_NEW,
|
||||||
Finding::STATUS_REOPENED,
|
Finding::STATUS_REOPENED,
|
||||||
Finding::STATUS_ACKNOWLEDGED,
|
Finding::STATUS_ACKNOWLEDGED,
|
||||||
@ -1212,7 +1342,7 @@ public static function startProgressAction(): Actions\Action
|
|||||||
->label('Start progress')
|
->label('Start progress')
|
||||||
->icon('heroicon-o-play')
|
->icon('heroicon-o-play')
|
||||||
->color('info')
|
->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_TRIAGED,
|
||||||
Finding::STATUS_ACKNOWLEDGED,
|
Finding::STATUS_ACKNOWLEDGED,
|
||||||
], true))
|
], true))
|
||||||
@ -1237,7 +1367,7 @@ public static function assignAction(): Actions\Action
|
|||||||
->label('Assign')
|
->label('Assign')
|
||||||
->icon('heroicon-o-user-plus')
|
->icon('heroicon-o-user-plus')
|
||||||
->color('gray')
|
->color('gray')
|
||||||
->visible(fn (Finding $record): bool => static::freshWorkflowRecord($record)->hasOpenStatus())
|
->visible(fn (Finding $record): bool => $record->hasOpenStatus())
|
||||||
->fillForm(fn (Finding $record): array => [
|
->fillForm(fn (Finding $record): array => [
|
||||||
'assignee_user_id' => $record->assignee_user_id,
|
'assignee_user_id' => $record->assignee_user_id,
|
||||||
'owner_user_id' => $record->owner_user_id,
|
'owner_user_id' => $record->owner_user_id,
|
||||||
@ -1281,7 +1411,7 @@ public static function resolveAction(): Actions\Action
|
|||||||
->label('Resolve')
|
->label('Resolve')
|
||||||
->icon('heroicon-o-check-badge')
|
->icon('heroicon-o-check-badge')
|
||||||
->color('success')
|
->color('success')
|
||||||
->visible(fn (Finding $record): bool => static::freshWorkflowRecord($record)->hasOpenStatus())
|
->visible(fn (Finding $record): bool => $record->hasOpenStatus())
|
||||||
->requiresConfirmation()
|
->requiresConfirmation()
|
||||||
->form([
|
->form([
|
||||||
Textarea::make('resolved_reason')
|
Textarea::make('resolved_reason')
|
||||||
@ -1316,7 +1446,7 @@ public static function closeAction(): Actions\Action
|
|||||||
->label('Close')
|
->label('Close')
|
||||||
->icon('heroicon-o-x-circle')
|
->icon('heroicon-o-x-circle')
|
||||||
->color('danger')
|
->color('danger')
|
||||||
->visible(fn (Finding $record): bool => static::freshWorkflowRecord($record)->hasOpenStatus())
|
->visible(fn (Finding $record): bool => $record->hasOpenStatus())
|
||||||
->requiresConfirmation()
|
->requiresConfirmation()
|
||||||
->form([
|
->form([
|
||||||
Textarea::make('closed_reason')
|
Textarea::make('closed_reason')
|
||||||
@ -1351,7 +1481,7 @@ public static function requestExceptionAction(): Actions\Action
|
|||||||
->label('Request exception')
|
->label('Request exception')
|
||||||
->icon('heroicon-o-shield-exclamation')
|
->icon('heroicon-o-shield-exclamation')
|
||||||
->color('warning')
|
->color('warning')
|
||||||
->visible(fn (Finding $record): bool => static::freshWorkflowRecord($record)->hasOpenStatus())
|
->visible(fn (Finding $record): bool => $record->hasOpenStatus())
|
||||||
->requiresConfirmation()
|
->requiresConfirmation()
|
||||||
->form([
|
->form([
|
||||||
Select::make('owner_user_id')
|
Select::make('owner_user_id')
|
||||||
@ -1412,9 +1542,9 @@ public static function renewExceptionAction(): Actions\Action
|
|||||||
->label('Renew exception')
|
->label('Renew exception')
|
||||||
->icon('heroicon-o-arrow-path')
|
->icon('heroicon-o-arrow-path')
|
||||||
->color('warning')
|
->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 => [
|
->fillForm(fn (Finding $record): array => [
|
||||||
'owner_user_id' => static::currentFindingException($record)?->owner_user_id,
|
'owner_user_id' => static::loadedFindingException($record)?->owner_user_id,
|
||||||
])
|
])
|
||||||
->requiresConfirmation()
|
->requiresConfirmation()
|
||||||
->form([
|
->form([
|
||||||
@ -1476,7 +1606,7 @@ public static function revokeExceptionAction(): Actions\Action
|
|||||||
->label('Revoke exception')
|
->label('Revoke exception')
|
||||||
->icon('heroicon-o-no-symbol')
|
->icon('heroicon-o-no-symbol')
|
||||||
->color('danger')
|
->color('danger')
|
||||||
->visible(fn (Finding $record): bool => static::currentFindingException($record)?->canBeRevoked() ?? false)
|
->visible(fn (Finding $record): bool => static::loadedFindingException($record)?->canBeRevoked() ?? false)
|
||||||
->requiresConfirmation()
|
->requiresConfirmation()
|
||||||
->form([
|
->form([
|
||||||
Textarea::make('revocation_reason')
|
Textarea::make('revocation_reason')
|
||||||
@ -1503,7 +1633,7 @@ public static function reopenAction(): Actions\Action
|
|||||||
->icon('heroicon-o-arrow-uturn-left')
|
->icon('heroicon-o-arrow-uturn-left')
|
||||||
->color('warning')
|
->color('warning')
|
||||||
->requiresConfirmation()
|
->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 {
|
->action(function (Finding $record, FindingWorkflowService $workflow): void {
|
||||||
static::runWorkflowMutation(
|
static::runWorkflowMutation(
|
||||||
record: $record,
|
record: $record,
|
||||||
@ -1701,6 +1831,21 @@ private static function currentFindingException(Finding $record): ?FindingExcept
|
|||||||
return static::resolvedFindingException($finding);
|
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
|
private static function resolvedFindingException(Finding $finding): ?FindingException
|
||||||
{
|
{
|
||||||
$exception = $finding->relationLoaded('findingException')
|
$exception = $finding->relationLoaded('findingException')
|
||||||
@ -1748,6 +1893,10 @@ private static function governanceWarningColor(Finding $finding): string
|
|||||||
{
|
{
|
||||||
$exception = static::resolvedFindingException($finding);
|
$exception = static::resolvedFindingException($finding);
|
||||||
|
|
||||||
|
if (static::governanceValidityState($finding) === FindingException::VALIDITY_EXPIRING) {
|
||||||
|
return 'warning';
|
||||||
|
}
|
||||||
|
|
||||||
if ($exception instanceof FindingException && $exception->requiresFreshDecisionForFinding($finding)) {
|
if ($exception instanceof FindingException && $exception->requiresFreshDecisionForFinding($finding)) {
|
||||||
return 'warning';
|
return 'warning';
|
||||||
}
|
}
|
||||||
@ -1755,6 +1904,56 @@ private static function governanceWarningColor(Finding $finding): string
|
|||||||
return 'danger';
|
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>
|
* @return array<int, string>
|
||||||
*/
|
*/
|
||||||
@ -1774,4 +1973,35 @@ private static function tenantMemberOptions(): array
|
|||||||
->mapWithKeys(fn (string $name, int|string $id): array => [(int) $id => $name])
|
->mapWithKeys(fn (string $name, int|string $id): array => [(int) $id => $name])
|
||||||
->all();
|
->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\Concerns\ResolvesPanelTenantContext;
|
||||||
use App\Filament\Resources\FindingResource;
|
use App\Filament\Resources\FindingResource;
|
||||||
use App\Filament\Widgets\Tenant\BaselineCompareCoverageBanner;
|
use App\Filament\Widgets\Tenant\BaselineCompareCoverageBanner;
|
||||||
|
use App\Filament\Widgets\Tenant\FindingStatsOverview;
|
||||||
use App\Jobs\BackfillFindingLifecycleJob;
|
use App\Jobs\BackfillFindingLifecycleJob;
|
||||||
use App\Models\Finding;
|
use App\Models\Finding;
|
||||||
use App\Models\Tenant;
|
use App\Models\Tenant;
|
||||||
@ -22,6 +23,7 @@
|
|||||||
use Filament\Forms\Components\TextInput;
|
use Filament\Forms\Components\TextInput;
|
||||||
use Filament\Notifications\Notification;
|
use Filament\Notifications\Notification;
|
||||||
use Filament\Resources\Pages\ListRecords;
|
use Filament\Resources\Pages\ListRecords;
|
||||||
|
use Filament\Schemas\Components\Tabs\Tab;
|
||||||
use Illuminate\Database\Eloquent\Builder;
|
use Illuminate\Database\Eloquent\Builder;
|
||||||
use Illuminate\Database\Eloquent\ModelNotFoundException;
|
use Illuminate\Database\Eloquent\ModelNotFoundException;
|
||||||
use Illuminate\Support\Arr;
|
use Illuminate\Support\Arr;
|
||||||
@ -61,6 +63,42 @@ protected function getHeaderWidgets(): array
|
|||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
BaselineCompareCoverageBanner::class,
|
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;
|
namespace App\Filament\Resources\FindingResource\Pages;
|
||||||
|
|
||||||
|
use App\Filament\Resources\FindingExceptionResource;
|
||||||
use App\Filament\Resources\FindingResource;
|
use App\Filament\Resources\FindingResource;
|
||||||
|
use App\Models\Finding;
|
||||||
use App\Support\Navigation\CrossResourceNavigationMatrix;
|
use App\Support\Navigation\CrossResourceNavigationMatrix;
|
||||||
use App\Support\Navigation\RelatedNavigationResolver;
|
use App\Support\Navigation\RelatedNavigationResolver;
|
||||||
use Filament\Actions;
|
use Filament\Actions;
|
||||||
@ -30,6 +32,23 @@ protected function getHeaderActions(): array
|
|||||||
->hidden(fn (): bool => ! (app(RelatedNavigationResolver::class)
|
->hidden(fn (): bool => ! (app(RelatedNavigationResolver::class)
|
||||||
->primaryListAction(CrossResourceNavigationMatrix::SOURCE_FINDING, $this->getRecord())?->isAvailable() ?? false))
|
->primaryListAction(CrossResourceNavigationMatrix::SOURCE_FINDING, $this->getRecord())?->isAvailable() ?? false))
|
||||||
->color('gray'),
|
->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())
|
Actions\ActionGroup::make(FindingResource::workflowActions())
|
||||||
->label('Actions')
|
->label('Actions')
|
||||||
->icon('heroicon-o-ellipsis-vertical')
|
->icon('heroicon-o-ellipsis-vertical')
|
||||||
|
|||||||
@ -11,6 +11,7 @@
|
|||||||
use App\Support\Badges\BadgeCatalog;
|
use App\Support\Badges\BadgeCatalog;
|
||||||
use App\Support\Badges\BadgeDomain;
|
use App\Support\Badges\BadgeDomain;
|
||||||
use App\Support\Badges\BadgeRenderer;
|
use App\Support\Badges\BadgeRenderer;
|
||||||
|
use App\Support\Baselines\BaselineCompareEvidenceGapDetails;
|
||||||
use App\Support\Baselines\BaselineCompareReasonCode;
|
use App\Support\Baselines\BaselineCompareReasonCode;
|
||||||
use App\Support\Filament\FilterOptionCatalog;
|
use App\Support\Filament\FilterOptionCatalog;
|
||||||
use App\Support\Filament\FilterPresets;
|
use App\Support\Filament\FilterPresets;
|
||||||
@ -32,7 +33,9 @@
|
|||||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceInspectAffordance;
|
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceInspectAffordance;
|
||||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceProfile;
|
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceProfile;
|
||||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceSlot;
|
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceSlot;
|
||||||
|
use App\Support\Ui\GovernanceArtifactTruth\ArtifactTruthEnvelope;
|
||||||
use App\Support\Ui\GovernanceArtifactTruth\ArtifactTruthPresenter;
|
use App\Support\Ui\GovernanceArtifactTruth\ArtifactTruthPresenter;
|
||||||
|
use App\Support\Ui\OperatorExplanation\OperatorExplanationPattern;
|
||||||
use App\Support\Workspaces\WorkspaceContext;
|
use App\Support\Workspaces\WorkspaceContext;
|
||||||
use BackedEnum;
|
use BackedEnum;
|
||||||
use Filament\Actions;
|
use Filament\Actions;
|
||||||
@ -256,7 +259,7 @@ private static function enterpriseDetailPage(OperationRun $record): \App\Support
|
|||||||
|
|
||||||
$statusSpec = BadgeRenderer::spec(BadgeDomain::OperationRunStatus, static::statusBadgeState($record));
|
$statusSpec = BadgeRenderer::spec(BadgeDomain::OperationRunStatus, static::statusBadgeState($record));
|
||||||
$outcomeSpec = BadgeRenderer::spec(BadgeDomain::OperationRunOutcome, static::outcomeBadgeState($record));
|
$outcomeSpec = BadgeRenderer::spec(BadgeDomain::OperationRunOutcome, static::outcomeBadgeState($record));
|
||||||
$targetScope = static::targetScopeDisplay($record);
|
$targetScope = static::targetScopeDisplay($record) ?? 'No target scope details were recorded for this run.';
|
||||||
$summaryLine = \App\Support\OpsUx\SummaryCountsNormalizer::renderSummaryLine(is_array($record->summary_counts) ? $record->summary_counts : []);
|
$summaryLine = \App\Support\OpsUx\SummaryCountsNormalizer::renderSummaryLine(is_array($record->summary_counts) ? $record->summary_counts : []);
|
||||||
$referencedTenantLifecycle = $record->tenant instanceof Tenant
|
$referencedTenantLifecycle = $record->tenant instanceof Tenant
|
||||||
? ReferencedTenantLifecyclePresentation::forOperationRun($record->tenant)
|
? ReferencedTenantLifecyclePresentation::forOperationRun($record->tenant)
|
||||||
@ -265,14 +268,14 @@ private static function enterpriseDetailPage(OperationRun $record): \App\Support
|
|||||||
? app(ArtifactTruthPresenter::class)->forOperationRun($record)
|
? app(ArtifactTruthPresenter::class)->forOperationRun($record)
|
||||||
: null;
|
: null;
|
||||||
$operatorExplanation = $artifactTruth?->operatorExplanation;
|
$operatorExplanation = $artifactTruth?->operatorExplanation;
|
||||||
$artifactTruthBadge = $artifactTruth !== null
|
$primaryNextStep = static::resolvePrimaryNextStep($record, $artifactTruth, $operatorExplanation);
|
||||||
? $factory->statusBadge(
|
$supportingGroups = static::supportingGroups(
|
||||||
$artifactTruth->primaryBadgeSpec()->label,
|
record: $record,
|
||||||
$artifactTruth->primaryBadgeSpec()->color,
|
factory: $factory,
|
||||||
$artifactTruth->primaryBadgeSpec()->icon,
|
referencedTenantLifecycle: $referencedTenantLifecycle,
|
||||||
$artifactTruth->primaryBadgeSpec()->iconColor,
|
operatorExplanation: $operatorExplanation,
|
||||||
)
|
primaryNextStep: $primaryNextStep,
|
||||||
: null;
|
);
|
||||||
|
|
||||||
$builder = \App\Support\Ui\EnterpriseDetail\EnterpriseDetailBuilder::make('operation_run', 'workspace-context')
|
$builder = \App\Support\Ui\EnterpriseDetail\EnterpriseDetailBuilder::make('operation_run', 'workspace-context')
|
||||||
->header(new \App\Support\Ui\EnterpriseDetail\SummaryHeaderData(
|
->header(new \App\Support\Ui\EnterpriseDetail\SummaryHeaderData(
|
||||||
@ -283,34 +286,59 @@ private static function enterpriseDetailPage(OperationRun $record): \App\Support
|
|||||||
$factory->statusBadge($outcomeSpec->label, $outcomeSpec->color, $outcomeSpec->icon, $outcomeSpec->iconColor),
|
$factory->statusBadge($outcomeSpec->label, $outcomeSpec->color, $outcomeSpec->icon, $outcomeSpec->iconColor),
|
||||||
],
|
],
|
||||||
keyFacts: [
|
keyFacts: [
|
||||||
$factory->keyFact('Target', $targetScope ?? 'No target scope details were recorded for this run.'),
|
$factory->keyFact('Target', $targetScope),
|
||||||
$factory->keyFact('Initiator', $record->initiator_name),
|
|
||||||
$factory->keyFact('Elapsed', RunDurationInsights::elapsedHuman($record)),
|
$factory->keyFact('Elapsed', RunDurationInsights::elapsedHuman($record)),
|
||||||
$factory->keyFact('Expected', RunDurationInsights::expectedHuman($record)),
|
|
||||||
],
|
],
|
||||||
descriptionHint: 'Run identity, outcome, scope, and related next steps stay ahead of stored payloads and diagnostic fragments.',
|
descriptionHint: 'Decision guidance and high-signal context stay ahead of diagnostic payloads and raw JSON.',
|
||||||
))
|
))
|
||||||
->addSection(
|
->decisionZone($factory->decisionZone(
|
||||||
$factory->factsSection(
|
facts: array_values(array_filter([
|
||||||
id: 'run_summary',
|
$factory->keyFact(
|
||||||
kind: 'core_details',
|
'Execution state',
|
||||||
title: 'Run summary',
|
$statusSpec->label,
|
||||||
items: [
|
badge: $factory->statusBadge($statusSpec->label, $statusSpec->color, $statusSpec->icon, $statusSpec->iconColor),
|
||||||
$factory->keyFact('Operation', OperationCatalog::label((string) $record->type)),
|
|
||||||
$factory->keyFact('Initiator', $record->initiator_name),
|
|
||||||
$factory->keyFact('Target scope', $targetScope ?? 'No target scope details were recorded for this run.'),
|
|
||||||
$factory->keyFact('Expected duration', RunDurationInsights::expectedHuman($record)),
|
|
||||||
],
|
|
||||||
),
|
),
|
||||||
$factory->viewSection(
|
$factory->keyFact(
|
||||||
id: 'artifact_truth',
|
'Outcome',
|
||||||
kind: 'current_status',
|
$outcomeSpec->label,
|
||||||
title: 'Artifact truth',
|
badge: $factory->statusBadge($outcomeSpec->label, $outcomeSpec->color, $outcomeSpec->icon, $outcomeSpec->iconColor),
|
||||||
view: 'filament.infolists.entries.governance-artifact-truth',
|
|
||||||
viewData: ['artifactTruthState' => $artifactTruth?->toArray()],
|
|
||||||
visible: $artifactTruth !== null,
|
|
||||||
description: 'Run lifecycle stays separate from whether the intended governance artifact was actually produced and usable.',
|
|
||||||
),
|
),
|
||||||
|
static::artifactTruthFact($factory, $artifactTruth),
|
||||||
|
$operatorExplanation instanceof OperatorExplanationPattern
|
||||||
|
? $factory->keyFact(
|
||||||
|
'Result meaning',
|
||||||
|
$operatorExplanation->evaluationResultLabel(),
|
||||||
|
$operatorExplanation->headline,
|
||||||
|
)
|
||||||
|
: null,
|
||||||
|
$operatorExplanation instanceof OperatorExplanationPattern
|
||||||
|
? $factory->keyFact(
|
||||||
|
'Result trust',
|
||||||
|
$operatorExplanation->trustworthinessLabel(),
|
||||||
|
static::detailHintUnlessDuplicate(
|
||||||
|
$operatorExplanation->reliabilityStatement,
|
||||||
|
$artifactTruth?->primaryExplanation,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
: null,
|
||||||
|
])),
|
||||||
|
primaryNextStep: $factory->primaryNextStep(
|
||||||
|
$primaryNextStep['text'],
|
||||||
|
$primaryNextStep['source'],
|
||||||
|
$primaryNextStep['secondaryGuidance'],
|
||||||
|
),
|
||||||
|
description: 'Start here to see how the run ended, whether the result is trustworthy enough to use, and the one primary next step.',
|
||||||
|
compactCounts: $summaryLine !== null
|
||||||
|
? $factory->countPresentation(summaryLine: $summaryLine)
|
||||||
|
: null,
|
||||||
|
attentionNote: static::decisionAttentionNote($record),
|
||||||
|
));
|
||||||
|
|
||||||
|
if ($supportingGroups !== []) {
|
||||||
|
$builder->addSupportingGroup(...$supportingGroups);
|
||||||
|
}
|
||||||
|
|
||||||
|
$builder->addSection(
|
||||||
$factory->viewSection(
|
$factory->viewSection(
|
||||||
id: 'related_context',
|
id: 'related_context',
|
||||||
kind: 'related_context',
|
kind: 'related_context',
|
||||||
@ -320,23 +348,216 @@ private static function enterpriseDetailPage(OperationRun $record): \App\Support
|
|||||||
->detailEntries(CrossResourceNavigationMatrix::SOURCE_OPERATION_RUN, $record)],
|
->detailEntries(CrossResourceNavigationMatrix::SOURCE_OPERATION_RUN, $record)],
|
||||||
emptyState: $factory->emptyState('No related context is available for this record.'),
|
emptyState: $factory->emptyState('No related context is available for this record.'),
|
||||||
),
|
),
|
||||||
)
|
$factory->viewSection(
|
||||||
->addSupportingCard(
|
id: 'artifact_truth',
|
||||||
$factory->supportingFactsCard(
|
kind: 'supporting_detail',
|
||||||
kind: 'status',
|
title: 'Artifact truth details',
|
||||||
title: 'Current state',
|
view: 'filament.infolists.entries.governance-artifact-truth',
|
||||||
items: array_values(array_filter([
|
viewData: [
|
||||||
$factory->keyFact('Status', $statusSpec->label, badge: $factory->statusBadge($statusSpec->label, $statusSpec->color, $statusSpec->icon, $statusSpec->iconColor)),
|
'artifactTruthState' => $artifactTruth?->toArray(),
|
||||||
$factory->keyFact('Outcome', $outcomeSpec->label, badge: $factory->statusBadge($outcomeSpec->label, $outcomeSpec->color, $outcomeSpec->icon, $outcomeSpec->iconColor)),
|
'surface' => 'expanded',
|
||||||
$artifactTruth !== null
|
],
|
||||||
? $factory->keyFact('Artifact truth', $artifactTruth->primaryLabel, badge: $artifactTruthBadge)
|
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->addTechnicalSection(
|
||||||
|
$factory->technicalDetail(
|
||||||
|
title: 'Count diagnostics',
|
||||||
|
entries: $counts,
|
||||||
|
description: 'Normalized run counters remain available for deeper inspection without competing with the primary decision.',
|
||||||
|
collapsible: true,
|
||||||
|
collapsed: true,
|
||||||
|
variant: 'diagnostic',
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! empty($record->failure_summary)) {
|
||||||
|
$builder->addTechnicalSection(
|
||||||
|
$factory->technicalDetail(
|
||||||
|
title: (string) $record->outcome === OperationRunOutcome::Blocked->value ? 'Blocked execution details' : 'Failures',
|
||||||
|
description: 'Detailed failure evidence stays available for investigation after the decision and supporting context.',
|
||||||
|
view: 'filament.infolists.entries.snapshot-json',
|
||||||
|
viewData: ['payload' => $record->failure_summary ?? []],
|
||||||
|
collapsible: true,
|
||||||
|
collapsed: false,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (static::reconciliationPayload($record) !== []) {
|
||||||
|
$builder->addTechnicalSection(
|
||||||
|
$factory->technicalDetail(
|
||||||
|
title: 'Lifecycle reconciliation',
|
||||||
|
description: 'Lifecycle reconciliation is diagnostic evidence showing when TenantPilot force-resolved the run.',
|
||||||
|
view: 'filament.infolists.entries.snapshot-json',
|
||||||
|
viewData: ['payload' => static::reconciliationPayload($record)],
|
||||||
|
collapsible: true,
|
||||||
|
collapsed: true,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ((string) $record->type === 'baseline_compare') {
|
||||||
|
$baselineCompareFacts = static::baselineCompareFacts($record, $factory);
|
||||||
|
$baselineCompareEvidence = static::baselineCompareEvidencePayload($record);
|
||||||
|
$gapDetails = BaselineCompareEvidenceGapDetails::fromOperationRun($record);
|
||||||
|
$gapSummary = is_array($gapDetails['summary'] ?? null) ? $gapDetails['summary'] : [];
|
||||||
|
$gapBuckets = is_array($gapDetails['buckets'] ?? null) ? $gapDetails['buckets'] : [];
|
||||||
|
|
||||||
|
if ($baselineCompareFacts !== []) {
|
||||||
|
$builder->addSection(
|
||||||
|
$factory->factsSection(
|
||||||
|
id: 'baseline_compare',
|
||||||
|
kind: 'type_specific_detail',
|
||||||
|
title: 'Baseline compare',
|
||||||
|
items: $baselineCompareFacts,
|
||||||
|
description: 'Type-specific comparison detail stays below the canonical decision and supporting layers.',
|
||||||
|
collapsible: true,
|
||||||
|
collapsed: true,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (($gapSummary['detail_state'] ?? 'no_gaps') !== 'no_gaps') {
|
||||||
|
$builder->addSection(
|
||||||
|
$factory->viewSection(
|
||||||
|
id: 'baseline_compare_gap_details',
|
||||||
|
kind: 'type_specific_detail',
|
||||||
|
title: 'Evidence gap details',
|
||||||
|
description: 'Policies affected by evidence gaps, grouped by reason and searchable by reason, policy type, or subject key.',
|
||||||
|
view: 'filament.infolists.entries.evidence-gap-subjects',
|
||||||
|
viewData: [
|
||||||
|
'summary' => $gapSummary,
|
||||||
|
'buckets' => $gapBuckets,
|
||||||
|
'searchId' => 'baseline-compare-gap-search-'.$record->getKey(),
|
||||||
|
],
|
||||||
|
collapsible: true,
|
||||||
|
collapsed: true,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($baselineCompareEvidence !== []) {
|
||||||
|
$builder->addSection(
|
||||||
|
$factory->viewSection(
|
||||||
|
id: 'baseline_compare_evidence',
|
||||||
|
kind: 'type_specific_detail',
|
||||||
|
title: 'Baseline compare evidence',
|
||||||
|
view: 'filament.infolists.entries.snapshot-json',
|
||||||
|
viewData: ['payload' => $baselineCompareEvidence],
|
||||||
|
collapsible: true,
|
||||||
|
collapsed: true,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ((string) $record->type === 'baseline_capture') {
|
||||||
|
$baselineCaptureEvidence = static::baselineCaptureEvidencePayload($record);
|
||||||
|
|
||||||
|
if ($baselineCaptureEvidence !== []) {
|
||||||
|
$builder->addSection(
|
||||||
|
$factory->viewSection(
|
||||||
|
id: 'baseline_capture_evidence',
|
||||||
|
kind: 'type_specific_detail',
|
||||||
|
title: 'Baseline capture evidence',
|
||||||
|
view: 'filament.infolists.entries.snapshot-json',
|
||||||
|
viewData: ['payload' => $baselineCaptureEvidence],
|
||||||
|
collapsible: true,
|
||||||
|
collapsed: true,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (VerificationReportViewer::shouldRenderForRun($record)) {
|
||||||
|
$builder->addSection(
|
||||||
|
$factory->viewSection(
|
||||||
|
id: 'verification_report',
|
||||||
|
kind: 'type_specific_detail',
|
||||||
|
title: 'Verification report',
|
||||||
|
view: 'filament.components.verification-report-viewer',
|
||||||
|
viewData: static::verificationReportViewData($record),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
$builder->addTechnicalSection(
|
||||||
|
$factory->technicalDetail(
|
||||||
|
title: 'Context',
|
||||||
|
entries: [
|
||||||
|
$factory->keyFact('Identity hash', $record->run_identity_hash, mono: true),
|
||||||
|
$factory->keyFact('Workspace scope', $record->workspace_id),
|
||||||
|
$factory->keyFact('Tenant scope', $record->tenant_id),
|
||||||
|
],
|
||||||
|
description: 'Stored run context stays available for debugging without dominating the default reading path.',
|
||||||
|
view: 'filament.infolists.entries.snapshot-json',
|
||||||
|
viewData: ['payload' => static::contextPayload($record)],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
return $builder->build();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return list<\App\Support\Ui\EnterpriseDetail\SupportingCardData>
|
||||||
|
*/
|
||||||
|
private static function supportingGroups(
|
||||||
|
OperationRun $record,
|
||||||
|
\App\Support\Ui\EnterpriseDetail\EnterpriseDetailSectionFactory $factory,
|
||||||
|
?ReferencedTenantLifecyclePresentation $referencedTenantLifecycle,
|
||||||
|
?OperatorExplanationPattern $operatorExplanation,
|
||||||
|
array $primaryNextStep,
|
||||||
|
): array {
|
||||||
|
$groups = [];
|
||||||
|
$hasElevatedLifecycleState = OperationUxPresenter::lifecycleAttentionSummary($record) !== null;
|
||||||
|
|
||||||
|
$guidanceItems = array_values(array_filter([
|
||||||
|
$operatorExplanation instanceof OperatorExplanationPattern && $operatorExplanation->coverageStatement !== null
|
||||||
|
? $factory->keyFact('Coverage', $operatorExplanation->coverageStatement)
|
||||||
: null,
|
: null,
|
||||||
$operatorExplanation !== null
|
$operatorExplanation instanceof OperatorExplanationPattern && $operatorExplanation->diagnosticsSummary !== null
|
||||||
? $factory->keyFact('Result meaning', $operatorExplanation->evaluationResultLabel())
|
? $factory->keyFact('Diagnostics summary', $operatorExplanation->diagnosticsSummary)
|
||||||
: null,
|
: null,
|
||||||
$operatorExplanation !== null
|
...array_map(
|
||||||
? $factory->keyFact('Result trust', $operatorExplanation->trustworthinessLabel())
|
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,
|
: null,
|
||||||
|
static::blockedExecutionDetail($record) !== null
|
||||||
|
? $factory->keyFact('Blocked detail', static::blockedExecutionDetail($record))
|
||||||
|
: null,
|
||||||
|
static::blockedExecutionSource($record) !== null
|
||||||
|
? $factory->keyFact('Blocked by', static::blockedExecutionSource($record))
|
||||||
|
: null,
|
||||||
|
RunDurationInsights::stuckGuidance($record) !== null
|
||||||
|
? $factory->keyFact('Queue guidance', RunDurationInsights::stuckGuidance($record))
|
||||||
|
: null,
|
||||||
|
]));
|
||||||
|
|
||||||
|
if ($guidanceItems !== []) {
|
||||||
|
$groups[] = $factory->supportingFactsCard(
|
||||||
|
kind: 'guidance',
|
||||||
|
title: 'Guidance',
|
||||||
|
items: $guidanceItems,
|
||||||
|
description: 'Secondary guidance explains caveats and context without competing with the primary next step.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
$lifecycleItems = array_values(array_filter([
|
||||||
$referencedTenantLifecycle !== null
|
$referencedTenantLifecycle !== null
|
||||||
? $factory->keyFact(
|
? $factory->keyFact(
|
||||||
'Tenant lifecycle',
|
'Tenant lifecycle',
|
||||||
@ -355,10 +576,10 @@ private static function enterpriseDetailPage(OperationRun $record): \App\Support
|
|||||||
$referencedTenantLifecycle?->contextNote !== null
|
$referencedTenantLifecycle?->contextNote !== null
|
||||||
? $factory->keyFact('Viewer context', $referencedTenantLifecycle->contextNote)
|
? $factory->keyFact('Viewer context', $referencedTenantLifecycle->contextNote)
|
||||||
: null,
|
: null,
|
||||||
static::freshnessLabel($record) !== null
|
! $hasElevatedLifecycleState && static::freshnessLabel($record) !== null
|
||||||
? $factory->keyFact('Freshness', (string) static::freshnessLabel($record))
|
? $factory->keyFact('Freshness', (string) static::freshnessLabel($record))
|
||||||
: null,
|
: null,
|
||||||
static::reconciliationHeadline($record) !== null
|
! $hasElevatedLifecycleState && static::reconciliationHeadline($record) !== null
|
||||||
? $factory->keyFact('Lifecycle truth', (string) static::reconciliationHeadline($record))
|
? $factory->keyFact('Lifecycle truth', (string) static::reconciliationHeadline($record))
|
||||||
: null,
|
: null,
|
||||||
static::reconciledAtLabel($record) !== null
|
static::reconciledAtLabel($record) !== null
|
||||||
@ -367,150 +588,221 @@ private static function enterpriseDetailPage(OperationRun $record): \App\Support
|
|||||||
static::reconciliationSourceLabel($record) !== null
|
static::reconciliationSourceLabel($record) !== null
|
||||||
? $factory->keyFact('Reconciled by', (string) static::reconciliationSourceLabel($record))
|
? $factory->keyFact('Reconciled by', (string) static::reconciliationSourceLabel($record))
|
||||||
: null,
|
: null,
|
||||||
$operatorExplanation !== null
|
]));
|
||||||
? $factory->keyFact('Artifact next step', $operatorExplanation->nextActionText)
|
|
||||||
: ($artifactTruth !== null
|
if ($lifecycleItems !== []) {
|
||||||
? $factory->keyFact('Artifact next step', $artifactTruth->nextStepText())
|
$groups[] = $factory->supportingFactsCard(
|
||||||
: null),
|
kind: 'lifecycle',
|
||||||
$operatorExplanation !== null && $operatorExplanation->coverageStatement !== null
|
title: 'Lifecycle',
|
||||||
? $factory->keyFact('Coverage', $operatorExplanation->coverageStatement)
|
items: $lifecycleItems,
|
||||||
: null,
|
description: 'Lifecycle context explains freshness, reconciliation, and tenant-scoped caveats.',
|
||||||
OperationUxPresenter::surfaceGuidance($record) !== null
|
);
|
||||||
? $factory->keyFact('Next step', OperationUxPresenter::surfaceGuidance($record))
|
}
|
||||||
: null,
|
|
||||||
$summaryLine !== null ? $factory->keyFact('Counts', $summaryLine) : null,
|
$timingItems = [
|
||||||
static::blockedExecutionReasonCode($record) !== null
|
|
||||||
? $factory->keyFact('Blocked reason', static::blockedExecutionReasonCode($record))
|
|
||||||
: null,
|
|
||||||
static::blockedExecutionDetail($record) !== null
|
|
||||||
? $factory->keyFact('Blocked detail', static::blockedExecutionDetail($record))
|
|
||||||
: null,
|
|
||||||
static::blockedExecutionSource($record) !== null
|
|
||||||
? $factory->keyFact('Blocked by', static::blockedExecutionSource($record))
|
|
||||||
: null,
|
|
||||||
RunDurationInsights::stuckGuidance($record) !== null ? $factory->keyFact('Guidance', RunDurationInsights::stuckGuidance($record)) : null,
|
|
||||||
])),
|
|
||||||
),
|
|
||||||
$factory->supportingFactsCard(
|
|
||||||
kind: 'timestamps',
|
|
||||||
title: 'Timing',
|
|
||||||
items: [
|
|
||||||
$factory->keyFact('Created', static::formatDetailTimestamp($record->created_at)),
|
$factory->keyFact('Created', static::formatDetailTimestamp($record->created_at)),
|
||||||
$factory->keyFact('Started', static::formatDetailTimestamp($record->started_at)),
|
$factory->keyFact('Started', static::formatDetailTimestamp($record->started_at)),
|
||||||
$factory->keyFact('Completed', static::formatDetailTimestamp($record->completed_at)),
|
$factory->keyFact('Completed', static::formatDetailTimestamp($record->completed_at)),
|
||||||
$factory->keyFact('Elapsed', RunDurationInsights::elapsedHuman($record)),
|
$factory->keyFact('Elapsed', RunDurationInsights::elapsedHuman($record)),
|
||||||
|
];
|
||||||
|
|
||||||
|
$groups[] = $factory->supportingFactsCard(
|
||||||
|
kind: 'timing',
|
||||||
|
title: 'Timing',
|
||||||
|
items: $timingItems,
|
||||||
|
);
|
||||||
|
|
||||||
|
$metadataItems = array_values(array_filter([
|
||||||
|
$factory->keyFact('Initiator', $record->initiator_name),
|
||||||
|
RunDurationInsights::expectedHuman($record) !== null
|
||||||
|
? $factory->keyFact('Expected duration', RunDurationInsights::expectedHuman($record))
|
||||||
|
: null,
|
||||||
|
]));
|
||||||
|
|
||||||
|
if ($metadataItems !== []) {
|
||||||
|
$groups[] = $factory->supportingFactsCard(
|
||||||
|
kind: 'metadata',
|
||||||
|
title: 'Metadata',
|
||||||
|
items: $metadataItems,
|
||||||
|
description: 'Secondary metadata remains visible without crowding the top decision surface.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $groups;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array{
|
||||||
|
* text: string,
|
||||||
|
* source: string,
|
||||||
|
* secondaryGuidance: list<array{label: string, text: string, source: string}>
|
||||||
|
* }
|
||||||
|
*/
|
||||||
|
private static function resolvePrimaryNextStep(
|
||||||
|
OperationRun $record,
|
||||||
|
?ArtifactTruthEnvelope $artifactTruth,
|
||||||
|
?OperatorExplanationPattern $operatorExplanation,
|
||||||
|
): array {
|
||||||
|
$candidates = [];
|
||||||
|
|
||||||
|
static::pushNextStepCandidate($candidates, $operatorExplanation?->nextActionText, 'operator_explanation');
|
||||||
|
static::pushNextStepCandidate($candidates, $artifactTruth?->nextStepText(), 'artifact_truth');
|
||||||
|
|
||||||
|
$opsUxSource = match (true) {
|
||||||
|
(string) $record->outcome === OperationRunOutcome::Blocked->value => 'blocked_reason',
|
||||||
|
OperationUxPresenter::lifecycleAttentionSummary($record) !== null => 'lifecycle_attention',
|
||||||
|
default => 'ops_ux',
|
||||||
|
};
|
||||||
|
|
||||||
|
static::pushNextStepCandidate($candidates, OperationUxPresenter::surfaceGuidance($record), $opsUxSource);
|
||||||
|
|
||||||
|
if ($candidates === []) {
|
||||||
|
return [
|
||||||
|
'text' => 'No action needed.',
|
||||||
|
'source' => 'none_required',
|
||||||
|
'secondaryGuidance' => [],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
$primary = $candidates[0];
|
||||||
|
$primarySource = static::normalizeGuidance($primary['text']) === 'no action needed'
|
||||||
|
? 'none_required'
|
||||||
|
: $primary['source'];
|
||||||
|
|
||||||
|
$secondaryGuidance = array_map(
|
||||||
|
static fn (array $candidate): array => [
|
||||||
|
'label' => static::guidanceLabel($candidate['source']),
|
||||||
|
'text' => $candidate['text'],
|
||||||
|
'source' => $candidate['source'],
|
||||||
],
|
],
|
||||||
),
|
array_slice($candidates, 1),
|
||||||
)
|
|
||||||
->addTechnicalSection(
|
|
||||||
$factory->technicalDetail(
|
|
||||||
title: 'Context',
|
|
||||||
entries: [
|
|
||||||
$factory->keyFact('Identity hash', $record->run_identity_hash),
|
|
||||||
$factory->keyFact('Workspace scope', $record->workspace_id),
|
|
||||||
$factory->keyFact('Tenant scope', $record->tenant_id),
|
|
||||||
],
|
|
||||||
description: 'Stored run context stays available for debugging without dominating the default reading path.',
|
|
||||||
view: 'filament.infolists.entries.snapshot-json',
|
|
||||||
viewData: ['payload' => static::contextPayload($record)],
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
|
|
||||||
$counts = static::summaryCountFacts($record, $factory);
|
return [
|
||||||
|
'text' => $primary['text'],
|
||||||
|
'source' => $primarySource,
|
||||||
|
'secondaryGuidance' => $secondaryGuidance,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
if ($counts !== []) {
|
/**
|
||||||
$builder->addSection(
|
* @param array<int, array{text: string, source: string, normalized: string}> $candidates
|
||||||
$factory->factsSection(
|
*/
|
||||||
id: 'counts',
|
private static function pushNextStepCandidate(array &$candidates, ?string $text, string $source): void
|
||||||
kind: 'current_status',
|
{
|
||||||
title: 'Counts',
|
$formattedText = static::formatGuidanceText($text);
|
||||||
items: $counts,
|
|
||||||
),
|
if ($formattedText === null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$normalized = static::normalizeGuidance($formattedText);
|
||||||
|
|
||||||
|
foreach ($candidates as $candidate) {
|
||||||
|
if (($candidate['normalized'] ?? null) === $normalized) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$candidates[] = [
|
||||||
|
'text' => $formattedText,
|
||||||
|
'source' => $source,
|
||||||
|
'normalized' => $normalized,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function formatGuidanceText(?string $text): ?string
|
||||||
|
{
|
||||||
|
if (! is_string($text)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$text = trim($text);
|
||||||
|
|
||||||
|
if ($text === '') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (preg_match('/[.!?]$/', $text) === 1) {
|
||||||
|
return $text;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $text.'.';
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function normalizeGuidance(string $text): string
|
||||||
|
{
|
||||||
|
$normalized = mb_strtolower(trim($text));
|
||||||
|
$normalized = preg_replace('/^next step:\s*/', '', $normalized) ?? $normalized;
|
||||||
|
|
||||||
|
return trim($normalized, " \t\n\r\0\x0B.!?");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function guidanceLabel(string $source): string
|
||||||
|
{
|
||||||
|
return match ($source) {
|
||||||
|
'operator_explanation' => 'Operator guidance',
|
||||||
|
'artifact_truth' => 'Artifact guidance',
|
||||||
|
'blocked_reason' => 'Blocked prerequisite',
|
||||||
|
'lifecycle_attention' => 'Lifecycle guidance',
|
||||||
|
default => 'General guidance',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, mixed>|null
|
||||||
|
*/
|
||||||
|
private static function artifactTruthFact(
|
||||||
|
\App\Support\Ui\EnterpriseDetail\EnterpriseDetailSectionFactory $factory,
|
||||||
|
?ArtifactTruthEnvelope $artifactTruth,
|
||||||
|
): ?array {
|
||||||
|
if (! $artifactTruth instanceof ArtifactTruthEnvelope) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$badge = $artifactTruth->primaryBadgeSpec();
|
||||||
|
|
||||||
|
return $factory->keyFact(
|
||||||
|
'Artifact truth',
|
||||||
|
$artifactTruth->primaryLabel,
|
||||||
|
$artifactTruth->primaryExplanation,
|
||||||
|
$factory->statusBadge($badge->label, $badge->color, $badge->icon, $badge->iconColor),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (! empty($record->failure_summary)) {
|
private static function decisionAttentionNote(OperationRun $record): ?string
|
||||||
$builder->addSection(
|
{
|
||||||
$factory->viewSection(
|
return null;
|
||||||
id: 'failures',
|
|
||||||
kind: 'operational_context',
|
|
||||||
title: (string) $record->outcome === OperationRunOutcome::Blocked->value ? 'Blocked execution details' : 'Failures',
|
|
||||||
view: 'filament.infolists.entries.snapshot-json',
|
|
||||||
viewData: ['payload' => $record->failure_summary ?? []],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (static::reconciliationPayload($record) !== []) {
|
private static function detailHintUnlessDuplicate(?string $hint, ?string $duplicateOf): ?string
|
||||||
$builder->addSection(
|
{
|
||||||
$factory->viewSection(
|
$normalizedHint = static::normalizeDetailText($hint);
|
||||||
id: 'reconciliation',
|
|
||||||
kind: 'operational_context',
|
if ($normalizedHint === null) {
|
||||||
title: 'Lifecycle reconciliation',
|
return null;
|
||||||
view: 'filament.infolists.entries.snapshot-json',
|
|
||||||
viewData: ['payload' => static::reconciliationPayload($record)],
|
|
||||||
description: 'Lifecycle reconciliation is diagnostic evidence showing when TenantPilot force-resolved the run.',
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if ((string) $record->type === 'baseline_compare') {
|
if ($normalizedHint === static::normalizeDetailText($duplicateOf)) {
|
||||||
$baselineCompareFacts = static::baselineCompareFacts($record, $factory);
|
return null;
|
||||||
$baselineCompareEvidence = static::baselineCompareEvidencePayload($record);
|
|
||||||
|
|
||||||
if ($baselineCompareFacts !== []) {
|
|
||||||
$builder->addSection(
|
|
||||||
$factory->factsSection(
|
|
||||||
id: 'baseline_compare',
|
|
||||||
kind: 'operational_context',
|
|
||||||
title: 'Baseline compare',
|
|
||||||
items: $baselineCompareFacts,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($baselineCompareEvidence !== []) {
|
return trim($hint ?? '');
|
||||||
$builder->addSection(
|
|
||||||
$factory->viewSection(
|
|
||||||
id: 'baseline_compare_evidence',
|
|
||||||
kind: 'operational_context',
|
|
||||||
title: 'Baseline compare evidence',
|
|
||||||
view: 'filament.infolists.entries.snapshot-json',
|
|
||||||
viewData: ['payload' => $baselineCompareEvidence],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if ((string) $record->type === 'baseline_capture') {
|
private static function normalizeDetailText(?string $value): ?string
|
||||||
$baselineCaptureEvidence = static::baselineCaptureEvidencePayload($record);
|
{
|
||||||
|
if (! is_string($value)) {
|
||||||
if ($baselineCaptureEvidence !== []) {
|
return null;
|
||||||
$builder->addSection(
|
|
||||||
$factory->viewSection(
|
|
||||||
id: 'baseline_capture_evidence',
|
|
||||||
kind: 'operational_context',
|
|
||||||
title: 'Baseline capture evidence',
|
|
||||||
view: 'filament.infolists.entries.snapshot-json',
|
|
||||||
viewData: ['payload' => $baselineCaptureEvidence],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (VerificationReportViewer::shouldRenderForRun($record)) {
|
$normalized = trim((string) preg_replace('/\s+/', ' ', $value));
|
||||||
$builder->addSection(
|
|
||||||
$factory->viewSection(
|
if ($normalized === '') {
|
||||||
id: 'verification_report',
|
return null;
|
||||||
kind: 'operational_context',
|
|
||||||
title: 'Verification report',
|
|
||||||
view: 'filament.components.verification-report-viewer',
|
|
||||||
viewData: static::verificationReportViewData($record),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return $builder->build();
|
return mb_strtolower($normalized);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -523,12 +815,29 @@ private static function summaryCountFacts(
|
|||||||
$counts = \App\Support\OpsUx\SummaryCountsNormalizer::normalize(is_array($record->summary_counts) ? $record->summary_counts : []);
|
$counts = \App\Support\OpsUx\SummaryCountsNormalizer::normalize(is_array($record->summary_counts) ? $record->summary_counts : []);
|
||||||
|
|
||||||
return array_map(
|
return array_map(
|
||||||
static fn (string $key, int $value): array => $factory->keyFact(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_keys($counts),
|
||||||
array_values($counts),
|
array_values($counts),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static function countTone(string $key, int $value): ?string
|
||||||
|
{
|
||||||
|
if (in_array($key, ['failed', 'errors_recorded', 'findings_reopened'], true)) {
|
||||||
|
return $value > 0 ? 'danger' : 'success';
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($key === 'succeeded' && $value > 0) {
|
||||||
|
return 'success';
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
private static function blockedExecutionReasonCode(OperationRun $record): ?string
|
private static function blockedExecutionReasonCode(OperationRun $record): ?string
|
||||||
{
|
{
|
||||||
if ((string) $record->outcome !== OperationRunOutcome::Blocked->value) {
|
if ((string) $record->outcome !== OperationRunOutcome::Blocked->value) {
|
||||||
@ -593,6 +902,8 @@ private static function baselineCompareFacts(
|
|||||||
\App\Support\Ui\EnterpriseDetail\EnterpriseDetailSectionFactory $factory,
|
\App\Support\Ui\EnterpriseDetail\EnterpriseDetailSectionFactory $factory,
|
||||||
): array {
|
): array {
|
||||||
$context = is_array($record->context) ? $record->context : [];
|
$context = is_array($record->context) ? $record->context : [];
|
||||||
|
$gapDetails = BaselineCompareEvidenceGapDetails::fromOperationRun($record);
|
||||||
|
$gapSummary = is_array($gapDetails['summary'] ?? null) ? $gapDetails['summary'] : [];
|
||||||
$facts = [];
|
$facts = [];
|
||||||
|
|
||||||
$fidelity = data_get($context, 'baseline_compare.fidelity');
|
$fidelity = data_get($context, 'baseline_compare.fidelity');
|
||||||
@ -624,6 +935,30 @@ private static function baselineCompareFacts(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if ((int) ($gapSummary['count'] ?? 0) > 0) {
|
||||||
|
$facts[] = $factory->keyFact(
|
||||||
|
'Evidence gap detail',
|
||||||
|
match ($gapSummary['detail_state'] ?? 'no_gaps') {
|
||||||
|
'structured_details_recorded' => 'Structured subject details available',
|
||||||
|
'details_not_recorded' => 'Detailed rows were not recorded',
|
||||||
|
'legacy_broad_reason' => 'Legacy development payload should be regenerated',
|
||||||
|
default => 'No evidence gaps recorded',
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ((int) ($gapSummary['structural_count'] ?? 0) > 0) {
|
||||||
|
$facts[] = $factory->keyFact('Structural gaps', (string) (int) $gapSummary['structural_count']);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ((int) ($gapSummary['operational_count'] ?? 0) > 0) {
|
||||||
|
$facts[] = $factory->keyFact('Operational gaps', (string) (int) $gapSummary['operational_count']);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ((int) ($gapSummary['transient_count'] ?? 0) > 0) {
|
||||||
|
$facts[] = $factory->keyFact('Transient gaps', (string) (int) $gapSummary['transient_count']);
|
||||||
|
}
|
||||||
|
|
||||||
if ($uncoveredTypes !== []) {
|
if ($uncoveredTypes !== []) {
|
||||||
sort($uncoveredTypes, SORT_STRING);
|
sort($uncoveredTypes, SORT_STRING);
|
||||||
$facts[] = $factory->keyFact('Uncovered types', implode(', ', array_slice($uncoveredTypes, 0, 12)).(count($uncoveredTypes) > 12 ? '…' : ''));
|
$facts[] = $factory->keyFact('Uncovered types', implode(', ', array_slice($uncoveredTypes, 0, 12)).(count($uncoveredTypes) > 12 ? '…' : ''));
|
||||||
|
|||||||
@ -4,8 +4,11 @@
|
|||||||
|
|
||||||
namespace App\Filament\Widgets\Dashboard;
|
namespace App\Filament\Widgets\Dashboard;
|
||||||
|
|
||||||
|
use App\Filament\Pages\BaselineCompareLanding;
|
||||||
|
use App\Filament\Resources\FindingResource;
|
||||||
use App\Models\Tenant;
|
use App\Models\Tenant;
|
||||||
use App\Support\Baselines\BaselineCompareStats;
|
use App\Support\Baselines\BaselineCompareStats;
|
||||||
|
use App\Support\OperationRunLinks;
|
||||||
use Filament\Facades\Filament;
|
use Filament\Facades\Filament;
|
||||||
use Filament\Widgets\Widget;
|
use Filament\Widgets\Widget;
|
||||||
|
|
||||||
@ -22,38 +25,47 @@ protected function getViewData(): array
|
|||||||
|
|
||||||
$empty = [
|
$empty = [
|
||||||
'hasAssignment' => false,
|
'hasAssignment' => false,
|
||||||
'state' => 'no_assignment',
|
|
||||||
'message' => null,
|
|
||||||
'profileName' => null,
|
'profileName' => null,
|
||||||
'findingsCount' => 0,
|
|
||||||
'highCount' => 0,
|
|
||||||
'mediumCount' => 0,
|
|
||||||
'lowCount' => 0,
|
|
||||||
'lastComparedAt' => null,
|
'lastComparedAt' => null,
|
||||||
'landingUrl' => null,
|
'landingUrl' => null,
|
||||||
|
'runUrl' => null,
|
||||||
|
'findingsUrl' => null,
|
||||||
|
'nextActionUrl' => null,
|
||||||
|
'summaryAssessment' => null,
|
||||||
];
|
];
|
||||||
|
|
||||||
if (! $tenant instanceof Tenant) {
|
if (! $tenant instanceof Tenant) {
|
||||||
return $empty;
|
return $empty;
|
||||||
}
|
}
|
||||||
|
|
||||||
$stats = BaselineCompareStats::forWidget($tenant);
|
$stats = BaselineCompareStats::forTenant($tenant);
|
||||||
|
|
||||||
if (in_array($stats->state, ['no_tenant', 'no_assignment'], true)) {
|
if (in_array($stats->state, ['no_tenant', 'no_assignment'], true)) {
|
||||||
return $empty;
|
return $empty;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$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 [
|
return [
|
||||||
'hasAssignment' => true,
|
'hasAssignment' => true,
|
||||||
'state' => $stats->state,
|
|
||||||
'message' => $stats->message,
|
|
||||||
'profileName' => $stats->profileName,
|
'profileName' => $stats->profileName,
|
||||||
'findingsCount' => $stats->findingsCount ?? 0,
|
|
||||||
'highCount' => $stats->severityCounts['high'] ?? 0,
|
|
||||||
'mediumCount' => $stats->severityCounts['medium'] ?? 0,
|
|
||||||
'lowCount' => $stats->severityCounts['low'] ?? 0,
|
|
||||||
'lastComparedAt' => $stats->lastComparedHuman,
|
'lastComparedAt' => $stats->lastComparedHuman,
|
||||||
'landingUrl' => \App\Filament\Pages\BaselineCompareLanding::getUrl(tenant: $tenant),
|
'landingUrl' => $tenantLandingUrl,
|
||||||
|
'runUrl' => $runUrl,
|
||||||
|
'findingsUrl' => $findingsUrl,
|
||||||
|
'nextActionUrl' => $nextActionUrl,
|
||||||
|
'summaryAssessment' => $summaryAssessment->toArray(),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -4,12 +4,9 @@
|
|||||||
|
|
||||||
namespace App\Filament\Widgets\Dashboard;
|
namespace App\Filament\Widgets\Dashboard;
|
||||||
|
|
||||||
use App\Filament\Pages\BaselineCompareLanding;
|
|
||||||
use App\Filament\Resources\FindingResource;
|
|
||||||
use App\Models\Finding;
|
use App\Models\Finding;
|
||||||
use App\Models\OperationRun;
|
|
||||||
use App\Models\Tenant;
|
use App\Models\Tenant;
|
||||||
use App\Support\OperationRunLinks;
|
use App\Support\Baselines\BaselineCompareStats;
|
||||||
use App\Support\OpsUx\ActiveRuns;
|
use App\Support\OpsUx\ActiveRuns;
|
||||||
use Filament\Facades\Filament;
|
use Filament\Facades\Filament;
|
||||||
use Filament\Widgets\Widget;
|
use Filament\Widgets\Widget;
|
||||||
@ -34,85 +31,107 @@ protected function getViewData(): array
|
|||||||
}
|
}
|
||||||
|
|
||||||
$tenantId = (int) $tenant->getKey();
|
$tenantId = (int) $tenant->getKey();
|
||||||
|
$compareStats = BaselineCompareStats::forTenant($tenant);
|
||||||
|
$compareAssessment = $compareStats->summaryAssessment();
|
||||||
|
|
||||||
$items = [];
|
$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()
|
$highSeverityCount = (int) Finding::query()
|
||||||
->where('tenant_id', $tenantId)
|
->where('tenant_id', $tenantId)
|
||||||
->where('finding_type', Finding::FINDING_TYPE_DRIFT)
|
->whereIn('status', Finding::openStatusesForQuery())
|
||||||
->where('status', Finding::STATUS_NEW)
|
->whereIn('severity', [
|
||||||
->where('severity', Finding::SEVERITY_HIGH)
|
Finding::SEVERITY_HIGH,
|
||||||
|
Finding::SEVERITY_CRITICAL,
|
||||||
|
])
|
||||||
->count();
|
->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) {
|
if ($highSeverityCount > 0) {
|
||||||
$items[] = [
|
$items[] = [
|
||||||
'title' => 'High severity drift findings',
|
'title' => 'High severity active findings',
|
||||||
'body' => "{$highSeverityCount} finding(s) need review.",
|
'body' => "{$highSeverityCount} active finding(s) need review.",
|
||||||
'url' => FindingResource::getUrl('index', tenant: $tenant),
|
|
||||||
'badge' => 'Drift',
|
'badge' => 'Drift',
|
||||||
'badgeColor' => 'danger',
|
'badgeColor' => 'danger',
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
$latestBaselineCompareSuccess = OperationRun::query()
|
if ($compareAssessment->stateFamily !== 'positive') {
|
||||||
->where('tenant_id', $tenantId)
|
|
||||||
->where('type', 'baseline_compare')
|
|
||||||
->where('status', 'completed')
|
|
||||||
->where('outcome', 'succeeded')
|
|
||||||
->whereNotNull('completed_at')
|
|
||||||
->latest('completed_at')
|
|
||||||
->first();
|
|
||||||
|
|
||||||
if (! $latestBaselineCompareSuccess) {
|
|
||||||
$items[] = [
|
$items[] = [
|
||||||
'title' => 'No baseline compare yet',
|
'title' => 'Baseline compare posture',
|
||||||
'body' => 'Run a baseline compare after your tenant has an assigned baseline snapshot.',
|
'body' => $compareAssessment->headline,
|
||||||
'url' => BaselineCompareLanding::getUrl(tenant: $tenant),
|
'supportingMessage' => $compareAssessment->supportingMessage,
|
||||||
'badge' => 'Drift',
|
'badge' => 'Baseline',
|
||||||
'badgeColor' => 'warning',
|
'badgeColor' => $compareAssessment->tone,
|
||||||
];
|
'nextStep' => $compareAssessment->nextActionLabel(),
|
||||||
} else {
|
|
||||||
$isStale = $latestBaselineCompareSuccess->completed_at?->lt(now()->subDays(7)) ?? true;
|
|
||||||
|
|
||||||
if ($isStale) {
|
|
||||||
$items[] = [
|
|
||||||
'title' => 'Baseline compare stale',
|
|
||||||
'body' => 'Last baseline compare is older than 7 days.',
|
|
||||||
'url' => BaselineCompareLanding::getUrl(tenant: $tenant),
|
|
||||||
'badge' => 'Drift',
|
|
||||||
'badgeColor' => 'warning',
|
|
||||||
];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
$latestBaselineCompareFailure = OperationRun::query()
|
|
||||||
->where('tenant_id', $tenantId)
|
|
||||||
->where('type', 'baseline_compare')
|
|
||||||
->where('status', 'completed')
|
|
||||||
->where('outcome', 'failed')
|
|
||||||
->latest('id')
|
|
||||||
->first();
|
|
||||||
|
|
||||||
if ($latestBaselineCompareFailure instanceof OperationRun) {
|
|
||||||
$items[] = [
|
|
||||||
'title' => 'Baseline compare failed',
|
|
||||||
'body' => 'Investigate the latest failed run.',
|
|
||||||
'url' => OperationRunLinks::view($latestBaselineCompareFailure, $tenant),
|
|
||||||
'badge' => 'Operations',
|
|
||||||
'badgeColor' => 'danger',
|
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
$activeRuns = (int) OperationRun::query()
|
$activeRuns = ActiveRuns::existForTenant($tenant)
|
||||||
->where('tenant_id', $tenantId)
|
? (int) \App\Models\OperationRun::query()->where('tenant_id', $tenantId)->active()->count()
|
||||||
->active()
|
: 0;
|
||||||
->count();
|
|
||||||
|
|
||||||
if ($activeRuns > 0) {
|
if ($activeRuns > 0) {
|
||||||
$items[] = [
|
$items[] = [
|
||||||
'title' => 'Operations in progress',
|
'title' => 'Operations in progress',
|
||||||
'body' => "{$activeRuns} run(s) are active.",
|
'body' => "{$activeRuns} run(s) are active.",
|
||||||
'url' => OperationRunLinks::index($tenant),
|
|
||||||
'badge' => 'Operations',
|
'badge' => 'Operations',
|
||||||
'badgeColor' => 'warning',
|
'badgeColor' => 'warning',
|
||||||
];
|
];
|
||||||
@ -125,24 +144,24 @@ protected function getViewData(): array
|
|||||||
if ($items === []) {
|
if ($items === []) {
|
||||||
$healthyChecks = [
|
$healthyChecks = [
|
||||||
[
|
[
|
||||||
'title' => 'Drift findings look healthy',
|
'title' => 'Baseline compare looks trustworthy',
|
||||||
'body' => 'No high severity drift findings are open.',
|
'body' => $compareAssessment->headline,
|
||||||
'url' => FindingResource::getUrl('index', tenant: $tenant),
|
|
||||||
'linkLabel' => 'View findings',
|
|
||||||
],
|
],
|
||||||
[
|
[
|
||||||
'title' => 'Baseline compares are up to date',
|
'title' => 'No overdue findings',
|
||||||
'body' => $latestBaselineCompareSuccess?->completed_at
|
'body' => 'No open findings are currently overdue for this tenant.',
|
||||||
? 'Last baseline compare: '.$latestBaselineCompareSuccess->completed_at->diffForHumans(['short' => true]).'.'
|
],
|
||||||
: 'Baseline compare history is available in Baseline Compare.',
|
[
|
||||||
'url' => BaselineCompareLanding::getUrl(tenant: $tenant),
|
'title' => 'Accepted-risk governance is healthy',
|
||||||
'linkLabel' => 'Open Baseline Compare',
|
'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',
|
'title' => 'No active operations',
|
||||||
'body' => 'Nothing is currently running for this tenant.',
|
'body' => 'Nothing is currently running for this tenant.',
|
||||||
'url' => OperationRunLinks::index($tenant),
|
|
||||||
'linkLabel' => 'View operations',
|
|
||||||
],
|
],
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|||||||
@ -4,6 +4,7 @@
|
|||||||
|
|
||||||
namespace App\Filament\Widgets\Tenant;
|
namespace App\Filament\Widgets\Tenant;
|
||||||
|
|
||||||
|
use App\Filament\Pages\BaselineCompareLanding;
|
||||||
use App\Models\Tenant;
|
use App\Models\Tenant;
|
||||||
use App\Support\Baselines\BaselineCompareStats;
|
use App\Support\Baselines\BaselineCompareStats;
|
||||||
use App\Support\OperationRunLinks;
|
use App\Support\OperationRunLinks;
|
||||||
@ -30,28 +31,30 @@ protected function getViewData(): array
|
|||||||
}
|
}
|
||||||
|
|
||||||
$stats = BaselineCompareStats::forTenant($tenant);
|
$stats = BaselineCompareStats::forTenant($tenant);
|
||||||
|
$summaryAssessment = $stats->summaryAssessment();
|
||||||
$uncoveredTypes = $stats->uncoveredTypes ?? [];
|
|
||||||
$uncoveredTypes = is_array($uncoveredTypes) ? $uncoveredTypes : [];
|
|
||||||
|
|
||||||
$coverageStatus = $stats->coverageStatus;
|
|
||||||
$hasWarnings = in_array($coverageStatus, ['warning', 'unproven'], true) && $uncoveredTypes !== [];
|
|
||||||
|
|
||||||
$runUrl = null;
|
$runUrl = null;
|
||||||
|
|
||||||
if ($stats->operationRunId !== null) {
|
if ($stats->operationRunId !== null) {
|
||||||
$runUrl = OperationRunLinks::view($stats->operationRunId, $tenant);
|
$runUrl = OperationRunLinks::view($stats->operationRunId, $tenant);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$landingUrl = BaselineCompareLanding::getUrl(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 [
|
return [
|
||||||
'shouldShow' => ($hasWarnings && $runUrl !== null) || $stats->state === 'no_snapshot',
|
'shouldShow' => $shouldShow,
|
||||||
|
'landingUrl' => $landingUrl,
|
||||||
'runUrl' => $runUrl,
|
'runUrl' => $runUrl,
|
||||||
|
'nextActionUrl' => $nextActionUrl,
|
||||||
|
'summaryAssessment' => $summaryAssessment->toArray(),
|
||||||
'state' => $stats->state,
|
'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;
|
: null;
|
||||||
|
|
||||||
$effectiveScope = BaselineScope::fromJsonb($context['effective_scope'] ?? null);
|
$effectiveScope = BaselineScope::fromJsonb($context['effective_scope'] ?? null);
|
||||||
|
$truthfulTypes = $this->truthfulTypesFromContext($context, $effectiveScope);
|
||||||
|
|
||||||
$captureMode = $profile->capture_mode instanceof BaselineCaptureMode
|
$captureMode = $profile->capture_mode instanceof BaselineCaptureMode
|
||||||
? $profile->capture_mode
|
? $profile->capture_mode
|
||||||
@ -127,6 +128,7 @@ public function handle(
|
|||||||
scope: $effectiveScope,
|
scope: $effectiveScope,
|
||||||
identity: $identity,
|
identity: $identity,
|
||||||
latestInventorySyncRunId: $latestInventorySyncRunId,
|
latestInventorySyncRunId: $latestInventorySyncRunId,
|
||||||
|
policyTypes: $truthfulTypes,
|
||||||
);
|
);
|
||||||
|
|
||||||
$subjects = $inventoryResult['subjects'];
|
$subjects = $inventoryResult['subjects'];
|
||||||
@ -262,6 +264,9 @@ public function handle(
|
|||||||
'gaps' => [
|
'gaps' => [
|
||||||
'count' => $gapsCount,
|
'count' => $gapsCount,
|
||||||
'by_reason' => $gapsByReason,
|
'by_reason' => $gapsByReason,
|
||||||
|
'subjects' => is_array($phaseResult['gap_subjects'] ?? null) && $phaseResult['gap_subjects'] !== []
|
||||||
|
? array_values($phaseResult['gap_subjects'])
|
||||||
|
: null,
|
||||||
],
|
],
|
||||||
'resume_token' => $resumeToken,
|
'resume_token' => $resumeToken,
|
||||||
],
|
],
|
||||||
@ -296,7 +301,7 @@ public function handle(
|
|||||||
/**
|
/**
|
||||||
* @return array{
|
* @return array{
|
||||||
* subjects_total: int,
|
* subjects_total: int,
|
||||||
* subjects: list<array{policy_type: string, subject_external_id: string}>,
|
* subjects: list<array{policy_type: string, subject_external_id: string, subject_key: string}>,
|
||||||
* inventory_by_key: array<string, array{
|
* inventory_by_key: array<string, array{
|
||||||
* tenant_subject_external_id: string,
|
* tenant_subject_external_id: string,
|
||||||
* workspace_subject_external_id: string,
|
* workspace_subject_external_id: string,
|
||||||
@ -317,6 +322,7 @@ private function collectInventorySubjects(
|
|||||||
BaselineScope $scope,
|
BaselineScope $scope,
|
||||||
BaselineSnapshotIdentity $identity,
|
BaselineSnapshotIdentity $identity,
|
||||||
?int $latestInventorySyncRunId = null,
|
?int $latestInventorySyncRunId = null,
|
||||||
|
?array $policyTypes = null,
|
||||||
): array {
|
): array {
|
||||||
$query = InventoryItem::query()
|
$query = InventoryItem::query()
|
||||||
->where('tenant_id', $sourceTenant->getKey());
|
->where('tenant_id', $sourceTenant->getKey());
|
||||||
@ -325,7 +331,7 @@ private function collectInventorySubjects(
|
|||||||
$query->where('last_seen_operation_run_id', $latestInventorySyncRunId);
|
$query->where('last_seen_operation_run_id', $latestInventorySyncRunId);
|
||||||
}
|
}
|
||||||
|
|
||||||
$query->whereIn('policy_type', $scope->allTypes());
|
$query->whereIn('policy_type', is_array($policyTypes) && $policyTypes !== [] ? $policyTypes : $scope->allTypes());
|
||||||
|
|
||||||
/** @var array<string, array{tenant_subject_external_id: string, workspace_subject_external_id: string, subject_key: string, policy_type: string, identity_strategy: string, display_name: ?string, category: ?string, platform: ?string, is_built_in: ?bool, role_permission_count: ?int}> $inventoryByKey */
|
/** @var array<string, array{tenant_subject_external_id: string, workspace_subject_external_id: string, subject_key: string, policy_type: string, identity_strategy: string, display_name: ?string, category: ?string, platform: ?string, is_built_in: ?bool, role_permission_count: ?int}> $inventoryByKey */
|
||||||
$inventoryByKey = [];
|
$inventoryByKey = [];
|
||||||
@ -413,6 +419,7 @@ private function collectInventorySubjects(
|
|||||||
static fn (array $item): array => [
|
static fn (array $item): array => [
|
||||||
'policy_type' => (string) $item['policy_type'],
|
'policy_type' => (string) $item['policy_type'],
|
||||||
'subject_external_id' => (string) $item['tenant_subject_external_id'],
|
'subject_external_id' => (string) $item['tenant_subject_external_id'],
|
||||||
|
'subject_key' => (string) $item['subject_key'],
|
||||||
],
|
],
|
||||||
$inventoryByKey,
|
$inventoryByKey,
|
||||||
));
|
));
|
||||||
@ -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{
|
* @param array<string, array{
|
||||||
* tenant_subject_external_id: string,
|
* tenant_subject_external_id: string,
|
||||||
|
|||||||
@ -43,6 +43,7 @@
|
|||||||
use App\Support\Baselines\BaselineScope;
|
use App\Support\Baselines\BaselineScope;
|
||||||
use App\Support\Baselines\BaselineSubjectKey;
|
use App\Support\Baselines\BaselineSubjectKey;
|
||||||
use App\Support\Baselines\PolicyVersionCapturePurpose;
|
use App\Support\Baselines\PolicyVersionCapturePurpose;
|
||||||
|
use App\Support\Baselines\SubjectResolver;
|
||||||
use App\Support\Inventory\InventoryCoverage;
|
use App\Support\Inventory\InventoryCoverage;
|
||||||
use App\Support\OperationRunOutcome;
|
use App\Support\OperationRunOutcome;
|
||||||
use App\Support\OperationRunStatus;
|
use App\Support\OperationRunStatus;
|
||||||
@ -144,7 +145,7 @@ public function handle(
|
|||||||
: null;
|
: null;
|
||||||
|
|
||||||
$effectiveScope = BaselineScope::fromJsonb($context['effective_scope'] ?? null);
|
$effectiveScope = BaselineScope::fromJsonb($context['effective_scope'] ?? null);
|
||||||
$effectiveTypes = $effectiveScope->allTypes();
|
$effectiveTypes = $this->truthfulTypesFromContext($context, $effectiveScope);
|
||||||
$scopeKey = 'baseline_profile:'.$profile->getKey();
|
$scopeKey = 'baseline_profile:'.$profile->getKey();
|
||||||
|
|
||||||
$captureMode = $profile->capture_mode instanceof BaselineCaptureMode
|
$captureMode = $profile->capture_mode instanceof BaselineCaptureMode
|
||||||
@ -363,6 +364,7 @@ public function handle(
|
|||||||
static fn (array $item): array => [
|
static fn (array $item): array => [
|
||||||
'policy_type' => (string) $item['policy_type'],
|
'policy_type' => (string) $item['policy_type'],
|
||||||
'subject_external_id' => (string) $item['subject_external_id'],
|
'subject_external_id' => (string) $item['subject_external_id'],
|
||||||
|
'subject_key' => (string) $item['subject_key'],
|
||||||
],
|
],
|
||||||
$currentItems,
|
$currentItems,
|
||||||
));
|
));
|
||||||
@ -388,6 +390,7 @@ public function handle(
|
|||||||
];
|
];
|
||||||
$phaseResult = [];
|
$phaseResult = [];
|
||||||
$phaseGaps = [];
|
$phaseGaps = [];
|
||||||
|
$phaseGapSubjects = [];
|
||||||
$resumeToken = null;
|
$resumeToken = null;
|
||||||
|
|
||||||
if ($captureMode === BaselineCaptureMode::FullContent) {
|
if ($captureMode === BaselineCaptureMode::FullContent) {
|
||||||
@ -416,6 +419,7 @@ public function handle(
|
|||||||
|
|
||||||
$phaseStats = is_array($phaseResult['stats'] ?? null) ? $phaseResult['stats'] : $phaseStats;
|
$phaseStats = is_array($phaseResult['stats'] ?? null) ? $phaseResult['stats'] : $phaseStats;
|
||||||
$phaseGaps = is_array($phaseResult['gaps'] ?? null) ? $phaseResult['gaps'] : [];
|
$phaseGaps = is_array($phaseResult['gaps'] ?? null) ? $phaseResult['gaps'] : [];
|
||||||
|
$phaseGapSubjects = is_array($phaseResult['gap_subjects'] ?? null) ? $phaseResult['gap_subjects'] : [];
|
||||||
$resumeToken = is_string($phaseResult['resume_token'] ?? null) ? $phaseResult['resume_token'] : null;
|
$resumeToken = is_string($phaseResult['resume_token'] ?? null) ? $phaseResult['resume_token'] : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -495,6 +499,12 @@ public function handle(
|
|||||||
$gapsByReason = $this->mergeGapCounts($baselineGaps, $currentGaps, $phaseGaps, $driftGaps);
|
$gapsByReason = $this->mergeGapCounts($baselineGaps, $currentGaps, $phaseGaps, $driftGaps);
|
||||||
$gapsCount = array_sum($gapsByReason);
|
$gapsCount = array_sum($gapsByReason);
|
||||||
|
|
||||||
|
$gapSubjects = $this->collectGapSubjects(
|
||||||
|
ambiguousKeys: $ambiguousKeys,
|
||||||
|
phaseGapSubjects: $phaseGapSubjects ?? [],
|
||||||
|
driftGapSubjects: $computeResult['evidence_gap_subjects'] ?? [],
|
||||||
|
);
|
||||||
|
|
||||||
$summaryCounts = [
|
$summaryCounts = [
|
||||||
'total' => count($driftResults),
|
'total' => count($driftResults),
|
||||||
'processed' => count($driftResults),
|
'processed' => count($driftResults),
|
||||||
@ -572,6 +582,7 @@ public function handle(
|
|||||||
'count' => $gapsCount,
|
'count' => $gapsCount,
|
||||||
'by_reason' => $gapsByReason,
|
'by_reason' => $gapsByReason,
|
||||||
...$gapsByReason,
|
...$gapsByReason,
|
||||||
|
'subjects' => $gapSubjects !== [] ? $gapSubjects : null,
|
||||||
],
|
],
|
||||||
'resume_token' => $resumeToken,
|
'resume_token' => $resumeToken,
|
||||||
'coverage' => [
|
'coverage' => [
|
||||||
@ -1102,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.
|
* Compare baseline items vs current inventory and produce drift results.
|
||||||
*
|
*
|
||||||
@ -1134,6 +1166,7 @@ private function computeDrift(
|
|||||||
): array {
|
): array {
|
||||||
$drift = [];
|
$drift = [];
|
||||||
$evidenceGaps = [];
|
$evidenceGaps = [];
|
||||||
|
$evidenceGapSubjects = [];
|
||||||
$rbacRoleDefinitionSummary = $this->emptyRbacRoleDefinitionSummary();
|
$rbacRoleDefinitionSummary = $this->emptyRbacRoleDefinitionSummary();
|
||||||
$roleDefinitionNormalizer = app(IntuneRoleDefinitionNormalizer::class);
|
$roleDefinitionNormalizer = app(IntuneRoleDefinitionNormalizer::class);
|
||||||
|
|
||||||
@ -1175,6 +1208,7 @@ private function computeDrift(
|
|||||||
if (! is_array($currentItem)) {
|
if (! is_array($currentItem)) {
|
||||||
if ($isRbacRoleDefinition && $baselinePolicyVersionId === null) {
|
if ($isRbacRoleDefinition && $baselinePolicyVersionId === null) {
|
||||||
$evidenceGaps['missing_role_definition_baseline_version_reference'] = ($evidenceGaps['missing_role_definition_baseline_version_reference'] ?? 0) + 1;
|
$evidenceGaps['missing_role_definition_baseline_version_reference'] = ($evidenceGaps['missing_role_definition_baseline_version_reference'] ?? 0) + 1;
|
||||||
|
$evidenceGapSubjects['missing_role_definition_baseline_version_reference'][] = $key;
|
||||||
|
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@ -1239,6 +1273,7 @@ private function computeDrift(
|
|||||||
|
|
||||||
if (! $currentEvidence instanceof ResolvedEvidence) {
|
if (! $currentEvidence instanceof ResolvedEvidence) {
|
||||||
$evidenceGaps['missing_current'] = ($evidenceGaps['missing_current'] ?? 0) + 1;
|
$evidenceGaps['missing_current'] = ($evidenceGaps['missing_current'] ?? 0) + 1;
|
||||||
|
$evidenceGapSubjects['missing_current'][] = $key;
|
||||||
|
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@ -1255,12 +1290,14 @@ private function computeDrift(
|
|||||||
if ($isRbacRoleDefinition) {
|
if ($isRbacRoleDefinition) {
|
||||||
if ($baselinePolicyVersionId === null) {
|
if ($baselinePolicyVersionId === null) {
|
||||||
$evidenceGaps['missing_role_definition_baseline_version_reference'] = ($evidenceGaps['missing_role_definition_baseline_version_reference'] ?? 0) + 1;
|
$evidenceGaps['missing_role_definition_baseline_version_reference'] = ($evidenceGaps['missing_role_definition_baseline_version_reference'] ?? 0) + 1;
|
||||||
|
$evidenceGapSubjects['missing_role_definition_baseline_version_reference'][] = $key;
|
||||||
|
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($currentPolicyVersionId === null) {
|
if ($currentPolicyVersionId === null) {
|
||||||
$evidenceGaps['missing_role_definition_current_version_reference'] = ($evidenceGaps['missing_role_definition_current_version_reference'] ?? 0) + 1;
|
$evidenceGaps['missing_role_definition_current_version_reference'] = ($evidenceGaps['missing_role_definition_current_version_reference'] ?? 0) + 1;
|
||||||
|
$evidenceGapSubjects['missing_role_definition_current_version_reference'][] = $key;
|
||||||
|
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@ -1274,6 +1311,7 @@ private function computeDrift(
|
|||||||
|
|
||||||
if ($roleDefinitionDiff === null) {
|
if ($roleDefinitionDiff === null) {
|
||||||
$evidenceGaps['missing_role_definition_compare_surface'] = ($evidenceGaps['missing_role_definition_compare_surface'] ?? 0) + 1;
|
$evidenceGaps['missing_role_definition_compare_surface'] = ($evidenceGaps['missing_role_definition_compare_surface'] ?? 0) + 1;
|
||||||
|
$evidenceGapSubjects['missing_role_definition_compare_surface'][] = $key;
|
||||||
|
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@ -1354,6 +1392,7 @@ private function computeDrift(
|
|||||||
|
|
||||||
if (! $currentEvidence instanceof ResolvedEvidence) {
|
if (! $currentEvidence instanceof ResolvedEvidence) {
|
||||||
$evidenceGaps['missing_current'] = ($evidenceGaps['missing_current'] ?? 0) + 1;
|
$evidenceGaps['missing_current'] = ($evidenceGaps['missing_current'] ?? 0) + 1;
|
||||||
|
$evidenceGapSubjects['missing_current'][] = $key;
|
||||||
|
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@ -1369,6 +1408,7 @@ private function computeDrift(
|
|||||||
|
|
||||||
if ($isRbacRoleDefinition && $currentPolicyVersionId === null) {
|
if ($isRbacRoleDefinition && $currentPolicyVersionId === null) {
|
||||||
$evidenceGaps['missing_role_definition_current_version_reference'] = ($evidenceGaps['missing_role_definition_current_version_reference'] ?? 0) + 1;
|
$evidenceGaps['missing_role_definition_current_version_reference'] = ($evidenceGaps['missing_role_definition_current_version_reference'] ?? 0) + 1;
|
||||||
|
$evidenceGapSubjects['missing_role_definition_current_version_reference'][] = $key;
|
||||||
|
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@ -1428,6 +1468,7 @@ private function computeDrift(
|
|||||||
return [
|
return [
|
||||||
'drift' => $drift,
|
'drift' => $drift,
|
||||||
'evidence_gaps' => $evidenceGaps,
|
'evidence_gaps' => $evidenceGaps,
|
||||||
|
'evidence_gap_subjects' => $evidenceGapSubjects,
|
||||||
'rbac_role_definitions' => $rbacRoleDefinitionSummary,
|
'rbac_role_definitions' => $rbacRoleDefinitionSummary,
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
@ -1939,6 +1980,163 @@ private function mergeGapCounts(array ...$gaps): array
|
|||||||
return $merged;
|
return $merged;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private const GAP_SUBJECTS_LIMIT = 50;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param list<string> $ambiguousKeys
|
||||||
|
* @return list<array<string, mixed>>
|
||||||
|
*/
|
||||||
|
private function collectGapSubjects(array $ambiguousKeys, mixed $phaseGapSubjects, mixed $driftGapSubjects): array
|
||||||
|
{
|
||||||
|
$subjects = [];
|
||||||
|
$seen = [];
|
||||||
|
|
||||||
|
if ($ambiguousKeys !== []) {
|
||||||
|
foreach (array_slice($ambiguousKeys, 0, self::GAP_SUBJECTS_LIMIT) as $ambiguousKey) {
|
||||||
|
if (! is_string($ambiguousKey) || $ambiguousKey === '') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
[$policyType, $subjectKey] = $this->splitGapSubjectKey($ambiguousKey);
|
||||||
|
|
||||||
|
if ($policyType === null || $subjectKey === null) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$descriptor = $this->subjectResolver()->describeForCompare($policyType, subjectKey: $subjectKey);
|
||||||
|
$record = array_merge($descriptor->toArray(), $this->subjectResolver()->ambiguousMatch($descriptor)->toArray());
|
||||||
|
$fingerprint = md5(json_encode([$record['policy_type'], $record['subject_key'], $record['reason_code']]));
|
||||||
|
|
||||||
|
if (isset($seen[$fingerprint])) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$seen[$fingerprint] = true;
|
||||||
|
$subjects[] = $record;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($this->normalizeStructuredGapSubjects($phaseGapSubjects) as $record) {
|
||||||
|
$fingerprint = md5(json_encode([$record['policy_type'] ?? null, $record['subject_key'] ?? null, $record['reason_code'] ?? null]));
|
||||||
|
|
||||||
|
if (isset($seen[$fingerprint])) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$seen[$fingerprint] = true;
|
||||||
|
$subjects[] = $record;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($this->normalizeLegacyGapSubjects($driftGapSubjects) as $record) {
|
||||||
|
$fingerprint = md5(json_encode([$record['policy_type'] ?? null, $record['subject_key'] ?? null, $record['reason_code'] ?? null]));
|
||||||
|
|
||||||
|
if (isset($seen[$fingerprint])) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$seen[$fingerprint] = true;
|
||||||
|
$subjects[] = $record;
|
||||||
|
}
|
||||||
|
|
||||||
|
return array_slice($subjects, 0, self::GAP_SUBJECTS_LIMIT);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return list<array<string, mixed>>
|
||||||
|
*/
|
||||||
|
private function normalizeStructuredGapSubjects(mixed $value): array
|
||||||
|
{
|
||||||
|
if (! is_array($value)) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
$subjects = [];
|
||||||
|
|
||||||
|
foreach ($value as $record) {
|
||||||
|
if (! is_array($record)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! is_string($record['policy_type'] ?? null) || ! is_string($record['subject_key'] ?? null) || ! is_string($record['reason_code'] ?? null)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$subjects[] = $record;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $subjects;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return list<array<string, mixed>>
|
||||||
|
*/
|
||||||
|
private function normalizeLegacyGapSubjects(mixed $value): array
|
||||||
|
{
|
||||||
|
if (! is_array($value)) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
$subjects = [];
|
||||||
|
|
||||||
|
foreach ($value as $reasonCode => $keys) {
|
||||||
|
if (! is_string($reasonCode) || ! is_array($keys)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($keys as $key) {
|
||||||
|
if (! is_string($key) || $key === '') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
[$policyType, $subjectKey] = $this->splitGapSubjectKey($key);
|
||||||
|
|
||||||
|
if ($policyType === null || $subjectKey === null) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$descriptor = $this->subjectResolver()->describeForCompare($policyType, subjectKey: $subjectKey);
|
||||||
|
$outcome = match ($reasonCode) {
|
||||||
|
'missing_current' => $this->subjectResolver()->missingExpectedRecord($descriptor),
|
||||||
|
'ambiguous_match' => $this->subjectResolver()->ambiguousMatch($descriptor),
|
||||||
|
default => $this->subjectResolver()->captureFailed($descriptor),
|
||||||
|
};
|
||||||
|
|
||||||
|
$record = array_merge($descriptor->toArray(), $outcome->toArray());
|
||||||
|
$record['reason_code'] = $reasonCode;
|
||||||
|
$subjects[] = $record;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $subjects;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array{0: ?string, 1: ?string}
|
||||||
|
*/
|
||||||
|
private function splitGapSubjectKey(string $value): array
|
||||||
|
{
|
||||||
|
$parts = explode('|', $value, 2);
|
||||||
|
|
||||||
|
if (count($parts) !== 2) {
|
||||||
|
return [null, null];
|
||||||
|
}
|
||||||
|
|
||||||
|
[$policyType, $subjectKey] = $parts;
|
||||||
|
$policyType = trim($policyType);
|
||||||
|
$subjectKey = trim($subjectKey);
|
||||||
|
|
||||||
|
if ($policyType === '' || $subjectKey === '') {
|
||||||
|
return [null, null];
|
||||||
|
}
|
||||||
|
|
||||||
|
return [$policyType, $subjectKey];
|
||||||
|
}
|
||||||
|
|
||||||
|
private function subjectResolver(): SubjectResolver
|
||||||
|
{
|
||||||
|
return app(SubjectResolver::class);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param array<string, array{subject_external_id: string, policy_type: string, meta_jsonb: array<string, mixed>}> $currentItems
|
* @param array<string, array{subject_external_id: string, policy_type: string, meta_jsonb: array<string, mixed>}> $currentItems
|
||||||
* @param array<string, ResolvedEvidence|null> $resolvedCurrentEvidence
|
* @param array<string, ResolvedEvidence|null> $resolvedCurrentEvidence
|
||||||
|
|||||||
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -193,4 +193,81 @@ public function freshnessState(): OperationRunFreshnessState
|
|||||||
{
|
{
|
||||||
return OperationRunFreshnessState::forRun($this);
|
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;
|
namespace App\Models;
|
||||||
|
|
||||||
|
use App\Services\Auth\CapabilityResolver;
|
||||||
use App\Services\Tenants\TenantOperabilityService;
|
use App\Services\Tenants\TenantOperabilityService;
|
||||||
use App\Support\Auth\Capabilities;
|
use App\Support\Auth\Capabilities;
|
||||||
use App\Support\Workspaces\WorkspaceContext;
|
use App\Support\Workspaces\WorkspaceContext;
|
||||||
@ -119,15 +120,10 @@ public function tenantRoleValue(Tenant $tenant): ?string
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
$role = $this->tenants()
|
/** @var CapabilityResolver $resolver */
|
||||||
->whereKey($tenant->getKey())
|
$resolver = app(CapabilityResolver::class);
|
||||||
->value('role');
|
|
||||||
|
|
||||||
if (! is_string($role)) {
|
return $resolver->getRole($this, $tenant)?->value;
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return $role;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public function allowsTenantSync(Tenant $tenant): bool
|
public function allowsTenantSync(Tenant $tenant): bool
|
||||||
@ -145,9 +141,10 @@ public function canAccessTenant(Model $tenant): bool
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
return $this->tenantMemberships()
|
/** @var CapabilityResolver $resolver */
|
||||||
->where('tenant_id', $tenant->getKey())
|
$resolver = app(CapabilityResolver::class);
|
||||||
->exists();
|
|
||||||
|
return $resolver->isMember($this, $tenant);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function getTenants(Panel $panel): array|Collection
|
public function getTenants(Panel $panel): array|Collection
|
||||||
|
|||||||
@ -17,6 +17,8 @@
|
|||||||
use App\Policies\EntraGroupPolicy;
|
use App\Policies\EntraGroupPolicy;
|
||||||
use App\Policies\FindingPolicy;
|
use App\Policies\FindingPolicy;
|
||||||
use App\Policies\OperationRunPolicy;
|
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\DeviceComplianceSnapshotTypeRenderer;
|
||||||
use App\Services\Baselines\SnapshotRendering\Renderers\FallbackSnapshotTypeRenderer;
|
use App\Services\Baselines\SnapshotRendering\Renderers\FallbackSnapshotTypeRenderer;
|
||||||
use App\Services\Baselines\SnapshotRendering\Renderers\IntuneRoleDefinitionSnapshotTypeRenderer;
|
use App\Services\Baselines\SnapshotRendering\Renderers\IntuneRoleDefinitionSnapshotTypeRenderer;
|
||||||
@ -76,6 +78,9 @@ class AppServiceProvider extends ServiceProvider
|
|||||||
*/
|
*/
|
||||||
public function register(): void
|
public function register(): void
|
||||||
{
|
{
|
||||||
|
$this->app->singleton(CapabilityResolver::class);
|
||||||
|
$this->app->singleton(WorkspaceCapabilityResolver::class);
|
||||||
|
|
||||||
$this->app->bind(FindingGeneratorContract::class, PermissionPostureFindingGenerator::class);
|
$this->app->bind(FindingGeneratorContract::class, PermissionPostureFindingGenerator::class);
|
||||||
|
|
||||||
$this->app->bind(
|
$this->app->bind(
|
||||||
|
|||||||
@ -15,6 +15,7 @@
|
|||||||
use App\Support\Baselines\BaselineProfileStatus;
|
use App\Support\Baselines\BaselineProfileStatus;
|
||||||
use App\Support\Baselines\BaselineReasonCodes;
|
use App\Support\Baselines\BaselineReasonCodes;
|
||||||
use App\Support\Baselines\BaselineScope;
|
use App\Support\Baselines\BaselineScope;
|
||||||
|
use App\Support\Baselines\BaselineSupportCapabilityGuard;
|
||||||
use App\Support\OperationRunType;
|
use App\Support\OperationRunType;
|
||||||
|
|
||||||
final class BaselineCaptureService
|
final class BaselineCaptureService
|
||||||
@ -22,6 +23,7 @@ final class BaselineCaptureService
|
|||||||
public function __construct(
|
public function __construct(
|
||||||
private readonly OperationRunService $runs,
|
private readonly OperationRunService $runs,
|
||||||
private readonly BaselineFullContentRolloutGate $rolloutGate,
|
private readonly BaselineFullContentRolloutGate $rolloutGate,
|
||||||
|
private readonly BaselineSupportCapabilityGuard $capabilityGuard,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -53,7 +55,7 @@ public function startCapture(
|
|||||||
],
|
],
|
||||||
'baseline_profile_id' => (int) $profile->getKey(),
|
'baseline_profile_id' => (int) $profile->getKey(),
|
||||||
'source_tenant_id' => (int) $sourceTenant->getKey(),
|
'source_tenant_id' => (int) $sourceTenant->getKey(),
|
||||||
'effective_scope' => $effectiveScope->toEffectiveScopeContext(),
|
'effective_scope' => $effectiveScope->toEffectiveScopeContext($this->capabilityGuard, 'capture'),
|
||||||
'capture_mode' => $captureMode->value,
|
'capture_mode' => $captureMode->value,
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
@ -17,6 +17,7 @@
|
|||||||
use App\Support\Baselines\BaselineProfileStatus;
|
use App\Support\Baselines\BaselineProfileStatus;
|
||||||
use App\Support\Baselines\BaselineReasonCodes;
|
use App\Support\Baselines\BaselineReasonCodes;
|
||||||
use App\Support\Baselines\BaselineScope;
|
use App\Support\Baselines\BaselineScope;
|
||||||
|
use App\Support\Baselines\BaselineSupportCapabilityGuard;
|
||||||
use App\Support\OperationRunType;
|
use App\Support\OperationRunType;
|
||||||
use App\Support\ReasonTranslation\ReasonPresenter;
|
use App\Support\ReasonTranslation\ReasonPresenter;
|
||||||
|
|
||||||
@ -26,6 +27,7 @@ public function __construct(
|
|||||||
private readonly OperationRunService $runs,
|
private readonly OperationRunService $runs,
|
||||||
private readonly BaselineFullContentRolloutGate $rolloutGate,
|
private readonly BaselineFullContentRolloutGate $rolloutGate,
|
||||||
private readonly BaselineSnapshotTruthResolver $snapshotTruthResolver,
|
private readonly BaselineSnapshotTruthResolver $snapshotTruthResolver,
|
||||||
|
private readonly BaselineSupportCapabilityGuard $capabilityGuard,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -101,7 +103,7 @@ public function startCompare(
|
|||||||
],
|
],
|
||||||
'baseline_profile_id' => (int) $profile->getKey(),
|
'baseline_profile_id' => (int) $profile->getKey(),
|
||||||
'baseline_snapshot_id' => $snapshotId,
|
'baseline_snapshot_id' => $snapshotId,
|
||||||
'effective_scope' => $effectiveScope->toEffectiveScopeContext(),
|
'effective_scope' => $effectiveScope->toEffectiveScopeContext($this->capabilityGuard, 'compare'),
|
||||||
'capture_mode' => $captureMode->value,
|
'capture_mode' => $captureMode->value,
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
@ -10,22 +10,28 @@
|
|||||||
use App\Services\Intune\PolicyCaptureOrchestrator;
|
use App\Services\Intune\PolicyCaptureOrchestrator;
|
||||||
use App\Support\Baselines\BaselineEvidenceResumeToken;
|
use App\Support\Baselines\BaselineEvidenceResumeToken;
|
||||||
use App\Support\Baselines\PolicyVersionCapturePurpose;
|
use App\Support\Baselines\PolicyVersionCapturePurpose;
|
||||||
|
use App\Support\Baselines\ResolutionOutcomeRecord;
|
||||||
|
use App\Support\Baselines\ResolutionPath;
|
||||||
|
use App\Support\Baselines\SubjectDescriptor;
|
||||||
|
use App\Support\Baselines\SubjectResolver;
|
||||||
use Throwable;
|
use Throwable;
|
||||||
|
|
||||||
final class BaselineContentCapturePhase
|
final class BaselineContentCapturePhase
|
||||||
{
|
{
|
||||||
public function __construct(
|
public function __construct(
|
||||||
private readonly PolicyCaptureOrchestrator $captureOrchestrator,
|
private readonly PolicyCaptureOrchestrator $captureOrchestrator,
|
||||||
|
private readonly ?SubjectResolver $subjectResolver = null,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Capture baseline-purpose policy versions (content + assignments + scope tags) within a run budget.
|
* Capture baseline-purpose policy versions (content + assignments + scope tags) within a run budget.
|
||||||
*
|
*
|
||||||
* @param list<array{policy_type: string, subject_external_id: string}> $subjects
|
* @param list<array{policy_type: string, subject_external_id: string, subject_key?: string}> $subjects
|
||||||
* @param array{max_items_per_run: int, max_concurrency: int, max_retries: int} $budgets
|
* @param array{max_items_per_run: int, max_concurrency: int, max_retries: int} $budgets
|
||||||
* @return array{
|
* @return array{
|
||||||
* stats: array{requested: int, succeeded: int, skipped: int, failed: int, throttled: int},
|
* stats: array{requested: int, succeeded: int, skipped: int, failed: int, throttled: int},
|
||||||
* gaps: array<string, int>,
|
* gaps: array<string, int>,
|
||||||
|
* gap_subjects: list<array<string, mixed>>,
|
||||||
* resume_token: ?string,
|
* resume_token: ?string,
|
||||||
* captured_versions: array<string, array{
|
* captured_versions: array<string, array{
|
||||||
* policy_type: string,
|
* policy_type: string,
|
||||||
@ -76,6 +82,8 @@ public function capture(
|
|||||||
|
|
||||||
/** @var array<string, int> $gaps */
|
/** @var array<string, int> $gaps */
|
||||||
$gaps = [];
|
$gaps = [];
|
||||||
|
/** @var list<array<string, mixed>> $gapSubjects */
|
||||||
|
$gapSubjects = [];
|
||||||
$capturedVersions = [];
|
$capturedVersions = [];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -87,24 +95,40 @@ public function capture(
|
|||||||
foreach ($chunk as $subject) {
|
foreach ($chunk as $subject) {
|
||||||
$policyType = trim((string) ($subject['policy_type'] ?? ''));
|
$policyType = trim((string) ($subject['policy_type'] ?? ''));
|
||||||
$externalId = trim((string) ($subject['subject_external_id'] ?? ''));
|
$externalId = trim((string) ($subject['subject_external_id'] ?? ''));
|
||||||
|
$subjectKey = trim((string) ($subject['subject_key'] ?? ''));
|
||||||
|
$descriptor = $this->resolver()->describeForCapture(
|
||||||
|
$policyType !== '' ? $policyType : 'unknown',
|
||||||
|
$externalId !== '' ? $externalId : null,
|
||||||
|
$subjectKey !== '' ? $subjectKey : null,
|
||||||
|
);
|
||||||
|
|
||||||
if ($policyType === '' || $externalId === '') {
|
if ($policyType === '' || $externalId === '') {
|
||||||
$gaps['invalid_subject'] = ($gaps['invalid_subject'] ?? 0) + 1;
|
$this->recordGap($gaps, $gapSubjects, $descriptor, $this->resolver()->invalidSubject($descriptor));
|
||||||
$stats['failed']++;
|
$stats['failed']++;
|
||||||
|
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
$subjectKey = $policyType.'|'.$externalId;
|
$captureKey = $policyType.'|'.$externalId;
|
||||||
|
|
||||||
if (isset($seen[$subjectKey])) {
|
if (isset($seen[$captureKey])) {
|
||||||
$gaps['duplicate_subject'] = ($gaps['duplicate_subject'] ?? 0) + 1;
|
$this->recordGap($gaps, $gapSubjects, $descriptor, $this->resolver()->duplicateSubject($descriptor));
|
||||||
$stats['skipped']++;
|
$stats['skipped']++;
|
||||||
|
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
$seen[$subjectKey] = true;
|
$seen[$captureKey] = true;
|
||||||
|
|
||||||
|
if (
|
||||||
|
$descriptor->resolutionPath === ResolutionPath::FoundationInventory
|
||||||
|
|| $descriptor->resolutionPath === ResolutionPath::Inventory
|
||||||
|
) {
|
||||||
|
$this->recordGap($gaps, $gapSubjects, $descriptor, $this->resolver()->structuralInventoryOnly($descriptor));
|
||||||
|
$stats['skipped']++;
|
||||||
|
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
$policy = Policy::query()
|
$policy = Policy::query()
|
||||||
->where('tenant_id', (int) $tenant->getKey())
|
->where('tenant_id', (int) $tenant->getKey())
|
||||||
@ -113,7 +137,7 @@ public function capture(
|
|||||||
->first();
|
->first();
|
||||||
|
|
||||||
if (! $policy instanceof Policy) {
|
if (! $policy instanceof Policy) {
|
||||||
$gaps['policy_not_found'] = ($gaps['policy_not_found'] ?? 0) + 1;
|
$this->recordGap($gaps, $gapSubjects, $descriptor, $this->resolver()->missingExpectedRecord($descriptor));
|
||||||
$stats['failed']++;
|
$stats['failed']++;
|
||||||
|
|
||||||
continue;
|
continue;
|
||||||
@ -152,7 +176,7 @@ public function capture(
|
|||||||
$version = $result['version'] ?? null;
|
$version = $result['version'] ?? null;
|
||||||
|
|
||||||
if ($version instanceof PolicyVersion) {
|
if ($version instanceof PolicyVersion) {
|
||||||
$capturedVersions[$subjectKey] = [
|
$capturedVersions[$captureKey] = [
|
||||||
'policy_type' => $policyType,
|
'policy_type' => $policyType,
|
||||||
'subject_external_id' => $externalId,
|
'subject_external_id' => $externalId,
|
||||||
'version' => $version,
|
'version' => $version,
|
||||||
@ -178,10 +202,10 @@ public function capture(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if ($isThrottled) {
|
if ($isThrottled) {
|
||||||
$gaps['throttled'] = ($gaps['throttled'] ?? 0) + 1;
|
$this->recordGap($gaps, $gapSubjects, $descriptor, $this->resolver()->throttled($descriptor));
|
||||||
$stats['throttled']++;
|
$stats['throttled']++;
|
||||||
} else {
|
} else {
|
||||||
$gaps['capture_failed'] = ($gaps['capture_failed'] ?? 0) + 1;
|
$this->recordGap($gaps, $gapSubjects, $descriptor, $this->resolver()->captureFailed($descriptor));
|
||||||
$stats['failed']++;
|
$stats['failed']++;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -201,7 +225,22 @@ public function capture(
|
|||||||
|
|
||||||
$remainingCount = max(0, count($subjects) - $processed);
|
$remainingCount = max(0, count($subjects) - $processed);
|
||||||
if ($remainingCount > 0) {
|
if ($remainingCount > 0) {
|
||||||
$gaps['budget_exhausted'] = ($gaps['budget_exhausted'] ?? 0) + $remainingCount;
|
foreach (array_slice($subjects, $processed) as $remainingSubject) {
|
||||||
|
$remainingPolicyType = trim((string) ($remainingSubject['policy_type'] ?? ''));
|
||||||
|
$remainingExternalId = trim((string) ($remainingSubject['subject_external_id'] ?? ''));
|
||||||
|
$remainingSubjectKey = trim((string) ($remainingSubject['subject_key'] ?? ''));
|
||||||
|
|
||||||
|
if ($remainingPolicyType === '' || $remainingExternalId === '') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$remainingDescriptor = $this->resolver()->describeForCapture(
|
||||||
|
$remainingPolicyType,
|
||||||
|
$remainingExternalId,
|
||||||
|
$remainingSubjectKey !== '' ? $remainingSubjectKey : null,
|
||||||
|
);
|
||||||
|
$this->recordGap($gaps, $gapSubjects, $remainingDescriptor, $this->resolver()->budgetExhausted($remainingDescriptor));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -210,11 +249,27 @@ public function capture(
|
|||||||
return [
|
return [
|
||||||
'stats' => $stats,
|
'stats' => $stats,
|
||||||
'gaps' => $gaps,
|
'gaps' => $gaps,
|
||||||
|
'gap_subjects' => $gapSubjects,
|
||||||
'resume_token' => $resumeTokenOut,
|
'resume_token' => $resumeTokenOut,
|
||||||
'captured_versions' => $capturedVersions,
|
'captured_versions' => $capturedVersions,
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, int> $gaps
|
||||||
|
* @param list<array<string, mixed>> $gapSubjects
|
||||||
|
*/
|
||||||
|
private function recordGap(array &$gaps, array &$gapSubjects, SubjectDescriptor $descriptor, ResolutionOutcomeRecord $outcome): void
|
||||||
|
{
|
||||||
|
$gaps[$outcome->reasonCode] = ($gaps[$outcome->reasonCode] ?? 0) + 1;
|
||||||
|
$gapSubjects[] = array_merge($descriptor->toArray(), $outcome->toArray());
|
||||||
|
}
|
||||||
|
|
||||||
|
private function resolver(): SubjectResolver
|
||||||
|
{
|
||||||
|
return $this->subjectResolver ?? app(SubjectResolver::class);
|
||||||
|
}
|
||||||
|
|
||||||
private function retryDelayMs(int $attempt): int
|
private function retryDelayMs(int $attempt): int
|
||||||
{
|
{
|
||||||
$attempt = max(0, $attempt);
|
$attempt = max(0, $attempt);
|
||||||
|
|||||||
@ -12,6 +12,16 @@
|
|||||||
|
|
||||||
final class FindingRiskGovernanceResolver
|
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
|
public function resolveExceptionStatus(FindingException $exception, ?CarbonImmutable $now = null): string
|
||||||
{
|
{
|
||||||
$now ??= CarbonImmutable::instance(now());
|
$now ??= CarbonImmutable::instance(now());
|
||||||
@ -111,6 +121,43 @@ public function isValidGovernedAcceptedRisk(Finding $finding, ?FindingException
|
|||||||
], true);
|
], 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
|
public function resolveWarningMessage(Finding $finding, ?FindingException $exception = null, ?CarbonImmutable $now = null): ?string
|
||||||
{
|
{
|
||||||
$exception ??= $finding->relationLoaded('findingException')
|
$exception ??= $finding->relationLoaded('findingException')
|
||||||
@ -135,6 +182,7 @@ public function resolveWarningMessage(Finding $finding, ?FindingException $excep
|
|||||||
if ($finding->isRiskAccepted()) {
|
if ($finding->isRiskAccepted()) {
|
||||||
return match ($this->resolveFindingState($finding, $exception, $now)) {
|
return match ($this->resolveFindingState($finding, $exception, $now)) {
|
||||||
'risk_accepted_without_valid_exception' => 'This finding is marked as accepted risk without a valid exception record.',
|
'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.',
|
'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.',
|
'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.',
|
'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) {
|
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_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_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.',
|
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
|
public function syncExceptionState(FindingException $exception, ?CarbonImmutable $now = null): FindingException
|
||||||
{
|
{
|
||||||
$resolvedStatus = $this->resolveExceptionStatus($exception, $now);
|
$resolvedStatus = $this->resolveExceptionStatus($exception, $now);
|
||||||
@ -227,4 +329,26 @@ private function renewalAwareDate(FindingException $exception, string $metadataK
|
|||||||
? CarbonImmutable::instance($fallback)
|
? CarbonImmutable::instance($fallback)
|
||||||
: null;
|
: 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.',
|
||||||
|
};
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -18,8 +18,8 @@ public function spec(mixed $value): BadgeSpec
|
|||||||
FindingException::VALIDITY_EXPIRING => new BadgeSpec('Expiring', 'warning', 'heroicon-o-exclamation-triangle'),
|
FindingException::VALIDITY_EXPIRING => new BadgeSpec('Expiring', 'warning', 'heroicon-o-exclamation-triangle'),
|
||||||
FindingException::VALIDITY_EXPIRED => new BadgeSpec('Expired', 'danger', 'heroicon-o-clock'),
|
FindingException::VALIDITY_EXPIRED => new BadgeSpec('Expired', 'danger', 'heroicon-o-clock'),
|
||||||
FindingException::VALIDITY_REVOKED => new BadgeSpec('Revoked', 'danger', 'heroicon-o-no-symbol'),
|
FindingException::VALIDITY_REVOKED => new BadgeSpec('Revoked', 'danger', 'heroicon-o-no-symbol'),
|
||||||
FindingException::VALIDITY_REJECTED => new BadgeSpec('Rejected', 'gray', 'heroicon-o-x-circle'),
|
FindingException::VALIDITY_REJECTED => new BadgeSpec('Rejected', 'danger', 'heroicon-o-x-circle'),
|
||||||
FindingException::VALIDITY_MISSING_SUPPORT => new BadgeSpec('Missing support', 'gray', 'heroicon-o-question-mark-circle'),
|
FindingException::VALIDITY_MISSING_SUPPORT => new BadgeSpec('Missing support', 'danger', 'heroicon-o-question-mark-circle'),
|
||||||
default => BadgeSpec::unknown(),
|
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_TRIAGED => new BadgeSpec('Triaged', 'gray', 'heroicon-m-check-circle'),
|
||||||
Finding::STATUS_IN_PROGRESS => new BadgeSpec('In progress', 'info', 'heroicon-m-arrow-path'),
|
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_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_CLOSED => new BadgeSpec('Closed', 'gray', 'heroicon-o-x-circle'),
|
||||||
Finding::STATUS_RISK_ACCEPTED => new BadgeSpec('Risk accepted', 'gray', 'heroicon-o-shield-check'),
|
Finding::STATUS_RISK_ACCEPTED => new BadgeSpec('Risk accepted', 'gray', 'heroicon-o-shield-check'),
|
||||||
default => BadgeSpec::unknown(),
|
default => BadgeSpec::unknown(),
|
||||||
|
|||||||
@ -20,6 +20,7 @@ public function spec(mixed $value): BadgeSpec
|
|||||||
'full_result' => OperatorOutcomeTaxonomy::spec(BadgeDomain::OperatorExplanationEvaluationResult, $state, 'heroicon-m-check-badge'),
|
'full_result' => OperatorOutcomeTaxonomy::spec(BadgeDomain::OperatorExplanationEvaluationResult, $state, 'heroicon-m-check-badge'),
|
||||||
'incomplete_result' => OperatorOutcomeTaxonomy::spec(BadgeDomain::OperatorExplanationEvaluationResult, $state, 'heroicon-m-exclamation-triangle'),
|
'incomplete_result' => OperatorOutcomeTaxonomy::spec(BadgeDomain::OperatorExplanationEvaluationResult, $state, 'heroicon-m-exclamation-triangle'),
|
||||||
'suppressed_result' => OperatorOutcomeTaxonomy::spec(BadgeDomain::OperatorExplanationEvaluationResult, $state, 'heroicon-m-eye-slash'),
|
'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'),
|
'no_result' => OperatorOutcomeTaxonomy::spec(BadgeDomain::OperatorExplanationEvaluationResult, $state, 'heroicon-m-check'),
|
||||||
'unavailable' => OperatorOutcomeTaxonomy::spec(BadgeDomain::OperatorExplanationEvaluationResult, $state, 'heroicon-m-no-symbol'),
|
'unavailable' => OperatorOutcomeTaxonomy::spec(BadgeDomain::OperatorExplanationEvaluationResult, $state, 'heroicon-m-no-symbol'),
|
||||||
default => BadgeSpec::unknown(),
|
default => BadgeSpec::unknown(),
|
||||||
|
|||||||
@ -248,6 +248,15 @@ final class OperatorOutcomeTaxonomy
|
|||||||
'legacy_aliases' => ['Suppressed'],
|
'legacy_aliases' => ['Suppressed'],
|
||||||
'notes' => 'Normal output was intentionally suppressed because the system could not safely present it as final.',
|
'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' => [
|
'no_result' => [
|
||||||
'axis' => 'execution_outcome',
|
'axis' => 'execution_outcome',
|
||||||
'label' => 'No issues detected',
|
'label' => 'No issues detected',
|
||||||
|
|||||||
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -23,6 +23,8 @@ public function forStats(BaselineCompareStats $stats): OperatorExplanationPatter
|
|||||||
$reason = $stats->reasonCode !== null
|
$reason = $stats->reasonCode !== null
|
||||||
? $this->reasonPresenter->forArtifactTruth($stats->reasonCode, 'baseline_compare')
|
? $this->reasonPresenter->forArtifactTruth($stats->reasonCode, 'baseline_compare')
|
||||||
: null;
|
: null;
|
||||||
|
$isFailed = $stats->state === 'failed';
|
||||||
|
$isInProgress = $stats->state === 'comparing';
|
||||||
$hasCoverageWarnings = in_array($stats->coverageStatus, ['warning', 'unproven'], true);
|
$hasCoverageWarnings = in_array($stats->coverageStatus, ['warning', 'unproven'], true);
|
||||||
$hasEvidenceGaps = (int) ($stats->evidenceGapsCount ?? 0) > 0;
|
$hasEvidenceGaps = (int) ($stats->evidenceGapsCount ?? 0) > 0;
|
||||||
$hasWarnings = $hasCoverageWarnings || $hasEvidenceGaps;
|
$hasWarnings = $hasCoverageWarnings || $hasEvidenceGaps;
|
||||||
@ -42,8 +44,8 @@ public function forStats(BaselineCompareStats $stats): OperatorExplanationPatter
|
|||||||
? ExplanationFamily::tryFrom(str_replace('true_no_result', 'no_issues_detected', $reason->absencePattern)) ?? null
|
? ExplanationFamily::tryFrom(str_replace('true_no_result', 'no_issues_detected', $reason->absencePattern)) ?? null
|
||||||
: null;
|
: null;
|
||||||
$family ??= match (true) {
|
$family ??= match (true) {
|
||||||
$stats->state === 'comparing' => ExplanationFamily::InProgress,
|
$isInProgress => ExplanationFamily::InProgress,
|
||||||
$stats->state === 'failed' => ExplanationFamily::BlockedPrerequisite,
|
$isFailed => ExplanationFamily::BlockedPrerequisite,
|
||||||
$stats->state === 'no_tenant',
|
$stats->state === 'no_tenant',
|
||||||
$stats->state === 'no_assignment',
|
$stats->state === 'no_assignment',
|
||||||
$stats->state === 'no_snapshot',
|
$stats->state === 'no_snapshot',
|
||||||
@ -62,7 +64,9 @@ public function forStats(BaselineCompareStats $stats): OperatorExplanationPatter
|
|||||||
$family === ExplanationFamily::InProgress => TrustworthinessLevel::DiagnosticOnly,
|
$family === ExplanationFamily::InProgress => TrustworthinessLevel::DiagnosticOnly,
|
||||||
default => TrustworthinessLevel::Unusable,
|
default => TrustworthinessLevel::Unusable,
|
||||||
};
|
};
|
||||||
$evaluationResult = match ($family) {
|
$evaluationResult = $isFailed
|
||||||
|
? 'failed_result'
|
||||||
|
: match ($family) {
|
||||||
ExplanationFamily::TrustworthyResult => 'full_result',
|
ExplanationFamily::TrustworthyResult => 'full_result',
|
||||||
ExplanationFamily::NoIssuesDetected => 'no_result',
|
ExplanationFamily::NoIssuesDetected => 'no_result',
|
||||||
ExplanationFamily::SuppressedOutput => 'suppressed_result',
|
ExplanationFamily::SuppressedOutput => 'suppressed_result',
|
||||||
@ -74,7 +78,9 @@ public function forStats(BaselineCompareStats $stats): OperatorExplanationPatter
|
|||||||
? 'suppressed_result'
|
? 'suppressed_result'
|
||||||
: 'incomplete_result',
|
: 'incomplete_result',
|
||||||
};
|
};
|
||||||
$headline = match ($family) {
|
$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::NoIssuesDetected => 'The comparison found no actionable drift.',
|
||||||
ExplanationFamily::TrustworthyResult => 'The comparison produced a usable drift result.',
|
ExplanationFamily::TrustworthyResult => 'The comparison produced a usable drift result.',
|
||||||
ExplanationFamily::CompletedButLimited => $findingsCount > 0
|
ExplanationFamily::CompletedButLimited => $findingsCount > 0
|
||||||
@ -85,17 +91,21 @@ public function forStats(BaselineCompareStats $stats): OperatorExplanationPatter
|
|||||||
ExplanationFamily::BlockedPrerequisite => 'The comparison could not produce a usable result because a prerequisite blocked it.',
|
ExplanationFamily::BlockedPrerequisite => 'The comparison could not produce a usable result because a prerequisite blocked it.',
|
||||||
ExplanationFamily::InProgress => 'The comparison is still running.',
|
ExplanationFamily::InProgress => 'The comparison is still running.',
|
||||||
ExplanationFamily::Unavailable => 'A usable comparison result is not currently available.',
|
ExplanationFamily::Unavailable => 'A usable comparison result is not currently available.',
|
||||||
|
},
|
||||||
};
|
};
|
||||||
$coverageStatement = match (true) {
|
$coverageStatement = match (true) {
|
||||||
$stats->state === 'idle' => 'No compare run has produced a result yet for this tenant.',
|
$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->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.',
|
$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.',
|
$hasEvidenceGaps => 'Evidence gaps limited the quality of the visible result for some subjects.',
|
||||||
$stats->state === 'comparing' => 'Counts will become decision-grade after the compare run finishes.',
|
$isInProgress => 'Counts will become decision-grade after the compare run finishes.',
|
||||||
in_array($family, [ExplanationFamily::MissingInput, ExplanationFamily::BlockedPrerequisite, ExplanationFamily::Unavailable], true) => $reason?->shortExplanation ?? 'The compare inputs were not complete enough to produce a normal result.',
|
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.',
|
default => 'Coverage matched the in-scope compare input for this run.',
|
||||||
};
|
};
|
||||||
$reliabilityStatement = match ($trustworthiness) {
|
$reliabilityStatement = $isFailed
|
||||||
|
? 'The last compare failed, so the tenant needs review before you rely on this posture.'
|
||||||
|
: match ($trustworthiness) {
|
||||||
TrustworthinessLevel::Trustworthy => $findingsCount > 0
|
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 recorded drift as decision-grade.'
|
||||||
: 'The compare completed with enough coverage to treat the absence of findings as trustworthy.',
|
: 'The compare completed with enough coverage to treat the absence of findings as trustworthy.',
|
||||||
@ -103,7 +113,9 @@ public function forStats(BaselineCompareStats $stats): OperatorExplanationPatter
|
|||||||
TrustworthinessLevel::DiagnosticOnly => 'The compare is still in progress, so current counts are only diagnostic.',
|
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.',
|
TrustworthinessLevel::Unusable => 'The compare did not produce a result that should be used for the intended decision yet.',
|
||||||
};
|
};
|
||||||
$nextActionText = $reason?->firstNextStep()?->label ?? match ($family) {
|
$nextActionText = $isFailed
|
||||||
|
? 'Review the failed compare run before relying on this tenant posture'
|
||||||
|
: ($reason?->firstNextStep()?->label ?? match ($family) {
|
||||||
ExplanationFamily::NoIssuesDetected => 'No action needed',
|
ExplanationFamily::NoIssuesDetected => 'No action needed',
|
||||||
ExplanationFamily::TrustworthyResult => 'Review the detected drift findings',
|
ExplanationFamily::TrustworthyResult => 'Review the detected drift findings',
|
||||||
ExplanationFamily::SuppressedOutput => 'Review the missing coverage or evidence before treating this compare as complete',
|
ExplanationFamily::SuppressedOutput => 'Review the missing coverage or evidence before treating this compare as complete',
|
||||||
@ -114,7 +126,7 @@ public function forStats(BaselineCompareStats $stats): OperatorExplanationPatter
|
|||||||
ExplanationFamily::Unavailable => $stats->state === 'idle'
|
ExplanationFamily::Unavailable => $stats->state === 'idle'
|
||||||
? 'Run the baseline compare to generate a result'
|
? 'Run the baseline compare to generate a result'
|
||||||
: 'Review the blocking baseline or scope prerequisite',
|
: 'Review the blocking baseline or scope prerequisite',
|
||||||
};
|
});
|
||||||
|
|
||||||
return $this->builder->build(
|
return $this->builder->build(
|
||||||
family: $family,
|
family: $family,
|
||||||
@ -128,7 +140,9 @@ public function forStats(BaselineCompareStats $stats): OperatorExplanationPatter
|
|||||||
dominantCauseCode: $reason?->internalCode ?? $stats->reasonCode,
|
dominantCauseCode: $reason?->internalCode ?? $stats->reasonCode,
|
||||||
dominantCauseLabel: $reason?->operatorLabel,
|
dominantCauseLabel: $reason?->operatorLabel,
|
||||||
dominantCauseExplanation: $reason?->shortExplanation ?? $stats->message ?? $stats->failureReason,
|
dominantCauseExplanation: $reason?->shortExplanation ?? $stats->message ?? $stats->failureReason,
|
||||||
nextActionCategory: $family === ExplanationFamily::NoIssuesDetected
|
nextActionCategory: $isFailed
|
||||||
|
? 'inspect_run'
|
||||||
|
: ($family === ExplanationFamily::NoIssuesDetected
|
||||||
? 'none'
|
? 'none'
|
||||||
: match ($family) {
|
: match ($family) {
|
||||||
ExplanationFamily::TrustworthyResult => 'manual_validate',
|
ExplanationFamily::TrustworthyResult => 'manual_validate',
|
||||||
@ -136,7 +150,7 @@ public function forStats(BaselineCompareStats $stats): OperatorExplanationPatter
|
|||||||
ExplanationFamily::BlockedPrerequisite,
|
ExplanationFamily::BlockedPrerequisite,
|
||||||
ExplanationFamily::Unavailable => 'fix_prerequisite',
|
ExplanationFamily::Unavailable => 'fix_prerequisite',
|
||||||
default => 'review_evidence_gaps',
|
default => 'review_evidence_gaps',
|
||||||
},
|
}),
|
||||||
nextActionText: $nextActionText,
|
nextActionText: $nextActionText,
|
||||||
countDescriptors: $this->countDescriptors($stats, $hasCoverageWarnings, $hasEvidenceGaps),
|
countDescriptors: $this->countDescriptors($stats, $hasCoverageWarnings, $hasEvidenceGaps),
|
||||||
diagnosticsAvailable: $stats->operationRunId !== null || $reason !== null,
|
diagnosticsAvailable: $stats->operationRunId !== null || $reason !== null,
|
||||||
@ -181,6 +195,36 @@ private function countDescriptors(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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 !== []) {
|
if ($stats->severityCounts !== []) {
|
||||||
foreach (['high' => 'High severity', 'medium' => 'Medium severity', 'low' => 'Low severity'] as $key => $label) {
|
foreach (['high' => 'High severity', 'medium' => 'Medium severity', 'low' => 'Low severity'] as $key => $label) {
|
||||||
$value = (int) ($stats->severityCounts[$key] ?? 0);
|
$value = (int) ($stats->severityCounts[$key] ?? 0);
|
||||||
|
|||||||
@ -14,6 +14,9 @@ enum BaselineCompareReasonCode: string
|
|||||||
case EvidenceCaptureIncomplete = 'evidence_capture_incomplete';
|
case EvidenceCaptureIncomplete = 'evidence_capture_incomplete';
|
||||||
case RolloutDisabled = 'rollout_disabled';
|
case RolloutDisabled = 'rollout_disabled';
|
||||||
case NoDriftDetected = 'no_drift_detected';
|
case NoDriftDetected = 'no_drift_detected';
|
||||||
|
case OverdueFindingsRemain = 'overdue_findings_remain';
|
||||||
|
case GovernanceExpiring = 'governance_expiring';
|
||||||
|
case GovernanceLapsed = 'governance_lapsed';
|
||||||
|
|
||||||
public function message(): string
|
public function message(): string
|
||||||
{
|
{
|
||||||
@ -23,6 +26,9 @@ public function message(): string
|
|||||||
self::EvidenceCaptureIncomplete => 'Evidence capture was incomplete, so some drift evaluation may have been suppressed.',
|
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::RolloutDisabled => 'Full-content baseline compare is currently disabled by rollout configuration.',
|
||||||
self::NoDriftDetected => 'No drift was detected for in-scope subjects.',
|
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.',
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -32,7 +38,10 @@ public function explanationFamily(): ExplanationFamily
|
|||||||
self::NoDriftDetected => ExplanationFamily::NoIssuesDetected,
|
self::NoDriftDetected => ExplanationFamily::NoIssuesDetected,
|
||||||
self::CoverageUnproven,
|
self::CoverageUnproven,
|
||||||
self::EvidenceCaptureIncomplete,
|
self::EvidenceCaptureIncomplete,
|
||||||
self::RolloutDisabled => ExplanationFamily::CompletedButLimited,
|
self::RolloutDisabled,
|
||||||
|
self::OverdueFindingsRemain,
|
||||||
|
self::GovernanceExpiring,
|
||||||
|
self::GovernanceLapsed => ExplanationFamily::CompletedButLimited,
|
||||||
self::NoSubjectsInScope => ExplanationFamily::MissingInput,
|
self::NoSubjectsInScope => ExplanationFamily::MissingInput,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@ -42,7 +51,10 @@ public function trustworthinessLevel(): TrustworthinessLevel
|
|||||||
return match ($this) {
|
return match ($this) {
|
||||||
self::NoDriftDetected => TrustworthinessLevel::Trustworthy,
|
self::NoDriftDetected => TrustworthinessLevel::Trustworthy,
|
||||||
self::CoverageUnproven,
|
self::CoverageUnproven,
|
||||||
self::EvidenceCaptureIncomplete => TrustworthinessLevel::LimitedConfidence,
|
self::EvidenceCaptureIncomplete,
|
||||||
|
self::OverdueFindingsRemain,
|
||||||
|
self::GovernanceExpiring,
|
||||||
|
self::GovernanceLapsed => TrustworthinessLevel::LimitedConfidence,
|
||||||
self::RolloutDisabled,
|
self::RolloutDisabled,
|
||||||
self::NoSubjectsInScope => TrustworthinessLevel::Unusable,
|
self::NoSubjectsInScope => TrustworthinessLevel::Unusable,
|
||||||
};
|
};
|
||||||
@ -53,9 +65,17 @@ public function absencePattern(): ?string
|
|||||||
return match ($this) {
|
return match ($this) {
|
||||||
self::NoDriftDetected => 'true_no_result',
|
self::NoDriftDetected => 'true_no_result',
|
||||||
self::CoverageUnproven,
|
self::CoverageUnproven,
|
||||||
self::EvidenceCaptureIncomplete => 'suppressed_output',
|
self::EvidenceCaptureIncomplete,
|
||||||
|
self::OverdueFindingsRemain,
|
||||||
|
self::GovernanceExpiring,
|
||||||
|
self::GovernanceLapsed => 'suppressed_output',
|
||||||
self::RolloutDisabled => 'blocked_prerequisite',
|
self::RolloutDisabled => 'blocked_prerequisite',
|
||||||
self::NoSubjectsInScope => 'missing_input',
|
self::NoSubjectsInScope => 'missing_input',
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function supportsPositiveClaim(): bool
|
||||||
|
{
|
||||||
|
return $this === self::NoDriftDetected;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -24,6 +24,32 @@ final class BaselineCompareStats
|
|||||||
* @param array<string, int> $severityCounts
|
* @param array<string, int> $severityCounts
|
||||||
* @param list<string> $uncoveredTypes
|
* @param list<string> $uncoveredTypes
|
||||||
* @param array<string, int> $evidenceGapsTopReasons
|
* @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(
|
private function __construct(
|
||||||
public readonly string $state,
|
public readonly string $state,
|
||||||
@ -32,6 +58,7 @@ private function __construct(
|
|||||||
public readonly ?int $profileId,
|
public readonly ?int $profileId,
|
||||||
public readonly ?int $snapshotId,
|
public readonly ?int $snapshotId,
|
||||||
public readonly ?int $duplicateNamePoliciesCount,
|
public readonly ?int $duplicateNamePoliciesCount,
|
||||||
|
public readonly ?int $duplicateNameSubjectsCount,
|
||||||
public readonly ?int $operationRunId,
|
public readonly ?int $operationRunId,
|
||||||
public readonly ?int $findingsCount,
|
public readonly ?int $findingsCount,
|
||||||
public readonly array $severityCounts,
|
public readonly array $severityCounts,
|
||||||
@ -47,6 +74,17 @@ private function __construct(
|
|||||||
public readonly ?int $evidenceGapsCount = null,
|
public readonly ?int $evidenceGapsCount = null,
|
||||||
public readonly array $evidenceGapsTopReasons = [],
|
public readonly array $evidenceGapsTopReasons = [],
|
||||||
public readonly ?array $rbacRoleDefinitionSummary = null,
|
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
|
public static function forTenant(?Tenant $tenant): self
|
||||||
@ -91,7 +129,9 @@ public static function forTenant(?Tenant $tenant): self
|
|||||||
: null;
|
: null;
|
||||||
$effectiveScope = BaselineScope::effective($profileScope, $overrideScope);
|
$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) {
|
if ($snapshotId === null) {
|
||||||
return new self(
|
return new self(
|
||||||
@ -101,6 +141,7 @@ public static function forTenant(?Tenant $tenant): self
|
|||||||
profileId: $profileId,
|
profileId: $profileId,
|
||||||
snapshotId: null,
|
snapshotId: null,
|
||||||
duplicateNamePoliciesCount: $duplicateNamePoliciesCount,
|
duplicateNamePoliciesCount: $duplicateNamePoliciesCount,
|
||||||
|
duplicateNameSubjectsCount: $duplicateNameSubjectsCount,
|
||||||
operationRunId: null,
|
operationRunId: null,
|
||||||
findingsCount: null,
|
findingsCount: null,
|
||||||
severityCounts: [],
|
severityCounts: [],
|
||||||
@ -122,6 +163,22 @@ public static function forTenant(?Tenant $tenant): self
|
|||||||
[$evidenceGapsCount, $evidenceGapsTopReasons] = self::evidenceGapSummaryForRun($latestRun);
|
[$evidenceGapsCount, $evidenceGapsTopReasons] = self::evidenceGapSummaryForRun($latestRun);
|
||||||
[$reasonCode, $reasonMessage] = self::reasonInfoForRun($latestRun);
|
[$reasonCode, $reasonMessage] = self::reasonInfoForRun($latestRun);
|
||||||
$rbacRoleDefinitionSummary = self::rbacRoleDefinitionSummaryForRun($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)
|
// Active run (queued/running)
|
||||||
if ($latestRun instanceof OperationRun && in_array($latestRun->status, ['queued', 'running'], true)) {
|
if ($latestRun instanceof OperationRun && in_array($latestRun->status, ['queued', 'running'], true)) {
|
||||||
@ -132,6 +189,7 @@ public static function forTenant(?Tenant $tenant): self
|
|||||||
profileId: $profileId,
|
profileId: $profileId,
|
||||||
snapshotId: $snapshotId,
|
snapshotId: $snapshotId,
|
||||||
duplicateNamePoliciesCount: $duplicateNamePoliciesCount,
|
duplicateNamePoliciesCount: $duplicateNamePoliciesCount,
|
||||||
|
duplicateNameSubjectsCount: $duplicateNameSubjectsCount,
|
||||||
operationRunId: (int) $latestRun->getKey(),
|
operationRunId: (int) $latestRun->getKey(),
|
||||||
findingsCount: null,
|
findingsCount: null,
|
||||||
severityCounts: [],
|
severityCounts: [],
|
||||||
@ -147,6 +205,17 @@ public static function forTenant(?Tenant $tenant): self
|
|||||||
evidenceGapsCount: $evidenceGapsCount,
|
evidenceGapsCount: $evidenceGapsCount,
|
||||||
evidenceGapsTopReasons: $evidenceGapsTopReasons,
|
evidenceGapsTopReasons: $evidenceGapsTopReasons,
|
||||||
rbacRoleDefinitionSummary: $rbacRoleDefinitionSummary,
|
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'],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -164,6 +233,7 @@ public static function forTenant(?Tenant $tenant): self
|
|||||||
profileId: $profileId,
|
profileId: $profileId,
|
||||||
snapshotId: $snapshotId,
|
snapshotId: $snapshotId,
|
||||||
duplicateNamePoliciesCount: $duplicateNamePoliciesCount,
|
duplicateNamePoliciesCount: $duplicateNamePoliciesCount,
|
||||||
|
duplicateNameSubjectsCount: $duplicateNameSubjectsCount,
|
||||||
operationRunId: (int) $latestRun->getKey(),
|
operationRunId: (int) $latestRun->getKey(),
|
||||||
findingsCount: null,
|
findingsCount: null,
|
||||||
severityCounts: [],
|
severityCounts: [],
|
||||||
@ -179,6 +249,17 @@ public static function forTenant(?Tenant $tenant): self
|
|||||||
evidenceGapsCount: $evidenceGapsCount,
|
evidenceGapsCount: $evidenceGapsCount,
|
||||||
evidenceGapsTopReasons: $evidenceGapsTopReasons,
|
evidenceGapsTopReasons: $evidenceGapsTopReasons,
|
||||||
rbacRoleDefinitionSummary: $rbacRoleDefinitionSummary,
|
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'],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -218,6 +299,7 @@ public static function forTenant(?Tenant $tenant): self
|
|||||||
profileId: $profileId,
|
profileId: $profileId,
|
||||||
snapshotId: $snapshotId,
|
snapshotId: $snapshotId,
|
||||||
duplicateNamePoliciesCount: $duplicateNamePoliciesCount,
|
duplicateNamePoliciesCount: $duplicateNamePoliciesCount,
|
||||||
|
duplicateNameSubjectsCount: $duplicateNameSubjectsCount,
|
||||||
operationRunId: $latestRun instanceof OperationRun ? (int) $latestRun->getKey() : null,
|
operationRunId: $latestRun instanceof OperationRun ? (int) $latestRun->getKey() : null,
|
||||||
findingsCount: $totalFindings,
|
findingsCount: $totalFindings,
|
||||||
severityCounts: $severityCounts,
|
severityCounts: $severityCounts,
|
||||||
@ -233,6 +315,17 @@ public static function forTenant(?Tenant $tenant): self
|
|||||||
evidenceGapsCount: $evidenceGapsCount,
|
evidenceGapsCount: $evidenceGapsCount,
|
||||||
evidenceGapsTopReasons: $evidenceGapsTopReasons,
|
evidenceGapsTopReasons: $evidenceGapsTopReasons,
|
||||||
rbacRoleDefinitionSummary: $rbacRoleDefinitionSummary,
|
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'],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -246,6 +339,7 @@ public static function forTenant(?Tenant $tenant): self
|
|||||||
profileId: $profileId,
|
profileId: $profileId,
|
||||||
snapshotId: $snapshotId,
|
snapshotId: $snapshotId,
|
||||||
duplicateNamePoliciesCount: $duplicateNamePoliciesCount,
|
duplicateNamePoliciesCount: $duplicateNamePoliciesCount,
|
||||||
|
duplicateNameSubjectsCount: $duplicateNameSubjectsCount,
|
||||||
operationRunId: (int) $latestRun->getKey(),
|
operationRunId: (int) $latestRun->getKey(),
|
||||||
findingsCount: 0,
|
findingsCount: 0,
|
||||||
severityCounts: $severityCounts,
|
severityCounts: $severityCounts,
|
||||||
@ -261,6 +355,17 @@ public static function forTenant(?Tenant $tenant): self
|
|||||||
evidenceGapsCount: $evidenceGapsCount,
|
evidenceGapsCount: $evidenceGapsCount,
|
||||||
evidenceGapsTopReasons: $evidenceGapsTopReasons,
|
evidenceGapsTopReasons: $evidenceGapsTopReasons,
|
||||||
rbacRoleDefinitionSummary: $rbacRoleDefinitionSummary,
|
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'],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -271,6 +376,7 @@ public static function forTenant(?Tenant $tenant): self
|
|||||||
profileId: $profileId,
|
profileId: $profileId,
|
||||||
snapshotId: $snapshotId,
|
snapshotId: $snapshotId,
|
||||||
duplicateNamePoliciesCount: $duplicateNamePoliciesCount,
|
duplicateNamePoliciesCount: $duplicateNamePoliciesCount,
|
||||||
|
duplicateNameSubjectsCount: $duplicateNameSubjectsCount,
|
||||||
operationRunId: null,
|
operationRunId: null,
|
||||||
findingsCount: null,
|
findingsCount: null,
|
||||||
severityCounts: $severityCounts,
|
severityCounts: $severityCounts,
|
||||||
@ -286,6 +392,17 @@ public static function forTenant(?Tenant $tenant): self
|
|||||||
evidenceGapsCount: $evidenceGapsCount,
|
evidenceGapsCount: $evidenceGapsCount,
|
||||||
evidenceGapsTopReasons: $evidenceGapsTopReasons,
|
evidenceGapsTopReasons: $evidenceGapsTopReasons,
|
||||||
rbacRoleDefinitionSummary: $rbacRoleDefinitionSummary,
|
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'],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -342,6 +459,7 @@ public static function forWidget(?Tenant $tenant): self
|
|||||||
profileId: (int) $profile->getKey(),
|
profileId: (int) $profile->getKey(),
|
||||||
snapshotId: $snapshotId,
|
snapshotId: $snapshotId,
|
||||||
duplicateNamePoliciesCount: null,
|
duplicateNamePoliciesCount: null,
|
||||||
|
duplicateNameSubjectsCount: null,
|
||||||
operationRunId: $latestRun instanceof OperationRun ? (int) $latestRun->getKey() : null,
|
operationRunId: $latestRun instanceof OperationRun ? (int) $latestRun->getKey() : null,
|
||||||
findingsCount: $totalFindings,
|
findingsCount: $totalFindings,
|
||||||
severityCounts: [
|
severityCounts: [
|
||||||
@ -357,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();
|
$policyTypes = $effectiveScope->allTypes();
|
||||||
|
|
||||||
if ($policyTypes === []) {
|
if ($policyTypes === []) {
|
||||||
return 0;
|
return [
|
||||||
|
'policy_count' => 0,
|
||||||
|
'subject_count' => 0,
|
||||||
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
$latestInventorySyncRunId = self::latestInventorySyncRunId($tenant);
|
$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
|
* @var array<string, int> $countsByKey
|
||||||
*/
|
*/
|
||||||
@ -400,14 +524,19 @@ private static function duplicateNamePoliciesCount(Tenant $tenant, BaselineScope
|
|||||||
});
|
});
|
||||||
|
|
||||||
$duplicatePolicies = 0;
|
$duplicatePolicies = 0;
|
||||||
|
$duplicateSubjects = 0;
|
||||||
|
|
||||||
foreach ($countsByKey as $count) {
|
foreach ($countsByKey as $count) {
|
||||||
if ($count > 1) {
|
if ($count > 1) {
|
||||||
|
$duplicateSubjects++;
|
||||||
$duplicatePolicies += $count;
|
$duplicatePolicies += $count;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return $duplicatePolicies;
|
return [
|
||||||
|
'policy_count' => $duplicatePolicies,
|
||||||
|
'subject_count' => $duplicateSubjects,
|
||||||
|
];
|
||||||
};
|
};
|
||||||
|
|
||||||
if (app()->environment('testing')) {
|
if (app()->environment('testing')) {
|
||||||
@ -421,7 +550,10 @@ private static function duplicateNamePoliciesCount(Tenant $tenant, BaselineScope
|
|||||||
$latestInventorySyncRunId ?? 'all',
|
$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
|
private static function latestInventorySyncRunId(Tenant $tenant): ?int
|
||||||
@ -515,48 +647,67 @@ private static function evidenceGapSummaryForRun(?OperationRun $run): array
|
|||||||
return [null, []];
|
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 : [];
|
$context = is_array($run->context) ? $run->context : [];
|
||||||
$baselineCompare = $context['baseline_compare'] ?? null;
|
$baselineCompare = $context['baseline_compare'] ?? null;
|
||||||
|
|
||||||
if (! is_array($baselineCompare)) {
|
if (! is_array($baselineCompare)) {
|
||||||
return [null, []];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
$gaps = $baselineCompare['evidence_gaps'] ?? null;
|
return BaselineCompareEvidenceGapDetails::diagnosticsPayload($baselineCompare);
|
||||||
|
|
||||||
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)];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -585,6 +736,79 @@ 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
|
public function operatorExplanation(): OperatorExplanationPattern
|
||||||
{
|
{
|
||||||
/** @var BaselineCompareExplanationRegistry $registry */
|
/** @var BaselineCompareExplanationRegistry $registry */
|
||||||
@ -593,6 +817,14 @@ public function operatorExplanation(): OperatorExplanationPattern
|
|||||||
return $registry->forStats($this);
|
return $registry->forStats($this);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function summaryAssessment(): BaselineCompareSummaryAssessment
|
||||||
|
{
|
||||||
|
/** @var BaselineCompareSummaryAssessor $assessor */
|
||||||
|
$assessor = app(BaselineCompareSummaryAssessor::class);
|
||||||
|
|
||||||
|
return $assessor->assess($this);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @return array<int, array{
|
* @return array<int, array{
|
||||||
* label: string,
|
* label: string,
|
||||||
@ -616,6 +848,7 @@ private static function empty(
|
|||||||
?string $profileName = null,
|
?string $profileName = null,
|
||||||
?int $profileId = null,
|
?int $profileId = null,
|
||||||
?int $duplicateNamePoliciesCount = null,
|
?int $duplicateNamePoliciesCount = null,
|
||||||
|
?int $duplicateNameSubjectsCount = null,
|
||||||
): self {
|
): self {
|
||||||
return new self(
|
return new self(
|
||||||
state: $state,
|
state: $state,
|
||||||
@ -624,6 +857,7 @@ private static function empty(
|
|||||||
profileId: $profileId,
|
profileId: $profileId,
|
||||||
snapshotId: null,
|
snapshotId: null,
|
||||||
duplicateNamePoliciesCount: $duplicateNamePoliciesCount,
|
duplicateNamePoliciesCount: $duplicateNamePoliciesCount,
|
||||||
|
duplicateNameSubjectsCount: $duplicateNameSubjectsCount,
|
||||||
operationRunId: null,
|
operationRunId: null,
|
||||||
findingsCount: null,
|
findingsCount: null,
|
||||||
severityCounts: [],
|
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));
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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>
|
* @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}
|
* @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();
|
$expanded = $this->expandDefaults();
|
||||||
$allTypes = self::uniqueSorted(array_merge($expanded->policyTypes, $expanded->foundationTypes));
|
$allTypes = self::uniqueSorted(array_merge($expanded->policyTypes, $expanded->foundationTypes));
|
||||||
|
|
||||||
return [
|
$context = [
|
||||||
'policy_types' => $expanded->policyTypes,
|
'policy_types' => $expanded->policyTypes,
|
||||||
'foundation_types' => $expanded->foundationTypes,
|
'foundation_types' => $expanded->foundationTypes,
|
||||||
'all_types' => $allTypes,
|
'all_types' => $allTypes,
|
||||||
'foundations_included' => $expanded->foundationTypes !== [],
|
'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>
|
* @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
|
* @param iterable<mixed>|null $types
|
||||||
* @return array<string, string>
|
* @return array<string, string>
|
||||||
|
|||||||
@ -4,6 +4,9 @@
|
|||||||
|
|
||||||
namespace App\Support\Inventory;
|
namespace App\Support\Inventory;
|
||||||
|
|
||||||
|
use App\Support\Baselines\ResolutionPath;
|
||||||
|
use App\Support\Baselines\SubjectClass;
|
||||||
|
|
||||||
class InventoryPolicyTypeMeta
|
class InventoryPolicyTypeMeta
|
||||||
{
|
{
|
||||||
/**
|
/**
|
||||||
@ -175,4 +178,141 @@ public static function baselineCompareLabel(?string $type): ?string
|
|||||||
|
|
||||||
return static::label($type);
|
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',
|
||||||
|
};
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -220,9 +220,9 @@ private function translateBaselineCompareReason(string $reasonCode): ReasonResol
|
|||||||
[$operatorLabel, $shortExplanation, $actionability, $nextStep] = match ($enum) {
|
[$operatorLabel, $shortExplanation, $actionability, $nextStep] = match ($enum) {
|
||||||
BaselineCompareReasonCode::NoDriftDetected => [
|
BaselineCompareReasonCode::NoDriftDetected => [
|
||||||
'No drift detected',
|
'No drift detected',
|
||||||
'The comparison completed for the in-scope subjects without recording drift findings.',
|
'The comparison completed with enough coverage to treat the absence of drift findings as trustworthy.',
|
||||||
'non_actionable',
|
'non_actionable',
|
||||||
'No action needed unless you expected findings.',
|
'No action needed unless you expected a newer compare result.',
|
||||||
],
|
],
|
||||||
BaselineCompareReasonCode::CoverageUnproven => [
|
BaselineCompareReasonCode::CoverageUnproven => [
|
||||||
'Coverage proof missing',
|
'Coverage proof missing',
|
||||||
|
|||||||
@ -10,6 +10,11 @@ final class EnterpriseDetailBuilder
|
|||||||
{
|
{
|
||||||
private ?SummaryHeaderData $header = null;
|
private ?SummaryHeaderData $header = null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @var array<string, mixed>|null
|
||||||
|
*/
|
||||||
|
private ?array $decisionZone = null;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @var list<DetailSectionData>
|
* @var list<DetailSectionData>
|
||||||
*/
|
*/
|
||||||
@ -18,7 +23,7 @@ final class EnterpriseDetailBuilder
|
|||||||
/**
|
/**
|
||||||
* @var list<SupportingCardData>
|
* @var list<SupportingCardData>
|
||||||
*/
|
*/
|
||||||
private array $supportingCards = [];
|
private array $supportingGroups = [];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @var list<TechnicalDetailData>
|
* @var list<TechnicalDetailData>
|
||||||
@ -47,6 +52,16 @@ public function header(SummaryHeaderData $header): self
|
|||||||
return $this;
|
return $this;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $decisionZone
|
||||||
|
*/
|
||||||
|
public function decisionZone(array $decisionZone): self
|
||||||
|
{
|
||||||
|
$this->decisionZone = $decisionZone;
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
public function addSection(DetailSectionData ...$sections): self
|
public function addSection(DetailSectionData ...$sections): self
|
||||||
{
|
{
|
||||||
foreach ($sections as $section) {
|
foreach ($sections as $section) {
|
||||||
@ -58,8 +73,13 @@ public function addSection(DetailSectionData ...$sections): self
|
|||||||
|
|
||||||
public function addSupportingCard(SupportingCardData ...$cards): self
|
public function addSupportingCard(SupportingCardData ...$cards): self
|
||||||
{
|
{
|
||||||
foreach ($cards as $card) {
|
return $this->addSupportingGroup(...$cards);
|
||||||
$this->supportingCards[] = $card;
|
}
|
||||||
|
|
||||||
|
public function addSupportingGroup(SupportingCardData ...$groups): self
|
||||||
|
{
|
||||||
|
foreach ($groups as $group) {
|
||||||
|
$this->supportingGroups[] = $group;
|
||||||
}
|
}
|
||||||
|
|
||||||
return $this;
|
return $this;
|
||||||
@ -94,13 +114,16 @@ public function build(): EnterpriseDetailPageData
|
|||||||
resourceType: $this->resourceType,
|
resourceType: $this->resourceType,
|
||||||
scope: $this->scope,
|
scope: $this->scope,
|
||||||
header: $this->header,
|
header: $this->header,
|
||||||
|
decisionZone: is_array($this->decisionZone) && $this->decisionZone !== []
|
||||||
|
? $this->decisionZone
|
||||||
|
: null,
|
||||||
mainSections: array_values(array_filter(
|
mainSections: array_values(array_filter(
|
||||||
$this->mainSections,
|
$this->mainSections,
|
||||||
static fn (DetailSectionData $section): bool => $section->shouldRender(),
|
static fn (DetailSectionData $section): bool => $section->shouldRender(),
|
||||||
)),
|
)),
|
||||||
supportingCards: array_values(array_filter(
|
supportingGroups: array_values(array_filter(
|
||||||
$this->supportingCards,
|
$this->supportingGroups,
|
||||||
static fn (SupportingCardData $card): bool => $card->shouldRender(),
|
static fn (SupportingCardData $group): bool => $group->shouldRender(),
|
||||||
)),
|
)),
|
||||||
technicalSections: array_values(array_filter(
|
technicalSections: array_values(array_filter(
|
||||||
$this->technicalSections,
|
$this->technicalSections,
|
||||||
|
|||||||
@ -7,8 +7,25 @@
|
|||||||
final readonly class EnterpriseDetailPageData
|
final readonly class EnterpriseDetailPageData
|
||||||
{
|
{
|
||||||
/**
|
/**
|
||||||
|
* @param array{
|
||||||
|
* title?: string,
|
||||||
|
* description?: ?string,
|
||||||
|
* facts?: list<array<string, mixed>>,
|
||||||
|
* primaryNextStep?: array{
|
||||||
|
* label?: string,
|
||||||
|
* text: string,
|
||||||
|
* source: string,
|
||||||
|
* secondaryGuidance?: list<array{label: string, text: string, source: string}>
|
||||||
|
* },
|
||||||
|
* compactCounts?: array{
|
||||||
|
* summaryLine?: ?string,
|
||||||
|
* primaryFacts?: list<array<string, mixed>>,
|
||||||
|
* diagnosticFacts?: list<array<string, mixed>>
|
||||||
|
* },
|
||||||
|
* attentionNote?: ?string
|
||||||
|
* }|null $decisionZone
|
||||||
* @param list<DetailSectionData> $mainSections
|
* @param list<DetailSectionData> $mainSections
|
||||||
* @param list<SupportingCardData> $supportingCards
|
* @param list<SupportingCardData> $supportingGroups
|
||||||
* @param list<TechnicalDetailData> $technicalSections
|
* @param list<TechnicalDetailData> $technicalSections
|
||||||
* @param list<array{title: string, description?: ?string, icon?: ?string}> $emptyStateNotes
|
* @param list<array{title: string, description?: ?string, icon?: ?string}> $emptyStateNotes
|
||||||
*/
|
*/
|
||||||
@ -16,8 +33,9 @@ public function __construct(
|
|||||||
public string $resourceType,
|
public string $resourceType,
|
||||||
public string $scope,
|
public string $scope,
|
||||||
public SummaryHeaderData $header,
|
public SummaryHeaderData $header,
|
||||||
|
public ?array $decisionZone = null,
|
||||||
public array $mainSections = [],
|
public array $mainSections = [],
|
||||||
public array $supportingCards = [],
|
public array $supportingGroups = [],
|
||||||
public array $technicalSections = [],
|
public array $technicalSections = [],
|
||||||
public array $emptyStateNotes = [],
|
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}>,
|
* primaryActions: list<array{label: string, placement: string, url: ?string, actionName: ?string, destructive: bool, requiresConfirmation: bool, visible: bool, icon: ?string, openInNewTab: bool}>,
|
||||||
* descriptionHint: ?string
|
* descriptionHint: ?string
|
||||||
* },
|
* },
|
||||||
|
* decisionZone: array<string, mixed>|null,
|
||||||
* mainSections: list<array<string, mixed>>,
|
* mainSections: list<array<string, mixed>>,
|
||||||
* supportingCards: list<array<string, mixed>>,
|
* supportingGroups: list<array<string, mixed>>,
|
||||||
* technicalSections: list<array<string, mixed>>,
|
* technicalSections: list<array<string, mixed>>,
|
||||||
* emptyStateNotes: list<array{title: string, description?: ?string, icon?: ?string}>
|
* emptyStateNotes: list<array{title: string, description?: ?string, icon?: ?string}>
|
||||||
* }
|
* }
|
||||||
@ -46,13 +65,14 @@ public function toArray(): array
|
|||||||
'resourceType' => $this->resourceType,
|
'resourceType' => $this->resourceType,
|
||||||
'scope' => $this->scope,
|
'scope' => $this->scope,
|
||||||
'header' => $this->header->toArray(),
|
'header' => $this->header->toArray(),
|
||||||
|
'decisionZone' => $this->decisionZone,
|
||||||
'mainSections' => array_values(array_map(
|
'mainSections' => array_values(array_map(
|
||||||
static fn (DetailSectionData $section): array => $section->toArray(),
|
static fn (DetailSectionData $section): array => $section->toArray(),
|
||||||
$this->mainSections,
|
$this->mainSections,
|
||||||
)),
|
)),
|
||||||
'supportingCards' => array_values(array_map(
|
'supportingGroups' => array_values(array_map(
|
||||||
static fn (SupportingCardData $card): array => $card->toArray(),
|
static fn (SupportingCardData $group): array => $group->toArray(),
|
||||||
$this->supportingCards,
|
$this->supportingGroups,
|
||||||
)),
|
)),
|
||||||
'technicalSections' => array_values(array_map(
|
'technicalSections' => array_values(array_map(
|
||||||
static fn (TechnicalDetailData $section): array => $section->toArray(),
|
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
|
* @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) {
|
$displayValue = match (true) {
|
||||||
is_bool($value) => $value ? 'Yes' : 'No',
|
is_bool($value) => $value ? 'Yes' : 'No',
|
||||||
@ -24,6 +26,8 @@ public function keyFact(string $label, mixed $value, ?string $hint = null, ?arra
|
|||||||
'value' => $displayValue,
|
'value' => $displayValue,
|
||||||
'hint' => $hint,
|
'hint' => $hint,
|
||||||
'badge' => $badge,
|
'badge' => $badge,
|
||||||
|
'tone' => $tone,
|
||||||
|
'mono' => $mono ?: null,
|
||||||
], static fn (mixed $item): bool => $item !== null);
|
], static fn (mixed $item): bool => $item !== null);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -52,6 +56,92 @@ public function emptyState(string $title, ?string $description = null, ?string $
|
|||||||
], static fn (mixed $item): bool => $item !== null);
|
], static fn (mixed $item): bool => $item !== null);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param list<array<string, mixed>> $facts
|
||||||
|
* @param array{
|
||||||
|
* label?: string,
|
||||||
|
* text: string,
|
||||||
|
* source: string,
|
||||||
|
* secondaryGuidance?: list<array{label: string, text: string, source: string}>
|
||||||
|
* } $primaryNextStep
|
||||||
|
* @param array{
|
||||||
|
* summaryLine?: ?string,
|
||||||
|
* primaryFacts?: list<array<string, mixed>>,
|
||||||
|
* diagnosticFacts?: list<array<string, mixed>>
|
||||||
|
* }|null $compactCounts
|
||||||
|
* @return array{
|
||||||
|
* title: string,
|
||||||
|
* description?: ?string,
|
||||||
|
* facts: list<array<string, mixed>>,
|
||||||
|
* primaryNextStep: array{
|
||||||
|
* label?: string,
|
||||||
|
* text: string,
|
||||||
|
* source: string,
|
||||||
|
* secondaryGuidance?: list<array{label: string, text: string, source: string}>
|
||||||
|
* },
|
||||||
|
* compactCounts?: array{
|
||||||
|
* summaryLine?: ?string,
|
||||||
|
* primaryFacts?: list<array<string, mixed>>,
|
||||||
|
* diagnosticFacts?: list<array<string, mixed>>
|
||||||
|
* },
|
||||||
|
* attentionNote?: ?string
|
||||||
|
* }
|
||||||
|
*/
|
||||||
|
public function decisionZone(
|
||||||
|
array $facts,
|
||||||
|
array $primaryNextStep,
|
||||||
|
?string $description = null,
|
||||||
|
?array $compactCounts = null,
|
||||||
|
?string $attentionNote = null,
|
||||||
|
string $title = 'Decision',
|
||||||
|
): array {
|
||||||
|
return array_filter([
|
||||||
|
'title' => $title,
|
||||||
|
'description' => $description,
|
||||||
|
'facts' => array_values($facts),
|
||||||
|
'primaryNextStep' => $primaryNextStep,
|
||||||
|
'compactCounts' => $compactCounts,
|
||||||
|
'attentionNote' => $attentionNote,
|
||||||
|
], static fn (mixed $item): bool => $item !== null);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param list<array{label: string, text: string, source: string}> $secondaryGuidance
|
||||||
|
* @return array{
|
||||||
|
* label: string,
|
||||||
|
* text: string,
|
||||||
|
* source: string,
|
||||||
|
* secondaryGuidance: list<array{label: string, text: string, source: string}>
|
||||||
|
* }
|
||||||
|
*/
|
||||||
|
public function primaryNextStep(string $text, string $source, array $secondaryGuidance = [], string $label = 'Primary next step'): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'label' => $label,
|
||||||
|
'text' => $text,
|
||||||
|
'source' => $source,
|
||||||
|
'secondaryGuidance' => array_values($secondaryGuidance),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param list<array<string, mixed>> $primaryFacts
|
||||||
|
* @param list<array<string, mixed>> $diagnosticFacts
|
||||||
|
* @return array{
|
||||||
|
* summaryLine?: ?string,
|
||||||
|
* primaryFacts: list<array<string, mixed>>,
|
||||||
|
* diagnosticFacts: list<array<string, mixed>>
|
||||||
|
* }
|
||||||
|
*/
|
||||||
|
public function countPresentation(?string $summaryLine = null, array $primaryFacts = [], array $diagnosticFacts = []): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'summaryLine' => $summaryLine,
|
||||||
|
'primaryFacts' => array_values($primaryFacts),
|
||||||
|
'diagnosticFacts' => array_values($diagnosticFacts),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param list<array<string, mixed>> $items
|
* @param list<array<string, mixed>> $items
|
||||||
*/
|
*/
|
||||||
@ -174,6 +264,7 @@ public function technicalDetail(
|
|||||||
bool $visible = true,
|
bool $visible = true,
|
||||||
bool $collapsible = true,
|
bool $collapsible = true,
|
||||||
bool $collapsed = true,
|
bool $collapsed = true,
|
||||||
|
string $variant = 'technical',
|
||||||
): TechnicalDetailData {
|
): TechnicalDetailData {
|
||||||
return new TechnicalDetailData(
|
return new TechnicalDetailData(
|
||||||
title: $title,
|
title: $title,
|
||||||
@ -185,6 +276,7 @@ public function technicalDetail(
|
|||||||
view: $view,
|
view: $view,
|
||||||
viewData: $viewData,
|
viewData: $viewData,
|
||||||
emptyState: $emptyState,
|
emptyState: $emptyState,
|
||||||
|
variant: $variant,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -21,6 +21,7 @@ public function __construct(
|
|||||||
public ?string $view = null,
|
public ?string $view = null,
|
||||||
public array $viewData = [],
|
public array $viewData = [],
|
||||||
public ?array $emptyState = null,
|
public ?array $emptyState = null,
|
||||||
|
public string $variant = 'technical',
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
public function shouldRender(): bool
|
public function shouldRender(): bool
|
||||||
@ -59,6 +60,7 @@ public function toArray(): array
|
|||||||
'view' => $this->view,
|
'view' => $this->view,
|
||||||
'viewData' => $this->viewData,
|
'viewData' => $this->viewData,
|
||||||
'emptyState' => $this->emptyState,
|
'emptyState' => $this->emptyState,
|
||||||
|
'variant' => $this->variant,
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -412,6 +412,13 @@
|
|||||||
'baseline_compare' => [
|
'baseline_compare' => [
|
||||||
'supported' => true,
|
'supported' => true,
|
||||||
'identity_strategy' => 'display_name',
|
'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' => [
|
'baseline_compare' => [
|
||||||
'supported' => true,
|
'supported' => true,
|
||||||
'identity_strategy' => 'display_name',
|
'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' => [
|
'baseline_compare' => [
|
||||||
'supported' => true,
|
'supported' => true,
|
||||||
'identity_strategy' => 'external_id',
|
'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' => [
|
'baseline_compare' => [
|
||||||
'supported' => false,
|
'supported' => false,
|
||||||
'identity_strategy' => 'external_id',
|
'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' => [
|
'baseline_compare' => [
|
||||||
'supported' => true,
|
'supported' => true,
|
||||||
'identity_strategy' => 'display_name',
|
'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)
|
## 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
|
### Core Principles
|
||||||
|
|
||||||
@ -131,6 +131,9 @@ ### Core Principles
|
|||||||
5. **Workspace Isolation** — Non-member → 404; workspace is primary session context. Enforced via `DenyNonMemberTenantAccess` middleware + `EnsureFilamentTenantSelected`.
|
5. **Workspace Isolation** — Non-member → 404; workspace is primary session context. Enforced via `DenyNonMemberTenantAccess` middleware + `EnsureFilamentTenantSelected`.
|
||||||
6. **Tenant Isolation** — Every read/write must be tenant-scoped; `DerivesWorkspaceIdFromTenant` concern auto-fills `workspace_id` from tenant.
|
6. **Tenant Isolation** — Every read/write must be tenant-scoped; `DerivesWorkspaceIdFromTenant` concern auto-fills `workspace_id` from tenant.
|
||||||
7. **Operator Surface Principles** — `/admin` defaults are operator-first, diagnostics are progressively disclosed, status dimensions stay distinct, mutation scope is explicit before execution, and every materially changed operator page carries an explicit page contract.
|
7. **Operator Surface Principles** — `/admin` defaults are operator-first, diagnostics are progressively disclosed, status dimensions stay distinct, mutation scope is explicit before execution, and every materially changed operator page carries an explicit page contract.
|
||||||
|
8. **Filament-native first / no ad-hoc styling** — Admin and operator UI must use Filament-native components or shared primitives before any local Blade/Tailwind assembly; page-local status styling is not an acceptable substitute.
|
||||||
|
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
|
### 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).
|
- **Action Surface Contract**: List/View/Create/Edit pages each have defined required surfaces (Spec 082, 090).
|
||||||
- **Layout**: Main/Aside layout, sections required, view pages use Infolists.
|
- **Layout**: Main/Aside layout, sections required, view pages use Infolists.
|
||||||
- **Badge Semantics**: Centralized via `BadgeCatalog`/`BadgeRenderer` (Specs 059, 060).
|
- **Badge Semantics**: Centralized via `BadgeCatalog`/`BadgeRenderer` (Specs 059, 060).
|
||||||
|
- **Filament-native UI**: Native Filament components and shared primitives come before any local styling or replacement markup for semantic UI elements.
|
||||||
- **No naked forms**: Everything in sections/cards with proper enterprise IA.
|
- **No naked forms**: Everything in sections/cards with proper enterprise IA.
|
||||||
|
|
||||||
### Provider Gateway
|
### Provider Gateway
|
||||||
|
|||||||
@ -3,7 +3,7 @@ # Product Principles
|
|||||||
> Permanent product principles that govern every spec, every UI decision, and every architectural choice.
|
> Permanent product principles that govern every spec, every UI decision, and every architectural choice.
|
||||||
> New specs must align with these. If a principle needs to change, update this file first.
|
> New specs must align with these. If a principle needs to change, update this file first.
|
||||||
|
|
||||||
**Last reviewed**: 2026-03-21
|
**Last reviewed**: 2026-03-27
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@ -60,6 +60,26 @@ ### Enterprise-grade auditability
|
|||||||
|
|
||||||
## Data & Architecture
|
## 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
|
### Inventory-first, Snapshots-second
|
||||||
- `InventoryItem` = last observed metadata
|
- `InventoryItem` = last observed metadata
|
||||||
- `PolicyVersion.snapshot` = explicit immutable JSONB capture
|
- `PolicyVersion.snapshot` = explicit immutable JSONB capture
|
||||||
@ -93,6 +113,15 @@ ### Action Surface Contract (non-negotiable)
|
|||||||
### Badge semantics centralized
|
### Badge semantics centralized
|
||||||
All status badges via `BadgeCatalog` / `BadgeRenderer`. No ad-hoc badge mappings.
|
All status badges via `BadgeCatalog` / `BadgeRenderer`. No ad-hoc badge mappings.
|
||||||
|
|
||||||
|
### Filament-native first, no ad-hoc styling
|
||||||
|
Admin and operator UI uses native Filament components or shared primitives first.
|
||||||
|
No hand-built status chips, alert cards, or local semantic color/border styling when Filament or a central primitive already expresses the meaning.
|
||||||
|
Any exception must be justified explicitly and stay minimal.
|
||||||
|
|
||||||
|
### 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
|
### Canonical navigation and terminology
|
||||||
Consistent naming, consistent routing, consistent mental model.
|
Consistent naming, consistent routing, consistent mental model.
|
||||||
No competing terms for the same concept.
|
No competing terms for the same concept.
|
||||||
@ -117,6 +146,9 @@ ### Spec-first workflow
|
|||||||
Runtime behavior changes require spec update first.
|
Runtime behavior changes require spec update first.
|
||||||
Every spec must declare: scope, primary routes, data ownership, RBAC requirements (SCOPE-002).
|
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
|
### Regression guards mandatory
|
||||||
RBAC regression tests per role. Ops-UX regression guards prevent direct status writes and ad-hoc notifications.
|
RBAC regression tests per role. Ops-UX regression guards prevent direct status writes and ad-hoc notifications.
|
||||||
Architectural guard tests enforce code-level contracts.
|
Architectural guard tests enforce code-level contracts.
|
||||||
|
|||||||
@ -47,6 +47,25 @@ ### Baseline Drift Engine (Cutover)
|
|||||||
|
|
||||||
**Active specs**: 119 (cutover)
|
**Active specs**: 119 (cutover)
|
||||||
|
|
||||||
|
### R1.9 Platform Localization v1 (DE/EN)
|
||||||
|
UI-Sprache umschaltbar (`de`, `en`) mit sauberem Locale-Foundation-Layer.
|
||||||
|
Goal: Konsistente, durchgängige Lokalisierung aller Governance-Oberflächen — ohne Brüche in Export, Audit oder Maschinenformaten.
|
||||||
|
|
||||||
|
- Locale-Priorität: expliziter Override → User Preference → Workspace Default → System Default
|
||||||
|
- Workspace Default Language für neue Nutzer, User kann persönliche Sprache überschreiben
|
||||||
|
- Core-Surfaces zuerst: Navigation, Dashboard, Tenant Views, Findings, Baseline Compare, Risk Exceptions, Alerts, Operations, Audit-nahe Grundtexte
|
||||||
|
- Canonical Glossary für Governance-Begriffe (Finding, Baseline, Drift, Risk Accepted, Evidence Gap, Run) — konsistente Terminologie über alle Views
|
||||||
|
- Locale-aware Anzeigeformate für Datum, Uhrzeit, Zahlen und relative Zeiten
|
||||||
|
- Maschinen- und Exportformate bleiben invariant/stabil (keine lokalisierte Semantik in CSV/JSON/Audit-Artefakten)
|
||||||
|
- Notifications, E-Mails und operatorseitige Systemtexte nutzen die aufgelöste Locale des Empfängers
|
||||||
|
- Fallback-Regel: fehlende Übersetzungen fallen kontrolliert auf Englisch zurück; keine leeren/rohen Keys im UI
|
||||||
|
- Translation-Key Governance für Labels, Actions, Statuswerte, Empty States, Table Filters, Notifications und Validation-/Systemtexte
|
||||||
|
- HTML/UI i18n-Foundation: korrektes `lang`/Locale-Setup, keine hartcodierten kritischen UI-Strings, layouts sprachrobust
|
||||||
|
- Search/Sort/Filter auf kritischen Listen für locale-sensitives Verhalten prüfen
|
||||||
|
- QA/Foundation: Missing-Key Detection, Locale Regression Tests, Pseudolocalization Smoke Tests für kritische Flows
|
||||||
|
|
||||||
|
**Active specs**: — (not yet specced)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Planned (Next Quarter)
|
## Planned (Next Quarter)
|
||||||
|
|||||||
@ -5,7 +5,7 @@ # Spec Candidates
|
|||||||
>
|
>
|
||||||
> **Flow**: Inbox → Qualified → Planned → Spec created → moved to `Promoted to Spec`
|
> **Flow**: Inbox → Qualified → Planned → Spec created → moved to `Promoted to Spec`
|
||||||
|
|
||||||
**Last reviewed**: 2026-03-24 (added Operation Run Active-State Visibility & Stale Escalation candidate)
|
**Last reviewed**: 2026-03-24 (added Baseline Compare Scope Guardrails & Ambiguity Guidance candidate)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@ -359,6 +359,50 @@ ### Baseline Snapshot Fidelity Semantics
|
|||||||
- **Dependencies**: Operator Outcome Taxonomy and Cross-Domain State Separation, Structured Snapshot Rendering (Spec 130)
|
- **Dependencies**: Operator Outcome Taxonomy and Cross-Domain State Separation, Structured Snapshot Rendering (Spec 130)
|
||||||
- **Priority**: high
|
- **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
|
### Restore Lifecycle Semantic Clarity
|
||||||
- **Type**: hardening
|
- **Type**: hardening
|
||||||
- **Source**: semantic clarity & operator-language audit 2026-03-21
|
- **Source**: semantic clarity & operator-language audit 2026-03-21
|
||||||
@ -379,6 +423,70 @@ ### Inventory, Provider & Operability Semantics
|
|||||||
- **Dependencies**: Operator Outcome Taxonomy and Cross-Domain State Separation, provider connection vocabulary/cutover work, onboarding and verification spec family
|
- **Dependencies**: Operator Outcome Taxonomy and Cross-Domain State Separation, provider connection vocabulary/cutover work, onboarding and verification spec family
|
||||||
- **Priority**: medium
|
- **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
|
### Exception / Risk-Acceptance Workflow for Findings
|
||||||
- **Type**: feature
|
- **Type**: feature
|
||||||
- **Source**: HANDOVER gap analysis, Spec 111 follow-up
|
- **Source**: HANDOVER gap analysis, Spec 111 follow-up
|
||||||
|
|||||||
@ -4,7 +4,7 @@ # Product Standards
|
|||||||
> Specs reference these standards; they do not redefine them.
|
> Specs reference these standards; they do not redefine them.
|
||||||
> Guard tests enforce critical constraints automatically.
|
> Guard tests enforce critical constraints automatically.
|
||||||
|
|
||||||
**Last reviewed**: 2026-03-21
|
**Last reviewed**: 2026-03-27
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@ -21,7 +21,7 @@ ## Standards Index
|
|||||||
|
|
||||||
## How Standards Are Enforced
|
## 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.
|
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.
|
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.
|
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 |
|
| 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 |
|
| Product Principles | `docs/product/principles.md` | High-level product decisions |
|
||||||
| Table Rollout Audit | `docs/ui/filament-table-standard.md` | Rollout inventory and implementation state from Spec 125 |
|
| Table Rollout Audit | `docs/ui/filament-table-standard.md` | Rollout inventory and implementation state from Spec 125 |
|
||||||
| Action Surface Contract | `docs/ui/action-surface-contract.md` | Original action surface reference (now governed by this standard) |
|
| Action Surface Contract | `docs/ui/action-surface-contract.md` | Original action surface reference (now governed by this standard) |
|
||||||
|
|||||||
@ -12,8 +12,8 @@
|
|||||||
|
|
||||||
// Duplicate-name warning banner
|
// Duplicate-name warning banner
|
||||||
'duplicate_warning_title' => 'Warning',
|
'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_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 the same display name. :app cannot match it to the baseline. Please rename the duplicate in the Microsoft Intune portal.',
|
'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
|
// Stats card labels
|
||||||
'stat_assigned_baseline' => 'Assigned Baseline',
|
'stat_assigned_baseline' => 'Assigned Baseline',
|
||||||
@ -29,15 +29,50 @@
|
|||||||
'badge_fidelity' => 'Fidelity: :level',
|
'badge_fidelity' => 'Fidelity: :level',
|
||||||
'badge_evidence_gaps' => 'Evidence gaps: :count',
|
'badge_evidence_gaps' => 'Evidence gaps: :count',
|
||||||
'evidence_gaps_tooltip' => 'Top gaps: :summary',
|
'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 state
|
||||||
'comparing_indicator' => 'Comparing…',
|
'comparing_indicator' => 'Comparing…',
|
||||||
|
|
||||||
// Why-no-findings explanations
|
// Why-no-findings explanations
|
||||||
'no_findings_all_clear' => 'All clear',
|
'no_findings_all_clear' => 'No confirmed drift in the latest compare',
|
||||||
'no_findings_coverage_warnings' => 'Coverage warnings',
|
'no_findings_coverage_warnings' => 'No drift is shown, but coverage limits this compare',
|
||||||
'no_findings_evidence_gaps' => 'Evidence gaps',
|
'no_findings_evidence_gaps' => 'No drift is shown, but evidence gaps still need review',
|
||||||
'no_findings_default' => 'No findings',
|
'no_findings_default' => 'No drift findings are currently visible',
|
||||||
|
|
||||||
// Coverage warning banner
|
// Coverage warning banner
|
||||||
'coverage_warning_title' => 'Comparison completed with warnings',
|
'coverage_warning_title' => 'Comparison completed with warnings',
|
||||||
@ -70,11 +105,11 @@
|
|||||||
|
|
||||||
// No drift
|
// No drift
|
||||||
'no_drift_title' => 'No Drift Detected',
|
'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 (no findings)
|
||||||
'coverage_warnings_title' => 'Coverage Warnings',
|
'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
|
||||||
'idle_title' => 'Ready to Compare',
|
'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>
|
||||||
@ -9,9 +9,45 @@
|
|||||||
$primaryActions = array_values(array_filter($header['primaryActions'] ?? [], 'is_array'));
|
$primaryActions = array_values(array_filter($header['primaryActions'] ?? [], 'is_array'));
|
||||||
@endphp
|
@endphp
|
||||||
|
|
||||||
<div class="rounded-2xl border border-gray-200 bg-white/90 p-5 shadow-sm dark:border-gray-800 dark:bg-gray-900/80">
|
<x-filament::section
|
||||||
<div class="flex flex-col gap-5 xl:flex-row xl:items-start xl:justify-between">
|
:heading="$header['title'] ?? 'Detail'"
|
||||||
<div class="space-y-3">
|
:description="$header['subtitle'] ?? null"
|
||||||
|
>
|
||||||
|
@if ($primaryActions !== [])
|
||||||
|
<x-slot name="afterHeader">
|
||||||
|
<div class="flex flex-wrap items-center gap-2">
|
||||||
|
@foreach ($primaryActions as $action)
|
||||||
|
@if (filled($action['url'] ?? null))
|
||||||
|
@if (($action['openInNewTab'] ?? false) === true)
|
||||||
|
<x-filament::button
|
||||||
|
tag="a"
|
||||||
|
size="sm"
|
||||||
|
:color="($action['destructive'] ?? false) === true ? 'danger' : 'gray'"
|
||||||
|
:href="$action['url']"
|
||||||
|
:icon="$action['icon'] ?? null"
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer noopener"
|
||||||
|
>
|
||||||
|
{{ $action['label'] }}
|
||||||
|
</x-filament::button>
|
||||||
|
@else
|
||||||
|
<x-filament::button
|
||||||
|
tag="a"
|
||||||
|
size="sm"
|
||||||
|
:color="($action['destructive'] ?? false) === true ? 'danger' : 'gray'"
|
||||||
|
:href="$action['url']"
|
||||||
|
:icon="$action['icon'] ?? null"
|
||||||
|
>
|
||||||
|
{{ $action['label'] }}
|
||||||
|
</x-filament::button>
|
||||||
|
@endif
|
||||||
|
@endif
|
||||||
|
@endforeach
|
||||||
|
</div>
|
||||||
|
</x-slot>
|
||||||
|
@endif
|
||||||
|
|
||||||
|
<div class="space-y-4">
|
||||||
@if ($statusBadges !== [])
|
@if ($statusBadges !== [])
|
||||||
<div class="flex flex-wrap items-center gap-2">
|
<div class="flex flex-wrap items-center gap-2">
|
||||||
@foreach ($statusBadges as $badge)
|
@foreach ($statusBadges as $badge)
|
||||||
@ -26,81 +62,17 @@
|
|||||||
</div>
|
</div>
|
||||||
@endif
|
@endif
|
||||||
|
|
||||||
<div class="space-y-1">
|
|
||||||
<div class="text-2xl font-semibold tracking-tight text-gray-950 dark:text-white">
|
|
||||||
{{ $header['title'] ?? 'Detail' }}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
@if (filled($header['subtitle'] ?? null))
|
|
||||||
<div class="text-sm text-gray-600 dark:text-gray-300">
|
|
||||||
{{ $header['subtitle'] }}
|
|
||||||
</div>
|
|
||||||
@endif
|
|
||||||
|
|
||||||
@if (filled($header['descriptionHint'] ?? null))
|
@if (filled($header['descriptionHint'] ?? null))
|
||||||
<div class="max-w-3xl text-sm text-gray-600 dark:text-gray-300">
|
<div class="max-w-3xl text-sm text-gray-600 dark:text-gray-300">
|
||||||
{{ $header['descriptionHint'] }}
|
{{ $header['descriptionHint'] }}
|
||||||
</div>
|
</div>
|
||||||
@endif
|
@endif
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
@if ($primaryActions !== [])
|
|
||||||
<div class="flex flex-wrap items-center gap-2">
|
|
||||||
@foreach ($primaryActions as $action)
|
|
||||||
@if (filled($action['url'] ?? null))
|
|
||||||
<a
|
|
||||||
href="{{ $action['url'] }}"
|
|
||||||
@if (($action['openInNewTab'] ?? false) === true) target="_blank" rel="noreferrer noopener" @endif
|
|
||||||
class="inline-flex items-center gap-2 rounded-full border px-3 py-1.5 text-sm font-medium transition {{ ($action['destructive'] ?? false) === true ? 'border-rose-300 bg-rose-50 text-rose-700 hover:bg-rose-100 dark:border-rose-500/40 dark:bg-rose-500/10 dark:text-rose-200' : 'border-gray-300 bg-gray-50 text-gray-700 hover:bg-gray-100 dark:border-gray-700 dark:bg-gray-900 dark:text-gray-200 dark:hover:bg-gray-800' }}"
|
|
||||||
>
|
|
||||||
@if (filled($action['icon'] ?? null))
|
|
||||||
<x-filament::icon :icon="$action['icon']" class="h-4 w-4" />
|
|
||||||
@endif
|
|
||||||
{{ $action['label'] }}
|
|
||||||
</a>
|
|
||||||
@endif
|
|
||||||
@endforeach
|
|
||||||
</div>
|
|
||||||
@endif
|
|
||||||
</div>
|
|
||||||
|
|
||||||
@if ($keyFacts !== [])
|
@if ($keyFacts !== [])
|
||||||
<div class="mt-5 grid gap-3 sm:grid-cols-2 xl:grid-cols-4">
|
@include('filament.infolists.entries.enterprise-detail.section-items', [
|
||||||
@foreach ($keyFacts as $fact)
|
'items' => $keyFacts,
|
||||||
@php
|
'variant' => 'header',
|
||||||
$displayValue = FactPresentation::value($fact);
|
])
|
||||||
$badge = is_array($fact['badge'] ?? null) ? $fact['badge'] : null;
|
|
||||||
@endphp
|
|
||||||
|
|
||||||
<div class="rounded-xl border border-gray-200 bg-gray-50/80 px-4 py-3 dark:border-gray-800 dark:bg-gray-950/40">
|
|
||||||
<div class="text-xs font-semibold uppercase tracking-[0.16em] text-gray-500 dark:text-gray-400">
|
|
||||||
{{ $fact['label'] ?? 'Fact' }}
|
|
||||||
</div>
|
|
||||||
<div class="mt-2 flex min-w-0 flex-wrap items-center gap-2 text-sm font-medium text-gray-900 dark:text-white">
|
|
||||||
@if ($displayValue !== null)
|
|
||||||
<span class="min-w-0 break-all whitespace-normal">{{ $displayValue }}</span>
|
|
||||||
@endif
|
|
||||||
|
|
||||||
@if ($badge !== null)
|
|
||||||
<x-filament::badge
|
|
||||||
:color="$badge['color'] ?? 'gray'"
|
|
||||||
:icon="$badge['icon'] ?? null"
|
|
||||||
:icon-color="$badge['iconColor'] ?? null"
|
|
||||||
size="sm"
|
|
||||||
>
|
|
||||||
{{ $badge['label'] ?? 'State' }}
|
|
||||||
</x-filament::badge>
|
|
||||||
@endif
|
@endif
|
||||||
</div>
|
</div>
|
||||||
|
</x-filament::section>
|
||||||
@if (filled($fact['hint'] ?? null))
|
|
||||||
<div class="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
|
||||||
{{ $fact['hint'] }}
|
|
||||||
</div>
|
|
||||||
@endif
|
|
||||||
</div>
|
|
||||||
@endforeach
|
|
||||||
</div>
|
|
||||||
@endif
|
|
||||||
</div>
|
|
||||||
|
|||||||
@ -2,8 +2,9 @@
|
|||||||
$detail = isset($getState) ? $getState() : ($detail ?? null);
|
$detail = isset($getState) ? $getState() : ($detail ?? null);
|
||||||
$detail = is_array($detail) ? $detail : [];
|
$detail = is_array($detail) ? $detail : [];
|
||||||
|
|
||||||
|
$decisionZone = is_array($detail['decisionZone'] ?? null) ? $detail['decisionZone'] : [];
|
||||||
$mainSections = array_values(array_filter($detail['mainSections'] ?? [], 'is_array'));
|
$mainSections = array_values(array_filter($detail['mainSections'] ?? [], 'is_array'));
|
||||||
$supportingCards = array_values(array_filter($detail['supportingCards'] ?? [], 'is_array'));
|
$supportingGroups = array_values(array_filter($detail['supportingGroups'] ?? [], 'is_array'));
|
||||||
$technicalSections = array_values(array_filter($detail['technicalSections'] ?? [], 'is_array'));
|
$technicalSections = array_values(array_filter($detail['technicalSections'] ?? [], 'is_array'));
|
||||||
$emptyStateNotes = array_values(array_filter($detail['emptyStateNotes'] ?? [], 'is_array'));
|
$emptyStateNotes = array_values(array_filter($detail['emptyStateNotes'] ?? [], 'is_array'));
|
||||||
@endphp
|
@endphp
|
||||||
@ -13,6 +14,12 @@
|
|||||||
'header' => is_array($detail['header'] ?? null) ? $detail['header'] : [],
|
'header' => is_array($detail['header'] ?? null) ? $detail['header'] : [],
|
||||||
])
|
])
|
||||||
|
|
||||||
|
@if ($decisionZone !== [])
|
||||||
|
@include('filament.infolists.entries.enterprise-detail.decision-zone', [
|
||||||
|
'decisionZone' => $decisionZone,
|
||||||
|
])
|
||||||
|
@endif
|
||||||
|
|
||||||
@if ($emptyStateNotes !== [])
|
@if ($emptyStateNotes !== [])
|
||||||
<div class="space-y-3">
|
<div class="space-y-3">
|
||||||
@foreach ($emptyStateNotes as $state)
|
@foreach ($emptyStateNotes as $state)
|
||||||
@ -21,8 +28,15 @@
|
|||||||
</div>
|
</div>
|
||||||
@endif
|
@endif
|
||||||
|
|
||||||
<div class="grid gap-6 xl:grid-cols-3">
|
@if ($supportingGroups !== [])
|
||||||
<div class="{{ $supportingCards === [] ? 'xl:col-span-3' : 'xl:col-span-2' }} space-y-6">
|
<div class="grid gap-4 xl:grid-cols-2">
|
||||||
|
@foreach ($supportingGroups as $card)
|
||||||
|
@include('filament.infolists.entries.enterprise-detail.supporting-card', ['card' => $card])
|
||||||
|
@endforeach
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
|
||||||
|
<div class="space-y-6">
|
||||||
@foreach ($mainSections as $section)
|
@foreach ($mainSections as $section)
|
||||||
@php
|
@php
|
||||||
$view = is_string($section['view'] ?? null) && trim($section['view']) !== '' ? trim($section['view']) : null;
|
$view = is_string($section['view'] ?? null) && trim($section['view']) !== '' ? trim($section['view']) : null;
|
||||||
@ -50,15 +64,6 @@
|
|||||||
@endforeach
|
@endforeach
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@if ($supportingCards !== [])
|
|
||||||
<aside class="space-y-4">
|
|
||||||
@foreach ($supportingCards as $card)
|
|
||||||
@include('filament.infolists.entries.enterprise-detail.supporting-card', ['card' => $card])
|
|
||||||
@endforeach
|
|
||||||
</aside>
|
|
||||||
@endif
|
|
||||||
</div>
|
|
||||||
|
|
||||||
@if ($technicalSections !== [])
|
@if ($technicalSections !== [])
|
||||||
<div class="space-y-4">
|
<div class="space-y-4">
|
||||||
@foreach ($technicalSections as $section)
|
@foreach ($technicalSections as $section)
|
||||||
|
|||||||
@ -5,23 +5,44 @@
|
|||||||
$items = is_array($items) ? array_values(array_filter($items, 'is_array')) : [];
|
$items = is_array($items) ? array_values(array_filter($items, 'is_array')) : [];
|
||||||
$action = $action ?? null;
|
$action = $action ?? null;
|
||||||
$action = is_array($action) ? $action : null;
|
$action = is_array($action) ? $action : null;
|
||||||
|
$variant = is_string($variant ?? null) && trim($variant) !== '' ? trim($variant) : 'default';
|
||||||
|
$gridClasses = match ($variant) {
|
||||||
|
'header' => 'grid gap-3 sm:grid-cols-2 xl:grid-cols-4',
|
||||||
|
'summary' => 'grid gap-3 lg:grid-cols-2',
|
||||||
|
'supporting' => 'grid gap-3 sm:grid-cols-2',
|
||||||
|
'diagnostic' => 'grid gap-3 sm:grid-cols-2 xl:grid-cols-4',
|
||||||
|
'technical' => 'grid gap-3 sm:grid-cols-2 xl:grid-cols-3',
|
||||||
|
default => 'grid gap-3 sm:grid-cols-2',
|
||||||
|
};
|
||||||
|
$cardClasses = match ($variant) {
|
||||||
|
'summary' => 'rounded-2xl border border-gray-200 bg-white px-4 py-4 shadow-sm dark:border-gray-800 dark:bg-gray-900',
|
||||||
|
default => 'rounded-xl border border-gray-200 bg-gray-50/70 px-4 py-3 dark:border-gray-800 dark:bg-gray-950/30',
|
||||||
|
};
|
||||||
@endphp
|
@endphp
|
||||||
|
|
||||||
<div class="space-y-4">
|
<div class="space-y-4">
|
||||||
<div class="grid gap-3 sm:grid-cols-2">
|
<div class="{{ $gridClasses }}">
|
||||||
@foreach ($items as $item)
|
@foreach ($items as $item)
|
||||||
@php
|
@php
|
||||||
$displayValue = FactPresentation::value($item);
|
$displayValue = FactPresentation::value($item);
|
||||||
$badge = is_array($item['badge'] ?? null) ? $item['badge'] : null;
|
$badge = is_array($item['badge'] ?? null) ? $item['badge'] : null;
|
||||||
|
$tone = is_string($item['tone'] ?? null) ? $item['tone'] : null;
|
||||||
|
$mono = (bool) ($item['mono'] ?? false);
|
||||||
|
$toneValueClasses = match ($tone) {
|
||||||
|
'danger' => 'text-danger-600 dark:text-danger-400',
|
||||||
|
'success' => 'text-success-600 dark:text-success-400',
|
||||||
|
'warning' => 'text-warning-600 dark:text-warning-400',
|
||||||
|
default => 'text-gray-900 dark:text-white',
|
||||||
|
};
|
||||||
@endphp
|
@endphp
|
||||||
|
|
||||||
<div class="rounded-xl border border-gray-200 bg-gray-50/70 px-4 py-3 dark:border-gray-800 dark:bg-gray-950/30">
|
<div class="{{ $cardClasses }}">
|
||||||
<div class="text-xs font-semibold uppercase tracking-[0.16em] text-gray-500 dark:text-gray-400">
|
<div class="text-xs font-semibold uppercase tracking-[0.16em] text-gray-500 dark:text-gray-400">
|
||||||
{{ $item['label'] ?? 'Detail' }}
|
{{ $item['label'] ?? 'Detail' }}
|
||||||
</div>
|
</div>
|
||||||
<div class="mt-2 flex min-w-0 flex-wrap items-center gap-2 text-sm font-medium text-gray-900 dark:text-white">
|
<div class="mt-2 flex min-w-0 flex-wrap items-center gap-2 text-sm font-medium {{ $toneValueClasses }}">
|
||||||
@if ($displayValue !== null)
|
@if ($displayValue !== null)
|
||||||
<span class="min-w-0 break-all whitespace-normal">{{ $displayValue }}</span>
|
<span class="min-w-0 break-all whitespace-normal {{ $mono ? 'font-mono text-xs' : '' }}">{{ $displayValue }}</span>
|
||||||
@endif
|
@endif
|
||||||
|
|
||||||
@if ($badge !== null)
|
@if ($badge !== null)
|
||||||
@ -47,16 +68,25 @@
|
|||||||
|
|
||||||
@if ($action !== null && filled($action['url'] ?? null))
|
@if ($action !== null && filled($action['url'] ?? null))
|
||||||
<div>
|
<div>
|
||||||
<a
|
@if (($action['openInNewTab'] ?? false) === true)
|
||||||
href="{{ $action['url'] }}"
|
<x-filament::link
|
||||||
@if (($action['openInNewTab'] ?? false) === true) target="_blank" rel="noreferrer noopener" @endif
|
:href="$action['url']"
|
||||||
class="inline-flex items-center gap-2 text-sm font-medium {{ ($action['destructive'] ?? false) === true ? 'text-rose-700 hover:text-rose-600 dark:text-rose-200 dark:hover:text-rose-100' : 'text-primary-600 hover:text-primary-500 dark:text-primary-400 dark:hover:text-primary-300' }}"
|
:icon="$action['icon'] ?? null"
|
||||||
|
size="sm"
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer noopener"
|
||||||
>
|
>
|
||||||
@if (filled($action['icon'] ?? null))
|
|
||||||
<x-filament::icon :icon="$action['icon']" class="h-4 w-4" />
|
|
||||||
@endif
|
|
||||||
{{ $action['label'] }}
|
{{ $action['label'] }}
|
||||||
</a>
|
</x-filament::link>
|
||||||
|
@else
|
||||||
|
<x-filament::link
|
||||||
|
:href="$action['url']"
|
||||||
|
:icon="$action['icon'] ?? null"
|
||||||
|
size="sm"
|
||||||
|
>
|
||||||
|
{{ $action['label'] }}
|
||||||
|
</x-filament::link>
|
||||||
|
@endif
|
||||||
</div>
|
</div>
|
||||||
@endif
|
@endif
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -6,21 +6,45 @@
|
|||||||
$items = is_array($card['items'] ?? null) ? $card['items'] : [];
|
$items = is_array($card['items'] ?? null) ? $card['items'] : [];
|
||||||
$emptyState = is_array($card['emptyState'] ?? null) ? $card['emptyState'] : null;
|
$emptyState = is_array($card['emptyState'] ?? null) ? $card['emptyState'] : null;
|
||||||
$action = is_array($card['action'] ?? null) ? $card['action'] : null;
|
$action = is_array($card['action'] ?? null) ? $card['action'] : null;
|
||||||
|
$eyebrow = match ($card['kind'] ?? null) {
|
||||||
|
'guidance' => 'Guidance',
|
||||||
|
'lifecycle' => 'Lifecycle',
|
||||||
|
'timing' => 'Timing',
|
||||||
|
'metadata' => 'Metadata',
|
||||||
|
default => 'Supporting detail',
|
||||||
|
};
|
||||||
@endphp
|
@endphp
|
||||||
|
|
||||||
<x-filament::section
|
<div class="space-y-2" @if (filled($card['kind'] ?? null)) data-supporting-group-kind="{{ $card['kind'] }}" @endif>
|
||||||
|
<div class="text-[11px] font-semibold uppercase tracking-[0.18em] text-gray-500 dark:text-gray-400">
|
||||||
|
{{ $eyebrow }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<x-filament::section
|
||||||
:heading="$card['title'] ?? 'Supporting detail'"
|
:heading="$card['title'] ?? 'Supporting detail'"
|
||||||
:description="$card['description'] ?? null"
|
:description="$card['description'] ?? null"
|
||||||
>
|
>
|
||||||
@if ($action !== null && filled($action['url'] ?? null))
|
@if ($action !== null && filled($action['url'] ?? null))
|
||||||
<x-slot name="headerEnd">
|
<x-slot name="afterHeader">
|
||||||
<a
|
@if (($action['openInNewTab'] ?? false) === true)
|
||||||
href="{{ $action['url'] }}"
|
<x-filament::link
|
||||||
@if (($action['openInNewTab'] ?? false) === true) target="_blank" rel="noreferrer noopener" @endif
|
:href="$action['url']"
|
||||||
class="text-xs font-medium {{ ($action['destructive'] ?? false) === true ? 'text-rose-700 hover:text-rose-600 dark:text-rose-200 dark:hover:text-rose-100' : 'text-primary-600 hover:text-primary-500 dark:text-primary-400 dark:hover:text-primary-300' }}"
|
:icon="$action['icon'] ?? null"
|
||||||
|
size="sm"
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer noopener"
|
||||||
>
|
>
|
||||||
{{ $action['label'] }}
|
{{ $action['label'] }}
|
||||||
</a>
|
</x-filament::link>
|
||||||
|
@else
|
||||||
|
<x-filament::link
|
||||||
|
:href="$action['url']"
|
||||||
|
:icon="$action['icon'] ?? null"
|
||||||
|
size="sm"
|
||||||
|
>
|
||||||
|
{{ $action['label'] }}
|
||||||
|
</x-filament::link>
|
||||||
|
@endif
|
||||||
</x-slot>
|
</x-slot>
|
||||||
@endif
|
@endif
|
||||||
|
|
||||||
@ -28,9 +52,13 @@ class="text-xs font-medium {{ ($action['destructive'] ?? false) === true ? 'text
|
|||||||
@if ($view !== null)
|
@if ($view !== null)
|
||||||
{!! view($view, is_array($card['viewData'] ?? null) ? $card['viewData'] : [])->render() !!}
|
{!! view($view, is_array($card['viewData'] ?? null) ? $card['viewData'] : [])->render() !!}
|
||||||
@elseif ($items !== [])
|
@elseif ($items !== [])
|
||||||
@include('filament.infolists.entries.enterprise-detail.section-items', ['items' => $items])
|
@include('filament.infolists.entries.enterprise-detail.section-items', [
|
||||||
|
'items' => $items,
|
||||||
|
'variant' => 'supporting',
|
||||||
|
])
|
||||||
@elseif ($emptyState !== null)
|
@elseif ($emptyState !== null)
|
||||||
@include('filament.infolists.entries.enterprise-detail.empty-state', ['state' => $emptyState])
|
@include('filament.infolists.entries.enterprise-detail.empty-state', ['state' => $emptyState])
|
||||||
@endif
|
@endif
|
||||||
</div>
|
</div>
|
||||||
</x-filament::section>
|
</x-filament::section>
|
||||||
|
</div>
|
||||||
|
|||||||
@ -14,17 +14,16 @@
|
|||||||
:collapsed="(bool) ($section['collapsed'] ?? true)"
|
:collapsed="(bool) ($section['collapsed'] ?? true)"
|
||||||
>
|
>
|
||||||
@if ($entries !== [])
|
@if ($entries !== [])
|
||||||
@include('filament.infolists.entries.enterprise-detail.section-items', ['items' => $entries])
|
@include('filament.infolists.entries.enterprise-detail.section-items', [
|
||||||
|
'items' => $entries,
|
||||||
|
'variant' => is_string($section['variant'] ?? null) ? $section['variant'] : 'technical',
|
||||||
|
])
|
||||||
@endif
|
@endif
|
||||||
|
|
||||||
@if ($view !== null)
|
@if ($view !== null)
|
||||||
@if ($entries !== [])
|
<div @class(['mt-4' => $entries !== []])>
|
||||||
<div class="mt-4">
|
|
||||||
{!! view($view, is_array($section['viewData'] ?? null) ? $section['viewData'] : [])->render() !!}
|
{!! view($view, is_array($section['viewData'] ?? null) ? $section['viewData'] : [])->render() !!}
|
||||||
</div>
|
</div>
|
||||||
@else
|
|
||||||
{!! view($view, is_array($section['viewData'] ?? null) ? $section['viewData'] : [])->render() !!}
|
|
||||||
@endif
|
|
||||||
@elseif ($emptyState !== null)
|
@elseif ($emptyState !== null)
|
||||||
<div @class(['mt-4' => $entries !== []])>
|
<div @class(['mt-4' => $entries !== []])>
|
||||||
@include('filament.infolists.entries.enterprise-detail.empty-state', ['state' => $emptyState])
|
@include('filament.infolists.entries.enterprise-detail.empty-state', ['state' => $emptyState])
|
||||||
|
|||||||
@ -0,0 +1,146 @@
|
|||||||
|
@php
|
||||||
|
$summary = is_array($summary ?? null) ? $summary : [];
|
||||||
|
$buckets = is_array($buckets ?? null) ? $buckets : [];
|
||||||
|
$detailState = is_string($summary['detail_state'] ?? null) ? $summary['detail_state'] : 'no_gaps';
|
||||||
|
$structuralCount = is_numeric($summary['structural_count'] ?? null) ? (int) $summary['structural_count'] : 0;
|
||||||
|
$operationalCount = is_numeric($summary['operational_count'] ?? null) ? (int) $summary['operational_count'] : 0;
|
||||||
|
$transientCount = is_numeric($summary['transient_count'] ?? null) ? (int) $summary['transient_count'] : 0;
|
||||||
|
$tableContext = is_string($searchId ?? null) && $searchId !== '' ? $searchId : 'evidence-gap-search';
|
||||||
|
@endphp
|
||||||
|
|
||||||
|
@if ($detailState === 'legacy_broad_reason')
|
||||||
|
<div class="rounded-xl border border-danger-300 bg-danger-50/80 p-4 dark:border-danger-800 dark:bg-danger-950/30">
|
||||||
|
<div class="space-y-1">
|
||||||
|
<div class="text-sm font-semibold text-danger-950 dark:text-danger-100">
|
||||||
|
{{ __('baseline-compare.evidence_gap_legacy_title') }}
|
||||||
|
</div>
|
||||||
|
<p class="text-sm text-danger-900 dark:text-danger-200">
|
||||||
|
{{ __('baseline-compare.evidence_gap_legacy_body') }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
@elseif ($detailState === 'details_not_recorded' && $buckets === [])
|
||||||
|
<div class="rounded-xl border border-warning-300 bg-warning-50/80 p-4 dark:border-warning-800 dark:bg-warning-950/30">
|
||||||
|
<div class="space-y-1">
|
||||||
|
<div class="text-sm font-semibold text-warning-950 dark:text-warning-100">
|
||||||
|
{{ __('baseline-compare.evidence_gap_missing_details_title') }}
|
||||||
|
</div>
|
||||||
|
<p class="text-sm text-warning-900 dark:text-warning-200">
|
||||||
|
{{ __('baseline-compare.evidence_gap_missing_details_body') }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
@elseif ($buckets !== [])
|
||||||
|
<div class="space-y-4">
|
||||||
|
@if ($detailState === 'structured_details_recorded' && ($structuralCount > 0 || $operationalCount > 0 || $transientCount > 0))
|
||||||
|
<div class="flex flex-wrap gap-2">
|
||||||
|
<x-filament::badge color="danger" size="sm">
|
||||||
|
{{ __('baseline-compare.evidence_gap_structural', ['count' => $structuralCount]) }}
|
||||||
|
</x-filament::badge>
|
||||||
|
<x-filament::badge color="primary" size="sm">
|
||||||
|
{{ __('baseline-compare.evidence_gap_operational', ['count' => $operationalCount]) }}
|
||||||
|
</x-filament::badge>
|
||||||
|
<x-filament::badge color="warning" size="sm">
|
||||||
|
{{ __('baseline-compare.evidence_gap_transient', ['count' => $transientCount]) }}
|
||||||
|
</x-filament::badge>
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
|
||||||
|
@if ($detailState === 'details_not_recorded')
|
||||||
|
<div class="rounded-xl border border-warning-300 bg-warning-50/80 p-4 dark:border-warning-800 dark:bg-warning-950/30">
|
||||||
|
<div class="space-y-1">
|
||||||
|
<div class="text-sm font-semibold text-warning-950 dark:text-warning-100">
|
||||||
|
{{ __('baseline-compare.evidence_gap_missing_details_title') }}
|
||||||
|
</div>
|
||||||
|
<p class="text-sm text-warning-900 dark:text-warning-200">
|
||||||
|
{{ __('baseline-compare.evidence_gap_missing_details_body') }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
|
||||||
|
<div class="grid gap-4 lg:grid-cols-2 xl:grid-cols-3">
|
||||||
|
@foreach ($buckets as $bucket)
|
||||||
|
@php
|
||||||
|
$reasonLabel = is_string($bucket['reason_label'] ?? null) ? $bucket['reason_label'] : 'Evidence gap';
|
||||||
|
$reasonCode = is_string($bucket['reason_code'] ?? null) ? $bucket['reason_code'] : 'default';
|
||||||
|
$count = is_numeric($bucket['count'] ?? null) ? (int) $bucket['count'] : 0;
|
||||||
|
$recordedCount = is_numeric($bucket['recorded_count'] ?? null) ? (int) $bucket['recorded_count'] : 0;
|
||||||
|
$missingDetailCount = is_numeric($bucket['missing_detail_count'] ?? null) ? (int) $bucket['missing_detail_count'] : 0;
|
||||||
|
$bucketHelpKey = 'baseline-compare.evidence_gap_bucket_help_'.$reasonCode;
|
||||||
|
$bucketHelp = __($bucketHelpKey) !== $bucketHelpKey
|
||||||
|
? __($bucketHelpKey)
|
||||||
|
: __('baseline-compare.evidence_gap_bucket_help_default');
|
||||||
|
@endphp
|
||||||
|
|
||||||
|
<section class="rounded-xl border border-gray-200 bg-white p-4 shadow-sm dark:border-gray-800 dark:bg-gray-900/70">
|
||||||
|
<div class="space-y-3">
|
||||||
|
<div class="space-y-1">
|
||||||
|
<h4 class="text-sm font-semibold text-gray-950 dark:text-white">
|
||||||
|
{{ $reasonLabel }}
|
||||||
|
</h4>
|
||||||
|
<p class="text-xs text-gray-500 dark:text-gray-400">
|
||||||
|
{{ $bucketHelp }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex flex-wrap gap-2">
|
||||||
|
<x-filament::badge color="warning" size="sm">
|
||||||
|
{{ __('baseline-compare.evidence_gap_reason_affected', ['count' => $count]) }}
|
||||||
|
</x-filament::badge>
|
||||||
|
<x-filament::badge color="primary" size="sm">
|
||||||
|
{{ __('baseline-compare.evidence_gap_reason_recorded', ['count' => $recordedCount]) }}
|
||||||
|
</x-filament::badge>
|
||||||
|
@if ($missingDetailCount > 0)
|
||||||
|
<x-filament::badge color="gray" size="sm">
|
||||||
|
{{ __('baseline-compare.evidence_gap_reason_missing_detail', ['count' => $missingDetailCount]) }}
|
||||||
|
</x-filament::badge>
|
||||||
|
@endif
|
||||||
|
@if ((int) ($bucket['structural_count'] ?? 0) > 0)
|
||||||
|
<x-filament::badge color="danger" size="sm">
|
||||||
|
{{ __('baseline-compare.evidence_gap_bucket_structural', ['count' => (int) $bucket['structural_count']]) }}
|
||||||
|
</x-filament::badge>
|
||||||
|
@endif
|
||||||
|
@if ((int) ($bucket['operational_count'] ?? 0) > 0)
|
||||||
|
<x-filament::badge color="primary" size="sm">
|
||||||
|
{{ __('baseline-compare.evidence_gap_bucket_operational', ['count' => (int) $bucket['operational_count']]) }}
|
||||||
|
</x-filament::badge>
|
||||||
|
@endif
|
||||||
|
@if ((int) ($bucket['transient_count'] ?? 0) > 0)
|
||||||
|
<x-filament::badge color="warning" size="sm">
|
||||||
|
{{ __('baseline-compare.evidence_gap_bucket_transient', ['count' => (int) $bucket['transient_count']]) }}
|
||||||
|
</x-filament::badge>
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if ($missingDetailCount > 0)
|
||||||
|
<div class="rounded-lg border border-gray-200 bg-gray-50 px-3 py-3 text-sm text-gray-700 dark:border-gray-800 dark:bg-gray-950/60 dark:text-gray-200">
|
||||||
|
{{ __('baseline-compare.evidence_gap_missing_reason_body', ['count' => $missingDetailCount]) }}
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
@endforeach
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="space-y-2">
|
||||||
|
<div class="space-y-1">
|
||||||
|
<h4 class="text-sm font-semibold text-gray-950 dark:text-white">
|
||||||
|
{{ __('baseline-compare.evidence_gap_search_label') }}
|
||||||
|
</h4>
|
||||||
|
<p class="text-xs text-gray-500 dark:text-gray-400">
|
||||||
|
{{ __('baseline-compare.evidence_gap_search_help') }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@livewire(
|
||||||
|
\App\Livewire\BaselineCompareEvidenceGapTable::class,
|
||||||
|
[
|
||||||
|
'buckets' => $buckets,
|
||||||
|
'context' => $tableContext,
|
||||||
|
],
|
||||||
|
key('baseline-compare-evidence-gap-table-'.$tableContext)
|
||||||
|
)
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
@ -32,6 +32,7 @@
|
|||||||
$reason = is_array($state['reason'] ?? null) ? $state['reason'] : [];
|
$reason = is_array($state['reason'] ?? null) ? $state['reason'] : [];
|
||||||
$nextSteps = is_array($reason['nextSteps'] ?? null) ? $reason['nextSteps'] : [];
|
$nextSteps = is_array($reason['nextSteps'] ?? null) ? $reason['nextSteps'] : [];
|
||||||
$operatorExplanation = is_array($state['operatorExplanation'] ?? null) ? $state['operatorExplanation'] : [];
|
$operatorExplanation = is_array($state['operatorExplanation'] ?? null) ? $state['operatorExplanation'] : [];
|
||||||
|
$surface = is_string($surface ?? null) && trim($surface) !== '' ? trim($surface) : 'summary';
|
||||||
$evaluationSpec = is_string($operatorExplanation['evaluationResult'] ?? null)
|
$evaluationSpec = is_string($operatorExplanation['evaluationResult'] ?? null)
|
||||||
? BadgeCatalog::spec(BadgeDomain::OperatorExplanationEvaluationResult, $operatorExplanation['evaluationResult'])
|
? BadgeCatalog::spec(BadgeDomain::OperatorExplanationEvaluationResult, $operatorExplanation['evaluationResult'])
|
||||||
: null;
|
: null;
|
||||||
@ -39,9 +40,188 @@
|
|||||||
? BadgeCatalog::spec(BadgeDomain::OperatorExplanationTrustworthiness, $operatorExplanation['trustworthinessLevel'])
|
? BadgeCatalog::spec(BadgeDomain::OperatorExplanationTrustworthiness, $operatorExplanation['trustworthinessLevel'])
|
||||||
: null;
|
: null;
|
||||||
$operatorCounts = collect(is_array($operatorExplanation['countDescriptors'] ?? null) ? $operatorExplanation['countDescriptors'] : []);
|
$operatorCounts = collect(is_array($operatorExplanation['countDescriptors'] ?? null) ? $operatorExplanation['countDescriptors'] : []);
|
||||||
|
$normalizeArtifactTruthText = static function (mixed $value): ?string {
|
||||||
|
if (! is_string($value)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$value = trim((string) preg_replace('/\s+/', ' ', $value));
|
||||||
|
|
||||||
|
return $value !== '' ? $value : null;
|
||||||
|
};
|
||||||
|
$uniqueArtifactTruthParagraphs = static function (array $values) use ($normalizeArtifactTruthText): array {
|
||||||
|
$paragraphs = [];
|
||||||
|
$seen = [];
|
||||||
|
|
||||||
|
foreach ($values as $value) {
|
||||||
|
$normalized = $normalizeArtifactTruthText($value);
|
||||||
|
|
||||||
|
if ($normalized === null) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$key = mb_strtolower($normalized);
|
||||||
|
|
||||||
|
if (array_key_exists($key, $seen)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$seen[$key] = true;
|
||||||
|
$paragraphs[] = $normalized;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $paragraphs;
|
||||||
|
};
|
||||||
|
$decisionSummaryArtifactTruthParagraph = $normalizeArtifactTruthText(
|
||||||
|
$operatorExplanation['reliabilityStatement'] ?? ($state['primaryExplanation'] ?? null)
|
||||||
|
);
|
||||||
|
$expandedArtifactTruthParagraphs = $uniqueArtifactTruthParagraphs([
|
||||||
|
$state['primaryExplanation'] ?? null,
|
||||||
|
$operatorExplanation['reliabilityStatement'] ?? null,
|
||||||
|
data_get($operatorExplanation, 'dominantCause.explanation'),
|
||||||
|
]);
|
||||||
|
$expandedArtifactTruthParagraphs = array_values(array_filter(
|
||||||
|
$expandedArtifactTruthParagraphs,
|
||||||
|
static fn (string $paragraph): bool => $decisionSummaryArtifactTruthParagraph === null
|
||||||
|
|| mb_strtolower($paragraph) !== mb_strtolower($decisionSummaryArtifactTruthParagraph),
|
||||||
|
));
|
||||||
|
$summaryArtifactTruthParagraphs = $uniqueArtifactTruthParagraphs([
|
||||||
|
$operatorExplanation['reliabilityStatement'] ?? ($state['primaryExplanation'] ?? null),
|
||||||
|
data_get($operatorExplanation, 'dominantCause.explanation'),
|
||||||
|
]);
|
||||||
@endphp
|
@endphp
|
||||||
|
|
||||||
<div class="space-y-4">
|
@if ($surface === 'expanded')
|
||||||
|
<div class="space-y-4">
|
||||||
|
<div class="rounded-xl border border-gray-200 bg-white p-4 shadow-sm dark:border-gray-800 dark:bg-gray-900">
|
||||||
|
<div class="text-xs font-semibold uppercase tracking-[0.16em] text-gray-500 dark:text-gray-400">
|
||||||
|
Detailed artifact-truth context
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-3 space-y-2">
|
||||||
|
@foreach ($expandedArtifactTruthParagraphs as $index => $paragraph)
|
||||||
|
<p class="text-sm {{ $index === 0 ? 'text-gray-700 dark:text-gray-200' : 'text-gray-600 dark:text-gray-300' }}">
|
||||||
|
{{ $paragraph }}
|
||||||
|
</p>
|
||||||
|
@endforeach
|
||||||
|
|
||||||
|
@if (is_string($operatorExplanation['coverageStatement'] ?? null) && trim($operatorExplanation['coverageStatement']) !== '')
|
||||||
|
<p class="text-xs uppercase tracking-wide text-gray-500 dark:text-gray-400">
|
||||||
|
Coverage: {{ $operatorExplanation['coverageStatement'] }}
|
||||||
|
</p>
|
||||||
|
@endif
|
||||||
|
|
||||||
|
@if (is_string($state['diagnosticLabel'] ?? null) && trim($state['diagnosticLabel']) !== '')
|
||||||
|
<p class="text-xs uppercase tracking-wide text-gray-500 dark:text-gray-400">
|
||||||
|
Diagnostic: {{ $state['diagnosticLabel'] }}
|
||||||
|
</p>
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<dl class="grid gap-3 sm:grid-cols-2 xl:grid-cols-4">
|
||||||
|
@if ($evaluationSpec && $evaluationSpec->label !== 'Unknown')
|
||||||
|
<div class="rounded-md border border-gray-100 bg-gray-50 px-3 py-2 dark:border-gray-800 dark:bg-gray-950/60">
|
||||||
|
<dt class="text-[11px] font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">Result meaning</dt>
|
||||||
|
<dd class="mt-1">
|
||||||
|
<x-filament::badge :color="$evaluationSpec->color" :icon="$evaluationSpec->icon" size="sm">
|
||||||
|
{{ $evaluationSpec->label }}
|
||||||
|
</x-filament::badge>
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
|
||||||
|
@if ($trustSpec && $trustSpec->label !== 'Unknown')
|
||||||
|
<div class="rounded-md border border-gray-100 bg-gray-50 px-3 py-2 dark:border-gray-800 dark:bg-gray-950/60">
|
||||||
|
<dt class="text-[11px] font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">Result trust</dt>
|
||||||
|
<dd class="mt-1">
|
||||||
|
<x-filament::badge :color="$trustSpec->color" :icon="$trustSpec->icon" size="sm">
|
||||||
|
{{ $trustSpec->label }}
|
||||||
|
</x-filament::badge>
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
|
||||||
|
@if ($primarySpec)
|
||||||
|
<div class="rounded-md border border-gray-100 bg-gray-50 px-3 py-2 dark:border-gray-800 dark:bg-gray-950/60">
|
||||||
|
<dt class="text-[11px] font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">Primary artifact state</dt>
|
||||||
|
<dd class="mt-1">
|
||||||
|
<x-filament::badge :color="$primarySpec->color" :icon="$primarySpec->icon" size="sm">
|
||||||
|
{{ $primarySpec->label }}
|
||||||
|
</x-filament::badge>
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
|
||||||
|
@if ($actionabilitySpec)
|
||||||
|
<div class="rounded-md border border-gray-100 bg-gray-50 px-3 py-2 dark:border-gray-800 dark:bg-gray-950/60">
|
||||||
|
<dt class="text-[11px] font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">Actionability</dt>
|
||||||
|
<dd class="mt-1">
|
||||||
|
<x-filament::badge :color="$actionabilitySpec->color" :icon="$actionabilitySpec->icon" size="sm">
|
||||||
|
{{ $actionabilitySpec->label }}
|
||||||
|
</x-filament::badge>
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
|
||||||
|
@if ($existenceSpec)
|
||||||
|
<div class="rounded-md border border-gray-100 bg-gray-50 px-3 py-2 dark:border-gray-800 dark:bg-gray-950/60">
|
||||||
|
<dt class="text-[11px] font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">Artifact exists</dt>
|
||||||
|
<dd class="mt-1">
|
||||||
|
<x-filament::badge :color="$existenceSpec->color" :icon="$existenceSpec->icon" size="sm">
|
||||||
|
{{ $existenceSpec->label }}
|
||||||
|
</x-filament::badge>
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
|
||||||
|
@if ($freshnessSpec)
|
||||||
|
<div class="rounded-md border border-gray-100 bg-gray-50 px-3 py-2 dark:border-gray-800 dark:bg-gray-950/60">
|
||||||
|
<dt class="text-[11px] font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">Freshness</dt>
|
||||||
|
<dd class="mt-1">
|
||||||
|
<x-filament::badge :color="$freshnessSpec->color" :icon="$freshnessSpec->icon" size="sm">
|
||||||
|
{{ $freshnessSpec->label }}
|
||||||
|
</x-filament::badge>
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
|
||||||
|
@if ($publicationSpec)
|
||||||
|
<div class="rounded-md border border-gray-100 bg-gray-50 px-3 py-2 dark:border-gray-800 dark:bg-gray-950/60">
|
||||||
|
<dt class="text-[11px] font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">Publication</dt>
|
||||||
|
<dd class="mt-1">
|
||||||
|
<x-filament::badge :color="$publicationSpec->color" :icon="$publicationSpec->icon" size="sm">
|
||||||
|
{{ $publicationSpec->label }}
|
||||||
|
</x-filament::badge>
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
</dl>
|
||||||
|
|
||||||
|
@if ($operatorCounts->isNotEmpty())
|
||||||
|
<div class="grid gap-3 sm:grid-cols-2 xl:grid-cols-4">
|
||||||
|
@foreach ($operatorCounts as $count)
|
||||||
|
@continue(! is_array($count))
|
||||||
|
|
||||||
|
<div class="rounded-md border border-gray-100 bg-gray-50 px-3 py-2 dark:border-gray-800 dark:bg-gray-950/60">
|
||||||
|
<div class="text-[11px] font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">
|
||||||
|
{{ $count['label'] ?? 'Count' }}
|
||||||
|
</div>
|
||||||
|
<div class="mt-1 text-lg font-semibold text-gray-900 dark:text-gray-100">
|
||||||
|
{{ (int) ($count['value'] ?? 0) }}
|
||||||
|
</div>
|
||||||
|
@if (filled($count['qualifier'] ?? null))
|
||||||
|
<div class="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||||
|
{{ $count['qualifier'] }}
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
@endforeach
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
@else
|
||||||
|
<div class="space-y-4">
|
||||||
<div class="rounded-xl border border-gray-200 bg-white p-4 shadow-sm dark:border-gray-800 dark:bg-gray-900">
|
<div class="rounded-xl border border-gray-200 bg-white p-4 shadow-sm dark:border-gray-800 dark:bg-gray-900">
|
||||||
<div class="flex flex-wrap items-start gap-2">
|
<div class="flex flex-wrap items-start gap-2">
|
||||||
@if ($evaluationSpec && $evaluationSpec->label !== 'Unknown')
|
@if ($evaluationSpec && $evaluationSpec->label !== 'Unknown')
|
||||||
@ -74,21 +254,11 @@
|
|||||||
{{ $operatorExplanation['headline'] ?? ($state['primaryLabel'] ?? 'Artifact truth') }}
|
{{ $operatorExplanation['headline'] ?? ($state['primaryLabel'] ?? 'Artifact truth') }}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@if (is_string($operatorExplanation['reliabilityStatement'] ?? null) && trim($operatorExplanation['reliabilityStatement']) !== '')
|
@foreach ($summaryArtifactTruthParagraphs as $paragraph)
|
||||||
<p class="text-sm text-gray-600 dark:text-gray-300">
|
<p class="text-sm text-gray-600 dark:text-gray-300">
|
||||||
{{ $operatorExplanation['reliabilityStatement'] }}
|
{{ $paragraph }}
|
||||||
</p>
|
</p>
|
||||||
@elseif (is_string($state['primaryExplanation'] ?? null) && trim($state['primaryExplanation']) !== '')
|
@endforeach
|
||||||
<p class="text-sm text-gray-600 dark:text-gray-300">
|
|
||||||
{{ $state['primaryExplanation'] }}
|
|
||||||
</p>
|
|
||||||
@endif
|
|
||||||
|
|
||||||
@if (is_string(data_get($operatorExplanation, 'dominantCause.explanation')) && trim(data_get($operatorExplanation, 'dominantCause.explanation')) !== '')
|
|
||||||
<p class="text-sm text-gray-600 dark:text-gray-300">
|
|
||||||
{{ data_get($operatorExplanation, 'dominantCause.explanation') }}
|
|
||||||
</p>
|
|
||||||
@endif
|
|
||||||
|
|
||||||
@if (is_string($operatorExplanation['coverageStatement'] ?? null) && trim($operatorExplanation['coverageStatement']) !== '')
|
@if (is_string($operatorExplanation['coverageStatement'] ?? null) && trim($operatorExplanation['coverageStatement']) !== '')
|
||||||
<p class="text-xs uppercase tracking-wide text-gray-500 dark:text-gray-400">
|
<p class="text-xs uppercase tracking-wide text-gray-500 dark:text-gray-400">
|
||||||
@ -193,4 +363,5 @@
|
|||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
@endif
|
@endif
|
||||||
</div>
|
</div>
|
||||||
|
@endif
|
||||||
|
|||||||
@ -27,9 +27,9 @@
|
|||||||
<div class="flex items-start justify-between gap-4">
|
<div class="flex items-start justify-between gap-4">
|
||||||
<div class="min-w-0 space-y-1">
|
<div class="min-w-0 space-y-1">
|
||||||
@if ($isAvailable)
|
@if ($isAvailable)
|
||||||
<a href="{{ $entry['targetUrl'] }}" class="text-sm font-semibold text-primary-600 hover:text-primary-500 hover:underline dark:text-primary-400">
|
<x-filament::link :href="$entry['targetUrl']">
|
||||||
{{ $entry['value'] ?? 'Open related record' }}
|
{{ $entry['value'] ?? 'Open related record' }}
|
||||||
</a>
|
</x-filament::link>
|
||||||
@else
|
@else
|
||||||
<div class="text-sm font-semibold text-gray-900 dark:text-white">
|
<div class="text-sm font-semibold text-gray-900 dark:text-white">
|
||||||
{{ $entry['value'] ?? 'Unavailable' }}
|
{{ $entry['value'] ?? 'Unavailable' }}
|
||||||
@ -63,12 +63,13 @@
|
|||||||
@endunless
|
@endunless
|
||||||
|
|
||||||
@if ($isAvailable && filled($entry['actionLabel'] ?? null))
|
@if ($isAvailable && filled($entry['actionLabel'] ?? null))
|
||||||
<a
|
<x-filament::link
|
||||||
href="{{ $entry['targetUrl'] }}"
|
:href="$entry['targetUrl']"
|
||||||
class="text-xs font-medium text-primary-600 hover:text-primary-500 hover:underline dark:text-primary-400"
|
icon="heroicon-m-arrow-top-right-on-square"
|
||||||
|
size="sm"
|
||||||
>
|
>
|
||||||
{{ $entry['actionLabel'] }}
|
{{ $entry['actionLabel'] }}
|
||||||
</a>
|
</x-filament::link>
|
||||||
@endif
|
@endif
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -6,7 +6,9 @@
|
|||||||
|
|
||||||
@php
|
@php
|
||||||
$duplicateNamePoliciesCountValue = (int) ($duplicateNamePoliciesCount ?? 0);
|
$duplicateNamePoliciesCountValue = (int) ($duplicateNamePoliciesCount ?? 0);
|
||||||
|
$duplicateNameSubjectsCountValue = (int) ($duplicateNameSubjectsCount ?? 0);
|
||||||
$explanation = is_array($operatorExplanation ?? null) ? $operatorExplanation : null;
|
$explanation = is_array($operatorExplanation ?? null) ? $operatorExplanation : null;
|
||||||
|
$summary = is_array($summaryAssessment ?? null) ? $summaryAssessment : null;
|
||||||
$explanationCounts = collect(is_array($explanation['countDescriptors'] ?? null) ? $explanation['countDescriptors'] : []);
|
$explanationCounts = collect(is_array($explanation['countDescriptors'] ?? null) ? $explanation['countDescriptors'] : []);
|
||||||
$evaluationSpec = is_string($explanation['evaluationResult'] ?? null)
|
$evaluationSpec = is_string($explanation['evaluationResult'] ?? null)
|
||||||
? \App\Support\Badges\BadgeCatalog::spec(\App\Support\Badges\BadgeDomain::OperatorExplanationEvaluationResult, $explanation['evaluationResult'])
|
? \App\Support\Badges\BadgeCatalog::spec(\App\Support\Badges\BadgeDomain::OperatorExplanationEvaluationResult, $explanation['evaluationResult'])
|
||||||
@ -14,6 +16,14 @@
|
|||||||
$trustSpec = is_string($explanation['trustworthinessLevel'] ?? null)
|
$trustSpec = is_string($explanation['trustworthinessLevel'] ?? null)
|
||||||
? \App\Support\Badges\BadgeCatalog::spec(\App\Support\Badges\BadgeDomain::OperatorExplanationTrustworthiness, $explanation['trustworthinessLevel'])
|
? \App\Support\Badges\BadgeCatalog::spec(\App\Support\Badges\BadgeDomain::OperatorExplanationTrustworthiness, $explanation['trustworthinessLevel'])
|
||||||
: null;
|
: null;
|
||||||
|
$summaryLabel = match ($summary['stateFamily'] ?? null) {
|
||||||
|
'positive' => 'Aligned',
|
||||||
|
'caution' => 'Needs review',
|
||||||
|
'stale' => 'Refresh recommended',
|
||||||
|
'action_required' => 'Action required',
|
||||||
|
'in_progress' => 'In progress',
|
||||||
|
default => 'Unavailable',
|
||||||
|
};
|
||||||
@endphp
|
@endphp
|
||||||
|
|
||||||
@if ($duplicateNamePoliciesCountValue > 0)
|
@if ($duplicateNamePoliciesCountValue > 0)
|
||||||
@ -27,6 +37,7 @@
|
|||||||
<div class="text-sm text-warning-800 dark:text-warning-300">
|
<div class="text-sm text-warning-800 dark:text-warning-300">
|
||||||
{{ __($duplicateNamePoliciesCountValue === 1 ? 'baseline-compare.duplicate_warning_body_singular' : 'baseline-compare.duplicate_warning_body_plural', [
|
{{ __($duplicateNamePoliciesCountValue === 1 ? 'baseline-compare.duplicate_warning_body_singular' : 'baseline-compare.duplicate_warning_body_plural', [
|
||||||
'count' => $duplicateNamePoliciesCountValue,
|
'count' => $duplicateNamePoliciesCountValue,
|
||||||
|
'ambiguous_count' => $duplicateNameSubjectsCountValue,
|
||||||
'app' => config('app.name', 'TenantPilot'),
|
'app' => config('app.name', 'TenantPilot'),
|
||||||
]) }}
|
]) }}
|
||||||
</div>
|
</div>
|
||||||
@ -39,6 +50,12 @@
|
|||||||
<x-filament::section>
|
<x-filament::section>
|
||||||
<div class="space-y-4">
|
<div class="space-y-4">
|
||||||
<div class="flex flex-wrap items-start gap-2">
|
<div class="flex flex-wrap items-start gap-2">
|
||||||
|
@if ($summary)
|
||||||
|
<x-filament::badge :color="$summary['tone'] ?? 'gray'" size="sm">
|
||||||
|
{{ $summaryLabel }}
|
||||||
|
</x-filament::badge>
|
||||||
|
@endif
|
||||||
|
|
||||||
@if ($evaluationSpec && $evaluationSpec->label !== 'Unknown')
|
@if ($evaluationSpec && $evaluationSpec->label !== 'Unknown')
|
||||||
<x-filament::badge :color="$evaluationSpec->color" :icon="$evaluationSpec->icon" size="sm">
|
<x-filament::badge :color="$evaluationSpec->color" :icon="$evaluationSpec->icon" size="sm">
|
||||||
{{ $evaluationSpec->label }}
|
{{ $evaluationSpec->label }}
|
||||||
@ -54,9 +71,15 @@
|
|||||||
|
|
||||||
<div class="space-y-2">
|
<div class="space-y-2">
|
||||||
<div class="text-lg font-semibold text-gray-950 dark:text-white">
|
<div class="text-lg font-semibold text-gray-950 dark:text-white">
|
||||||
{{ $explanation['headline'] ?? 'Compare explanation' }}
|
{{ $summary['headline'] ?? ($explanation['headline'] ?? 'Compare explanation') }}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
@if (filled($summary['supportingMessage'] ?? null))
|
||||||
|
<p class="text-sm text-gray-700 dark:text-gray-200">
|
||||||
|
{{ $summary['supportingMessage'] }}
|
||||||
|
</p>
|
||||||
|
@endif
|
||||||
|
|
||||||
@if (filled($explanation['reliabilityStatement'] ?? null))
|
@if (filled($explanation['reliabilityStatement'] ?? null))
|
||||||
<p class="text-sm text-gray-700 dark:text-gray-200">
|
<p class="text-sm text-gray-700 dark:text-gray-200">
|
||||||
{{ $explanation['reliabilityStatement'] }}
|
{{ $explanation['reliabilityStatement'] }}
|
||||||
@ -88,7 +111,7 @@
|
|||||||
<div class="rounded-lg border border-gray-200 bg-gray-50 px-4 py-3 dark:border-gray-800 dark:bg-gray-950/50 md:col-span-2">
|
<div class="rounded-lg border border-gray-200 bg-gray-50 px-4 py-3 dark:border-gray-800 dark:bg-gray-950/50 md:col-span-2">
|
||||||
<dt class="text-[11px] font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">What to do next</dt>
|
<dt class="text-[11px] font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">What to do next</dt>
|
||||||
<dd class="mt-1 text-sm font-medium text-gray-900 dark:text-white">
|
<dd class="mt-1 text-sm font-medium text-gray-900 dark:text-white">
|
||||||
{{ data_get($explanation, 'nextAction.text', 'Review the latest compare run.') }}
|
{{ data_get($summary, 'nextAction.label') ?? data_get($explanation, 'nextAction.text', 'Review the latest compare run.') }}
|
||||||
</dd>
|
</dd>
|
||||||
</div>
|
</div>
|
||||||
</dl>
|
</dl>
|
||||||
@ -188,7 +211,15 @@ class="w-fit"
|
|||||||
{{ __('baseline-compare.comparing_indicator') }}
|
{{ __('baseline-compare.comparing_indicator') }}
|
||||||
</div>
|
</div>
|
||||||
@elseif (($findingsCount ?? 0) === 0 && $state === 'ready')
|
@elseif (($findingsCount ?? 0) === 0 && $state === 'ready')
|
||||||
<span class="text-sm {{ $whyNoFindingsColor }}">{{ $whyNoFindingsMessage ?? $whyNoFindingsFallback }}</span>
|
<div class="space-y-1">
|
||||||
|
<span class="text-sm {{ $whyNoFindingsColor }}">{{ $summary['headline'] ?? ($whyNoFindingsMessage ?? $whyNoFindingsFallback) }}</span>
|
||||||
|
|
||||||
|
@if (filled($summary['supportingMessage'] ?? null))
|
||||||
|
<div class="text-xs text-gray-500 dark:text-gray-400">
|
||||||
|
{{ $summary['supportingMessage'] }}
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
@endif
|
@endif
|
||||||
</div>
|
</div>
|
||||||
</x-filament::section>
|
</x-filament::section>
|
||||||
@ -353,6 +384,20 @@ class="w-fit"
|
|||||||
</x-filament::section>
|
</x-filament::section>
|
||||||
@endif
|
@endif
|
||||||
|
|
||||||
|
@if ($hasEvidenceGapDetailSection)
|
||||||
|
<x-filament::section :heading="__('baseline-compare.evidence_gap_details_heading')">
|
||||||
|
<x-slot name="description">
|
||||||
|
{{ __('baseline-compare.evidence_gap_details_description') }}
|
||||||
|
</x-slot>
|
||||||
|
|
||||||
|
@include('filament.infolists.entries.evidence-gap-subjects', [
|
||||||
|
'summary' => $evidenceGapSummary,
|
||||||
|
'buckets' => $evidenceGapBuckets ?? [],
|
||||||
|
'searchId' => 'tenant-baseline-compare-gap-search',
|
||||||
|
])
|
||||||
|
</x-filament::section>
|
||||||
|
@endif
|
||||||
|
|
||||||
{{-- Severity breakdown + actions --}}
|
{{-- Severity breakdown + actions --}}
|
||||||
@if ($state === 'ready' && ($findingsCount ?? 0) > 0)
|
@if ($state === 'ready' && ($findingsCount ?? 0) > 0)
|
||||||
<x-filament::section>
|
<x-filament::section>
|
||||||
@ -476,4 +521,14 @@ class="w-fit"
|
|||||||
</div>
|
</div>
|
||||||
</x-filament::section>
|
</x-filament::section>
|
||||||
@endif
|
@endif
|
||||||
|
|
||||||
|
@if ($hasEvidenceGapDiagnostics)
|
||||||
|
<x-filament::section :heading="__('baseline-compare.evidence_gap_diagnostics_heading')">
|
||||||
|
<x-slot name="description">
|
||||||
|
{{ __('baseline-compare.evidence_gap_diagnostics_description') }}
|
||||||
|
</x-slot>
|
||||||
|
|
||||||
|
@include('filament.partials.json-viewer', ['value' => $baselineCompareDiagnostics])
|
||||||
|
</x-filament::section>
|
||||||
|
@endif
|
||||||
</x-filament::page>
|
</x-filament::page>
|
||||||
|
|||||||
@ -6,7 +6,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="text-sm text-gray-600 dark:text-gray-300">
|
<div class="text-sm text-gray-600 dark:text-gray-300">
|
||||||
Review pending exception requests across entitled tenants without leaving the Monitoring area.
|
Review pending requests, expiring governance, and lapsed exception coverage across entitled tenants without leaving the Monitoring area.
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</x-filament::section>
|
</x-filament::section>
|
||||||
@ -33,6 +33,17 @@
|
|||||||
<div class="mt-1 text-sm text-gray-600 dark:text-gray-300">
|
<div class="mt-1 text-sm text-gray-600 dark:text-gray-300">
|
||||||
{{ \App\Support\Badges\BadgeRenderer::label(\App\Support\Badges\BadgeDomain::FindingRiskGovernanceValidity)($selectedException->current_validity_state) }}
|
{{ \App\Support\Badges\BadgeRenderer::label(\App\Support\Badges\BadgeDomain::FindingRiskGovernanceValidity)($selectedException->current_validity_state) }}
|
||||||
</div>
|
</div>
|
||||||
|
@php
|
||||||
|
$governanceWarning = app(\App\Services\Findings\FindingRiskGovernanceResolver::class)->resolveWarningMessage($selectedException->finding, $selectedException);
|
||||||
|
$governanceWarningColor = (string) $selectedException->current_validity_state === \App\Models\FindingException::VALIDITY_EXPIRING
|
||||||
|
? 'text-warning-700 dark:text-warning-300'
|
||||||
|
: 'text-danger-700 dark:text-danger-300';
|
||||||
|
@endphp
|
||||||
|
@if (filled($governanceWarning))
|
||||||
|
<div class="mt-3 text-sm {{ $governanceWarningColor }}">
|
||||||
|
{{ $governanceWarning }}
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="rounded-2xl border border-gray-200 bg-white p-4 shadow-sm dark:border-gray-800 dark:bg-gray-900">
|
<div class="rounded-2xl border border-gray-200 bg-white p-4 shadow-sm dark:border-gray-800 dark:bg-gray-900">
|
||||||
|
|||||||
@ -7,9 +7,6 @@
|
|||||||
|
|
||||||
<x-filament-panels::page>
|
<x-filament-panels::page>
|
||||||
<div
|
<div
|
||||||
x-data
|
|
||||||
x-init="$wire.set('opsUxIsTabHidden', document.hidden)"
|
|
||||||
x-on:visibilitychange.window="$wire.set('opsUxIsTabHidden', document.hidden)"
|
|
||||||
@if ($pollInterval !== null) wire:poll.{{ $pollInterval }} @endif
|
@if ($pollInterval !== null) wire:poll.{{ $pollInterval }} @endif
|
||||||
>
|
>
|
||||||
@if ($contextBanner !== null)
|
@if ($contextBanner !== null)
|
||||||
|
|||||||
@ -1,3 +1,4 @@
|
|||||||
<script src="{{ asset('js/tenantpilot/livewire-intercept-shim.js') }}"></script>
|
<script src="{{ asset('js/tenantpilot/livewire-intercept-shim.js') }}"></script>
|
||||||
<script src="{{ asset('js/tenantpilot/filament-sidebar-store-fallback.js') }}"></script>
|
<script src="{{ asset('js/tenantpilot/filament-sidebar-store-fallback.js') }}"></script>
|
||||||
<script src="{{ asset('js/tenantpilot/ops-ux-progress-widget-poller.js') }}"></script>
|
<script src="{{ asset('js/tenantpilot/ops-ux-progress-widget-poller.js') }}"></script>
|
||||||
|
<script src="{{ asset('js/tenantpilot/unhandled-rejection-logger.js') }}"></script>
|
||||||
|
|||||||
@ -1,3 +1,58 @@
|
|||||||
|
@php
|
||||||
|
/** @var array<string, mixed>|null $summaryAssessment */
|
||||||
|
$summary = is_array($summaryAssessment ?? null) ? $summaryAssessment : null;
|
||||||
|
$summaryState = (string) ($summary['stateFamily'] ?? 'unavailable');
|
||||||
|
$summaryTone = (string) ($summary['tone'] ?? 'gray');
|
||||||
|
$findingsCount = (int) ($summary['findingsVisibleCount'] ?? 0);
|
||||||
|
$highSeverityCount = (int) ($summary['highSeverityCount'] ?? 0);
|
||||||
|
$nextAction = is_array($summary['nextAction'] ?? null) ? $summary['nextAction'] : ['label' => 'Review baseline compare', 'target' => 'none'];
|
||||||
|
|
||||||
|
$summaryLabel = match ($summaryState) {
|
||||||
|
'positive' => 'Aligned',
|
||||||
|
'caution' => 'Needs review',
|
||||||
|
'stale' => 'Refresh recommended',
|
||||||
|
'action_required' => 'Action required',
|
||||||
|
'in_progress' => 'In progress',
|
||||||
|
default => 'Unavailable',
|
||||||
|
};
|
||||||
|
|
||||||
|
[$cardClasses, $iconClasses, $textClasses] = match ($summaryTone) {
|
||||||
|
'success' => [
|
||||||
|
'rounded-lg border border-success-300 bg-success-50 p-4 dark:border-success-700 dark:bg-success-950/40',
|
||||||
|
'h-5 w-5 shrink-0 text-success-600 dark:text-success-400',
|
||||||
|
'text-success-900 dark:text-success-100',
|
||||||
|
],
|
||||||
|
'danger' => [
|
||||||
|
'rounded-lg border border-danger-300 bg-danger-50 p-4 dark:border-danger-700 dark:bg-danger-950/40',
|
||||||
|
'h-5 w-5 shrink-0 text-danger-600 dark:text-danger-400',
|
||||||
|
'text-danger-900 dark:text-danger-100',
|
||||||
|
],
|
||||||
|
'info' => [
|
||||||
|
'rounded-lg border border-info-300 bg-info-50 p-4 dark:border-info-700 dark:bg-info-950/40',
|
||||||
|
'h-5 w-5 shrink-0 text-info-600 dark:text-info-400',
|
||||||
|
'text-info-900 dark:text-info-100',
|
||||||
|
],
|
||||||
|
'warning' => [
|
||||||
|
'rounded-lg border border-warning-300 bg-warning-50 p-4 dark:border-warning-700 dark:bg-warning-950/40',
|
||||||
|
'h-5 w-5 shrink-0 text-warning-600 dark:text-warning-400',
|
||||||
|
'text-warning-900 dark:text-warning-100',
|
||||||
|
],
|
||||||
|
default => [
|
||||||
|
'rounded-lg border border-gray-200 bg-gray-50 p-4 dark:border-gray-800 dark:bg-white/5',
|
||||||
|
'h-5 w-5 shrink-0 text-gray-500 dark:text-gray-400',
|
||||||
|
'text-gray-900 dark:text-white',
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
$summaryIcon = match ($summaryState) {
|
||||||
|
'positive' => 'heroicon-o-check-circle',
|
||||||
|
'action_required' => 'heroicon-o-exclamation-triangle',
|
||||||
|
'in_progress' => 'heroicon-o-arrow-path',
|
||||||
|
'stale' => 'heroicon-o-clock',
|
||||||
|
default => 'heroicon-o-information-circle',
|
||||||
|
};
|
||||||
|
@endphp
|
||||||
|
|
||||||
<x-filament::section heading="Baseline Governance">
|
<x-filament::section heading="Baseline Governance">
|
||||||
@if ($landingUrl)
|
@if ($landingUrl)
|
||||||
<x-slot name="afterHeader">
|
<x-slot name="afterHeader">
|
||||||
@ -15,63 +70,67 @@
|
|||||||
<div class="mt-0.5 text-sm text-gray-500 dark:text-gray-400">Assign a baseline profile to start monitoring drift.</div>
|
<div class="mt-0.5 text-sm text-gray-500 dark:text-gray-400">Assign a baseline profile to start monitoring drift.</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@elseif (($state ?? null) === 'no_snapshot')
|
@elseif ($summary)
|
||||||
<div class="flex items-start gap-3 rounded-lg border border-warning-300 bg-warning-50 p-4 dark:border-warning-700 dark:bg-warning-950/40">
|
|
||||||
<x-heroicon-o-camera class="mt-0.5 h-5 w-5 shrink-0 text-warning-500 dark:text-warning-400" />
|
|
||||||
<div>
|
|
||||||
<div class="text-sm font-medium text-warning-900 dark:text-warning-100">Current Baseline Unavailable</div>
|
|
||||||
<div class="mt-0.5 text-sm text-warning-800 dark:text-warning-200">{{ $message }}</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
@else
|
|
||||||
<div class="flex flex-col gap-3">
|
<div class="flex flex-col gap-3">
|
||||||
{{-- Profile + last compared --}}
|
|
||||||
<div class="flex items-center justify-between text-sm">
|
<div class="flex items-center justify-between text-sm">
|
||||||
<div class="text-gray-600 dark:text-gray-300">
|
<div class="text-gray-600 dark:text-gray-300">
|
||||||
Baseline: <span class="font-medium text-gray-950 dark:text-white">{{ $profileName }}</span>
|
Baseline: <span class="font-medium text-gray-950 dark:text-white">{{ $profileName }}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@if ($lastComparedAt)
|
@if ($lastComparedAt)
|
||||||
<div class="text-gray-500 dark:text-gray-400">{{ $lastComparedAt }}</div>
|
<div class="text-gray-500 dark:text-gray-400">{{ $lastComparedAt }}</div>
|
||||||
@endif
|
@endif
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{{-- Findings summary --}}
|
<div class="{{ $cardClasses }}">
|
||||||
@if ($findingsCount > 0)
|
<div class="flex items-start gap-3">
|
||||||
{{-- Critical banner (inline) --}}
|
<x-filament::icon :icon="$summaryIcon" class="{{ $iconClasses }}" />
|
||||||
@if ($highCount > 0)
|
|
||||||
<div class="flex items-center gap-2 rounded-lg border border-danger-300 bg-danger-50 px-3 py-2 dark:border-danger-700 dark:bg-danger-950/50">
|
|
||||||
<x-heroicon-s-exclamation-triangle class="h-4 w-4 shrink-0 text-danger-600 dark:text-danger-400" />
|
|
||||||
<span class="text-sm font-medium text-danger-800 dark:text-danger-200">
|
|
||||||
{{ $highCount }} high-severity {{ Str::plural('finding', $highCount) }}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
@endif
|
|
||||||
|
|
||||||
<div class="flex items-center justify-between">
|
<div class="min-w-0 flex-1 space-y-3">
|
||||||
<div class="flex flex-wrap items-center gap-2">
|
<div class="flex flex-wrap items-center gap-2">
|
||||||
|
<x-filament::badge :color="$summaryTone" size="sm">
|
||||||
|
{{ $summaryLabel }}
|
||||||
|
</x-filament::badge>
|
||||||
|
|
||||||
|
@if ($findingsCount > 0)
|
||||||
<x-filament::badge color="danger" size="sm">
|
<x-filament::badge color="danger" size="sm">
|
||||||
{{ $findingsCount }} {{ Str::plural('finding', $findingsCount) }}
|
{{ $findingsCount }} {{ Str::plural('finding', $findingsCount) }}
|
||||||
</x-filament::badge>
|
</x-filament::badge>
|
||||||
|
|
||||||
@if ($mediumCount > 0)
|
|
||||||
<x-filament::badge color="warning" size="sm">
|
|
||||||
{{ $mediumCount }} medium
|
|
||||||
</x-filament::badge>
|
|
||||||
@endif
|
@endif
|
||||||
|
|
||||||
@if ($lowCount > 0)
|
@if ($highSeverityCount > 0)
|
||||||
<x-filament::badge color="gray" size="sm">
|
<x-filament::badge color="danger" size="sm">
|
||||||
{{ $lowCount }} low
|
{{ $highSeverityCount }} high severity
|
||||||
</x-filament::badge>
|
</x-filament::badge>
|
||||||
@endif
|
@endif
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="space-y-1">
|
||||||
|
<div class="text-sm font-semibold {{ $textClasses }}">
|
||||||
|
{{ $summary['headline'] }}
|
||||||
</div>
|
</div>
|
||||||
@else
|
|
||||||
<div class="flex items-center gap-2 rounded-lg bg-success-50 px-3 py-2 dark:bg-success-950/50">
|
@if (filled($summary['supportingMessage'] ?? null))
|
||||||
<x-heroicon-o-check-circle class="h-4 w-4 shrink-0 text-success-600 dark:text-success-400" />
|
<div class="text-sm text-gray-700 dark:text-gray-200">
|
||||||
<span class="text-sm font-medium text-success-700 dark:text-success-300">No open drift — baseline compliant</span>
|
{{ $summary['supportingMessage'] }}
|
||||||
</div>
|
</div>
|
||||||
@endif
|
@endif
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
@if (filled($nextActionUrl))
|
||||||
|
<x-filament::link :href="$nextActionUrl" size="sm" class="font-medium">
|
||||||
|
{{ $nextAction['label'] }}
|
||||||
|
</x-filament::link>
|
||||||
|
@elseif (filled($nextAction['label'] ?? null))
|
||||||
|
<div class="text-xs font-medium uppercase tracking-wide text-gray-600 dark:text-gray-300">
|
||||||
|
{{ $nextAction['label'] }}
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
@endif
|
@endif
|
||||||
</x-filament::section>
|
</x-filament::section>
|
||||||
|
|||||||
@ -3,17 +3,16 @@
|
|||||||
wire:poll.{{ $pollingInterval }}
|
wire:poll.{{ $pollingInterval }}
|
||||||
@endif
|
@endif
|
||||||
>
|
>
|
||||||
<x-filament::section heading="Needs Attention">
|
<x-filament::section heading="Needs Attention">
|
||||||
|
|
||||||
@if (count($items) === 0)
|
@if (count($items) === 0)
|
||||||
<div class="flex flex-col gap-3">
|
<div class="flex flex-col gap-3">
|
||||||
<div class="text-sm text-gray-600 dark:text-gray-300">
|
<div class="text-sm text-gray-600 dark:text-gray-300">
|
||||||
Everything looks healthy right now.
|
Current dashboard signals look trustworthy.
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex flex-col gap-3">
|
<div class="flex flex-col gap-3">
|
||||||
@foreach ($healthyChecks as $check)
|
@foreach ($healthyChecks as $check)
|
||||||
<div class="flex items-start gap-3">
|
<div class="flex items-start gap-3 rounded-lg bg-gray-50 p-4 dark:bg-white/5">
|
||||||
<x-filament::icon
|
<x-filament::icon
|
||||||
icon="heroicon-m-check-circle"
|
icon="heroicon-m-check-circle"
|
||||||
class="mt-0.5 h-5 w-5 text-success-600 dark:text-success-400"
|
class="mt-0.5 h-5 w-5 text-success-600 dark:text-success-400"
|
||||||
@ -22,12 +21,6 @@ class="mt-0.5 h-5 w-5 text-success-600 dark:text-success-400"
|
|||||||
<div class="flex-1">
|
<div class="flex-1">
|
||||||
<div class="text-sm font-medium text-gray-950 dark:text-white">{{ $check['title'] }}</div>
|
<div class="text-sm font-medium text-gray-950 dark:text-white">{{ $check['title'] }}</div>
|
||||||
<div class="mt-0.5 text-sm text-gray-600 dark:text-gray-300">{{ $check['body'] }}</div>
|
<div class="mt-0.5 text-sm text-gray-600 dark:text-gray-300">{{ $check['body'] }}</div>
|
||||||
|
|
||||||
<div class="mt-1">
|
|
||||||
<x-filament::link :href="$check['url']" size="sm">
|
|
||||||
{{ $check['linkLabel'] }}
|
|
||||||
</x-filament::link>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@endforeach
|
@endforeach
|
||||||
@ -36,20 +29,32 @@ class="mt-0.5 h-5 w-5 text-success-600 dark:text-success-400"
|
|||||||
@else
|
@else
|
||||||
<div class="flex flex-col gap-3">
|
<div class="flex flex-col gap-3">
|
||||||
@foreach ($items as $item)
|
@foreach ($items as $item)
|
||||||
<a
|
<div class="rounded-lg bg-gray-50 p-4 dark:bg-white/5">
|
||||||
href="{{ $item['url'] }}"
|
|
||||||
class="rounded-lg bg-gray-50 p-4 text-left transition hover:bg-gray-100 dark:bg-white/5 dark:hover:bg-white/10"
|
|
||||||
>
|
|
||||||
<div class="flex items-start justify-between gap-3">
|
<div class="flex items-start justify-between gap-3">
|
||||||
|
<div class="min-w-0 flex-1">
|
||||||
<div class="text-sm font-semibold text-gray-950 dark:text-white">{{ $item['title'] }}</div>
|
<div class="text-sm font-semibold text-gray-950 dark:text-white">{{ $item['title'] }}</div>
|
||||||
|
<div class="mt-1 text-sm text-gray-600 dark:text-gray-300">{{ $item['body'] }}</div>
|
||||||
|
|
||||||
|
@if (filled($item['supportingMessage'] ?? null))
|
||||||
|
<div class="mt-1 text-sm text-gray-600 dark:text-gray-300">
|
||||||
|
{{ $item['supportingMessage'] }}
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
|
||||||
|
@if (filled($item['nextStep'] ?? null))
|
||||||
|
<div class="mt-2 text-xs font-medium uppercase tracking-wide text-gray-500 dark:text-gray-400">
|
||||||
|
{{ $item['nextStep'] }}
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
|
||||||
<x-filament::badge :color="$item['badgeColor']" size="sm">
|
<x-filament::badge :color="$item['badgeColor']" size="sm">
|
||||||
{{ $item['badge'] }}
|
{{ $item['badge'] }}
|
||||||
</x-filament::badge>
|
</x-filament::badge>
|
||||||
</div>
|
</div>
|
||||||
<div class="mt-1 text-sm text-gray-600 dark:text-gray-300">{{ $item['body'] }}</div>
|
</div>
|
||||||
</a>
|
|
||||||
@endforeach
|
@endforeach
|
||||||
</div>
|
</div>
|
||||||
@endif
|
@endif
|
||||||
</x-filament::section>
|
</x-filament::section>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -1,53 +1,55 @@
|
|||||||
@php
|
@php
|
||||||
/** @var bool $shouldShow */
|
/** @var array<string, mixed>|null $summaryAssessment */
|
||||||
/** @var ?string $runUrl */
|
$summary = is_array($summaryAssessment ?? null) ? $summaryAssessment : null;
|
||||||
/** @var ?string $state */
|
$tone = (string) ($summary['tone'] ?? 'warning');
|
||||||
/** @var ?string $message */
|
$headline = (string) ($summary['headline'] ?? 'Baseline compare needs review.');
|
||||||
/** @var ?string $coverageStatus */
|
$supportingMessage = $summary['supportingMessage'] ?? null;
|
||||||
/** @var ?string $fidelity */
|
$nextAction = is_array($summary['nextAction'] ?? null) ? $summary['nextAction'] : ['label' => 'Review compare detail', 'target' => 'none'];
|
||||||
/** @var int $uncoveredTypesCount */
|
|
||||||
/** @var list<string> $uncoveredTypes */
|
|
||||||
|
|
||||||
$coverageHasWarnings = in_array(($coverageStatus ?? null), ['warning', 'unproven'], true);
|
[$wrapperClasses, $textClasses] = match ($tone) {
|
||||||
|
'danger' => [
|
||||||
|
'rounded-lg border border-danger-300 bg-danger-50 p-4 dark:border-danger-700 dark:bg-danger-950/40',
|
||||||
|
'text-danger-900 dark:text-danger-100',
|
||||||
|
],
|
||||||
|
'info' => [
|
||||||
|
'rounded-lg border border-info-300 bg-info-50 p-4 dark:border-info-700 dark:bg-info-950/40',
|
||||||
|
'text-info-900 dark:text-info-100',
|
||||||
|
],
|
||||||
|
'gray' => [
|
||||||
|
'rounded-lg border border-gray-200 bg-gray-50 p-4 dark:border-gray-800 dark:bg-white/5',
|
||||||
|
'text-gray-900 dark:text-white',
|
||||||
|
],
|
||||||
|
default => [
|
||||||
|
'rounded-lg border border-warning-300 bg-warning-50 p-4 dark:border-warning-700 dark:bg-warning-950/40',
|
||||||
|
'text-warning-900 dark:text-warning-100',
|
||||||
|
],
|
||||||
|
};
|
||||||
@endphp
|
@endphp
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
@if ($shouldShow && ($coverageHasWarnings || ($state ?? null) === 'no_snapshot'))
|
@if ($shouldShow && $summary)
|
||||||
<div class="rounded-lg border border-warning-300 bg-warning-50 p-4 text-warning-900 dark:border-warning-700 dark:bg-warning-950/40 dark:text-warning-100">
|
<div class="{{ $wrapperClasses }}">
|
||||||
<div class="flex flex-col gap-1">
|
<div class="flex flex-col gap-2">
|
||||||
<div class="text-sm font-semibold">
|
<div class="text-sm font-semibold {{ $textClasses }}">
|
||||||
@if (($state ?? null) === 'no_snapshot')
|
{{ $headline }}
|
||||||
Current baseline unavailable
|
|
||||||
@else
|
|
||||||
Baseline compare coverage warnings
|
|
||||||
@endif
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
@if (filled($supportingMessage))
|
||||||
<div class="text-sm">
|
<div class="text-sm">
|
||||||
@if (($state ?? null) === 'no_snapshot')
|
{{ $supportingMessage }}
|
||||||
{{ $message }}
|
|
||||||
@elseif (($coverageStatus ?? null) === 'unproven')
|
|
||||||
Coverage proof was missing or unreadable for the last baseline comparison, so findings were suppressed for safety.
|
|
||||||
@else
|
|
||||||
The last baseline comparison had incomplete coverage for {{ (int) $uncoveredTypesCount }} policy {{ Str::plural('type', (int) $uncoveredTypesCount) }}. Findings may be incomplete.
|
|
||||||
@endif
|
|
||||||
|
|
||||||
@if (filled($fidelity))
|
|
||||||
<span class="ml-1 text-xs text-warning-800 dark:text-warning-300">Fidelity: {{ Str::title($fidelity) }}</span>
|
|
||||||
@endif
|
|
||||||
</div>
|
|
||||||
|
|
||||||
@if (! empty($uncoveredTypes))
|
|
||||||
<div class="mt-1 text-xs">
|
|
||||||
Uncovered: {{ implode(', ', array_slice($uncoveredTypes, 0, 6)) }}@if (count($uncoveredTypes) > 6)…@endif
|
|
||||||
</div>
|
</div>
|
||||||
@endif
|
@endif
|
||||||
|
|
||||||
@if (($state ?? null) !== 'no_snapshot' && filled($runUrl))
|
@if (filled($nextActionUrl))
|
||||||
<div class="mt-2">
|
<div class="mt-1">
|
||||||
<a class="text-sm font-medium text-primary-600 hover:underline dark:text-primary-400" href="{{ $runUrl }}">
|
<a class="text-sm font-medium text-primary-600 hover:underline dark:text-primary-400" href="{{ $nextActionUrl }}">
|
||||||
View run
|
{{ $nextAction['label'] }}
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
|
@elseif (filled($nextAction['label'] ?? null))
|
||||||
|
<div class="text-xs font-medium uppercase tracking-wide">
|
||||||
|
{{ $nextAction['label'] }}
|
||||||
|
</div>
|
||||||
@endif
|
@endif
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -0,0 +1,3 @@
|
|||||||
|
<div class="space-y-2">
|
||||||
|
{{ $this->table }}
|
||||||
|
</div>
|
||||||
@ -4,6 +4,7 @@
|
|||||||
use App\Http\Controllers\AdminConsentCallbackController;
|
use App\Http\Controllers\AdminConsentCallbackController;
|
||||||
use App\Http\Controllers\Auth\EntraController;
|
use App\Http\Controllers\Auth\EntraController;
|
||||||
use App\Http\Controllers\ClearTenantContextController;
|
use App\Http\Controllers\ClearTenantContextController;
|
||||||
|
use App\Http\Controllers\OpenFindingExceptionsQueueController;
|
||||||
use App\Http\Controllers\RbacDelegatedAuthController;
|
use App\Http\Controllers\RbacDelegatedAuthController;
|
||||||
use App\Http\Controllers\ReviewPackDownloadController;
|
use App\Http\Controllers\ReviewPackDownloadController;
|
||||||
use App\Http\Controllers\SelectTenantController;
|
use App\Http\Controllers\SelectTenantController;
|
||||||
@ -67,6 +68,10 @@
|
|||||||
->post('/admin/switch-workspace', SwitchWorkspaceController::class)
|
->post('/admin/switch-workspace', SwitchWorkspaceController::class)
|
||||||
->name('admin.switch-workspace');
|
->name('admin.switch-workspace');
|
||||||
|
|
||||||
|
Route::middleware(['web', 'auth', 'ensure-correct-guard:web'])
|
||||||
|
->get('/admin/finding-exceptions/open-queue/{tenant}', OpenFindingExceptionsQueueController::class)
|
||||||
|
->name('admin.finding-exceptions.open-queue');
|
||||||
|
|
||||||
Route::middleware(['web', 'auth', 'ensure-correct-guard:web', 'ensure-workspace-selected'])
|
Route::middleware(['web', 'auth', 'ensure-correct-guard:web', 'ensure-workspace-selected'])
|
||||||
->post('/admin/select-tenant', SelectTenantController::class)
|
->post('/admin/select-tenant', SelectTenantController::class)
|
||||||
->name('admin.select-tenant');
|
->name('admin.select-tenant');
|
||||||
|
|||||||
36
specs/162-baseline-gap-details/checklists/requirements.md
Normal file
36
specs/162-baseline-gap-details/checklists/requirements.md
Normal file
@ -0,0 +1,36 @@
|
|||||||
|
# Specification Quality Checklist: Enterprise Evidence Gap Details for Baseline Compare
|
||||||
|
|
||||||
|
**Purpose**: Validate specification completeness and quality before proceeding to planning
|
||||||
|
**Created**: 2026-03-24
|
||||||
|
**Feature**: [spec.md](../spec.md)
|
||||||
|
|
||||||
|
## Content Quality
|
||||||
|
|
||||||
|
- [x] No implementation details (languages, frameworks, APIs)
|
||||||
|
- [x] Focused on user value and business needs
|
||||||
|
- [x] Written for non-technical stakeholders
|
||||||
|
- [x] All mandatory sections completed
|
||||||
|
|
||||||
|
## Requirement Completeness
|
||||||
|
|
||||||
|
- [x] No [NEEDS CLARIFICATION] markers remain
|
||||||
|
- [x] Requirements are testable and unambiguous
|
||||||
|
- [x] Success criteria are measurable
|
||||||
|
- [x] Success criteria are technology-agnostic (no implementation details)
|
||||||
|
- [x] All acceptance scenarios are defined
|
||||||
|
- [x] Edge cases are identified
|
||||||
|
- [x] Scope is clearly bounded
|
||||||
|
- [x] Dependencies and assumptions identified
|
||||||
|
|
||||||
|
## Feature Readiness
|
||||||
|
|
||||||
|
- [x] All functional requirements have clear acceptance criteria
|
||||||
|
- [x] User scenarios cover primary flows
|
||||||
|
- [x] Feature meets measurable outcomes defined in Success Criteria
|
||||||
|
- [x] No implementation details leak into specification
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
- Validation pass completed on 2026-03-24.
|
||||||
|
- Spec intentionally covers both canonical Monitoring → Operation Run Detail and tenant-scoped Baseline Compare review because operators move between both surfaces during the same investigation flow.
|
||||||
|
- No clarification blockers remain; spec is ready for /speckit.plan.
|
||||||
@ -0,0 +1,108 @@
|
|||||||
|
openapi: 3.1.0
|
||||||
|
info:
|
||||||
|
title: Baseline Compare Evidence Gap Details Contract
|
||||||
|
version: 0.1.0
|
||||||
|
summary: Internal reference schema for the shared operator-safe evidence-gap read model.
|
||||||
|
description: |
|
||||||
|
This artifact defines the stable read shape for baseline compare evidence-gap detail.
|
||||||
|
It is designed for canonical Monitoring run detail and tenant baseline compare review surfaces.
|
||||||
|
In this feature slice it is a reference schema for the shared page read model, not a commitment to add new HTTP endpoints.
|
||||||
|
servers:
|
||||||
|
- url: https://tenantpilot.local
|
||||||
|
paths: {}
|
||||||
|
components:
|
||||||
|
schemas:
|
||||||
|
OperationRunReference:
|
||||||
|
type: object
|
||||||
|
required:
|
||||||
|
- id
|
||||||
|
- type
|
||||||
|
- status
|
||||||
|
- outcome
|
||||||
|
properties:
|
||||||
|
id:
|
||||||
|
type: integer
|
||||||
|
minimum: 1
|
||||||
|
type:
|
||||||
|
type: string
|
||||||
|
enum:
|
||||||
|
- baseline_compare
|
||||||
|
status:
|
||||||
|
type: string
|
||||||
|
outcome:
|
||||||
|
type: string
|
||||||
|
tenant_id:
|
||||||
|
type: integer
|
||||||
|
nullable: true
|
||||||
|
workspace_id:
|
||||||
|
type: integer
|
||||||
|
nullable: true
|
||||||
|
EvidenceGapBucket:
|
||||||
|
type: object
|
||||||
|
required:
|
||||||
|
- reason_code
|
||||||
|
- reason_label
|
||||||
|
- count
|
||||||
|
- rows
|
||||||
|
properties:
|
||||||
|
reason_code:
|
||||||
|
type: string
|
||||||
|
reason_label:
|
||||||
|
type: string
|
||||||
|
count:
|
||||||
|
type: integer
|
||||||
|
minimum: 0
|
||||||
|
rows:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
$ref: '#/components/schemas/EvidenceGapRow'
|
||||||
|
EvidenceGapRow:
|
||||||
|
type: object
|
||||||
|
required:
|
||||||
|
- reason_code
|
||||||
|
- policy_type
|
||||||
|
- subject_key
|
||||||
|
properties:
|
||||||
|
reason_code:
|
||||||
|
type: string
|
||||||
|
policy_type:
|
||||||
|
type: string
|
||||||
|
subject_key:
|
||||||
|
type: string
|
||||||
|
EvidenceGapSummary:
|
||||||
|
type: object
|
||||||
|
required:
|
||||||
|
- count
|
||||||
|
- by_reason
|
||||||
|
- detail_state
|
||||||
|
properties:
|
||||||
|
count:
|
||||||
|
type: integer
|
||||||
|
minimum: 0
|
||||||
|
by_reason:
|
||||||
|
type: object
|
||||||
|
additionalProperties:
|
||||||
|
type: integer
|
||||||
|
minimum: 0
|
||||||
|
detail_state:
|
||||||
|
type: string
|
||||||
|
enum:
|
||||||
|
- no_gaps
|
||||||
|
- details_recorded
|
||||||
|
- details_not_recorded
|
||||||
|
EvidenceGapDetailResponse:
|
||||||
|
type: object
|
||||||
|
required:
|
||||||
|
- summary
|
||||||
|
- buckets
|
||||||
|
properties:
|
||||||
|
summary:
|
||||||
|
$ref: '#/components/schemas/EvidenceGapSummary'
|
||||||
|
buckets:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
$ref: '#/components/schemas/EvidenceGapBucket'
|
||||||
|
filters:
|
||||||
|
type: object
|
||||||
|
additionalProperties:
|
||||||
|
type: string
|
||||||
139
specs/162-baseline-gap-details/data-model.md
Normal file
139
specs/162-baseline-gap-details/data-model.md
Normal file
@ -0,0 +1,139 @@
|
|||||||
|
# Data Model: Enterprise Evidence Gap Details for Baseline Compare
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
This feature does not introduce new database tables. It extends the tenant-owned `OperationRun` JSONB context for `baseline_compare` runs and defines a derived operator-facing row model for evidence-gap detail rendering.
|
||||||
|
|
||||||
|
## Entity: OperationRun
|
||||||
|
|
||||||
|
- Ownership: Tenant-owned operational artifact with `workspace_id` and `tenant_id`
|
||||||
|
- Storage: Existing `operation_runs` table
|
||||||
|
- Relevant relationships:
|
||||||
|
- Belongs to `Tenant`
|
||||||
|
- Belongs to `Workspace`
|
||||||
|
- Belongs to initiating `User`
|
||||||
|
- Relevant invariant:
|
||||||
|
- `status` and `outcome` remain service-owned through `OperationRunService`
|
||||||
|
- `context` may be enriched by the compare job without changing lifecycle semantics
|
||||||
|
|
||||||
|
## Subdocument: `baseline_compare`
|
||||||
|
|
||||||
|
Stored under `OperationRun.context['baseline_compare']`.
|
||||||
|
|
||||||
|
### Fields
|
||||||
|
|
||||||
|
| Field | Type | Required | Description |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `inventory_sync_run_id` | integer | no | Source inventory sync run used for coverage and freshness context |
|
||||||
|
| `subjects_total` | integer | no | Total compare subjects considered for the run |
|
||||||
|
| `evidence_capture` | object | no | Capture stats such as requested, succeeded, skipped, failed, throttled |
|
||||||
|
| `coverage` | object | no | Coverage proof and covered/uncovered policy types |
|
||||||
|
| `fidelity` | string | no | Compare fidelity, typically `meta` or `content` |
|
||||||
|
| `reason_code` | string | no | Top-level compare reason code used by the explanation layer |
|
||||||
|
| `resume_token` | string or null | no | Resume state for incomplete content capture |
|
||||||
|
| `evidence_gaps` | object | no | Aggregate and subject-level evidence-gap contract |
|
||||||
|
|
||||||
|
## Subdocument: `baseline_compare.evidence_gaps`
|
||||||
|
|
||||||
|
### Fields
|
||||||
|
|
||||||
|
| Field | Type | Required | Description |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `count` | integer | no | Total evidence-gap count across all reasons |
|
||||||
|
| `by_reason` | object<string,int> | no | Aggregate counts keyed by evidence-gap reason code |
|
||||||
|
| `subjects` | object<string,list<string>> | no | Bounded reason-grouped list of concrete subject keys |
|
||||||
|
|
||||||
|
### Validation Rules
|
||||||
|
|
||||||
|
- `count` must be a non-negative integer.
|
||||||
|
- Each `by_reason` value must be a non-negative integer.
|
||||||
|
- Each `subjects` key must be a non-empty reason code string.
|
||||||
|
- Each `subjects[reason]` list item must be a non-empty string in `policy_type|subject_key` format.
|
||||||
|
- Subject lists are deduplicated per reason.
|
||||||
|
- Subject lists are capped at the compare job limit, currently 50 items per reason.
|
||||||
|
|
||||||
|
## Derived Entity: EvidenceGapDetailRow
|
||||||
|
|
||||||
|
This is not stored separately. It is derived from `baseline_compare.evidence_gaps.subjects` for rendering and filtering.
|
||||||
|
|
||||||
|
### Fields
|
||||||
|
|
||||||
|
| Field | Type | Source | Description |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `reason_code` | string | map key | Stable reason identifier such as `ambiguous_match` or `policy_not_found` |
|
||||||
|
| `reason_label` | string | derived | Operator-facing label from `reason_code` |
|
||||||
|
| `policy_type` | string | parsed from subject string | Policy family segment before the first pipe |
|
||||||
|
| `subject_key` | string | parsed from subject string | Subject identity segment after the first pipe |
|
||||||
|
| `search_text` | string | derived | Lowercased concatenation of reason, policy type, and subject key for local filtering |
|
||||||
|
|
||||||
|
### Validation Rules
|
||||||
|
|
||||||
|
- `policy_type` must be non-empty.
|
||||||
|
- `subject_key` may be human-readable text, GUID-like values, or workspace-safe identifiers, but must be non-empty once persisted.
|
||||||
|
- `search_text` must be deterministic and derived only from persisted row values.
|
||||||
|
|
||||||
|
## Derived Entity: EvidenceGapReasonBucket
|
||||||
|
|
||||||
|
Groups detail rows for rendering.
|
||||||
|
|
||||||
|
### Fields
|
||||||
|
|
||||||
|
| Field | Type | Description |
|
||||||
|
|---|---|---|
|
||||||
|
| `reason_code` | string | Stable bucket key |
|
||||||
|
| `reason_label` | string | Operator-facing label |
|
||||||
|
| `count` | integer | Number of visible persisted subjects in the bucket |
|
||||||
|
| `rows` | list<EvidenceGapDetailRow> | Rows shown for the reason |
|
||||||
|
|
||||||
|
## State Model
|
||||||
|
|
||||||
|
### Run detail evidence-gap states
|
||||||
|
|
||||||
|
1. `NoGaps`
|
||||||
|
- `evidence_gaps.count` is absent or `0`
|
||||||
|
- No evidence-gap detail section is required
|
||||||
|
|
||||||
|
2. `GapsWithRecordedSubjects`
|
||||||
|
- `evidence_gaps.count > 0`
|
||||||
|
- `evidence_gaps.subjects` exists with at least one non-empty reason bucket
|
||||||
|
- Render searchable grouped rows
|
||||||
|
|
||||||
|
3. `GapsWithoutRecordedSubjects`
|
||||||
|
- `evidence_gaps.count > 0`
|
||||||
|
- `evidence_gaps.subjects` is absent or empty
|
||||||
|
- Render explicit fallback copy indicating detail was not recorded for that run
|
||||||
|
|
||||||
|
## Example Payload
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"baseline_compare": {
|
||||||
|
"subjects_total": 50,
|
||||||
|
"fidelity": "meta",
|
||||||
|
"reason_code": "evidence_capture_incomplete",
|
||||||
|
"evidence_capture": {
|
||||||
|
"requested": 50,
|
||||||
|
"succeeded": 47,
|
||||||
|
"skipped": 0,
|
||||||
|
"failed": 3,
|
||||||
|
"throttled": 0
|
||||||
|
},
|
||||||
|
"evidence_gaps": {
|
||||||
|
"count": 5,
|
||||||
|
"by_reason": {
|
||||||
|
"ambiguous_match": 3,
|
||||||
|
"policy_not_found": 2
|
||||||
|
},
|
||||||
|
"subjects": {
|
||||||
|
"ambiguous_match": [
|
||||||
|
"deviceConfiguration|WiFi-Corp-Profile",
|
||||||
|
"deviceConfiguration|VPN-Always-On"
|
||||||
|
],
|
||||||
|
"policy_not_found": [
|
||||||
|
"deviceConfiguration|Deleted-Policy-ABC"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
139
specs/162-baseline-gap-details/plan.md
Normal file
139
specs/162-baseline-gap-details/plan.md
Normal file
@ -0,0 +1,139 @@
|
|||||||
|
# Implementation Plan: Enterprise Evidence Gap Details for Baseline Compare
|
||||||
|
|
||||||
|
**Branch**: `162-baseline-gap-details` | **Date**: 2026-03-24 | **Spec**: `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/specs/162-baseline-gap-details/spec.md`
|
||||||
|
**Input**: Feature specification from `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/specs/162-baseline-gap-details/spec.md`
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
Expose baseline compare evidence gaps as an operator-first, searchable, tenant-safe detail experience backed by immutable `OperationRun.context` data. The implementation keeps compare execution and `OperationRun` lifecycle unchanged, persists bounded per-reason subject details during compare/capture work, renders them in the canonical run-detail and tenant compare surfaces ahead of raw JSON diagnostics, and validates the behavior with Pest feature coverage for persistence, rendering, and legacy-run fallbacks.
|
||||||
|
|
||||||
|
## Technical Context
|
||||||
|
|
||||||
|
**Language/Version**: PHP 8.4 / Laravel 12, Blade, Alpine via Filament, Tailwind CSS v4
|
||||||
|
**Primary Dependencies**: Filament v5, Livewire v4, Pest v4, Laravel Sail, existing `OperationRun` and baseline compare services
|
||||||
|
**Storage**: PostgreSQL with JSONB-backed `operation_runs.context`; no new tables required
|
||||||
|
**Testing**: Pest feature tests run through Sail, plus existing Filament page rendering tests
|
||||||
|
**Target Platform**: Laravel web application in Sail locally and Linux container deployment in staging/production
|
||||||
|
**Project Type**: Web application
|
||||||
|
**Performance Goals**: DB-only render path; no render-time Graph calls; searchable gap detail remains usable for bounded per-reason subject lists; operator can isolate a relevant subject in under 30 seconds
|
||||||
|
**Constraints**: Preserve `OperationRunService` ownership of status/outcome, keep evidence-gap JSON bounded by existing caps, retain tenant-safe canonical monitoring behavior, add no destructive actions, and avoid new global/published Filament assets
|
||||||
|
**Scale/Scope**: Tenant-scoped baseline compare runs for enterprise tenants, subject-detail persistence within the existing compare job/capture pipeline, read-only UX changes across the canonical run detail and tenant baseline compare landing surfaces
|
||||||
|
|
||||||
|
## Constitution Check
|
||||||
|
|
||||||
|
*GATE: Passed before Phase 0 research. Re-checked after Phase 1 design and still passing.*
|
||||||
|
|
||||||
|
- Inventory-first: Pass. This feature reads existing inventory and baseline snapshot outputs and only improves how compare gaps are persisted and presented.
|
||||||
|
- Read/write separation: Pass. Compare initiation remains unchanged; the new surface is read-only and introduces no new mutation path.
|
||||||
|
- Graph contract path: Pass. No new Graph calls or contract bypasses are introduced; all render behavior remains DB-only.
|
||||||
|
- Deterministic capabilities: Pass. No new capability derivation is introduced.
|
||||||
|
- RBAC-UX plane separation and tenant safety: Pass. Canonical `/admin/operations/{run}` remains tenant-safe through existing workspace + tenant entitlement checks, and tenant landing remains tenant-scoped.
|
||||||
|
- Workspace isolation: Pass. No workspace-context semantics are broadened.
|
||||||
|
- Destructive confirmation: Pass. No destructive action is added or changed.
|
||||||
|
- Global search safety: Pass. No global-search behavior is introduced or modified.
|
||||||
|
- Tenant isolation: Pass. Evidence-gap detail is persisted on the tenant-owned `OperationRun` and revealed only through already authorized run/tenant surfaces.
|
||||||
|
- Run observability: Pass. Existing baseline compare `OperationRun` behavior stays intact; this feature enriches context only.
|
||||||
|
- Ops-UX 3-surface feedback: Pass. No additional toasts, progress surfaces, or terminal notifications are introduced.
|
||||||
|
- Ops-UX lifecycle ownership: Pass. `status`/`outcome` transitions remain service-owned; only `context` payload content is extended.
|
||||||
|
- Ops-UX summary counts: Pass. No new `summary_counts` keys are added.
|
||||||
|
- Ops-UX guards and system-run rules: Pass. Existing monitoring and notification invariants remain untouched.
|
||||||
|
- Automation/backoff: Pass. Existing capture-phase retry/backoff behavior remains the only throttling mechanism in scope.
|
||||||
|
- Data minimization: Pass. Persisted detail is bounded, operator-safe subject identity rather than raw payload dumps or secrets.
|
||||||
|
- Badge semantics: Pass. Existing run outcome/trust badges remain centralized; evidence-gap detail is plain text/searchable rows.
|
||||||
|
- UI naming: Pass. The operator copy uses domain terms such as `Evidence gap details`, `Policy type`, and `Subject key`.
|
||||||
|
- Operator surfaces: Pass. Outcome and trust stay ahead of diagnostics, and the detail section is secondary to result meaning.
|
||||||
|
- Filament Action Surface Contract: Pass. The change is read-only and does not alter header/row/bulk/destructive semantics.
|
||||||
|
- UX-001 Layout and IA: Pass. The detail stays sectioned and readable within the existing enterprise detail layout.
|
||||||
|
- Filament v5 / Livewire v4 compliance: Pass. The feature remains within the existing Filament v5 + Livewire v4 stack.
|
||||||
|
- Provider registration location: Pass. No panel provider changes are required; Laravel 11+ registration remains in `bootstrap/providers.php`.
|
||||||
|
- Global search hard rule: Pass. No resource searchability changes are required.
|
||||||
|
- Asset strategy: Pass. The design relies on existing Filament/Blade/Alpine capabilities and does not require new published or on-demand assets.
|
||||||
|
|
||||||
|
## Phase 0 Research
|
||||||
|
|
||||||
|
Research outcomes are captured in `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/specs/162-baseline-gap-details/research.md`.
|
||||||
|
|
||||||
|
Key decisions:
|
||||||
|
|
||||||
|
- Persist subject-level evidence gaps as an immutable read model under `baseline_compare.evidence_gaps.subjects` inside `OperationRun.context`.
|
||||||
|
- Merge subject detail from all compare evidence-gap sources: ambiguous inventory matches, capture-phase failures, and drift-time missing evidence.
|
||||||
|
- Keep filtering local to the rendered detail section because the stored dataset is intentionally bounded and render-time network/database chatter would add unnecessary complexity.
|
||||||
|
- Preserve operator-first reading order: result meaning and trust first, evidence-gap detail second, raw JSON last.
|
||||||
|
- Treat legacy runs with counts but no recorded subjects as a first-class fallback state rather than as empty/healthy runs.
|
||||||
|
|
||||||
|
## Phase 1 Design
|
||||||
|
|
||||||
|
Design artifacts are created under `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/specs/162-baseline-gap-details/`:
|
||||||
|
|
||||||
|
- `data-model.md`: JSONB-backed read-model contract and derived UI row shape.
|
||||||
|
- `contracts/baseline-gap-details.openapi.yaml`: internal reference schema for the shared evidence-gap read model used by both operator surfaces; it is not a commitment to add new HTTP endpoints in this slice.
|
||||||
|
- `quickstart.md`: verification path covering focused tests, tenant-safety checks, render-safety checks, local run review, and legacy-run fallback checks.
|
||||||
|
|
||||||
|
Design decisions:
|
||||||
|
|
||||||
|
- No schema migration is required; the durable contract is an extension of the existing JSONB payload.
|
||||||
|
- The canonical record remains `OperationRun`; no separate evidence-gap table or Eloquent model is introduced.
|
||||||
|
- Filtering is modeled as reason/policy-type/subject-key matching over persisted row data in the page surface, not via new server-side filtering endpoints in this slice.
|
||||||
|
- Tenant landing and canonical run detail must consume semantically identical evidence-gap groupings and preserve operator-first summary ordering ahead of diagnostics.
|
||||||
|
- Regression coverage must explicitly prove tenant-safe access semantics and the no-external-calls-on-render rule on both affected surfaces.
|
||||||
|
|
||||||
|
## Project Structure
|
||||||
|
|
||||||
|
### Documentation (this feature)
|
||||||
|
|
||||||
|
```text
|
||||||
|
specs/162-baseline-gap-details/
|
||||||
|
├── plan.md
|
||||||
|
├── research.md
|
||||||
|
├── data-model.md
|
||||||
|
├── quickstart.md
|
||||||
|
├── contracts/
|
||||||
|
│ └── baseline-gap-details.openapi.yaml
|
||||||
|
└── tasks.md
|
||||||
|
```
|
||||||
|
|
||||||
|
### Source Code (repository root)
|
||||||
|
|
||||||
|
```text
|
||||||
|
app/
|
||||||
|
├── Filament/
|
||||||
|
│ └── Resources/
|
||||||
|
│ └── OperationRunResource.php
|
||||||
|
├── Jobs/
|
||||||
|
│ └── CompareBaselineToTenantJob.php
|
||||||
|
├── Models/
|
||||||
|
│ └── OperationRun.php
|
||||||
|
└── Services/
|
||||||
|
└── Baselines/
|
||||||
|
└── BaselineContentCapturePhase.php
|
||||||
|
|
||||||
|
resources/
|
||||||
|
└── views/
|
||||||
|
└── filament/
|
||||||
|
└── infolists/
|
||||||
|
└── entries/
|
||||||
|
└── evidence-gap-subjects.blade.php
|
||||||
|
|
||||||
|
tests/
|
||||||
|
└── Feature/
|
||||||
|
├── Baselines/
|
||||||
|
│ ├── BaselineCompareAmbiguousMatchGapTest.php
|
||||||
|
│ └── BaselineCompareResumeTokenTest.php
|
||||||
|
└── Filament/
|
||||||
|
└── OperationRunEnterpriseDetailPageTest.php
|
||||||
|
```
|
||||||
|
|
||||||
|
**Structure Decision**: Single Laravel web application. The feature stays inside the existing baseline compare pipeline, `OperationRun` read model, Filament resource rendering, Blade view composition, and Pest feature-test layout.
|
||||||
|
|
||||||
|
## Implementation Strategy
|
||||||
|
|
||||||
|
1. Normalize and persist subject-level gap detail in the compare/capture pipeline while preserving bounded payload size and legacy compare semantics.
|
||||||
|
2. Render the evidence-gap detail section from `OperationRun.context` on canonical run detail and keep the tenant landing semantics aligned.
|
||||||
|
3. Support operator filtering across reason, policy type, and subject key without introducing a new server-side search endpoint in the first implementation.
|
||||||
|
4. Keep operator-first summary content ahead of diagnostics on both the canonical run detail and tenant landing surfaces.
|
||||||
|
5. Add regression coverage for new-run persistence, tenant-safe access semantics, render-safety, UI visibility, and legacy/no-detail behavior.
|
||||||
|
|
||||||
|
## Complexity Tracking
|
||||||
|
|
||||||
|
No constitution violations or justified complexity exceptions were identified.
|
||||||
|
|
||||||
61
specs/162-baseline-gap-details/quickstart.md
Normal file
61
specs/162-baseline-gap-details/quickstart.md
Normal file
@ -0,0 +1,61 @@
|
|||||||
|
# Quickstart: Enterprise Evidence Gap Details for Baseline Compare
|
||||||
|
|
||||||
|
## Prerequisites
|
||||||
|
|
||||||
|
1. Start the local stack.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
vendor/bin/sail up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
2. Ensure the app is clean enough to run focused tests.
|
||||||
|
|
||||||
|
## Focused Verification
|
||||||
|
|
||||||
|
Run the minimum regression pack for the feature:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
vendor/bin/sail artisan test --compact tests/Feature/Baselines/BaselineCompareAmbiguousMatchGapTest.php tests/Feature/Baselines/BaselineCompareResumeTokenTest.php tests/Feature/Filament/OperationRunEnterpriseDetailPageTest.php
|
||||||
|
```
|
||||||
|
|
||||||
|
Format touched files before shipping implementation updates:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
vendor/bin/sail bin pint --dirty --format agent
|
||||||
|
```
|
||||||
|
|
||||||
|
## Manual Verification Flow
|
||||||
|
|
||||||
|
1. Trigger or locate a completed `baseline_compare` run with evidence gaps.
|
||||||
|
2. Open the canonical run detail page at `/admin/operations/{run}`.
|
||||||
|
3. Confirm the page shows outcome/trust guidance before diagnostics.
|
||||||
|
4. Confirm the `Evidence gap details` section is visible when subject-level details exist.
|
||||||
|
5. Use `Search gap details` to filter by:
|
||||||
|
- reason text such as `ambiguous`
|
||||||
|
- policy type such as `deviceConfiguration`
|
||||||
|
- subject key fragment such as part of a display name or GUID
|
||||||
|
6. Confirm raw JSON evidence remains available in the separate `Baseline compare evidence` section.
|
||||||
|
|
||||||
|
## Legacy-Run Verification
|
||||||
|
|
||||||
|
1. Open an older baseline compare run that contains `evidence_gaps.count` but no `evidence_gaps.subjects`.
|
||||||
|
2. Confirm the UI distinguishes missing recorded detail from the absence of gaps.
|
||||||
|
3. Confirm the page still renders successfully and does not imply a healthy compare result.
|
||||||
|
|
||||||
|
## Tenant-Safety Verification
|
||||||
|
|
||||||
|
1. Verify an entitled user can inspect the same run through canonical monitoring.
|
||||||
|
2. Verify a non-member cannot discover tenant-owned detail through canonical or tenant-scoped surfaces.
|
||||||
|
3. Verify member-but-underprivileged behavior remains enforced by existing authorization rules.
|
||||||
|
|
||||||
|
## Render-Safety Verification
|
||||||
|
|
||||||
|
1. Bind the fail-hard graph client in the affected UI tests.
|
||||||
|
2. Verify the canonical run detail renders evidence-gap detail without invoking `GraphClientInterface`.
|
||||||
|
3. Verify the tenant landing evidence-gap state renders without invoking `GraphClientInterface`.
|
||||||
|
|
||||||
|
## Deployment Notes
|
||||||
|
|
||||||
|
- No new database migration is required.
|
||||||
|
- No new Filament assets are registered, so this feature does not add a new `filament:assets` deployment requirement.
|
||||||
|
- Filament remains on Livewire v4-compatible patterns and requires no panel provider changes.
|
||||||
57
specs/162-baseline-gap-details/research.md
Normal file
57
specs/162-baseline-gap-details/research.md
Normal file
@ -0,0 +1,57 @@
|
|||||||
|
# Research: Enterprise Evidence Gap Details for Baseline Compare
|
||||||
|
|
||||||
|
## Decision 1: Persist evidence-gap subjects inside `OperationRun.context`
|
||||||
|
|
||||||
|
- Decision: Store concrete evidence-gap subject detail in `baseline_compare.evidence_gaps.subjects` within the existing JSONB `OperationRun.context` payload.
|
||||||
|
- Rationale: The canonical operator review surface is already backed by `OperationRun`, Monitoring pages must remain DB-only at render time, and compare runs are immutable operational artifacts. Extending the existing context preserves observability without introducing a new persistence model.
|
||||||
|
- Alternatives considered:
|
||||||
|
- Create a dedicated relational evidence-gap table: rejected because the feature needs a bounded, immutable run snapshot rather than an independently mutable dataset.
|
||||||
|
- Recompute detail on demand from inventory and baseline state: rejected because it would violate DB-only render expectations and risk drift between recorded compare outcome and later inventory state.
|
||||||
|
|
||||||
|
## Decision 2: Merge all evidence-gap subject sources before persistence
|
||||||
|
|
||||||
|
- Decision: Consolidate gap subjects from ambiguous current-inventory matches, capture-phase failures, and drift-time missing evidence into one reason-grouped structure.
|
||||||
|
- Rationale: Operators need one coherent explanation of why confidence is reduced. Splitting detail across multiple internal sources would force the UI to know too much about compare internals and would create inconsistent trust messaging.
|
||||||
|
- Alternatives considered:
|
||||||
|
- Persist only ambiguous matches: rejected because it would leave `policy_not_found`, `missing_current`, and similar reasons as counts-only.
|
||||||
|
- Persist per-phase fragments separately: rejected because the UI contract is reason-oriented, not phase-oriented.
|
||||||
|
|
||||||
|
## Decision 3: Keep filtering local to the rendered detail surface
|
||||||
|
|
||||||
|
- Decision: Use local filtering across reason, policy type, and subject key in the evidence-gap detail surface.
|
||||||
|
- Rationale: The payload is intentionally bounded by the compare job, the operator workflow is investigative rather than analytical, and local filtering avoids new server requests or additional read endpoints in the initial user experience.
|
||||||
|
- Alternatives considered:
|
||||||
|
- Server-side filtering via new query endpoints: rejected for the initial slice because it adds API surface without solving a current scale bottleneck.
|
||||||
|
- No filtering at all: rejected because enterprise runs can accumulate enough subjects to make manual scanning too slow.
|
||||||
|
|
||||||
|
## Decision 4: Preserve operator-first information hierarchy
|
||||||
|
|
||||||
|
- Decision: Keep result meaning, trust, and next-step guidance ahead of evidence-gap detail, and keep raw JSON diagnostics last.
|
||||||
|
- Rationale: The constitution requires operator-first `/admin` surfaces. Evidence-gap detail is important, but it supports the decision already summarized by the run outcome and explanation layers.
|
||||||
|
- Alternatives considered:
|
||||||
|
- Show raw JSON only: rejected because it fails the operator-first requirement.
|
||||||
|
- Put evidence-gap rows ahead of result meaning: rejected because it would over-prioritize diagnostics and weaken the page contract.
|
||||||
|
|
||||||
|
## Decision 5: Explicitly model legacy and partial-detail runs
|
||||||
|
|
||||||
|
- Decision: Differentiate among runs with no evidence gaps, runs with gaps and recorded subjects, and runs with gaps but no recorded subject detail.
|
||||||
|
- Rationale: Historical compare runs already exist, and silence must not be interpreted as health. The UI needs an explicit fallback state to preserve trust in old data.
|
||||||
|
- Alternatives considered:
|
||||||
|
- Treat missing subjects as empty subjects: rejected because it misrepresents historical/partial runs.
|
||||||
|
- Hide the section when subjects are missing: rejected because operators would lose the signal that detail quality differs across runs.
|
||||||
|
|
||||||
|
## Decision 6: Use existing Filament/Blade patterns rather than new assets
|
||||||
|
|
||||||
|
- Decision: Implement the detail surface with existing Filament resource sections, Blade partials, and Alpine-powered filtering only.
|
||||||
|
- Rationale: The feature does not require a new panel plugin, custom published asset, or heavy client library. Existing Filament v5 and Livewire v4 patterns already support the interaction.
|
||||||
|
- Alternatives considered:
|
||||||
|
- Introduce a custom JS table package: rejected because it adds operational overhead and does not materially improve the bounded use case.
|
||||||
|
- Publish or override Filament internal views: rejected because render hooks and custom entries are sufficient.
|
||||||
|
|
||||||
|
## Decision 7: Validate with persistence and render-path regression tests
|
||||||
|
|
||||||
|
- Decision: Anchor verification in Pest feature tests for compare persistence, capture-phase subject storage, and run-detail rendering.
|
||||||
|
- Rationale: The root failure was data not being persisted, not just a missing view. The test plan must cover both the job path and the operator surface.
|
||||||
|
- Alternatives considered:
|
||||||
|
- UI-only assertions: rejected because they would not prove the persistence contract.
|
||||||
|
- Queue smoke tests only: rejected because they are too broad to protect the specific JSON contract.
|
||||||
144
specs/162-baseline-gap-details/spec.md
Normal file
144
specs/162-baseline-gap-details/spec.md
Normal file
@ -0,0 +1,144 @@
|
|||||||
|
# Feature Specification: Enterprise Evidence Gap Details for Baseline Compare
|
||||||
|
|
||||||
|
**Feature Branch**: `162-baseline-gap-details`
|
||||||
|
**Created**: 2026-03-24
|
||||||
|
**Status**: Draft
|
||||||
|
**Input**: User description: "Create an enterprise-grade baseline compare evidence gap details experience for operation runs, including searchable operator-first presentation of concrete gap subjects, diagnostic clarity, filtering expectations, audit-safe visibility, and best-practice information architecture for tenant-scoped operations."
|
||||||
|
|
||||||
|
## Spec Scope Fields *(mandatory)*
|
||||||
|
|
||||||
|
- **Scope**: tenant, canonical-view
|
||||||
|
- **Primary Routes**: `/admin/operations/{run}`, `/admin/t/{tenant}/baseline-compare-landing`, and existing related navigation back to tenant operations and baseline compare entry points
|
||||||
|
- **Data Ownership**: Tenant-owned `OperationRun` records remain the source of evidence-gap execution context. Workspace-owned baseline profiles and snapshots remain unchanged in ownership. This feature changes capture and presentation of tenant-owned evidence-gap detail, not record ownership.
|
||||||
|
- **RBAC**: Existing workspace membership, tenant entitlement, and baseline compare or monitoring view capabilities remain authoritative. No new role or capability is introduced.
|
||||||
|
- **Default filter behavior when tenant-context is active**: Canonical Monitoring entry points continue to respect active tenant context in navigation and related links, while direct run-detail access remains explicit to the run's tenant and must not silently widen visibility.
|
||||||
|
- **Explicit entitlement checks preventing cross-tenant leakage**: Canonical run detail and tenant compare review surfaces must continue to enforce workspace entitlement first and tenant entitlement second, with deny-as-not-found behavior for non-members and no cross-tenant hinting through gap details.
|
||||||
|
|
||||||
|
## Operator Surface Contract *(mandatory when operator-facing surfaces are changed)*
|
||||||
|
|
||||||
|
| Surface | Primary Persona | Surface Type | Primary Operator Question | Default-visible Information | Diagnostics-only Information | Status Dimensions Used | Mutation Scope | Primary Actions | Dangerous Actions |
|
||||||
|
|---|---|---|---|---|---|---|---|---|---|
|
||||||
|
| Monitoring → Operation Run Detail for baseline compare runs | Workspace manager or entitled tenant operator | Canonical detail | Which specific subjects caused evidence gaps, and can I trust this compare result? | Outcome, trust statement, next step, grouped gap-detail summary, searchable gap subjects, related context | Raw JSON, internal payload fragments, low-level capture fragments | execution outcome, result trust, data completeness, follow-up readiness | Simulation only for compare results, TenantPilot only for page rendering | View run, search gap details, open related tenant operations | None |
|
||||||
|
| Tenant Baseline Compare landing | Tenant operator | Tenant-scoped review surface | Which evidence gaps are blocking a trustworthy compare, and what should I inspect next? | Result meaning, gap counts, grouped reasons, searchable concrete subjects, next-step guidance | Raw evidence payload, secondary technical context | evaluation result, reliability, completeness, actionability | Simulation only | Compare now, inspect latest run, filter concrete gaps | None |
|
||||||
|
|
||||||
|
## User Scenarios & Testing *(mandatory)*
|
||||||
|
|
||||||
|
### User Story 1 - Inspect concrete evidence gaps quickly (Priority: P1)
|
||||||
|
|
||||||
|
An operator reviewing a degraded baseline compare run needs to see which exact policy subjects caused evidence gaps so they can determine whether the visible result is trustworthy and what needs follow-up.
|
||||||
|
|
||||||
|
**Why this priority**: Aggregate counts alone do not support operational action. The core value is turning an abstract warning into concrete, reviewable subjects.
|
||||||
|
|
||||||
|
**Independent Test**: Create a baseline compare run with evidence gaps and verify that the operator can identify the affected policy subjects from the default-visible detail surface without opening JSON or database tooling.
|
||||||
|
|
||||||
|
**Acceptance Scenarios**:
|
||||||
|
|
||||||
|
1. **Given** a completed baseline compare run with evidence gaps, **When** an entitled operator opens the run detail page, **Then** the page shows grouped concrete gap subjects tied to the relevant evidence-gap reasons.
|
||||||
|
2. **Given** a completed baseline compare run with both aggregate counts and concrete gap subjects, **When** the operator reads the page, **Then** the concrete details align with the same reason buckets as the counts and do not contradict the top-level trust statement.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### User Story 2 - Filter large gap sets without scanning raw diagnostics (Priority: P2)
|
||||||
|
|
||||||
|
An operator dealing with many evidence gaps needs to filter the list by reason, policy type, or subject key so they can isolate the specific policy family or identifier they are investigating.
|
||||||
|
|
||||||
|
**Why this priority**: Evidence-gap sets can be too large to inspect manually. Filtering is required for operational usefulness at enterprise scale.
|
||||||
|
|
||||||
|
**Independent Test**: Open a run with multiple reasons and many gap subjects, enter a partial policy type or subject key, and confirm that the visible rows narrow to the relevant subset without leaving the page.
|
||||||
|
|
||||||
|
**Acceptance Scenarios**:
|
||||||
|
|
||||||
|
1. **Given** a run with many evidence-gap rows, **When** the operator filters by a policy type token, **Then** only matching reasons and rows remain visible.
|
||||||
|
2. **Given** a run with GUID-like subject keys or mixed human-readable names, **When** the operator filters by a partial subject key value, **Then** matching rows remain visible regardless of whether the value is human-readable text or an identifier.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### User Story 3 - Distinguish missing detail from no gaps (Priority: P3)
|
||||||
|
|
||||||
|
An operator reviewing a legacy or partially recorded run needs the surface to distinguish between runs that had no evidence-gap details and runs where details were never recorded, so they do not misread silence as health.
|
||||||
|
|
||||||
|
**Why this priority**: Historical runs and partial payloads will continue to exist. The system must preserve trust even when detail quality varies over time.
|
||||||
|
|
||||||
|
**Independent Test**: Open one run with gaps but no recorded subject-level details and one run with no gaps, then verify the page communicates the difference clearly.
|
||||||
|
|
||||||
|
**Acceptance Scenarios**:
|
||||||
|
|
||||||
|
1. **Given** a run with evidence-gap counts but no stored concrete subjects, **When** the operator opens the detail surface, **Then** the page explains that no detailed gap rows were recorded for that run.
|
||||||
|
2. **Given** a run with no evidence gaps, **When** the operator opens the detail surface, **Then** no misleading gap-detail section appears and the run reads as having no gap details because none exist.
|
||||||
|
|
||||||
|
### Edge Cases
|
||||||
|
|
||||||
|
- A run contains aggregate evidence-gap counts, but only some reasons have concrete subject details. The surface must show what is known without implying the missing reasons have zero affected subjects.
|
||||||
|
- A run predates subject-level evidence-gap storage. The surface must explicitly say that detailed rows were not recorded for that run.
|
||||||
|
- The same subject appears under different reasons across the same run. The surface must preserve each reason association rather than collapsing away meaning.
|
||||||
|
- Subject keys may contain spaces, GUIDs, underscores, or mixed human-readable and machine-generated values. Filtering must still work predictably.
|
||||||
|
- Very large gap sets must remain searchable and readable without requiring raw JSON inspection.
|
||||||
|
|
||||||
|
## Requirements *(mandatory)*
|
||||||
|
|
||||||
|
**Constitution alignment (required):** This feature does not introduce new Microsoft Graph calls or new long-running job types. It reuses existing baseline compare execution and extends what tenant-owned `OperationRun` records capture and reveal about evidence gaps. Existing tenant isolation, audit, and safe execution rules remain unchanged.
|
||||||
|
|
||||||
|
**Constitution alignment (OPS-UX):** Existing baseline compare `OperationRun` behavior remains within the current three-surface feedback contract. `OperationRun.status` and `OperationRun.outcome` remain service-owned. Summary counts remain numeric and lifecycle-oriented. This feature adds richer evidence-gap interpretation and detail within existing run context rather than redefining lifecycle semantics. Scheduled or system-run behavior remains unchanged.
|
||||||
|
|
||||||
|
**Constitution alignment (RBAC-UX):** This feature changes what is visible on tenant and canonical run-detail surfaces, but not who is authorized. Non-members remain 404. Members without the relevant view capability remain 403 only after membership is established. No raw capability strings or role-specific shortcuts are introduced.
|
||||||
|
|
||||||
|
**Constitution alignment (OPS-EX-AUTH-001):** Not applicable beyond reaffirming that no auth-handshake behavior is added.
|
||||||
|
|
||||||
|
**Constitution alignment (BADGE-001):** Existing run outcome, trust, and completeness semantics remain centralized. This feature must not invent new ad-hoc badge mappings for evidence-gap states.
|
||||||
|
|
||||||
|
**Constitution alignment (UI-NAMING-001):** Operator-facing labels must use domain language such as “Evidence gap details”, “Policy type”, and “Subject key” rather than implementation-first phrasing. Internal reason codes remain diagnostic, not primary.
|
||||||
|
|
||||||
|
**Constitution alignment (OPSURF-001):** Default-visible content must remain operator-first. Outcome, trust, and next-step guidance remain ahead of raw JSON. Evidence-gap details are diagnostic, but promoted enough to support first-pass action without forcing operators into raw payloads.
|
||||||
|
|
||||||
|
**Constitution alignment (Filament Action Surfaces):** The affected Filament pages remain compliant with the Action Surface Contract. No new destructive actions are introduced. Existing compare actions remain unchanged. This feature only improves read and investigation behavior on existing surfaces.
|
||||||
|
|
||||||
|
**Constitution alignment (UX-001 — Layout & Information Architecture):** The evidence-gap experience must remain sectioned, searchable, and readable inside existing detail layouts. The searchable detail table is secondary to the result summary but primary within the diagnostics path.
|
||||||
|
|
||||||
|
### Functional Requirements
|
||||||
|
|
||||||
|
- **FR-001**: The system MUST persist subject-level evidence-gap details for new baseline compare runs whenever the compare process can identify affected subjects.
|
||||||
|
- **FR-002**: The system MUST retain aggregate evidence-gap counts and reason groupings alongside subject-level evidence-gap details so both summary and detail remain available on the same run.
|
||||||
|
- **FR-003**: The system MUST present evidence-gap details on the baseline compare run-detail experience in a searchable table-oriented format rather than as raw JSON alone.
|
||||||
|
- **FR-004**: The system MUST let operators filter evidence-gap details by at least reason, policy type, and subject key from within the operator-facing surface.
|
||||||
|
- **FR-005**: The system MUST group evidence-gap details by reason so operators can understand whether subjects are blocked by ambiguity, missing current evidence, missing policy references, or similar distinct causes.
|
||||||
|
- **FR-006**: The system MUST preserve operator-safe default reading order on the run-detail surface so execution outcome, result trust, and next-step guidance appear before searchable evidence-gap detail and before raw JSON.
|
||||||
|
- **FR-007**: The system MUST distinguish between “no evidence gaps exist” and “evidence-gap details were not recorded” so historical runs and partial payloads are not misread.
|
||||||
|
- **FR-008**: The system MUST keep evidence-gap details tenant-safe on both tenant-scoped and canonical monitoring surfaces, revealing them only to entitled workspace and tenant members.
|
||||||
|
- **FR-009**: The system MUST keep the baseline compare landing experience and the canonical run-detail experience semantically aligned when they reference the same evidence-gap state.
|
||||||
|
- **FR-010**: The system MUST preserve existing compare initiation behavior, mutation scope messaging, and audit semantics for baseline compare actions. This feature MUST NOT add new dangerous actions or broaden mutation scope.
|
||||||
|
- **FR-011**: The system MUST continue to support raw JSON diagnostics for support and deep troubleshooting, but those diagnostics MUST remain secondary to the searchable evidence-gap detail experience.
|
||||||
|
- **FR-012**: The system MUST remain usable for large enterprise tenants by allowing an operator to isolate a relevant gap subject without manually scanning the full visible set.
|
||||||
|
- **FR-013**: The system MUST continue rendering older runs that only contain aggregate evidence-gap counts without failing the page or hiding the existence of evidence gaps.
|
||||||
|
- **FR-014**: The system MUST provide regression coverage for subject-level evidence-gap persistence, operator-surface rendering, and filtering-visible affordances on the affected pages.
|
||||||
|
- **FR-015**: The system MUST preserve the current no-external-calls-on-render rule for Monitoring and run-detail surfaces.
|
||||||
|
|
||||||
|
### Assumptions
|
||||||
|
|
||||||
|
- This slice focuses on baseline compare evidence-gap detail, not every diagnostic surface in the product.
|
||||||
|
- Existing baseline compare reason-code and trust semantics remain the semantic source of truth for the top-level operator reading path.
|
||||||
|
- The primary enterprise need is fast investigation of concrete gap subjects, not full ad hoc reporting from the run detail page.
|
||||||
|
- Historical runs may continue to exist without subject-level evidence-gap detail and must remain readable.
|
||||||
|
|
||||||
|
## UI Action Matrix *(mandatory when Filament is changed)*
|
||||||
|
|
||||||
|
| Surface | Location | Header Actions | Inspect Affordance (List/Table) | Row Actions (max 2 visible) | Bulk Actions (grouped) | Empty-State CTA(s) | View Header Actions | Create/Edit Save+Cancel | Audit log? | Notes / Exemptions |
|
||||||
|
|---|---|---|---|---|---|---|---|---|---|---|
|
||||||
|
| Baseline compare run detail | Existing canonical run detail surface | Existing navigation and refresh actions remain | Existing detail sections remain the inspect affordance | None added | None | No new CTA. If no gap rows are recorded, the section explains why. | Existing run-detail header actions remain | Not applicable | Existing run and compare audit semantics remain | Read-only information architecture change only |
|
||||||
|
| Tenant baseline compare landing | Existing tenant compare review surface | Existing compare action remains | Existing navigation to latest run remains | None added | None | Existing compare CTA remains | Existing page-level actions remain | Not applicable | Existing run-backed audit semantics remain | Read-only information architecture change only |
|
||||||
|
|
||||||
|
### Key Entities *(include if feature involves data)*
|
||||||
|
|
||||||
|
- **Evidence Gap Detail**: A concrete affected subject associated with a specific evidence-gap reason for a baseline compare run, including enough operator-readable identity to investigate the issue.
|
||||||
|
- **Evidence Gap Reason Group**: A reason bucket that explains why one or more subjects limited compare confidence, used to structure both counts and detailed rows.
|
||||||
|
- **Baseline Compare Run Context**: The tenant-owned run context that stores both summary evidence-gap information and subject-level detail for later operator review.
|
||||||
|
|
||||||
|
## Success Criteria *(mandatory)*
|
||||||
|
|
||||||
|
### Measurable Outcomes
|
||||||
|
|
||||||
|
- **SC-001**: On a baseline compare run with recorded subject-level evidence gaps, an entitled operator can identify at least one affected subject from the default-visible detail surface in under 30 seconds without opening raw JSON.
|
||||||
|
- **SC-002**: On a run with multiple gap reasons, an entitled operator can isolate the relevant reason, policy type, or subject key using the on-page filter in one filtering action.
|
||||||
|
- **SC-003**: Legacy runs without subject-level detail continue to render successfully and clearly distinguish missing recorded detail from absence of evidence gaps.
|
||||||
|
- **SC-004**: The canonical run-detail surface and tenant baseline compare review surface remain semantically consistent in how they describe evidence-gap-driven limited-confidence results.
|
||||||
|
- **SC-005**: Regression coverage exists for subject-level detail persistence, operator-facing rendering, and search-visible affordances on the affected surfaces.
|
||||||
|
|
||||||
208
specs/162-baseline-gap-details/tasks.md
Normal file
208
specs/162-baseline-gap-details/tasks.md
Normal file
@ -0,0 +1,208 @@
|
|||||||
|
# Tasks: Enterprise Evidence Gap Details for Baseline Compare
|
||||||
|
|
||||||
|
**Input**: Design documents from `/specs/162-baseline-gap-details/`
|
||||||
|
**Prerequisites**: `plan.md`, `spec.md`, `research.md`, `data-model.md`, `contracts/baseline-gap-details.openapi.yaml`, `quickstart.md`
|
||||||
|
|
||||||
|
**Tests**: Runtime behavior changes in this repo require Pest coverage. Each user story below includes the minimum focused tests needed to prove the slice works independently.
|
||||||
|
**Organization**: Tasks are grouped by user story so each story can be implemented and validated independently.
|
||||||
|
|
||||||
|
## Phase 1: Setup (Shared Infrastructure)
|
||||||
|
|
||||||
|
**Purpose**: Confirm the feature slice, target files, and verification entrypoints before implementation changes start.
|
||||||
|
|
||||||
|
- [X] T001 Confirm the feature scope, affected files, and verification commands in `specs/162-baseline-gap-details/plan.md`, `specs/162-baseline-gap-details/quickstart.md`, and `specs/162-baseline-gap-details/contracts/baseline-gap-details.openapi.yaml`
|
||||||
|
- [X] T002 Map the current baseline compare detail flow in `app/Jobs/CompareBaselineToTenantJob.php`, `app/Services/Baselines/BaselineContentCapturePhase.php`, `app/Filament/Resources/OperationRunResource.php`, and `app/Filament/Pages/BaselineCompareLanding.php`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 2: Foundational (Blocking Prerequisites)
|
||||||
|
|
||||||
|
**Purpose**: Establish the shared evidence-gap read model and rendering helpers required by all user stories.
|
||||||
|
|
||||||
|
**⚠️ CRITICAL**: No user story work should start until this phase is complete.
|
||||||
|
|
||||||
|
- [X] T003 Define the shared evidence-gap read-model contract and state mapping in `app/Filament/Resources/OperationRunResource.php` to match `specs/162-baseline-gap-details/data-model.md`
|
||||||
|
- [X] T004 [P] Normalize canonical run-detail reason-bucket extraction helpers for `baseline_compare.evidence_gaps.subjects` in `app/Filament/Resources/OperationRunResource.php`
|
||||||
|
- [X] T005 [P] Normalize tenant-landing evidence-gap summary/detail extraction in `app/Support/Baselines/BaselineCompareStats.php`
|
||||||
|
- [X] T006 Establish the tenant-landing integration points for evidence-gap summary, detail, and diagnostics sections in `app/Filament/Pages/BaselineCompareLanding.php` and `resources/views/filament/pages/baseline-compare-landing.blade.php`
|
||||||
|
|
||||||
|
**Checkpoint**: Shared read model and page integration points are clear; user-story implementation can proceed.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 3: User Story 1 - Inspect concrete evidence gaps quickly (Priority: P1) 🎯 MVP
|
||||||
|
|
||||||
|
**Goal**: Persist and render grouped concrete evidence-gap subjects so an operator can inspect affected policies without opening raw JSON.
|
||||||
|
|
||||||
|
**Independent Test**: Create a baseline compare run with evidence gaps and verify the canonical run detail shows grouped concrete subjects aligned with the aggregate reason buckets.
|
||||||
|
|
||||||
|
### Tests for User Story 1
|
||||||
|
|
||||||
|
- [X] T007 [P] [US1] Extend compare persistence coverage for reason-grouped subject details in `tests/Feature/Baselines/BaselineCompareAmbiguousMatchGapTest.php`
|
||||||
|
- [X] T008 [P] [US1] Extend capture-phase subject persistence coverage in `tests/Feature/Baselines/BaselineCompareResumeTokenTest.php`
|
||||||
|
- [X] T009 [P] [US1] Add or update canonical run-detail rendering coverage for grouped evidence-gap rows in `tests/Feature/Filament/OperationRunEnterpriseDetailPageTest.php`
|
||||||
|
- [X] T010 [P] [US1] Add canonical run-detail render-safety coverage with `bindFailHardGraphClient()` in `tests/Feature/Filament/OperationRunEnterpriseDetailPageTest.php`
|
||||||
|
|
||||||
|
### Implementation for User Story 1
|
||||||
|
|
||||||
|
- [X] T011 [US1] Persist bounded subject-level evidence-gap buckets for new compare runs in `app/Jobs/CompareBaselineToTenantJob.php`
|
||||||
|
- [X] T012 [US1] Persist capture-phase `gap_subjects` alongside aggregate gaps in `app/Services/Baselines/BaselineContentCapturePhase.php`
|
||||||
|
- [X] T013 [US1] Render grouped evidence-gap detail sections from the shared read model in `app/Filament/Resources/OperationRunResource.php`
|
||||||
|
- [X] T014 [US1] Create or refine the grouped read-only evidence-gap detail view in `resources/views/filament/infolists/entries/evidence-gap-subjects.blade.php`
|
||||||
|
- [X] T015 [US1] Keep raw JSON diagnostics secondary and aligned with the new evidence-gap detail contract in `app/Filament/Resources/OperationRunResource.php`
|
||||||
|
|
||||||
|
**Checkpoint**: Operators can inspect concrete evidence-gap subjects on the canonical run detail page without using raw JSON.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 4: User Story 2 - Filter large gap sets without scanning raw diagnostics (Priority: P2)
|
||||||
|
|
||||||
|
**Goal**: Let operators narrow evidence-gap detail by reason, policy type, or subject key directly on the page.
|
||||||
|
|
||||||
|
**Independent Test**: Open a run with multiple gap reasons and many subject rows, enter a partial search token, and confirm only the relevant grouped rows remain visible.
|
||||||
|
|
||||||
|
### Tests for User Story 2
|
||||||
|
|
||||||
|
- [X] T016 [P] [US2] Add filtering-affordance assertions for the canonical run detail in `tests/Feature/Filament/OperationRunEnterpriseDetailPageTest.php`
|
||||||
|
- [X] T017 [P] [US2] Add tenant-landing parity and filtering visibility coverage in `tests/Feature/Filament/BaselineCompareLandingAdminTenantParityTest.php`
|
||||||
|
- [X] T018 [P] [US2] Add tenant-landing render-safety coverage with `bindFailHardGraphClient()` in `tests/Feature/Filament/BaselineCompareLandingAdminTenantParityTest.php`
|
||||||
|
- [X] T019 [P] [US2] Add unchanged compare-start surface and mutation-scope messaging coverage in `tests/Feature/Filament/BaselineCompareLandingStartSurfaceTest.php`
|
||||||
|
- [X] T020 [P] [US2] Add unchanged baseline-compare audit semantics coverage in `tests/Feature/Baselines/BaselineCompareAuditEventsTest.php`
|
||||||
|
|
||||||
|
### Implementation for User Story 2
|
||||||
|
|
||||||
|
- [X] T021 [US2] Implement local filtering across reason, policy type, and subject key in `resources/views/filament/infolists/entries/evidence-gap-subjects.blade.php`
|
||||||
|
- [X] T022 [US2] Render the tenant-landing evidence-gap detail block and search affordances in `app/Filament/Pages/BaselineCompareLanding.php` and `resources/views/filament/pages/baseline-compare-landing.blade.php`
|
||||||
|
- [X] T023 [US2] Align `BaselineCompareStats` reason-group payloads with the canonical run-detail bucket contract in `app/Support/Baselines/BaselineCompareStats.php`, `app/Filament/Pages/BaselineCompareLanding.php`, and `app/Filament/Resources/OperationRunResource.php`
|
||||||
|
- [X] T024 [US2] Keep tenant-landing raw diagnostics explicitly secondary to summary and evidence-gap detail in `app/Filament/Pages/BaselineCompareLanding.php` and `resources/views/filament/pages/baseline-compare-landing.blade.php`
|
||||||
|
|
||||||
|
**Checkpoint**: Operators can isolate relevant evidence-gap rows on both affected surfaces without leaving the page.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 5: User Story 3 - Distinguish missing detail from no gaps (Priority: P3)
|
||||||
|
|
||||||
|
**Goal**: Preserve operator trust by clearly distinguishing legacy or partially recorded runs from runs that truly have no evidence gaps.
|
||||||
|
|
||||||
|
**Independent Test**: Open one run with aggregate evidence-gap counts but no recorded subject details and another run with no evidence gaps, then verify the UI communicates the difference clearly.
|
||||||
|
|
||||||
|
### Tests for User Story 3
|
||||||
|
|
||||||
|
- [X] T025 [P] [US3] Add legacy-run fallback coverage to `tests/Feature/Filament/OperationRunEnterpriseDetailPageTest.php`
|
||||||
|
- [X] T026 [P] [US3] Add tenant-landing fallback coverage for missing-detail versus no-gap states in `tests/Feature/Filament/BaselineCompareLandingWhyNoFindingsTest.php`
|
||||||
|
- [X] T027 [P] [US3] Add explicit tenant-safety regression coverage for canonical and tenant surfaces in `tests/Feature/Filament/OperationRunEnterpriseDetailPageTest.php` and `tests/Feature/Filament/BaselineCompareLandingAdminTenantParityTest.php`
|
||||||
|
|
||||||
|
### Implementation for User Story 3
|
||||||
|
|
||||||
|
- [X] T028 [US3] Implement canonical run-detail fallback messaging for `details_not_recorded` versus `no_gaps` in `app/Filament/Resources/OperationRunResource.php` and `resources/views/filament/infolists/entries/evidence-gap-subjects.blade.php`
|
||||||
|
- [X] T029 [US3] Implement tenant-landing fallback messaging for legacy and partial-detail compare runs in `app/Filament/Pages/BaselineCompareLanding.php` and `resources/views/filament/pages/baseline-compare-landing.blade.php`
|
||||||
|
- [X] T030 [US3] Ensure partial reason coverage does not imply zero affected subjects for missing buckets in `app/Filament/Resources/OperationRunResource.php` and `app/Support/Baselines/BaselineCompareStats.php`
|
||||||
|
|
||||||
|
**Checkpoint**: Legacy and partial-detail runs remain readable and trustworthy without being mistaken for healthy no-gap runs.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 6: Polish & Cross-Cutting Concerns
|
||||||
|
|
||||||
|
**Purpose**: Final consistency, regression validation, and release readiness across all user stories.
|
||||||
|
|
||||||
|
- [X] T031 [P] Update feature artifacts for any final contract or wording changes in `specs/162-baseline-gap-details/spec.md`, `specs/162-baseline-gap-details/plan.md`, `specs/162-baseline-gap-details/quickstart.md`, and `specs/162-baseline-gap-details/contracts/baseline-gap-details.openapi.yaml`
|
||||||
|
- [X] T032 Run focused formatting on touched PHP files with `vendor/bin/sail bin pint --dirty --format agent`
|
||||||
|
- [X] T033 Run the focused canonical verification pack from `specs/162-baseline-gap-details/quickstart.md` with `vendor/bin/sail artisan test --compact tests/Feature/Baselines/BaselineCompareAmbiguousMatchGapTest.php tests/Feature/Baselines/BaselineCompareResumeTokenTest.php tests/Feature/Filament/OperationRunEnterpriseDetailPageTest.php`
|
||||||
|
- [X] T034 [P] Run the tenant-landing evidence-gap verification tests with `vendor/bin/sail artisan test --compact tests/Feature/Filament/BaselineCompareLandingAdminTenantParityTest.php tests/Feature/Filament/BaselineCompareLandingWhyNoFindingsTest.php tests/Feature/Filament/BaselineCompareLandingStartSurfaceTest.php tests/Feature/Baselines/BaselineCompareAuditEventsTest.php`
|
||||||
|
- [X] T035 Execute the manual run-review checks in `specs/162-baseline-gap-details/quickstart.md` against `/admin/operations/{run}` and `/admin/t/{tenant}/baseline-compare-landing`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Dependencies & Execution Order
|
||||||
|
|
||||||
|
### Phase Dependencies
|
||||||
|
|
||||||
|
- **Phase 1: Setup**: No dependencies.
|
||||||
|
- **Phase 2: Foundational**: Depends on Phase 1 and blocks all user stories.
|
||||||
|
- **Phase 3: US1**: Starts after Phase 2 and delivers the MVP.
|
||||||
|
- **Phase 4: US2**: Starts after Phase 2; depends functionally on the US1 detail structure being present.
|
||||||
|
- **Phase 5: US3**: Starts after Phase 2; depends functionally on the shared read model and both surface integrations.
|
||||||
|
- **Phase 6: Polish**: Starts after the desired user stories are complete.
|
||||||
|
|
||||||
|
### User Story Dependencies
|
||||||
|
|
||||||
|
- **US1 (P1)**: No dependency on other user stories once the foundational phase is complete.
|
||||||
|
- **US2 (P2)**: Depends on US1 detail rendering because filtering operates on the rendered evidence-gap rows.
|
||||||
|
- **US3 (P3)**: Depends on US1 read-model/rendering and should also align with US2 surface semantics where filtering is visible.
|
||||||
|
|
||||||
|
### Within Each User Story
|
||||||
|
|
||||||
|
- Tests should be written or updated before implementation and fail for the missing behavior.
|
||||||
|
- Persistence work precedes rendering work.
|
||||||
|
- Rendering work precedes parity and fallback polish.
|
||||||
|
- Each story should be independently runnable through the focused tests listed above.
|
||||||
|
|
||||||
|
## Parallel Opportunities
|
||||||
|
|
||||||
|
- `T004` and `T005` can run in parallel after `T003`.
|
||||||
|
- `T007`, `T008`, `T009`, and `T010` can run in parallel within US1.
|
||||||
|
- `T016`, `T017`, `T018`, `T019`, and `T020` can run in parallel within US2.
|
||||||
|
- `T025`, `T026`, and `T027` can run in parallel within US3.
|
||||||
|
- `T031` and `T034` can run in parallel during Polish.
|
||||||
|
|
||||||
|
## Parallel Example: User Story 1
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Parallelize the US1 regression updates first
|
||||||
|
Task: T007 tests/Feature/Baselines/BaselineCompareAmbiguousMatchGapTest.php
|
||||||
|
Task: T008 tests/Feature/Baselines/BaselineCompareResumeTokenTest.php
|
||||||
|
Task: T009 tests/Feature/Filament/OperationRunEnterpriseDetailPageTest.php
|
||||||
|
Task: T010 tests/Feature/Filament/OperationRunEnterpriseDetailPageTest.php
|
||||||
|
|
||||||
|
# Then implement the persistence and render paths
|
||||||
|
Task: T011 app/Jobs/CompareBaselineToTenantJob.php
|
||||||
|
Task: T012 app/Services/Baselines/BaselineContentCapturePhase.php
|
||||||
|
Task: T013 app/Filament/Resources/OperationRunResource.php
|
||||||
|
Task: T014 resources/views/filament/infolists/entries/evidence-gap-subjects.blade.php
|
||||||
|
```
|
||||||
|
|
||||||
|
## Parallel Example: User Story 2
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Parallelize test scaffolding for both surfaces
|
||||||
|
Task: T016 tests/Feature/Filament/OperationRunEnterpriseDetailPageTest.php
|
||||||
|
Task: T017 tests/Feature/Filament/BaselineCompareLandingAdminTenantParityTest.php
|
||||||
|
Task: T018 tests/Feature/Filament/BaselineCompareLandingAdminTenantParityTest.php
|
||||||
|
Task: T019 tests/Feature/Filament/BaselineCompareLandingStartSurfaceTest.php
|
||||||
|
Task: T020 tests/Feature/Baselines/BaselineCompareAuditEventsTest.php
|
||||||
|
|
||||||
|
# Then implement filtering and surface parity
|
||||||
|
Task: T021 resources/views/filament/infolists/entries/evidence-gap-subjects.blade.php
|
||||||
|
Task: T022 app/Filament/Pages/BaselineCompareLanding.php
|
||||||
|
Task: T023 app/Support/Baselines/BaselineCompareStats.php
|
||||||
|
Task: T024 resources/views/filament/pages/baseline-compare-landing.blade.php
|
||||||
|
```
|
||||||
|
|
||||||
|
## Parallel Example: User Story 3
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Parallelize legacy-state tests
|
||||||
|
Task: T025 tests/Feature/Filament/OperationRunEnterpriseDetailPageTest.php
|
||||||
|
Task: T026 tests/Feature/Filament/BaselineCompareLandingWhyNoFindingsTest.php
|
||||||
|
Task: T027 tests/Feature/Filament/BaselineCompareLandingAdminTenantParityTest.php
|
||||||
|
|
||||||
|
# Then implement fallback states on both surfaces
|
||||||
|
Task: T028 app/Filament/Resources/OperationRunResource.php
|
||||||
|
Task: T029 resources/views/filament/pages/baseline-compare-landing.blade.php
|
||||||
|
Task: T030 app/Support/Baselines/BaselineCompareStats.php
|
||||||
|
```
|
||||||
|
|
||||||
|
## Implementation Strategy
|
||||||
|
|
||||||
|
### MVP First
|
||||||
|
|
||||||
|
Deliver Phase 3 first. That yields the core value: subject-level persistence plus canonical run-detail visibility for evidence-gap details.
|
||||||
|
|
||||||
|
### Incremental Delivery
|
||||||
|
|
||||||
|
1. Complete Setup and Foundational phases.
|
||||||
|
2. Deliver US1 as the MVP for operator visibility.
|
||||||
|
3. Add US2 for enterprise-scale filtering and landing-page parity.
|
||||||
|
4. Add US3 for legacy-run trust and fallback clarity.
|
||||||
|
5. Finish with formatting, focused regression runs, and manual validation.
|
||||||
@ -0,0 +1,35 @@
|
|||||||
|
# Specification Quality Checklist: Baseline Subject Resolution and Evidence Gap Semantics Foundation
|
||||||
|
|
||||||
|
**Purpose**: Validate specification completeness and quality before proceeding to planning
|
||||||
|
**Created**: 2026-03-24
|
||||||
|
**Feature**: [spec.md](/Users/ahmeddarrazi/Documents/projects/TenantAtlas/specs/163-baseline-subject-resolution/spec.md)
|
||||||
|
|
||||||
|
## Content Quality
|
||||||
|
|
||||||
|
- [x] No implementation details (languages, frameworks, APIs)
|
||||||
|
- [x] Focused on user value and business needs
|
||||||
|
- [x] Written for non-technical stakeholders
|
||||||
|
- [x] All mandatory sections completed
|
||||||
|
|
||||||
|
## Requirement Completeness
|
||||||
|
|
||||||
|
- [x] No [NEEDS CLARIFICATION] markers remain
|
||||||
|
- [x] Requirements are testable and unambiguous
|
||||||
|
- [x] Success criteria are measurable
|
||||||
|
- [x] Success criteria are technology-agnostic (no implementation details)
|
||||||
|
- [x] All acceptance scenarios are defined
|
||||||
|
- [x] Edge cases are identified
|
||||||
|
- [x] Scope is clearly bounded
|
||||||
|
- [x] Dependencies and assumptions identified
|
||||||
|
|
||||||
|
## Feature Readiness
|
||||||
|
|
||||||
|
- [x] All functional requirements have clear acceptance criteria
|
||||||
|
- [x] User scenarios cover primary flows
|
||||||
|
- [x] Feature meets measurable outcomes defined in Success Criteria
|
||||||
|
- [x] No implementation details leak into specification
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
- Validated on first pass.
|
||||||
|
- The spec intentionally establishes root-cause and runtime-support semantics only; fidelity richness, renderer density, and wording refinement remain deferred follow-on work.
|
||||||
334
specs/163-baseline-subject-resolution/contracts/openapi.yaml
Normal file
334
specs/163-baseline-subject-resolution/contracts/openapi.yaml
Normal file
@ -0,0 +1,334 @@
|
|||||||
|
openapi: 3.1.0
|
||||||
|
info:
|
||||||
|
title: Baseline Subject Resolution Semantics Contract
|
||||||
|
version: 0.1.0
|
||||||
|
description: >-
|
||||||
|
Read-model and validation contracts for baseline compare and capture subject-resolution
|
||||||
|
semantics. This contract documents the payloads existing operator surfaces consume after
|
||||||
|
the foundation upgrade and the capability matrix used to keep baseline support truthful
|
||||||
|
at runtime.
|
||||||
|
servers:
|
||||||
|
- url: https://tenantpilot.local
|
||||||
|
paths:
|
||||||
|
/admin/api/operations/{operationRun}/baseline-gap-semantics:
|
||||||
|
get:
|
||||||
|
summary: Get structured baseline gap semantics for an operation run
|
||||||
|
operationId: getBaselineGapSemanticsForRun
|
||||||
|
parameters:
|
||||||
|
- name: operationRun
|
||||||
|
in: path
|
||||||
|
required: true
|
||||||
|
schema:
|
||||||
|
type: integer
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: Structured baseline gap semantics for the run
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/BaselineGapSemanticsResponse'
|
||||||
|
'404':
|
||||||
|
description: Run not found or not visible in current workspace or tenant scope
|
||||||
|
'403':
|
||||||
|
description: Caller is a member but lacks permission to inspect the run
|
||||||
|
|
||||||
|
/admin/api/tenants/{tenant}/baseline-compare/latest-gap-semantics:
|
||||||
|
get:
|
||||||
|
summary: Get latest baseline compare gap semantics for a tenant and profile
|
||||||
|
operationId: getLatestTenantBaselineCompareGapSemantics
|
||||||
|
parameters:
|
||||||
|
- name: tenant
|
||||||
|
in: path
|
||||||
|
required: true
|
||||||
|
schema:
|
||||||
|
type: integer
|
||||||
|
- name: baseline_profile_id
|
||||||
|
in: query
|
||||||
|
required: true
|
||||||
|
schema:
|
||||||
|
type: integer
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: Latest tenant baseline compare semantics projection
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/BaselineGapSemanticsResponse'
|
||||||
|
'404':
|
||||||
|
description: Tenant or run not found for current entitled scope
|
||||||
|
'403':
|
||||||
|
description: Caller lacks compare or review visibility within the established tenant scope
|
||||||
|
|
||||||
|
/admin/api/baseline-support/resolution-capabilities:
|
||||||
|
get:
|
||||||
|
summary: Get runtime baseline support and resolution capability matrix
|
||||||
|
operationId: getBaselineResolutionCapabilities
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: Capability matrix used to validate truthful compare and capture support
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: object
|
||||||
|
required:
|
||||||
|
- data
|
||||||
|
properties:
|
||||||
|
data:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
$ref: '#/components/schemas/SupportCapabilityRecord'
|
||||||
|
|
||||||
|
components:
|
||||||
|
schemas:
|
||||||
|
BaselineGapSemanticsResponse:
|
||||||
|
type: object
|
||||||
|
required:
|
||||||
|
- summary
|
||||||
|
- buckets
|
||||||
|
properties:
|
||||||
|
summary:
|
||||||
|
$ref: '#/components/schemas/BaselineGapSummary'
|
||||||
|
buckets:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
$ref: '#/components/schemas/BaselineGapBucket'
|
||||||
|
|
||||||
|
BaselineGapSummary:
|
||||||
|
type: object
|
||||||
|
required:
|
||||||
|
- count
|
||||||
|
- by_reason
|
||||||
|
- detail_state
|
||||||
|
- recorded_subjects_total
|
||||||
|
- missing_detail_count
|
||||||
|
- structural_count
|
||||||
|
- operational_count
|
||||||
|
- transient_count
|
||||||
|
- legacy_mode
|
||||||
|
- requires_regeneration
|
||||||
|
properties:
|
||||||
|
count:
|
||||||
|
type: integer
|
||||||
|
minimum: 0
|
||||||
|
by_reason:
|
||||||
|
type: object
|
||||||
|
additionalProperties:
|
||||||
|
type: integer
|
||||||
|
minimum: 0
|
||||||
|
detail_state:
|
||||||
|
type: string
|
||||||
|
enum:
|
||||||
|
- no_gaps
|
||||||
|
- structured_details_recorded
|
||||||
|
- details_not_recorded
|
||||||
|
- legacy_broad_reason
|
||||||
|
recorded_subjects_total:
|
||||||
|
type: integer
|
||||||
|
minimum: 0
|
||||||
|
missing_detail_count:
|
||||||
|
type: integer
|
||||||
|
minimum: 0
|
||||||
|
structural_count:
|
||||||
|
type: integer
|
||||||
|
minimum: 0
|
||||||
|
operational_count:
|
||||||
|
type: integer
|
||||||
|
minimum: 0
|
||||||
|
transient_count:
|
||||||
|
type: integer
|
||||||
|
minimum: 0
|
||||||
|
legacy_mode:
|
||||||
|
type: boolean
|
||||||
|
requires_regeneration:
|
||||||
|
type: boolean
|
||||||
|
|
||||||
|
BaselineGapBucket:
|
||||||
|
type: object
|
||||||
|
required:
|
||||||
|
- reason_code
|
||||||
|
- reason_label
|
||||||
|
- count
|
||||||
|
- recorded_count
|
||||||
|
- missing_detail_count
|
||||||
|
- structural_count
|
||||||
|
- operational_count
|
||||||
|
- transient_count
|
||||||
|
- detail_state
|
||||||
|
- search_text
|
||||||
|
- rows
|
||||||
|
properties:
|
||||||
|
reason_code:
|
||||||
|
type: string
|
||||||
|
reason_label:
|
||||||
|
type: string
|
||||||
|
count:
|
||||||
|
type: integer
|
||||||
|
minimum: 0
|
||||||
|
recorded_count:
|
||||||
|
type: integer
|
||||||
|
minimum: 0
|
||||||
|
missing_detail_count:
|
||||||
|
type: integer
|
||||||
|
minimum: 0
|
||||||
|
structural_count:
|
||||||
|
type: integer
|
||||||
|
minimum: 0
|
||||||
|
operational_count:
|
||||||
|
type: integer
|
||||||
|
minimum: 0
|
||||||
|
transient_count:
|
||||||
|
type: integer
|
||||||
|
minimum: 0
|
||||||
|
detail_state:
|
||||||
|
type: string
|
||||||
|
enum:
|
||||||
|
- structured_details_recorded
|
||||||
|
- details_not_recorded
|
||||||
|
- legacy_broad_reason
|
||||||
|
search_text:
|
||||||
|
type: string
|
||||||
|
rows:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
$ref: '#/components/schemas/EvidenceGapDetailRecord'
|
||||||
|
|
||||||
|
EvidenceGapDetailRecord:
|
||||||
|
type: object
|
||||||
|
required:
|
||||||
|
- policy_type
|
||||||
|
- subject_key
|
||||||
|
- subject_class
|
||||||
|
- resolution_path
|
||||||
|
- resolution_outcome
|
||||||
|
- reason_code
|
||||||
|
- operator_action_category
|
||||||
|
- structural
|
||||||
|
- retryable
|
||||||
|
properties:
|
||||||
|
policy_type:
|
||||||
|
type: string
|
||||||
|
subject_external_id:
|
||||||
|
type:
|
||||||
|
- string
|
||||||
|
- 'null'
|
||||||
|
subject_key:
|
||||||
|
type: string
|
||||||
|
subject_class:
|
||||||
|
$ref: '#/components/schemas/SubjectClass'
|
||||||
|
resolution_path:
|
||||||
|
$ref: '#/components/schemas/ResolutionPath'
|
||||||
|
resolution_outcome:
|
||||||
|
$ref: '#/components/schemas/ResolutionOutcome'
|
||||||
|
reason_code:
|
||||||
|
type: string
|
||||||
|
operator_action_category:
|
||||||
|
$ref: '#/components/schemas/OperatorActionCategory'
|
||||||
|
structural:
|
||||||
|
type: boolean
|
||||||
|
retryable:
|
||||||
|
type: boolean
|
||||||
|
source_model_expected:
|
||||||
|
type:
|
||||||
|
- string
|
||||||
|
- 'null'
|
||||||
|
enum:
|
||||||
|
- policy
|
||||||
|
- inventory
|
||||||
|
- derived
|
||||||
|
- 'null'
|
||||||
|
source_model_found:
|
||||||
|
type:
|
||||||
|
- string
|
||||||
|
- 'null'
|
||||||
|
enum:
|
||||||
|
- policy
|
||||||
|
- inventory
|
||||||
|
- derived
|
||||||
|
- 'null'
|
||||||
|
legacy_reason_code:
|
||||||
|
type:
|
||||||
|
- string
|
||||||
|
- 'null'
|
||||||
|
|
||||||
|
SupportCapabilityRecord:
|
||||||
|
type: object
|
||||||
|
required:
|
||||||
|
- policy_type
|
||||||
|
- subject_class
|
||||||
|
- compare_capability
|
||||||
|
- capture_capability
|
||||||
|
- resolution_path
|
||||||
|
- config_supported
|
||||||
|
- runtime_valid
|
||||||
|
properties:
|
||||||
|
policy_type:
|
||||||
|
type: string
|
||||||
|
subject_class:
|
||||||
|
$ref: '#/components/schemas/SubjectClass'
|
||||||
|
compare_capability:
|
||||||
|
type: string
|
||||||
|
enum:
|
||||||
|
- supported
|
||||||
|
- limited
|
||||||
|
- unsupported
|
||||||
|
capture_capability:
|
||||||
|
type: string
|
||||||
|
enum:
|
||||||
|
- supported
|
||||||
|
- limited
|
||||||
|
- unsupported
|
||||||
|
resolution_path:
|
||||||
|
$ref: '#/components/schemas/ResolutionPath'
|
||||||
|
config_supported:
|
||||||
|
type: boolean
|
||||||
|
runtime_valid:
|
||||||
|
type: boolean
|
||||||
|
|
||||||
|
SubjectClass:
|
||||||
|
type: string
|
||||||
|
enum:
|
||||||
|
- policy_backed
|
||||||
|
- inventory_backed
|
||||||
|
- foundation_backed
|
||||||
|
- derived
|
||||||
|
|
||||||
|
ResolutionPath:
|
||||||
|
type: string
|
||||||
|
enum:
|
||||||
|
- policy
|
||||||
|
- inventory
|
||||||
|
- foundation_policy
|
||||||
|
- foundation_inventory
|
||||||
|
- derived
|
||||||
|
- unsupported
|
||||||
|
|
||||||
|
ResolutionOutcome:
|
||||||
|
type: string
|
||||||
|
enum:
|
||||||
|
- resolved_policy
|
||||||
|
- resolved_inventory
|
||||||
|
- policy_record_missing
|
||||||
|
- inventory_record_missing
|
||||||
|
- foundation_inventory_only
|
||||||
|
- resolution_type_mismatch
|
||||||
|
- unresolvable_subject
|
||||||
|
- permission_or_scope_blocked
|
||||||
|
- retryable_capture_failure
|
||||||
|
- capture_failed
|
||||||
|
- throttled
|
||||||
|
- budget_exhausted
|
||||||
|
- ambiguous_match
|
||||||
|
- invalid_subject
|
||||||
|
- duplicate_subject
|
||||||
|
- invalid_support_config
|
||||||
|
|
||||||
|
OperatorActionCategory:
|
||||||
|
type: string
|
||||||
|
enum:
|
||||||
|
- none
|
||||||
|
- retry
|
||||||
|
- run_inventory_sync
|
||||||
|
- run_policy_sync_or_backup
|
||||||
|
- review_permissions
|
||||||
|
- inspect_subject_mapping
|
||||||
|
- product_follow_up
|
||||||
167
specs/163-baseline-subject-resolution/data-model.md
Normal file
167
specs/163-baseline-subject-resolution/data-model.md
Normal file
@ -0,0 +1,167 @@
|
|||||||
|
# Data Model: Baseline Subject Resolution and Evidence Gap Semantics Foundation
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
This feature does not require a new primary database table. It introduces a richer logical model for subject classification and resolution, then persists that model inside existing compare and capture run context for new runs. Development-only run payloads using the old broad reason shape may be removed or regenerated instead of preserved through a compatibility contract.
|
||||||
|
|
||||||
|
## Entity: SubjectDescriptor
|
||||||
|
|
||||||
|
Business-level descriptor for a compare or capture target before local resolution.
|
||||||
|
|
||||||
|
### Fields
|
||||||
|
|
||||||
|
| Field | Type | Required | Description |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `policy_type` | string | yes | Canonical policy or foundation type from support metadata |
|
||||||
|
| `subject_external_id` | string | no | Stable local or tenant-local external identifier when available |
|
||||||
|
| `subject_key` | string | yes | Deterministic comparison key used across capture and compare |
|
||||||
|
| `subject_class` | enum | yes | One of `policy_backed`, `inventory_backed`, `foundation_backed`, `derived` |
|
||||||
|
| `resolution_path` | enum | yes | Intended local path, such as `policy`, `inventory`, `foundation_policy`, `foundation_inventory`, or `derived` |
|
||||||
|
| `support_mode` | enum | yes | `supported`, `limited`, `excluded`, or `invalid_support_config` |
|
||||||
|
| `source_model_expected` | enum | no | Which local model is expected to satisfy the lookup |
|
||||||
|
|
||||||
|
### Validation Rules
|
||||||
|
|
||||||
|
- `policy_type` must exist in canonical support metadata.
|
||||||
|
- `subject_class` must be one of the supported subject-class values.
|
||||||
|
- `resolution_path` must be compatible with `subject_class`.
|
||||||
|
- `support_mode=invalid_support_config` is only valid when metadata claims support but no truthful runtime path exists.
|
||||||
|
|
||||||
|
## Entity: ResolutionOutcomeRecord
|
||||||
|
|
||||||
|
Deterministic result of attempting to resolve a `SubjectDescriptor` against tenant-local state.
|
||||||
|
|
||||||
|
### Fields
|
||||||
|
|
||||||
|
| Field | Type | Required | Description |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `resolution_outcome` | enum | yes | Precise outcome such as `resolved_policy`, `resolved_inventory`, `policy_record_missing`, `inventory_record_missing`, `foundation_inventory_only`, `resolution_type_mismatch`, `unresolvable_subject`, `permission_or_scope_blocked`, `retryable_capture_failure`, `capture_failed`, `throttled`, or `budget_exhausted` |
|
||||||
|
| `reason_code` | string | yes | Stable operator-facing reason family persisted with the run |
|
||||||
|
| `operator_action_category` | enum | yes | `none`, `retry`, `run_inventory_sync`, `run_policy_sync_or_backup`, `review_permissions`, `inspect_subject_mapping`, or `product_follow_up` |
|
||||||
|
| `structural` | boolean | yes | Whether the outcome is structural rather than operational or transient |
|
||||||
|
| `retryable` | boolean | yes | Whether retry may change the outcome without prerequisite changes |
|
||||||
|
| `source_model_found` | enum or null | no | Which local model actually satisfied the lookup when resolution succeeded |
|
||||||
|
|
||||||
|
### State Families
|
||||||
|
|
||||||
|
| Family | Outcomes |
|
||||||
|
|---|---|
|
||||||
|
| `resolved` | `resolved_policy`, `resolved_inventory` |
|
||||||
|
| `structural` | `foundation_inventory_only`, `resolution_type_mismatch`, `unresolvable_subject`, `invalid_support_config` |
|
||||||
|
| `operational` | `policy_record_missing`, `inventory_record_missing`, `permission_or_scope_blocked`, `ambiguous_match`, `invalid_subject`, `duplicate_subject` |
|
||||||
|
| `transient` | `retryable_capture_failure`, `throttled`, `budget_exhausted`, `capture_failed` |
|
||||||
|
|
||||||
|
### Validation Rules
|
||||||
|
|
||||||
|
- `resolution_outcome` must map to exactly one state family.
|
||||||
|
- `structural=true` is only valid for structural state-family outcomes.
|
||||||
|
- `retryable=true` is only valid for transient outcomes or explicitly retryable operational outcomes.
|
||||||
|
|
||||||
|
## Entity: SupportCapabilityRecord
|
||||||
|
|
||||||
|
Runtime truth contract for whether a subject type may enter baseline compare or capture.
|
||||||
|
|
||||||
|
### Fields
|
||||||
|
|
||||||
|
| Field | Type | Required | Description |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `policy_type` | string | yes | Canonical type key |
|
||||||
|
| `subject_class` | enum | yes | Dominant subject class for the type |
|
||||||
|
| `compare_capability` | enum | yes | `supported`, `limited`, or `unsupported` |
|
||||||
|
| `capture_capability` | enum | yes | `supported`, `limited`, or `unsupported` |
|
||||||
|
| `resolution_path` | enum | yes | Truthful runtime resolution path |
|
||||||
|
| `config_supported` | boolean | yes | Whether metadata claims support |
|
||||||
|
| `runtime_valid` | boolean | yes | Whether the runtime can honor that support claim |
|
||||||
|
|
||||||
|
### Validation Rules
|
||||||
|
|
||||||
|
- `config_supported=true` and `runtime_valid=false` must be surfaced as `invalid_support_config` rather than silently ignored.
|
||||||
|
- Types with `compare_capability=unsupported` must not enter compare scope.
|
||||||
|
- Types with `capture_capability=unsupported` must not enter capture execution.
|
||||||
|
|
||||||
|
## Entity: EvidenceGapDetailRecord
|
||||||
|
|
||||||
|
Structured subject-level record persisted under compare or capture run context for new runs.
|
||||||
|
|
||||||
|
### Fields
|
||||||
|
|
||||||
|
| Field | Type | Required | Description |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `policy_type` | string | yes | Canonical type |
|
||||||
|
| `subject_external_id` | string or null | no | Stable identifier when available |
|
||||||
|
| `subject_key` | string | yes | Deterministic subject identity |
|
||||||
|
| `subject_class` | enum | yes | Classified subject class |
|
||||||
|
| `resolution_path` | enum | yes | Path attempted or declared |
|
||||||
|
| `resolution_outcome` | enum | yes | Deterministic resolution result |
|
||||||
|
| `reason_code` | string | yes | Stable reason family |
|
||||||
|
| `operator_action_category` | enum | yes | Recommended next action family |
|
||||||
|
| `structural` | boolean | yes | Structural versus non-structural marker |
|
||||||
|
| `retryable` | boolean | yes | Retryability marker |
|
||||||
|
| `source_model_expected` | enum or null | no | Expected local evidence model |
|
||||||
|
| `source_model_found` | enum or null | no | Actual local evidence model when present |
|
||||||
|
|
||||||
|
### Storage Locations
|
||||||
|
|
||||||
|
- `operation_runs.context.baseline_compare.evidence_gaps.subjects[]`
|
||||||
|
- `operation_runs.context.baseline_capture.gaps.subjects[]` or equivalent capture-context namespace
|
||||||
|
|
||||||
|
### Validation Rules
|
||||||
|
|
||||||
|
- New-run records must store structured objects, not only string subject tokens.
|
||||||
|
- `subject_key` must be deterministic for identical inputs.
|
||||||
|
- `reason_code` and `resolution_outcome` must not contradict each other.
|
||||||
|
- Old development rows that omit the new fields are cleanup candidates and should be regenerated or deleted rather than treated as a first-class runtime shape.
|
||||||
|
|
||||||
|
## Derived Entity: EvidenceGapProjection
|
||||||
|
|
||||||
|
Read-model projection used by canonical run-detail and tenant review surfaces.
|
||||||
|
|
||||||
|
### Fields
|
||||||
|
|
||||||
|
| Field | Type | Description |
|
||||||
|
|---|---|---|
|
||||||
|
| `detail_state` | enum | `no_gaps`, `structured_details_recorded`, `details_not_recorded`, `legacy_broad_reason` |
|
||||||
|
| `count` | integer | Total gap count |
|
||||||
|
| `by_reason` | map<string,int> | Aggregate counts by reason |
|
||||||
|
| `recorded_subjects_total` | integer | Number of structured subject rows available for projection |
|
||||||
|
| `missing_detail_count` | integer | Gap count that has no structured row attached |
|
||||||
|
| `structural_count` | integer | Number of recorded structural gap rows |
|
||||||
|
| `operational_count` | integer | Number of recorded non-structural, non-retryable rows |
|
||||||
|
| `transient_count` | integer | Number of recorded retryable rows |
|
||||||
|
| `legacy_mode` | boolean | Indicates the run still stores a broad legacy gap payload |
|
||||||
|
| `buckets` | list | Grouped records by reason with searchable row payload |
|
||||||
|
| `requires_regeneration` | boolean | Whether stale local development data should be regenerated rather than interpreted semantically |
|
||||||
|
|
||||||
|
## State Transitions
|
||||||
|
|
||||||
|
### Resolution lifecycle for a subject
|
||||||
|
|
||||||
|
1. `described`
|
||||||
|
- `SubjectDescriptor` is created from scope, metadata, and capability information.
|
||||||
|
2. `validated`
|
||||||
|
- Runtime support guard confirms whether the subject may enter compare or capture.
|
||||||
|
3. `resolved`
|
||||||
|
- The system attempts the appropriate local path and emits a `ResolutionOutcomeRecord`.
|
||||||
|
4. `persisted`
|
||||||
|
- New runs store the structured `EvidenceGapDetailRecord` or resolved outcome details in `OperationRun.context`.
|
||||||
|
5. `projected`
|
||||||
|
- Existing operator surfaces consume the new structured projection. Stale development data is regenerated or removed instead of driving a permanent compatibility path.
|
||||||
|
|
||||||
|
## Example New-Run Compare Gap Record
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"policy_type": "roleScopeTag",
|
||||||
|
"subject_external_id": "42f4fcb8-5b35-44ec-9240-0a1ad7c31fb1",
|
||||||
|
"subject_key": "rolescopetag|42f4fcb8-5b35-44ec-9240-0a1ad7c31fb1",
|
||||||
|
"subject_class": "foundation_backed",
|
||||||
|
"resolution_path": "foundation_inventory",
|
||||||
|
"resolution_outcome": "foundation_inventory_only",
|
||||||
|
"reason_code": "foundation_not_policy_backed",
|
||||||
|
"operator_action_category": "product_follow_up",
|
||||||
|
"structural": true,
|
||||||
|
"retryable": false,
|
||||||
|
"source_model_expected": "inventory_item",
|
||||||
|
"source_model_found": "inventory_item"
|
||||||
|
}
|
||||||
|
```
|
||||||
246
specs/163-baseline-subject-resolution/plan.md
Normal file
246
specs/163-baseline-subject-resolution/plan.md
Normal file
@ -0,0 +1,246 @@
|
|||||||
|
# Implementation Plan: 163 — Baseline Subject Resolution and Evidence Gap Semantics Foundation
|
||||||
|
|
||||||
|
**Branch**: `163-baseline-subject-resolution` | **Date**: 2026-03-24 | **Spec**: `specs/163-baseline-subject-resolution/spec.md`
|
||||||
|
**Input**: Feature specification from `specs/163-baseline-subject-resolution/spec.md`
|
||||||
|
|
||||||
|
**Note**: This template is filled in by the `/speckit.plan` command. See `.specify/scripts/` for helper scripts.
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
Introduce an explicit backend subject-resolution contract for baseline compare and baseline capture so the system can classify each subject before resolution, select the correct local model path, and persist precise operator-safe gap semantics instead of collapsing structural, operational, and transient causes into broad `policy_not_found` style states. The implementation will extend existing baseline scope, inventory policy-type metadata, compare and capture jobs, baseline evidence-gap detail parsing, and OperationRun context persistence rather than introducing a parallel execution stack, with a bounded runtime support guard that prevents baseline-supported types from entering compare or capture on a resolver path that cannot truthfully classify them.
|
||||||
|
|
||||||
|
## Technical Context
|
||||||
|
|
||||||
|
**Language/Version**: PHP 8.4
|
||||||
|
**Primary Dependencies**: Laravel 12, Filament v5, Livewire v4
|
||||||
|
**Storage**: PostgreSQL via existing application tables, especially `operation_runs.context` and baseline snapshot summary JSON
|
||||||
|
**Testing**: Pest v4 on PHPUnit 12
|
||||||
|
**Target Platform**: Dockerized Laravel web application running through Sail locally and Dokploy in deployment
|
||||||
|
**Project Type**: Web application
|
||||||
|
**Performance Goals**: Preserve DB-only render behavior for Monitoring and tenant review surfaces, add no render-time Graph calls, and keep evidence-gap interpretation deterministic and lightweight enough for existing run-detail and landing surfaces
|
||||||
|
**Constraints**:
|
||||||
|
- No new render-time remote work and no bypass of `GraphClientInterface`
|
||||||
|
- No change to `OperationRun` lifecycle ownership, notification channels, or summary-count rules
|
||||||
|
- No new operator screen; existing surfaces must present richer semantics
|
||||||
|
- Existing development-only run payloads may be deleted or regenerated if that simplifies migration to the new structured contract
|
||||||
|
- Baseline-supported configuration must not overpromise runtime capability
|
||||||
|
**Scale/Scope**: Cross-cutting backend semantic work across baseline compare and capture pipelines, support-layer parsers and translators, OperationRun context contracts, tenant and canonical read surfaces, and focused Pest coverage for deterministic classification and development-safe contract cleanup
|
||||||
|
|
||||||
|
## Constitution Check
|
||||||
|
|
||||||
|
*GATE: Must pass before Phase 0 research. Re-check after Phase 1 design.*
|
||||||
|
|
||||||
|
- Inventory-first: PASS — the design keeps inventory as last-observed truth and distinguishes inventory-backed evidence from policy-backed evidence rather than conflating them.
|
||||||
|
- Read/write separation: PASS — this feature changes classification and persisted run semantics inside existing compare and capture flows; it does not add new write or restore actions.
|
||||||
|
- Graph contract path: PASS — no new Graph contract or direct endpoint use is introduced; existing capture and sync services remain the only remote paths.
|
||||||
|
- Deterministic capabilities: PASS — subject-class derivation, resolution outcome mapping, and support-capability guards are explicitly designed to be deterministic and testable.
|
||||||
|
- RBAC-UX: PASS — existing `/admin` and tenant-context authorization boundaries remain unchanged; only read semantics improve.
|
||||||
|
- Workspace isolation: PASS — no new workspace leakage is introduced and canonical run-detail remains tenant-safe.
|
||||||
|
- RBAC confirmations: PASS — no new destructive actions are added.
|
||||||
|
- Global search: PASS — unaffected.
|
||||||
|
- Tenant isolation: PASS — all compare, capture, inventory, and run data remain tenant-bound and entitlement-checked.
|
||||||
|
- Run observability: PASS — compare and capture continue to use existing `OperationRun` types; this slice enriches context semantics only.
|
||||||
|
- Ops-UX 3-surface feedback: PASS — no new toast, progress, or terminal-notification channels are added.
|
||||||
|
- Ops-UX lifecycle: PASS — `OperationRun.status` and `OperationRun.outcome` remain service-owned; only context enrichment changes.
|
||||||
|
- Ops-UX summary counts: PASS — no non-numeric values are moved into `summary_counts`; richer semantics live in context and read models.
|
||||||
|
- Ops-UX guards: PASS — focused regression tests can protect classification determinism and development cleanup behavior without relaxing existing CI rules.
|
||||||
|
- Ops-UX system runs: PASS — unchanged.
|
||||||
|
- Automation: PASS — existing queue, retry, and backoff behavior stays intact; transient outcomes are classified more precisely, not re-executed differently.
|
||||||
|
- Data minimization: PASS — the new gap detail contract stores classification and stable identifiers, not raw policy payloads or secrets.
|
||||||
|
- Badge semantics (BADGE-001): PASS — if structural, operational, or transient labels surface as badges, they must route through centralized badge or presentation helpers rather than ad hoc maps.
|
||||||
|
- UI naming (UI-NAMING-001): PASS — the feature exists to replace implementation-first broad error prose with domain-first operator meaning.
|
||||||
|
- Operator surfaces (OPSURF-001): PASS — existing run detail and tenant review surfaces remain operator-first and diagnostics-secondary.
|
||||||
|
- Filament UI Action Surface Contract: PASS — action topology stays unchanged; this is a read-surface semantics upgrade.
|
||||||
|
- Filament UI UX-001 (Layout & IA): PASS — existing layouts remain, but sections become more semantically truthful. No exemption required.
|
||||||
|
|
||||||
|
## Project Structure
|
||||||
|
|
||||||
|
### Documentation (this feature)
|
||||||
|
|
||||||
|
```text
|
||||||
|
specs/163-baseline-subject-resolution/
|
||||||
|
├── plan.md
|
||||||
|
├── research.md
|
||||||
|
├── data-model.md
|
||||||
|
├── quickstart.md
|
||||||
|
├── contracts/
|
||||||
|
│ └── openapi.yaml
|
||||||
|
├── checklists/
|
||||||
|
│ └── requirements.md
|
||||||
|
└── tasks.md
|
||||||
|
```
|
||||||
|
|
||||||
|
### Source Code (repository root)
|
||||||
|
|
||||||
|
```text
|
||||||
|
app/
|
||||||
|
├── Filament/
|
||||||
|
│ ├── Pages/
|
||||||
|
│ │ └── BaselineCompareLanding.php
|
||||||
|
│ ├── Resources/
|
||||||
|
│ │ ├── OperationRunResource.php
|
||||||
|
│ │ └── BaselineSnapshotResource.php
|
||||||
|
│ └── Widgets/
|
||||||
|
├── Jobs/
|
||||||
|
│ ├── CompareBaselineToTenantJob.php
|
||||||
|
│ └── CaptureBaselineSnapshotJob.php
|
||||||
|
├── Services/
|
||||||
|
│ ├── Baselines/
|
||||||
|
│ │ ├── BaselineCompareService.php
|
||||||
|
│ │ ├── BaselineCaptureService.php
|
||||||
|
│ │ ├── BaselineContentCapturePhase.php
|
||||||
|
│ │ └── Evidence/
|
||||||
|
│ ├── Intune/
|
||||||
|
│ │ └── PolicySyncService.php
|
||||||
|
│ └── Inventory/
|
||||||
|
│ └── InventorySyncService.php
|
||||||
|
├── Support/
|
||||||
|
│ ├── Baselines/
|
||||||
|
│ ├── Inventory/
|
||||||
|
│ ├── OpsUx/
|
||||||
|
│ └── Ui/
|
||||||
|
├── Livewire/
|
||||||
|
└── Models/
|
||||||
|
config/
|
||||||
|
├── tenantpilot.php
|
||||||
|
└── graph_contracts.php
|
||||||
|
tests/
|
||||||
|
├── Feature/
|
||||||
|
│ ├── Baselines/
|
||||||
|
│ ├── Filament/
|
||||||
|
│ └── Monitoring/
|
||||||
|
└── Unit/
|
||||||
|
└── Support/
|
||||||
|
```
|
||||||
|
|
||||||
|
**Structure Decision**: Web application. The work stays inside existing baseline jobs and services, support-layer value objects and presenters, current Filament surfaces, and focused Pest coverage. No new top-level architecture area is required.
|
||||||
|
|
||||||
|
## Complexity Tracking
|
||||||
|
|
||||||
|
No constitution violations are required for this feature.
|
||||||
|
|
||||||
|
## Phase 0 — Outline & Research (DONE)
|
||||||
|
|
||||||
|
Outputs:
|
||||||
|
- `specs/163-baseline-subject-resolution/research.md`
|
||||||
|
|
||||||
|
Key decisions captured:
|
||||||
|
- Introduce a first-class subject-resolution contract in the backend instead of solving the problem with UI-only relabeling.
|
||||||
|
- Persist both `subject_class` and `resolution_outcome` because they answer different operator questions.
|
||||||
|
- Keep foundation-backed subjects eligible only when the runtime can truthfully classify them through an inventory-backed or limited-capability path.
|
||||||
|
- Add a runtime consistency guard during scope or resolver preparation so `baseline_compare.supported` cannot silently overpromise structural capability.
|
||||||
|
- Preserve transient reasons such as throttling and capture failure as precise operational outcomes rather than absorbing them into structural taxonomy.
|
||||||
|
- Treat broad legacy gap shapes as development-only cleanup candidates rather than a compatibility requirement for the new runtime contract.
|
||||||
|
|
||||||
|
## Phase 1 — Design & Contracts (DONE)
|
||||||
|
|
||||||
|
Outputs:
|
||||||
|
- `specs/163-baseline-subject-resolution/data-model.md`
|
||||||
|
- `specs/163-baseline-subject-resolution/contracts/openapi.yaml`
|
||||||
|
- `specs/163-baseline-subject-resolution/quickstart.md`
|
||||||
|
|
||||||
|
Design highlights:
|
||||||
|
- The core semantic unit is a `SubjectDescriptor` that is classified before resolution and yields a deterministic `ResolutionOutcomeRecord`.
|
||||||
|
- `OperationRun.context` remains the canonical persisted contract for compare and capture evidence-gap semantics, but new runs store richer subject-level objects instead of reason plus raw string only.
|
||||||
|
- The runtime support guard sits before compare and capture execution so unsupported structural mismatches are blocked or reclassified before misleading `policy_not_found`-style outcomes are emitted.
|
||||||
|
- Existing detail and landing surfaces are updated for the new structured gap contract, and development fixtures or stale local run data are regenerated instead of driving a permanent compatibility layer.
|
||||||
|
- Compare and capture share the same root-cause vocabulary, but retain operation-specific outcome families where needed.
|
||||||
|
|
||||||
|
## Phase 1 — Agent Context Update (REQUIRED)
|
||||||
|
|
||||||
|
Run:
|
||||||
|
- `.specify/scripts/bash/update-agent-context.sh copilot`
|
||||||
|
|
||||||
|
## Constitution Check — Post-Design Re-evaluation
|
||||||
|
|
||||||
|
- PASS — the design remains inside existing compare and capture operations and does not add new remote-call paths or lifecycle mutations.
|
||||||
|
- PASS — inventory-first semantics are strengthened because inventory-backed subjects are no longer mislabeled as missing policy records.
|
||||||
|
- PASS — operator surfaces stay on existing pages and remain DB-only at render time.
|
||||||
|
- PASS — development cleanup is explicit and bounded; the new contract remains the only forward-looking runtime shape.
|
||||||
|
- PASS — no Action Surface or UX-001 exemptions are needed because action topology and layouts remain intact.
|
||||||
|
|
||||||
|
## Phase 2 — Implementation Plan
|
||||||
|
|
||||||
|
### Step 1 — Subject classification and runtime capability foundation
|
||||||
|
|
||||||
|
Goal: implement FR-001 through FR-003, FR-008, FR-015, and FR-016 by creating a deterministic subject-resolution foundation shared by compare and capture.
|
||||||
|
|
||||||
|
Changes:
|
||||||
|
- Introduce a dedicated subject-resolution support layer under `app/Support/Baselines/` that defines:
|
||||||
|
- subject classes
|
||||||
|
- resolution paths
|
||||||
|
- resolution outcomes
|
||||||
|
- operator action categories
|
||||||
|
- structural versus operational versus transient classification
|
||||||
|
- Extend `InventoryPolicyTypeMeta` and related metadata accessors so baseline support can express whether a type is policy-backed, inventory-backed, foundation-backed, or limited.
|
||||||
|
- Add a runtime capability guard used by `BaselineScope`, `BaselineCompareService`, and `BaselineCaptureService` so types only enter compare or capture on a truthful path.
|
||||||
|
- Keep the guard deterministic and explicit in logs or run context when support is limited or excluded.
|
||||||
|
|
||||||
|
Tests:
|
||||||
|
- Add unit tests for subject-class derivation, resolution-path derivation, and runtime-capability guard behavior.
|
||||||
|
- Add golden-style tests covering supported, limited, and structurally invalid foundation types.
|
||||||
|
|
||||||
|
### Step 2 — Capture-path resolution and gap taxonomy upgrade
|
||||||
|
|
||||||
|
Goal: implement FR-004 through FR-010 on the capture side so structural resolver mismatches are no longer emitted as generic missing-policy cases.
|
||||||
|
|
||||||
|
Changes:
|
||||||
|
- Refactor `BaselineContentCapturePhase` so it resolves subjects through the new subject contract rather than assuming a policy lookup for all subjects.
|
||||||
|
- Replace broad `policy_not_found` capture gaps with precise structured outcomes such as:
|
||||||
|
- policy record missing
|
||||||
|
- inventory record missing
|
||||||
|
- foundation-backed via inventory path
|
||||||
|
- resolution type mismatch
|
||||||
|
- unresolvable subject
|
||||||
|
- Preserve existing transient outcomes like `throttled`, `capture_failed`, and `budget_exhausted` unchanged except for richer structured metadata.
|
||||||
|
- Persist new structured gap-subject objects for new runs and remove any requirement to keep broad legacy reason shapes alive for future writes.
|
||||||
|
|
||||||
|
Tests:
|
||||||
|
- Add feature and unit coverage for capture-path classification across policy-backed, inventory-backed, foundation-backed, duplicate, invalid, and transient cases.
|
||||||
|
- Add deterministic replay coverage proving unchanged capture inputs produce unchanged outcomes.
|
||||||
|
- Add regressions proving structural foundation subjects no longer produce new generic `policy_not_found` gaps.
|
||||||
|
|
||||||
|
### Step 3 — Compare-path resolution and evidence-gap detail contract
|
||||||
|
|
||||||
|
Goal: implement FR-004 through FR-014 on the compare side by aligning current-evidence resolution, evidence-gap reasoning, and persisted run context with the new contract.
|
||||||
|
|
||||||
|
Changes:
|
||||||
|
- Refactor `CompareBaselineToTenantJob` so baseline item interpretation and current-state resolution produce explicit `resolution_outcome` records rather than only count buckets and raw subject keys.
|
||||||
|
- Add structured evidence-gap subject records under `baseline_compare.evidence_gaps.subjects` for new runs, including subject class, resolution path, resolution outcome, reason code, operator action category, and retryability or structural flags.
|
||||||
|
- Preserve already precise compare reasons such as `missing_current`, `ambiguous_match`, and role-definition-specific gap families while separating them from structural non-policy-backed outcomes.
|
||||||
|
- Ensure baseline compare reason translation remains aligned with the new detailed reason taxonomy instead of flattening distinct root causes.
|
||||||
|
|
||||||
|
Tests:
|
||||||
|
- Add feature tests for mixed compare runs containing structural, operational, transient, and successful subjects.
|
||||||
|
- Add deterministic compare tests proving identical inputs yield identical resolution outcomes.
|
||||||
|
- Add regressions for evidence-gap persistence shape and compare-surface rendering against the new structured contract.
|
||||||
|
|
||||||
|
### Step 4 — Development cleanup and operator-surface adoption
|
||||||
|
|
||||||
|
Goal: implement FR-011 through FR-014 and the User Story 3 acceptance scenarios by moving existing read surfaces to the new gap contract and treating stale development data as disposable.
|
||||||
|
|
||||||
|
Changes:
|
||||||
|
- Extend `BaselineCompareEvidenceGapDetails`, `BaselineCompareStats`, `OperationRunResource`, `BaselineCompareLanding`, and any related Livewire gap tables so they read the new structured gap subject records consistently.
|
||||||
|
- Add an explicit development cleanup mechanism for stale local run payloads, preferably a dedicated development-only Artisan command plus fixture regeneration steps, so old broad string-only gap subjects can be purged instead of preserved.
|
||||||
|
- Introduce operator-facing labels that answer root cause before action advice while keeping diagnostics secondary.
|
||||||
|
- Keep existing pages and sections, but expose structural versus operational versus transient semantics consistently across dense and detailed surfaces.
|
||||||
|
- Update snapshot and compare summary surfaces where old broad reason aggregations would otherwise misread the new taxonomy.
|
||||||
|
|
||||||
|
Tests:
|
||||||
|
- Add or update Filament feature tests for canonical run detail and tenant baseline compare landing against the new structured run shape.
|
||||||
|
- Add cleanup-oriented tests proving the development cleanup mechanism removes or invalidates stale broad-reason run payloads without extending production semantics.
|
||||||
|
|
||||||
|
### Step 5 — Focused validation pack and rollout safety
|
||||||
|
|
||||||
|
Goal: protect the foundation from semantic regressions and make follow-on fidelity work safe.
|
||||||
|
|
||||||
|
Changes:
|
||||||
|
- Add a focused regression pack spanning compare, capture, capability guard, and development-safe contract cleanup.
|
||||||
|
- Review every touched reason-label and badge usage to ensure structural, operational, and transient meanings remain centralized.
|
||||||
|
- Document the new backend contract shape in code-level PHPDoc and tests so follow-on specs can build on stable semantics.
|
||||||
|
- Keep rollout bounded to baseline compare and capture semantics without adding renderer-richness work from Spec 164.
|
||||||
|
|
||||||
|
Tests:
|
||||||
|
- Run the focused Pest pack in `quickstart.md`.
|
||||||
|
- Add one regression proving no render-time Graph calls occur on affected run-detail or landing surfaces.
|
||||||
|
|
||||||
87
specs/163-baseline-subject-resolution/quickstart.md
Normal file
87
specs/163-baseline-subject-resolution/quickstart.md
Normal file
@ -0,0 +1,87 @@
|
|||||||
|
# Quickstart: Baseline Subject Resolution and Evidence Gap Semantics Foundation
|
||||||
|
|
||||||
|
## Prerequisites
|
||||||
|
|
||||||
|
1. Start the local stack.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
vendor/bin/sail up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
2. Clear stale cached state if you have been switching branches or configs.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
vendor/bin/sail artisan optimize:clear
|
||||||
|
```
|
||||||
|
|
||||||
|
## Focused Verification Pack
|
||||||
|
|
||||||
|
Run the minimum targeted regression pack for this foundation:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
vendor/bin/sail artisan test --compact tests/Unit/Support/Baselines tests/Feature/Baselines tests/Feature/Filament/OperationRunEnterpriseDetailPageTest.php
|
||||||
|
```
|
||||||
|
|
||||||
|
If the implementation introduces dedicated new files, narrow the pack further to the new subject-resolution, compare, capture, and development-cleanup tests.
|
||||||
|
|
||||||
|
Format touched files before final review:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
vendor/bin/sail bin pint --dirty --format agent
|
||||||
|
```
|
||||||
|
|
||||||
|
## Manual Verification Flow
|
||||||
|
|
||||||
|
1. Ensure a tenant has fresh inventory data for at least one policy-backed type and one baseline-supported foundation type.
|
||||||
|
2. Trigger or locate a baseline capture run and a baseline compare run for that tenant and profile.
|
||||||
|
3. Open the canonical run detail at `/admin/operations/{run}`.
|
||||||
|
4. Confirm the page distinguishes:
|
||||||
|
- structural cases
|
||||||
|
- operational or missing-local-data cases
|
||||||
|
- transient retryable cases
|
||||||
|
5. Confirm inventory-only foundation subjects no longer surface as a new generic `policy_not_found` gap.
|
||||||
|
6. Confirm policy-backed missing-local-record cases still surface as an operational missing-record outcome.
|
||||||
|
|
||||||
|
## Development Cleanup Verification
|
||||||
|
|
||||||
|
1. Remove or invalidate old local compare or capture runs that still contain broad legacy gap reasons.
|
||||||
|
Dry-run:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
vendor/bin/sail artisan tenantpilot:baselines:purge-legacy-gap-runs
|
||||||
|
```
|
||||||
|
|
||||||
|
Apply deletion:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
vendor/bin/sail artisan tenantpilot:baselines:purge-legacy-gap-runs --force
|
||||||
|
```
|
||||||
|
|
||||||
|
2. Regenerate fresh runs under the new structured contract.
|
||||||
|
3. Confirm the product and targeted tests no longer depend on the broad legacy shape being preserved in runtime code.
|
||||||
|
|
||||||
|
## Runtime Capability Guard Verification
|
||||||
|
|
||||||
|
1. Configure or seed one baseline-supported type whose runtime resolver path is valid.
|
||||||
|
2. Configure or seed one type whose support claim would be structurally invalid without the new guard.
|
||||||
|
3. Start compare or capture preparation.
|
||||||
|
4. Confirm the valid type enters execution with a truthful path.
|
||||||
|
5. Confirm the invalid type is limited, excluded, or explicitly classified as invalid support configuration before misleading gap output is produced.
|
||||||
|
|
||||||
|
## Determinism Verification
|
||||||
|
|
||||||
|
1. Run the same compare scenario twice against unchanged tenant-local data.
|
||||||
|
2. Confirm both runs persist the same `subject_class`, `resolution_outcome`, and `operator_action_category` values for the same subject.
|
||||||
|
|
||||||
|
## Render-Safety Verification
|
||||||
|
|
||||||
|
1. Bind the fail-hard Graph client in affected UI tests.
|
||||||
|
2. Verify canonical run detail and tenant baseline compare landing render without triggering Graph calls.
|
||||||
|
3. Verify the richer semantics are derived solely from persisted run context and local metadata.
|
||||||
|
|
||||||
|
## Deployment Notes
|
||||||
|
|
||||||
|
- No new panel provider is required; Laravel 12 continues to register providers in `bootstrap/providers.php`.
|
||||||
|
- Filament remains on Livewire v4-compatible patterns and does not require view publishing.
|
||||||
|
- No new shared or panel assets are required, so this slice adds no new `filament:assets` deployment step beyond the existing deployment baseline.
|
||||||
|
- Existing compare and capture operations remain on current `OperationRun` types and notification behavior.
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user