Compare commits
2 Commits
166-findin
...
dev
| Author | SHA1 | Date | |
|---|---|---|---|
| d98dc30520 | |||
| 55aef627aa |
8
.github/agents/copilot-instructions.md
vendored
8
.github/agents/copilot-instructions.md
vendored
@ -110,6 +110,10 @@ ## Active Technologies
|
|||||||
- PostgreSQL with existing `operation_runs` JSONB-backed `context`, `summary_counts`, and `failure_summary`; no schema change planned (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)
|
- 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)
|
- PostgreSQL with existing baseline, findings, and `operation_runs` tables plus JSONB-backed compare context; no schema change planned (165-baseline-summary-trust)
|
||||||
|
- PHP 8.4.15 + Laravel 12, Filament v5, Livewire v4, Pest v4, Tailwind CSS v4, existing `Finding`, `FindingException`, `FindingRiskGovernanceResolver`, `BadgeCatalog`, `BadgeRenderer`, `FilterOptionCatalog`, and tenant dashboard widgets (166-finding-governance-health)
|
||||||
|
- PostgreSQL using existing `findings`, `finding_exceptions`, related decision tables, and existing DB-backed summary sources; no schema changes required (166-finding-governance-health)
|
||||||
|
- PHP 8.4.15 + Laravel 12, Filament v5, Livewire v4, Pest v4, Tailwind CSS v4, existing `ArtifactTruthPresenter`, `OperationUxPresenter`, `RelatedNavigationResolver`, `AppServiceProvider`, `BadgeCatalog`, `BadgeRenderer`, and current Filament resource/page seams (167-derived-state-memoization)
|
||||||
|
- PostgreSQL unchanged; feature adds no persistence and relies on request-local in-memory state only (167-derived-state-memoization)
|
||||||
|
|
||||||
- PHP 8.4.15 (feat/005-bulk-operations)
|
- PHP 8.4.15 (feat/005-bulk-operations)
|
||||||
|
|
||||||
@ -129,8 +133,8 @@ ## Code Style
|
|||||||
PHP 8.4.15: Follow standard conventions
|
PHP 8.4.15: Follow standard conventions
|
||||||
|
|
||||||
## Recent Changes
|
## Recent Changes
|
||||||
|
- 167-derived-state-memoization: Added PHP 8.4.15 + Laravel 12, Filament v5, Livewire v4, Pest v4, Tailwind CSS v4, existing `ArtifactTruthPresenter`, `OperationUxPresenter`, `RelatedNavigationResolver`, `AppServiceProvider`, `BadgeCatalog`, `BadgeRenderer`, and current Filament resource/page seams
|
||||||
|
- 166-finding-governance-health: Added PHP 8.4.15 + Laravel 12, Filament v5, Livewire v4, Pest v4, Tailwind CSS v4, existing `Finding`, `FindingException`, `FindingRiskGovernanceResolver`, `BadgeCatalog`, `BadgeRenderer`, `FilterOptionCatalog`, and tenant dashboard widgets
|
||||||
- 165-baseline-summary-trust: Added PHP 8.4, Laravel 12, Blade, Filament v5, Livewire v4 + Filament v5, Livewire v4, Pest v4, Laravel Sail, existing `BaselineCompareStats`, `BaselineCompareExplanationRegistry`, `ReasonPresenter`, `BadgeCatalog` or `BadgeRenderer`, `UiEnforcement`, and `OperationRunLinks`
|
- 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`
|
||||||
- 164-run-detail-hardening: Added PHP 8.4, Laravel 12, Blade views, Alpine via Filament v5 / Livewire v4 + Filament v5, Livewire v4, Pest v4, Laravel Sail, existing `OperationRunResource`, `TenantlessOperationRunViewer`, `EnterpriseDetailBuilder`, `ArtifactTruthPresenter`, `OperationUxPresenter`, and `SummaryCountsNormalizer`
|
|
||||||
- 163-baseline-subject-resolution-session-1774398153: Added PHP 8.4 + Laravel 12, Filament v5, Livewire v4
|
|
||||||
<!-- MANUAL ADDITIONS START -->
|
<!-- MANUAL ADDITIONS START -->
|
||||||
<!-- MANUAL ADDITIONS END -->
|
<!-- MANUAL ADDITIONS END -->
|
||||||
|
|||||||
@ -1,20 +1,32 @@
|
|||||||
<!--
|
<!--
|
||||||
Sync Impact Report
|
Sync Impact Report
|
||||||
|
|
||||||
- Version change: 1.12.0 → 1.13.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:
|
||||||
- Filament Native First / No Ad-hoc Styling (UI-FIL-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/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:
|
- Commands checked:
|
||||||
- N/A `.specify/templates/commands/*.md` directory is not present in this repo
|
- N/A `.specify/templates/commands/*.md` directory is not present in this repo
|
||||||
- Follow-up TODOs:
|
- Follow-up TODOs:
|
||||||
@ -45,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).
|
||||||
@ -473,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`.
|
||||||
@ -487,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.13.0 | **Ratified**: 2026-01-03 | **Last Amended**: 2026-03-26
|
**Version**: 1.14.0 | **Ratified**: 2026-01-03 | **Last Amended**: 2026-03-27
|
||||||
|
|||||||
@ -48,6 +48,13 @@ ## 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
|
- 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
|
||||||
@ -122,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`),
|
||||||
@ -150,6 +181,13 @@ ## 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.
|
||||||
|
|||||||
@ -67,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.
|
||||||
|
|
||||||
@ -213,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)
|
||||||
|
|||||||
@ -14,6 +14,7 @@
|
|||||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceInspectAffordance;
|
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceInspectAffordance;
|
||||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceProfile;
|
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceProfile;
|
||||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceSlot;
|
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceSlot;
|
||||||
|
use App\Support\Ui\GovernanceArtifactTruth\ArtifactTruthEnvelope;
|
||||||
use App\Support\Ui\GovernanceArtifactTruth\ArtifactTruthPresenter;
|
use App\Support\Ui\GovernanceArtifactTruth\ArtifactTruthPresenter;
|
||||||
use App\Support\Workspaces\WorkspaceContext;
|
use App\Support\Workspaces\WorkspaceContext;
|
||||||
use BackedEnum;
|
use BackedEnum;
|
||||||
@ -90,7 +91,7 @@ public function mount(): void
|
|||||||
$snapshots = $query->get()->unique('tenant_id')->values();
|
$snapshots = $query->get()->unique('tenant_id')->values();
|
||||||
|
|
||||||
$this->rows = $snapshots->map(function (EvidenceSnapshot $snapshot): array {
|
$this->rows = $snapshots->map(function (EvidenceSnapshot $snapshot): array {
|
||||||
$truth = app(ArtifactTruthPresenter::class)->forEvidenceSnapshot($snapshot);
|
$truth = $this->snapshotTruth($snapshot);
|
||||||
$freshnessSpec = BadgeCatalog::spec(BadgeDomain::GovernanceArtifactFreshness, $truth->freshnessState);
|
$freshnessSpec = BadgeCatalog::spec(BadgeDomain::GovernanceArtifactFreshness, $truth->freshnessState);
|
||||||
|
|
||||||
return [
|
return [
|
||||||
@ -133,4 +134,13 @@ protected function getHeaderActions(): array
|
|||||||
->url(route('admin.evidence.overview')),
|
->url(route('admin.evidence.overview')),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private function snapshotTruth(EvidenceSnapshot $snapshot, bool $fresh = false): ArtifactTruthEnvelope
|
||||||
|
{
|
||||||
|
$presenter = app(ArtifactTruthPresenter::class);
|
||||||
|
|
||||||
|
return $fresh
|
||||||
|
? $presenter->forEvidenceSnapshotFresh($snapshot)
|
||||||
|
: $presenter->forEvidenceSnapshot($snapshot);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -12,6 +12,7 @@
|
|||||||
use App\Models\Workspace;
|
use App\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';
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -14,6 +14,7 @@
|
|||||||
use App\Services\Tenants\TenantOperabilityService;
|
use App\Services\Tenants\TenantOperabilityService;
|
||||||
use App\Support\Auth\Capabilities;
|
use App\Support\Auth\Capabilities;
|
||||||
use App\Support\Navigation\CanonicalNavigationContext;
|
use App\Support\Navigation\CanonicalNavigationContext;
|
||||||
|
use App\Support\Navigation\RelatedNavigationResolver;
|
||||||
use App\Support\OperateHub\OperateHubShell;
|
use App\Support\OperateHub\OperateHubShell;
|
||||||
use App\Support\OperationRunLinks;
|
use App\Support\OperationRunLinks;
|
||||||
use App\Support\OpsUx\OperationUxPresenter;
|
use App\Support\OpsUx\OperationUxPresenter;
|
||||||
@ -24,7 +25,6 @@
|
|||||||
use App\Support\Tenants\ReferencedTenantLifecyclePresentation;
|
use App\Support\Tenants\ReferencedTenantLifecyclePresentation;
|
||||||
use App\Support\Tenants\TenantInteractionLane;
|
use App\Support\Tenants\TenantInteractionLane;
|
||||||
use App\Support\Tenants\TenantOperabilityQuestion;
|
use App\Support\Tenants\TenantOperabilityQuestion;
|
||||||
use App\Support\Ui\GovernanceArtifactTruth\ArtifactTruthPresenter;
|
|
||||||
use App\Support\Ui\OperatorExplanation\OperatorExplanationPattern;
|
use App\Support\Ui\OperatorExplanation\OperatorExplanationPattern;
|
||||||
use Filament\Actions\Action;
|
use Filament\Actions\Action;
|
||||||
use Filament\Actions\ActionGroup;
|
use Filament\Actions\ActionGroup;
|
||||||
@ -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>
|
||||||
*/
|
*/
|
||||||
@ -109,7 +107,7 @@ protected function getHeaderActions(): array
|
|||||||
return $actions;
|
return $actions;
|
||||||
}
|
}
|
||||||
|
|
||||||
$related = OperationRunLinks::related($this->run, $this->relatedLinksTenant());
|
$related = $this->relatedLinks();
|
||||||
|
|
||||||
$relatedActions = [];
|
$relatedActions = [];
|
||||||
|
|
||||||
@ -180,7 +178,7 @@ public function blockedExecutionBanner(): ?array
|
|||||||
$operatorExplanation->dominantCauseExplanation,
|
$operatorExplanation->dominantCauseExplanation,
|
||||||
]))
|
]))
|
||||||
: ($reasonEnvelope?->toBodyLines(false) ?? [
|
: ($reasonEnvelope?->toBodyLines(false) ?? [
|
||||||
OperationUxPresenter::surfaceFailureDetail($this->run) ?? 'The queued run was refused before side effects could begin.',
|
$this->surfaceFailureDetail() ?? 'The queued run was refused before side effects could begin.',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
return [
|
return [
|
||||||
@ -199,13 +197,13 @@ public function lifecycleBanner(): ?array
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
$attention = OperationUxPresenter::lifecycleAttentionSummary($this->run);
|
$attention = $this->lifecycleAttentionSummary();
|
||||||
|
|
||||||
if ($attention === null) {
|
if ($attention === null) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
$detail = OperationUxPresenter::surfaceFailureDetail($this->run) ?? 'Lifecycle truth needs operator review.';
|
$detail = $this->surfaceFailureDetail() ?? 'Lifecycle truth needs operator review.';
|
||||||
|
|
||||||
return match ($this->run->freshnessState()->value) {
|
return match ($this->run->freshnessState()->value) {
|
||||||
'likely_stale' => [
|
'likely_stale' => [
|
||||||
@ -286,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;
|
||||||
}
|
}
|
||||||
@ -463,6 +457,44 @@ private function governanceOperatorExplanation(): ?OperatorExplanationPattern
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
return app(ArtifactTruthPresenter::class)->forOperationRun($this->run)?->operatorExplanation;
|
return OperationUxPresenter::governanceOperatorExplanation($this->run);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, string>
|
||||||
|
*/
|
||||||
|
private function relatedLinks(bool $fresh = false): array
|
||||||
|
{
|
||||||
|
if (! isset($this->run)) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
$resolver = app(RelatedNavigationResolver::class);
|
||||||
|
|
||||||
|
return $fresh
|
||||||
|
? $resolver->operationLinksFresh($this->run, $this->relatedLinksTenant())
|
||||||
|
: $resolver->operationLinks($this->run, $this->relatedLinksTenant());
|
||||||
|
}
|
||||||
|
|
||||||
|
private function lifecycleAttentionSummary(bool $fresh = false): ?string
|
||||||
|
{
|
||||||
|
if (! isset($this->run)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $fresh
|
||||||
|
? OperationUxPresenter::lifecycleAttentionSummaryFresh($this->run)
|
||||||
|
: OperationUxPresenter::lifecycleAttentionSummary($this->run);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function surfaceFailureDetail(bool $fresh = false): ?string
|
||||||
|
{
|
||||||
|
if (! isset($this->run)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $fresh
|
||||||
|
? OperationUxPresenter::surfaceFailureDetailFresh($this->run)
|
||||||
|
: OperationUxPresenter::surfaceFailureDetail($this->run);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -22,6 +22,7 @@
|
|||||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceInspectAffordance;
|
use App\Support\Ui\ActionSurface\Enums\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\Workspaces\WorkspaceContext;
|
use App\Support\Workspaces\WorkspaceContext;
|
||||||
use BackedEnum;
|
use BackedEnum;
|
||||||
@ -118,11 +119,11 @@ public function table(Table $table): Table
|
|||||||
TextColumn::make('artifact_truth')
|
TextColumn::make('artifact_truth')
|
||||||
->label('Artifact truth')
|
->label('Artifact truth')
|
||||||
->badge()
|
->badge()
|
||||||
->getStateUsing(fn (TenantReview $record): string => app(ArtifactTruthPresenter::class)->forTenantReview($record)->primaryLabel)
|
->getStateUsing(fn (TenantReview $record): string => $this->reviewTruth($record)->primaryLabel)
|
||||||
->color(fn (TenantReview $record): string => app(ArtifactTruthPresenter::class)->forTenantReview($record)->primaryBadgeSpec()->color)
|
->color(fn (TenantReview $record): string => $this->reviewTruth($record)->primaryBadgeSpec()->color)
|
||||||
->icon(fn (TenantReview $record): ?string => app(ArtifactTruthPresenter::class)->forTenantReview($record)->primaryBadgeSpec()->icon)
|
->icon(fn (TenantReview $record): ?string => $this->reviewTruth($record)->primaryBadgeSpec()->icon)
|
||||||
->iconColor(fn (TenantReview $record): ?string => app(ArtifactTruthPresenter::class)->forTenantReview($record)->primaryBadgeSpec()->iconColor)
|
->iconColor(fn (TenantReview $record): ?string => $this->reviewTruth($record)->primaryBadgeSpec()->iconColor)
|
||||||
->description(fn (TenantReview $record): ?string => app(ArtifactTruthPresenter::class)->forTenantReview($record)->operatorExplanation?->headline ?? app(ArtifactTruthPresenter::class)->forTenantReview($record)->primaryExplanation)
|
->description(fn (TenantReview $record): ?string => $this->reviewTruth($record)->operatorExplanation?->headline ?? $this->reviewTruth($record)->primaryExplanation)
|
||||||
->wrap(),
|
->wrap(),
|
||||||
TextColumn::make('completeness_state')
|
TextColumn::make('completeness_state')
|
||||||
->label('Completeness')
|
->label('Completeness')
|
||||||
@ -138,23 +139,23 @@ public function table(Table $table): Table
|
|||||||
->badge()
|
->badge()
|
||||||
->getStateUsing(fn (TenantReview $record): string => BadgeCatalog::spec(
|
->getStateUsing(fn (TenantReview $record): string => BadgeCatalog::spec(
|
||||||
BadgeDomain::GovernanceArtifactPublicationReadiness,
|
BadgeDomain::GovernanceArtifactPublicationReadiness,
|
||||||
app(ArtifactTruthPresenter::class)->forTenantReview($record)->publicationReadiness ?? 'internal_only',
|
$this->reviewTruth($record)->publicationReadiness ?? 'internal_only',
|
||||||
)->label)
|
)->label)
|
||||||
->color(fn (TenantReview $record): string => BadgeCatalog::spec(
|
->color(fn (TenantReview $record): string => BadgeCatalog::spec(
|
||||||
BadgeDomain::GovernanceArtifactPublicationReadiness,
|
BadgeDomain::GovernanceArtifactPublicationReadiness,
|
||||||
app(ArtifactTruthPresenter::class)->forTenantReview($record)->publicationReadiness ?? 'internal_only',
|
$this->reviewTruth($record)->publicationReadiness ?? 'internal_only',
|
||||||
)->color)
|
)->color)
|
||||||
->icon(fn (TenantReview $record): ?string => BadgeCatalog::spec(
|
->icon(fn (TenantReview $record): ?string => BadgeCatalog::spec(
|
||||||
BadgeDomain::GovernanceArtifactPublicationReadiness,
|
BadgeDomain::GovernanceArtifactPublicationReadiness,
|
||||||
app(ArtifactTruthPresenter::class)->forTenantReview($record)->publicationReadiness ?? 'internal_only',
|
$this->reviewTruth($record)->publicationReadiness ?? 'internal_only',
|
||||||
)->icon)
|
)->icon)
|
||||||
->iconColor(fn (TenantReview $record): ?string => BadgeCatalog::spec(
|
->iconColor(fn (TenantReview $record): ?string => BadgeCatalog::spec(
|
||||||
BadgeDomain::GovernanceArtifactPublicationReadiness,
|
BadgeDomain::GovernanceArtifactPublicationReadiness,
|
||||||
app(ArtifactTruthPresenter::class)->forTenantReview($record)->publicationReadiness ?? 'internal_only',
|
$this->reviewTruth($record)->publicationReadiness ?? 'internal_only',
|
||||||
)->iconColor),
|
)->iconColor),
|
||||||
TextColumn::make('artifact_next_step')
|
TextColumn::make('artifact_next_step')
|
||||||
->label('Next step')
|
->label('Next step')
|
||||||
->getStateUsing(fn (TenantReview $record): string => app(ArtifactTruthPresenter::class)->forTenantReview($record)->operatorExplanation?->nextActionText ?? app(ArtifactTruthPresenter::class)->forTenantReview($record)->nextStepText())
|
->getStateUsing(fn (TenantReview $record): string => $this->reviewTruth($record)->operatorExplanation?->nextActionText ?? $this->reviewTruth($record)->nextStepText())
|
||||||
->wrap(),
|
->wrap(),
|
||||||
])
|
])
|
||||||
->filters([
|
->filters([
|
||||||
@ -325,4 +326,13 @@ private function workspace(): ?Workspace
|
|||||||
? Workspace::query()->whereKey((int) $workspaceId)->first()
|
? Workspace::query()->whereKey((int) $workspaceId)->first()
|
||||||
: null;
|
: null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private function reviewTruth(TenantReview $record, bool $fresh = false): ArtifactTruthEnvelope
|
||||||
|
{
|
||||||
|
$presenter = app(ArtifactTruthPresenter::class);
|
||||||
|
|
||||||
|
return $fresh
|
||||||
|
? $presenter->forTenantReviewFresh($record)
|
||||||
|
: $presenter->forTenantReview($record);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -385,9 +385,13 @@ private static function gapSpec(BaselineSnapshot $snapshot): \App\Support\Badges
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static function truthEnvelope(BaselineSnapshot $snapshot): ArtifactTruthEnvelope
|
private static function truthEnvelope(BaselineSnapshot $snapshot, bool $fresh = false): ArtifactTruthEnvelope
|
||||||
{
|
{
|
||||||
return app(ArtifactTruthPresenter::class)->forBaselineSnapshot($snapshot);
|
$presenter = app(ArtifactTruthPresenter::class);
|
||||||
|
|
||||||
|
return $fresh
|
||||||
|
? $presenter->forBaselineSnapshotFresh($snapshot)
|
||||||
|
: $presenter->forBaselineSnapshot($snapshot);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static function currentTruthLabel(BaselineSnapshot $snapshot): string
|
private static function currentTruthLabel(BaselineSnapshot $snapshot): string
|
||||||
|
|||||||
@ -274,6 +274,7 @@ public static function table(Table $table): Table
|
|||||||
}
|
}
|
||||||
|
|
||||||
app(EvidenceSnapshotService::class)->expire($record, $user);
|
app(EvidenceSnapshotService::class)->expire($record, $user);
|
||||||
|
static::truthEnvelope($record->refresh(), fresh: true);
|
||||||
|
|
||||||
Notification::make()->success()->title('Snapshot expired')->send();
|
Notification::make()->success()->title('Snapshot expired')->send();
|
||||||
}),
|
}),
|
||||||
@ -612,9 +613,13 @@ private static function badgeLabel(BadgeDomain $domain, ?string $state): ?string
|
|||||||
return $label === 'Unknown' ? null : $label;
|
return $label === 'Unknown' ? null : $label;
|
||||||
}
|
}
|
||||||
|
|
||||||
private static function truthEnvelope(EvidenceSnapshot $record): ArtifactTruthEnvelope
|
private static function truthEnvelope(EvidenceSnapshot $record, bool $fresh = false): ArtifactTruthEnvelope
|
||||||
{
|
{
|
||||||
return app(ArtifactTruthPresenter::class)->forEvidenceSnapshot($record);
|
$presenter = app(ArtifactTruthPresenter::class);
|
||||||
|
|
||||||
|
return $fresh
|
||||||
|
? $presenter->forEvidenceSnapshotFresh($record)
|
||||||
|
: $presenter->forEvidenceSnapshot($record);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static function stringifySummaryValue(mixed $value): string
|
private static function stringifySummaryValue(mixed $value): string
|
||||||
@ -646,6 +651,7 @@ public static function executeGeneration(array $data): void
|
|||||||
user: $user,
|
user: $user,
|
||||||
allowStale: (bool) ($data['allow_stale'] ?? false),
|
allowStale: (bool) ($data['allow_stale'] ?? false),
|
||||||
);
|
);
|
||||||
|
static::truthEnvelope($snapshot->refresh(), fresh: true);
|
||||||
|
|
||||||
if (! $snapshot->wasRecentlyCreated) {
|
if (! $snapshot->wasRecentlyCreated) {
|
||||||
Notification::make()
|
Notification::make()
|
||||||
|
|||||||
@ -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')
|
||||||
|
|||||||
@ -146,6 +146,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 +687,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 +737,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 +746,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 +786,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 +879,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 +1195,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');
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -1129,10 +1246,13 @@ private static function primaryRelatedAction(): Actions\Action
|
|||||||
->color('gray');
|
->color('gray');
|
||||||
}
|
}
|
||||||
|
|
||||||
private static function primaryRelatedEntry(Finding $record): ?RelatedContextEntry
|
private static function primaryRelatedEntry(Finding $record, bool $fresh = false): ?RelatedContextEntry
|
||||||
{
|
{
|
||||||
return app(RelatedNavigationResolver::class)
|
$resolver = app(RelatedNavigationResolver::class);
|
||||||
->primaryListAction(CrossResourceNavigationMatrix::SOURCE_FINDING, $record);
|
|
||||||
|
return $fresh
|
||||||
|
? $resolver->primaryListActionFresh(CrossResourceNavigationMatrix::SOURCE_FINDING, $record)
|
||||||
|
: $resolver->primaryListAction(CrossResourceNavigationMatrix::SOURCE_FINDING, $record);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static function findingRunNavigationContext(Finding $record): CanonicalNavigationContext
|
private static function findingRunNavigationContext(Finding $record): CanonicalNavigationContext
|
||||||
@ -1186,7 +1306,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 +1332,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 +1357,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 +1401,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 +1436,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 +1471,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 +1532,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 +1596,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 +1623,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 +1821,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 +1883,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 +1894,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 +1963,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')
|
||||||
|
|||||||
@ -135,7 +135,7 @@ public static function table(Table $table): Table
|
|||||||
->color(fn (mixed $state, OperationRun $record): string => BadgeRenderer::spec(BadgeDomain::OperationRunStatus, static::statusBadgeState($record))->color)
|
->color(fn (mixed $state, OperationRun $record): string => BadgeRenderer::spec(BadgeDomain::OperationRunStatus, static::statusBadgeState($record))->color)
|
||||||
->icon(fn (mixed $state, OperationRun $record): ?string => BadgeRenderer::spec(BadgeDomain::OperationRunStatus, static::statusBadgeState($record))->icon)
|
->icon(fn (mixed $state, OperationRun $record): ?string => BadgeRenderer::spec(BadgeDomain::OperationRunStatus, static::statusBadgeState($record))->icon)
|
||||||
->iconColor(fn (mixed $state, OperationRun $record): ?string => BadgeRenderer::spec(BadgeDomain::OperationRunStatus, static::statusBadgeState($record))->iconColor)
|
->iconColor(fn (mixed $state, OperationRun $record): ?string => BadgeRenderer::spec(BadgeDomain::OperationRunStatus, static::statusBadgeState($record))->iconColor)
|
||||||
->description(fn (OperationRun $record): ?string => OperationUxPresenter::lifecycleAttentionSummary($record)),
|
->description(fn (OperationRun $record): ?string => static::lifecycleAttentionSummary($record)),
|
||||||
Tables\Columns\TextColumn::make('type')
|
Tables\Columns\TextColumn::make('type')
|
||||||
->label('Operation')
|
->label('Operation')
|
||||||
->formatStateUsing(fn (?string $state): string => OperationCatalog::label((string) $state))
|
->formatStateUsing(fn (?string $state): string => OperationCatalog::label((string) $state))
|
||||||
@ -162,7 +162,7 @@ public static function table(Table $table): Table
|
|||||||
->color(fn (mixed $state, OperationRun $record): string => BadgeRenderer::spec(BadgeDomain::OperationRunOutcome, static::outcomeBadgeState($record))->color)
|
->color(fn (mixed $state, OperationRun $record): string => BadgeRenderer::spec(BadgeDomain::OperationRunOutcome, static::outcomeBadgeState($record))->color)
|
||||||
->icon(fn (mixed $state, OperationRun $record): ?string => BadgeRenderer::spec(BadgeDomain::OperationRunOutcome, static::outcomeBadgeState($record))->icon)
|
->icon(fn (mixed $state, OperationRun $record): ?string => BadgeRenderer::spec(BadgeDomain::OperationRunOutcome, static::outcomeBadgeState($record))->icon)
|
||||||
->iconColor(fn (mixed $state, OperationRun $record): ?string => BadgeRenderer::spec(BadgeDomain::OperationRunOutcome, static::outcomeBadgeState($record))->iconColor)
|
->iconColor(fn (mixed $state, OperationRun $record): ?string => BadgeRenderer::spec(BadgeDomain::OperationRunOutcome, static::outcomeBadgeState($record))->iconColor)
|
||||||
->description(fn (OperationRun $record): ?string => OperationUxPresenter::surfaceGuidance($record)),
|
->description(fn (OperationRun $record): ?string => static::surfaceGuidance($record)),
|
||||||
])
|
])
|
||||||
->filters([
|
->filters([
|
||||||
Tables\Filters\SelectFilter::make('tenant_id')
|
Tables\Filters\SelectFilter::make('tenant_id')
|
||||||
@ -264,9 +264,7 @@ private static function enterpriseDetailPage(OperationRun $record): \App\Support
|
|||||||
$referencedTenantLifecycle = $record->tenant instanceof Tenant
|
$referencedTenantLifecycle = $record->tenant instanceof Tenant
|
||||||
? ReferencedTenantLifecyclePresentation::forOperationRun($record->tenant)
|
? ReferencedTenantLifecyclePresentation::forOperationRun($record->tenant)
|
||||||
: null;
|
: null;
|
||||||
$artifactTruth = $record->supportsOperatorExplanation()
|
$artifactTruth = static::artifactTruthEnvelope($record);
|
||||||
? app(ArtifactTruthPresenter::class)->forOperationRun($record)
|
|
||||||
: null;
|
|
||||||
$operatorExplanation = $artifactTruth?->operatorExplanation;
|
$operatorExplanation = $artifactTruth?->operatorExplanation;
|
||||||
$primaryNextStep = static::resolvePrimaryNextStep($record, $artifactTruth, $operatorExplanation);
|
$primaryNextStep = static::resolvePrimaryNextStep($record, $artifactTruth, $operatorExplanation);
|
||||||
$supportingGroups = static::supportingGroups(
|
$supportingGroups = static::supportingGroups(
|
||||||
@ -344,8 +342,7 @@ private static function enterpriseDetailPage(OperationRun $record): \App\Support
|
|||||||
kind: 'related_context',
|
kind: 'related_context',
|
||||||
title: 'Related context',
|
title: 'Related context',
|
||||||
view: 'filament.infolists.entries.related-context',
|
view: 'filament.infolists.entries.related-context',
|
||||||
viewData: ['entries' => app(RelatedNavigationResolver::class)
|
viewData: ['entries' => static::relatedContextEntries($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(
|
$factory->viewSection(
|
||||||
@ -518,7 +515,7 @@ private static function supportingGroups(
|
|||||||
array $primaryNextStep,
|
array $primaryNextStep,
|
||||||
): array {
|
): array {
|
||||||
$groups = [];
|
$groups = [];
|
||||||
$hasElevatedLifecycleState = OperationUxPresenter::lifecycleAttentionSummary($record) !== null;
|
$hasElevatedLifecycleState = static::lifecycleAttentionSummary($record) !== null;
|
||||||
|
|
||||||
$guidanceItems = array_values(array_filter([
|
$guidanceItems = array_values(array_filter([
|
||||||
$operatorExplanation instanceof OperatorExplanationPattern && $operatorExplanation->coverageStatement !== null
|
$operatorExplanation instanceof OperatorExplanationPattern && $operatorExplanation->coverageStatement !== null
|
||||||
@ -650,11 +647,11 @@ private static function resolvePrimaryNextStep(
|
|||||||
|
|
||||||
$opsUxSource = match (true) {
|
$opsUxSource = match (true) {
|
||||||
(string) $record->outcome === OperationRunOutcome::Blocked->value => 'blocked_reason',
|
(string) $record->outcome === OperationRunOutcome::Blocked->value => 'blocked_reason',
|
||||||
OperationUxPresenter::lifecycleAttentionSummary($record) !== null => 'lifecycle_attention',
|
static::lifecycleAttentionSummary($record) !== null => 'lifecycle_attention',
|
||||||
default => 'ops_ux',
|
default => 'ops_ux',
|
||||||
};
|
};
|
||||||
|
|
||||||
static::pushNextStepCandidate($candidates, OperationUxPresenter::surfaceGuidance($record), $opsUxSource);
|
static::pushNextStepCandidate($candidates, static::surfaceGuidance($record), $opsUxSource);
|
||||||
|
|
||||||
if ($candidates === []) {
|
if ($candidates === []) {
|
||||||
return [
|
return [
|
||||||
@ -1189,6 +1186,57 @@ public static function getPages(): array
|
|||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static function artifactTruthEnvelope(OperationRun $record, bool $fresh = false): ?ArtifactTruthEnvelope
|
||||||
|
{
|
||||||
|
if (! $record->supportsOperatorExplanation()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$presenter = app(ArtifactTruthPresenter::class);
|
||||||
|
|
||||||
|
return $fresh
|
||||||
|
? $presenter->forOperationRunFresh($record)
|
||||||
|
: $presenter->forOperationRun($record);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function lifecycleAttentionSummary(OperationRun $record, bool $fresh = false): ?string
|
||||||
|
{
|
||||||
|
return $fresh
|
||||||
|
? OperationUxPresenter::lifecycleAttentionSummaryFresh($record)
|
||||||
|
: OperationUxPresenter::lifecycleAttentionSummary($record);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function surfaceGuidance(OperationRun $record, bool $fresh = false): ?string
|
||||||
|
{
|
||||||
|
return $fresh
|
||||||
|
? OperationUxPresenter::surfaceGuidanceFresh($record)
|
||||||
|
: OperationUxPresenter::surfaceGuidance($record);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return list<array{
|
||||||
|
* key: string,
|
||||||
|
* label: string,
|
||||||
|
* value: string,
|
||||||
|
* secondaryValue: ?string,
|
||||||
|
* targetUrl: ?string,
|
||||||
|
* targetKind: string,
|
||||||
|
* availability: string,
|
||||||
|
* unavailableReason: ?string,
|
||||||
|
* contextBadge: ?string,
|
||||||
|
* priority: int,
|
||||||
|
* actionLabel: string
|
||||||
|
* }>
|
||||||
|
*/
|
||||||
|
private static function relatedContextEntries(OperationRun $record, bool $fresh = false): array
|
||||||
|
{
|
||||||
|
$resolver = app(RelatedNavigationResolver::class);
|
||||||
|
|
||||||
|
return $fresh
|
||||||
|
? $resolver->detailEntriesFresh(CrossResourceNavigationMatrix::SOURCE_OPERATION_RUN, $record)
|
||||||
|
: $resolver->detailEntries(CrossResourceNavigationMatrix::SOURCE_OPERATION_RUN, $record);
|
||||||
|
}
|
||||||
|
|
||||||
private static function targetScopeDisplay(OperationRun $record): ?string
|
private static function targetScopeDisplay(OperationRun $record): ?string
|
||||||
{
|
{
|
||||||
$context = is_array($record->context) ? $record->context : [];
|
$context = is_array($record->context) ? $record->context : [];
|
||||||
|
|||||||
@ -4,6 +4,7 @@
|
|||||||
|
|
||||||
use App\Filament\Resources\PolicyVersionResource;
|
use App\Filament\Resources\PolicyVersionResource;
|
||||||
use App\Support\Navigation\CrossResourceNavigationMatrix;
|
use App\Support\Navigation\CrossResourceNavigationMatrix;
|
||||||
|
use App\Support\Navigation\RelatedContextEntry;
|
||||||
use App\Support\Navigation\RelatedNavigationResolver;
|
use App\Support\Navigation\RelatedNavigationResolver;
|
||||||
use Filament\Actions\Action;
|
use Filament\Actions\Action;
|
||||||
use Filament\Resources\Pages\ViewRecord;
|
use Filament\Resources\Pages\ViewRecord;
|
||||||
@ -26,12 +27,9 @@ protected function getHeaderActions(): array
|
|||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
Action::make('primary_related')
|
Action::make('primary_related')
|
||||||
->label(fn (): string => app(RelatedNavigationResolver::class)
|
->label(fn (): string => $this->primaryRelatedEntry()?->actionLabel ?? 'Open related record')
|
||||||
->primaryListAction(CrossResourceNavigationMatrix::SOURCE_POLICY_VERSION, $this->getRecord())?->actionLabel ?? 'Open related record')
|
->url(fn (): ?string => $this->primaryRelatedEntry()?->targetUrl)
|
||||||
->url(fn (): ?string => app(RelatedNavigationResolver::class)
|
->hidden(fn (): bool => ! ($this->primaryRelatedEntry()?->isAvailable() ?? false))
|
||||||
->primaryListAction(CrossResourceNavigationMatrix::SOURCE_POLICY_VERSION, $this->getRecord())?->targetUrl)
|
|
||||||
->hidden(fn (): bool => ! (app(RelatedNavigationResolver::class)
|
|
||||||
->primaryListAction(CrossResourceNavigationMatrix::SOURCE_POLICY_VERSION, $this->getRecord())?->isAvailable() ?? false))
|
|
||||||
->color('gray'),
|
->color('gray'),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
@ -42,4 +40,13 @@ public function getFooter(): ?View
|
|||||||
'record' => $this->getRecord(),
|
'record' => $this->getRecord(),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private function primaryRelatedEntry(bool $fresh = false): ?RelatedContextEntry
|
||||||
|
{
|
||||||
|
$resolver = app(RelatedNavigationResolver::class);
|
||||||
|
|
||||||
|
return $fresh
|
||||||
|
? $resolver->primaryListActionFresh(CrossResourceNavigationMatrix::SOURCE_POLICY_VERSION, $this->getRecord())
|
||||||
|
: $resolver->primaryListAction(CrossResourceNavigationMatrix::SOURCE_POLICY_VERSION, $this->getRecord());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -337,6 +337,7 @@ public static function table(Table $table): Table
|
|||||||
}
|
}
|
||||||
|
|
||||||
$record->update(['status' => ReviewPackStatus::Expired->value]);
|
$record->update(['status' => ReviewPackStatus::Expired->value]);
|
||||||
|
static::truthEnvelope($record->refresh(), fresh: true);
|
||||||
|
|
||||||
Notification::make()
|
Notification::make()
|
||||||
->success()
|
->success()
|
||||||
@ -397,9 +398,13 @@ public static function getPages(): array
|
|||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
private static function truthEnvelope(ReviewPack $record): ArtifactTruthEnvelope
|
private static function truthEnvelope(ReviewPack $record, bool $fresh = false): ArtifactTruthEnvelope
|
||||||
{
|
{
|
||||||
return app(ArtifactTruthPresenter::class)->forReviewPack($record);
|
$presenter = app(ArtifactTruthPresenter::class);
|
||||||
|
|
||||||
|
return $fresh
|
||||||
|
? $presenter->forReviewPackFresh($record)
|
||||||
|
: $presenter->forReviewPack($record);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -447,6 +452,8 @@ public static function executeGeneration(array $data): void
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static::truthEnvelope($reviewPack->refresh(), fresh: true);
|
||||||
|
|
||||||
if (! $reviewPack->wasRecentlyCreated) {
|
if (! $reviewPack->wasRecentlyCreated) {
|
||||||
Notification::make()
|
Notification::make()
|
||||||
->success()
|
->success()
|
||||||
|
|||||||
@ -419,6 +419,8 @@ public static function executeCreateReview(array $data): void
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static::truthEnvelope($review->refresh(), fresh: true);
|
||||||
|
|
||||||
if (! $review->wasRecentlyCreated) {
|
if (! $review->wasRecentlyCreated) {
|
||||||
Notification::make()
|
Notification::make()
|
||||||
->success()
|
->success()
|
||||||
@ -488,6 +490,9 @@ public static function executeExport(TenantReview $review): void
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static::truthEnvelope($review->refresh(), fresh: true);
|
||||||
|
app(ArtifactTruthPresenter::class)->forReviewPackFresh($pack->refresh());
|
||||||
|
|
||||||
if (! $pack->wasRecentlyCreated) {
|
if (! $pack->wasRecentlyCreated) {
|
||||||
Notification::make()
|
Notification::make()
|
||||||
->success()
|
->success()
|
||||||
@ -606,8 +611,12 @@ private static function sectionPresentation(TenantReviewSection $section): array
|
|||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
private static function truthEnvelope(TenantReview $record): ArtifactTruthEnvelope
|
private static function truthEnvelope(TenantReview $record, bool $fresh = false): ArtifactTruthEnvelope
|
||||||
{
|
{
|
||||||
return app(ArtifactTruthPresenter::class)->forTenantReview($record);
|
$presenter = app(ArtifactTruthPresenter::class);
|
||||||
|
|
||||||
|
return $fresh
|
||||||
|
? $presenter->forTenantReviewFresh($record)
|
||||||
|
: $presenter->forTenantReview($record);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -44,16 +44,16 @@ protected function getViewData(): array
|
|||||||
return $empty;
|
return $empty;
|
||||||
}
|
}
|
||||||
|
|
||||||
$landingUrl = BaselineCompareLanding::getUrl(tenant: $tenant);
|
$tenantLandingUrl = BaselineCompareLanding::getUrl(panel: 'tenant', tenant: $tenant);
|
||||||
$runUrl = $stats->operationRunId !== null
|
$runUrl = $stats->operationRunId !== null
|
||||||
? OperationRunLinks::view($stats->operationRunId, $tenant)
|
? OperationRunLinks::view($stats->operationRunId, $tenant)
|
||||||
: null;
|
: null;
|
||||||
$findingsUrl = FindingResource::getUrl('index', tenant: $tenant);
|
$findingsUrl = FindingResource::getUrl('index', panel: 'tenant', tenant: $tenant);
|
||||||
$summaryAssessment = $stats->summaryAssessment();
|
$summaryAssessment = $stats->summaryAssessment();
|
||||||
$nextActionUrl = match ($summaryAssessment->nextActionTarget()) {
|
$nextActionUrl = match ($summaryAssessment->nextActionTarget()) {
|
||||||
'run' => $runUrl,
|
'run' => $runUrl,
|
||||||
'findings' => $findingsUrl,
|
'findings' => $findingsUrl,
|
||||||
'landing' => $landingUrl,
|
'landing' => $tenantLandingUrl,
|
||||||
default => null,
|
default => null,
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -61,7 +61,7 @@ protected function getViewData(): array
|
|||||||
'hasAssignment' => true,
|
'hasAssignment' => true,
|
||||||
'profileName' => $stats->profileName,
|
'profileName' => $stats->profileName,
|
||||||
'lastComparedAt' => $stats->lastComparedHuman,
|
'lastComparedAt' => $stats->lastComparedHuman,
|
||||||
'landingUrl' => $landingUrl,
|
'landingUrl' => $tenantLandingUrl,
|
||||||
'runUrl' => $runUrl,
|
'runUrl' => $runUrl,
|
||||||
'findingsUrl' => $findingsUrl,
|
'findingsUrl' => $findingsUrl,
|
||||||
'nextActionUrl' => $nextActionUrl,
|
'nextActionUrl' => $nextActionUrl,
|
||||||
|
|||||||
@ -36,17 +36,78 @@ protected function getViewData(): array
|
|||||||
|
|
||||||
$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.",
|
||||||
'badge' => 'Drift',
|
'badge' => 'Drift',
|
||||||
'badgeColor' => 'danger',
|
'badgeColor' => 'danger',
|
||||||
];
|
];
|
||||||
@ -87,8 +148,16 @@ protected function getViewData(): array
|
|||||||
'body' => $compareAssessment->headline,
|
'body' => $compareAssessment->headline,
|
||||||
],
|
],
|
||||||
[
|
[
|
||||||
'title' => 'No high severity drift is open',
|
'title' => 'No overdue findings',
|
||||||
'body' => 'No high severity drift findings are currently open for this tenant.',
|
'body' => 'No open findings are currently overdue for this tenant.',
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'title' => 'Accepted-risk governance is healthy',
|
||||||
|
'body' => 'No accepted-risk findings currently need governance follow-up.',
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'title' => 'No high severity active findings',
|
||||||
|
'body' => 'No high severity findings are currently open for this tenant.',
|
||||||
],
|
],
|
||||||
[
|
[
|
||||||
'title' => 'No active operations',
|
'title' => 'No active operations',
|
||||||
|
|||||||
@ -38,14 +38,15 @@ protected function getViewData(): array
|
|||||||
$runUrl = OperationRunLinks::view($stats->operationRunId, $tenant);
|
$runUrl = OperationRunLinks::view($stats->operationRunId, $tenant);
|
||||||
}
|
}
|
||||||
|
|
||||||
$landingUrl = BaselineCompareLanding::getUrl(tenant: $tenant);
|
$landingUrl = BaselineCompareLanding::getUrl(panel: 'tenant', tenant: $tenant);
|
||||||
$nextActionUrl = match ($summaryAssessment->nextActionTarget()) {
|
$nextActionUrl = match ($summaryAssessment->nextActionTarget()) {
|
||||||
'run' => $runUrl,
|
'run' => $runUrl,
|
||||||
|
'findings' => \App\Filament\Resources\FindingResource::getUrl('index', panel: 'tenant', tenant: $tenant),
|
||||||
'landing' => $landingUrl,
|
'landing' => $landingUrl,
|
||||||
default => null,
|
default => null,
|
||||||
};
|
};
|
||||||
$shouldShow = in_array($summaryAssessment->stateFamily, ['caution', 'stale', 'unavailable', 'in_progress'], true)
|
$shouldShow = in_array($summaryAssessment->stateFamily, ['caution', 'stale', 'unavailable', 'in_progress'], true)
|
||||||
|| ($summaryAssessment->stateFamily === 'action_required' && $summaryAssessment->evaluationResult === 'failed_result');
|
|| $summaryAssessment->stateFamily === 'action_required';
|
||||||
|
|
||||||
return [
|
return [
|
||||||
'shouldShow' => $shouldShow,
|
'shouldShow' => $shouldShow,
|
||||||
|
|||||||
@ -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'));
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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;
|
||||||
@ -60,6 +62,7 @@
|
|||||||
use App\Support\References\Resolvers\PolicyReferenceResolver;
|
use App\Support\References\Resolvers\PolicyReferenceResolver;
|
||||||
use App\Support\References\Resolvers\PolicyVersionReferenceResolver;
|
use App\Support\References\Resolvers\PolicyVersionReferenceResolver;
|
||||||
use App\Support\References\Resolvers\PrincipalReferenceResolver;
|
use App\Support\References\Resolvers\PrincipalReferenceResolver;
|
||||||
|
use App\Support\Ui\DerivedState\RequestScopedDerivedStateStore;
|
||||||
use Filament\Events\TenantSet;
|
use Filament\Events\TenantSet;
|
||||||
use Illuminate\Cache\RateLimiting\Limit;
|
use Illuminate\Cache\RateLimiting\Limit;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
@ -76,6 +79,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(
|
||||||
@ -113,6 +119,7 @@ public function register(): void
|
|||||||
$this->app->singleton(ReferenceTypeLabelCatalog::class);
|
$this->app->singleton(ReferenceTypeLabelCatalog::class);
|
||||||
$this->app->singleton(ReferenceStatePresenter::class);
|
$this->app->singleton(ReferenceStatePresenter::class);
|
||||||
$this->app->singleton(ResolvedReferencePresenter::class);
|
$this->app->singleton(ResolvedReferencePresenter::class);
|
||||||
|
$this->app->scoped(RequestScopedDerivedStateStore::class);
|
||||||
$this->app->singleton(FallbackReferenceResolver::class);
|
$this->app->singleton(FallbackReferenceResolver::class);
|
||||||
$this->app->singleton(PolicyReferenceResolver::class);
|
$this->app->singleton(PolicyReferenceResolver::class);
|
||||||
$this->app->singleton(PolicyVersionReferenceResolver::class);
|
$this->app->singleton(PolicyVersionReferenceResolver::class);
|
||||||
|
|||||||
@ -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(),
|
||||||
|
|||||||
@ -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,7 +65,10 @@ 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',
|
||||||
};
|
};
|
||||||
|
|||||||
@ -80,6 +80,11 @@ private function __construct(
|
|||||||
public readonly ?int $evidenceGapOperationalCount = null,
|
public readonly ?int $evidenceGapOperationalCount = null,
|
||||||
public readonly ?int $evidenceGapTransientCount = null,
|
public readonly ?int $evidenceGapTransientCount = null,
|
||||||
public readonly ?bool $evidenceGapLegacyMode = 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
|
||||||
@ -160,6 +165,7 @@ public static function forTenant(?Tenant $tenant): self
|
|||||||
$rbacRoleDefinitionSummary = self::rbacRoleDefinitionSummaryForRun($latestRun);
|
$rbacRoleDefinitionSummary = self::rbacRoleDefinitionSummaryForRun($latestRun);
|
||||||
$evidenceGapDetails = self::evidenceGapDetailsForRun($latestRun);
|
$evidenceGapDetails = self::evidenceGapDetailsForRun($latestRun);
|
||||||
$baselineCompareDiagnostics = self::baselineCompareDiagnosticsForRun($latestRun);
|
$baselineCompareDiagnostics = self::baselineCompareDiagnosticsForRun($latestRun);
|
||||||
|
$findingAttentionCounts = self::findingAttentionCounts($tenant);
|
||||||
$evidenceGapSummary = is_array($evidenceGapDetails['summary'] ?? null) ? $evidenceGapDetails['summary'] : [];
|
$evidenceGapSummary = is_array($evidenceGapDetails['summary'] ?? null) ? $evidenceGapDetails['summary'] : [];
|
||||||
$evidenceGapStructuralCount = is_numeric($evidenceGapSummary['structural_count'] ?? null)
|
$evidenceGapStructuralCount = is_numeric($evidenceGapSummary['structural_count'] ?? null)
|
||||||
? (int) $evidenceGapSummary['structural_count']
|
? (int) $evidenceGapSummary['structural_count']
|
||||||
@ -205,6 +211,11 @@ public static function forTenant(?Tenant $tenant): self
|
|||||||
evidenceGapOperationalCount: $evidenceGapOperationalCount,
|
evidenceGapOperationalCount: $evidenceGapOperationalCount,
|
||||||
evidenceGapTransientCount: $evidenceGapTransientCount,
|
evidenceGapTransientCount: $evidenceGapTransientCount,
|
||||||
evidenceGapLegacyMode: $evidenceGapLegacyMode,
|
evidenceGapLegacyMode: $evidenceGapLegacyMode,
|
||||||
|
overdueOpenFindingsCount: $findingAttentionCounts['overdue_open_findings_count'],
|
||||||
|
expiringGovernanceCount: $findingAttentionCounts['expiring_governance_count'],
|
||||||
|
lapsedGovernanceCount: $findingAttentionCounts['lapsed_governance_count'],
|
||||||
|
activeNonNewFindingsCount: $findingAttentionCounts['active_non_new_findings_count'],
|
||||||
|
highSeverityActiveFindingsCount: $findingAttentionCounts['high_severity_active_findings_count'],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -244,6 +255,11 @@ public static function forTenant(?Tenant $tenant): self
|
|||||||
evidenceGapOperationalCount: $evidenceGapOperationalCount,
|
evidenceGapOperationalCount: $evidenceGapOperationalCount,
|
||||||
evidenceGapTransientCount: $evidenceGapTransientCount,
|
evidenceGapTransientCount: $evidenceGapTransientCount,
|
||||||
evidenceGapLegacyMode: $evidenceGapLegacyMode,
|
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'],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -305,6 +321,11 @@ public static function forTenant(?Tenant $tenant): self
|
|||||||
evidenceGapOperationalCount: $evidenceGapOperationalCount,
|
evidenceGapOperationalCount: $evidenceGapOperationalCount,
|
||||||
evidenceGapTransientCount: $evidenceGapTransientCount,
|
evidenceGapTransientCount: $evidenceGapTransientCount,
|
||||||
evidenceGapLegacyMode: $evidenceGapLegacyMode,
|
evidenceGapLegacyMode: $evidenceGapLegacyMode,
|
||||||
|
overdueOpenFindingsCount: $findingAttentionCounts['overdue_open_findings_count'],
|
||||||
|
expiringGovernanceCount: $findingAttentionCounts['expiring_governance_count'],
|
||||||
|
lapsedGovernanceCount: $findingAttentionCounts['lapsed_governance_count'],
|
||||||
|
activeNonNewFindingsCount: $findingAttentionCounts['active_non_new_findings_count'],
|
||||||
|
highSeverityActiveFindingsCount: $findingAttentionCounts['high_severity_active_findings_count'],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -340,6 +361,11 @@ public static function forTenant(?Tenant $tenant): self
|
|||||||
evidenceGapOperationalCount: $evidenceGapOperationalCount,
|
evidenceGapOperationalCount: $evidenceGapOperationalCount,
|
||||||
evidenceGapTransientCount: $evidenceGapTransientCount,
|
evidenceGapTransientCount: $evidenceGapTransientCount,
|
||||||
evidenceGapLegacyMode: $evidenceGapLegacyMode,
|
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'],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -372,6 +398,11 @@ public static function forTenant(?Tenant $tenant): self
|
|||||||
evidenceGapOperationalCount: $evidenceGapOperationalCount,
|
evidenceGapOperationalCount: $evidenceGapOperationalCount,
|
||||||
evidenceGapTransientCount: $evidenceGapTransientCount,
|
evidenceGapTransientCount: $evidenceGapTransientCount,
|
||||||
evidenceGapLegacyMode: $evidenceGapLegacyMode,
|
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'],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -705,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 */
|
||||||
|
|||||||
@ -57,6 +57,9 @@ public function __construct(
|
|||||||
public array $nextAction,
|
public array $nextAction,
|
||||||
public ?string $lastComparedLabel = null,
|
public ?string $lastComparedLabel = null,
|
||||||
public ?string $reasonCode = null,
|
public ?string $reasonCode = null,
|
||||||
|
public int $overdueOpenFindingsCount = 0,
|
||||||
|
public int $expiringGovernanceCount = 0,
|
||||||
|
public int $lapsedGovernanceCount = 0,
|
||||||
) {
|
) {
|
||||||
if (! in_array($this->stateFamily, [
|
if (! in_array($this->stateFamily, [
|
||||||
self::STATE_POSITIVE,
|
self::STATE_POSITIVE,
|
||||||
@ -126,7 +129,10 @@ public function nextActionTarget(): string
|
|||||||
* highSeverityCount: int,
|
* highSeverityCount: int,
|
||||||
* nextAction: array{label: string, target: string},
|
* nextAction: array{label: string, target: string},
|
||||||
* lastComparedLabel: ?string,
|
* lastComparedLabel: ?string,
|
||||||
* reasonCode: ?string
|
* reasonCode: ?string,
|
||||||
|
* overdueOpenFindingsCount: int,
|
||||||
|
* expiringGovernanceCount: int,
|
||||||
|
* lapsedGovernanceCount: int
|
||||||
* }
|
* }
|
||||||
*/
|
*/
|
||||||
public function toArray(): array
|
public function toArray(): array
|
||||||
@ -145,6 +151,9 @@ public function toArray(): array
|
|||||||
'nextAction' => $this->nextAction,
|
'nextAction' => $this->nextAction,
|
||||||
'lastComparedLabel' => $this->lastComparedLabel,
|
'lastComparedLabel' => $this->lastComparedLabel,
|
||||||
'reasonCode' => $this->reasonCode,
|
'reasonCode' => $this->reasonCode,
|
||||||
|
'overdueOpenFindingsCount' => $this->overdueOpenFindingsCount,
|
||||||
|
'expiringGovernanceCount' => $this->expiringGovernanceCount,
|
||||||
|
'lapsedGovernanceCount' => $this->lapsedGovernanceCount,
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -17,18 +17,60 @@ public function assess(BaselineCompareStats $stats): BaselineCompareSummaryAsses
|
|||||||
$explanation = $stats->operatorExplanation();
|
$explanation = $stats->operatorExplanation();
|
||||||
$findingsVisibleCount = (int) ($stats->findingsCount ?? 0);
|
$findingsVisibleCount = (int) ($stats->findingsCount ?? 0);
|
||||||
$highSeverityCount = (int) ($stats->severityCounts['high'] ?? 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;
|
$reasonCode = is_string($stats->reasonCode) ? BaselineCompareReasonCode::tryFrom($stats->reasonCode) : null;
|
||||||
$evaluationResult = $stats->state === 'failed'
|
$evaluationResult = $stats->state === 'failed'
|
||||||
? 'failed_result'
|
? 'failed_result'
|
||||||
: $explanation->evaluationResult;
|
: $explanation->evaluationResult;
|
||||||
$positiveClaimAllowed = $this->positiveClaimAllowed($stats, $explanation, $reasonCode, $evaluationResult);
|
$positiveClaimAllowed = $this->positiveClaimAllowed(
|
||||||
|
$stats,
|
||||||
|
$explanation,
|
||||||
|
$reasonCode,
|
||||||
|
$evaluationResult,
|
||||||
|
$overdueOpenFindingsCount,
|
||||||
|
$expiringGovernanceCount,
|
||||||
|
$lapsedGovernanceCount,
|
||||||
|
);
|
||||||
$isStale = $this->hasStaleResult($stats, $evaluationResult);
|
$isStale = $this->hasStaleResult($stats, $evaluationResult);
|
||||||
$stateFamily = $this->stateFamily($stats, $findingsVisibleCount, $positiveClaimAllowed, $isStale);
|
$stateFamily = $this->stateFamily(
|
||||||
|
$stats,
|
||||||
|
$findingsVisibleCount,
|
||||||
|
$positiveClaimAllowed,
|
||||||
|
$isStale,
|
||||||
|
$overdueOpenFindingsCount,
|
||||||
|
$expiringGovernanceCount,
|
||||||
|
$lapsedGovernanceCount,
|
||||||
|
);
|
||||||
|
$summaryReasonCode = $this->summaryReasonCode(
|
||||||
|
$stats,
|
||||||
|
$overdueOpenFindingsCount,
|
||||||
|
$expiringGovernanceCount,
|
||||||
|
$lapsedGovernanceCount,
|
||||||
|
);
|
||||||
|
|
||||||
return new BaselineCompareSummaryAssessment(
|
return new BaselineCompareSummaryAssessment(
|
||||||
stateFamily: $stateFamily,
|
stateFamily: $stateFamily,
|
||||||
headline: $this->headline($stats, $stateFamily, $findingsVisibleCount, $highSeverityCount, $evaluationResult),
|
headline: $this->headline(
|
||||||
supportingMessage: $this->supportingMessage($stats, $stateFamily, $findingsVisibleCount, $evaluationResult),
|
$stats,
|
||||||
|
$stateFamily,
|
||||||
|
$findingsVisibleCount,
|
||||||
|
$highSeverityCount,
|
||||||
|
$evaluationResult,
|
||||||
|
$overdueOpenFindingsCount,
|
||||||
|
$expiringGovernanceCount,
|
||||||
|
$lapsedGovernanceCount,
|
||||||
|
),
|
||||||
|
supportingMessage: $this->supportingMessage(
|
||||||
|
$stats,
|
||||||
|
$stateFamily,
|
||||||
|
$findingsVisibleCount,
|
||||||
|
$evaluationResult,
|
||||||
|
$overdueOpenFindingsCount,
|
||||||
|
$expiringGovernanceCount,
|
||||||
|
$lapsedGovernanceCount,
|
||||||
|
),
|
||||||
tone: $this->tone($stats, $stateFamily),
|
tone: $this->tone($stats, $stateFamily),
|
||||||
positiveClaimAllowed: $positiveClaimAllowed,
|
positiveClaimAllowed: $positiveClaimAllowed,
|
||||||
trustworthinessLevel: $explanation->trustworthinessLevel->value,
|
trustworthinessLevel: $explanation->trustworthinessLevel->value,
|
||||||
@ -36,9 +78,20 @@ public function assess(BaselineCompareStats $stats): BaselineCompareSummaryAsses
|
|||||||
evidenceImpact: $this->evidenceImpact($stats, $evaluationResult, $isStale),
|
evidenceImpact: $this->evidenceImpact($stats, $evaluationResult, $isStale),
|
||||||
findingsVisibleCount: $findingsVisibleCount,
|
findingsVisibleCount: $findingsVisibleCount,
|
||||||
highSeverityCount: $highSeverityCount,
|
highSeverityCount: $highSeverityCount,
|
||||||
nextAction: $this->nextAction($stats, $stateFamily, $findingsVisibleCount, $evaluationResult),
|
nextAction: $this->nextAction(
|
||||||
|
$stats,
|
||||||
|
$stateFamily,
|
||||||
|
$findingsVisibleCount,
|
||||||
|
$evaluationResult,
|
||||||
|
$overdueOpenFindingsCount,
|
||||||
|
$expiringGovernanceCount,
|
||||||
|
$lapsedGovernanceCount,
|
||||||
|
),
|
||||||
lastComparedLabel: $stats->lastComparedHuman,
|
lastComparedLabel: $stats->lastComparedHuman,
|
||||||
reasonCode: $stats->reasonCode,
|
reasonCode: $summaryReasonCode,
|
||||||
|
overdueOpenFindingsCount: $overdueOpenFindingsCount,
|
||||||
|
expiringGovernanceCount: $expiringGovernanceCount,
|
||||||
|
lapsedGovernanceCount: $lapsedGovernanceCount,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -47,6 +100,9 @@ private function positiveClaimAllowed(
|
|||||||
OperatorExplanationPattern $explanation,
|
OperatorExplanationPattern $explanation,
|
||||||
?BaselineCompareReasonCode $reasonCode,
|
?BaselineCompareReasonCode $reasonCode,
|
||||||
string $evaluationResult,
|
string $evaluationResult,
|
||||||
|
int $overdueOpenFindingsCount,
|
||||||
|
int $expiringGovernanceCount,
|
||||||
|
int $lapsedGovernanceCount,
|
||||||
): bool {
|
): bool {
|
||||||
if ($stats->state !== 'ready') {
|
if ($stats->state !== 'ready') {
|
||||||
return false;
|
return false;
|
||||||
@ -72,6 +128,10 @@ private function positiveClaimAllowed(
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if ($overdueOpenFindingsCount > 0 || $expiringGovernanceCount > 0 || $lapsedGovernanceCount > 0) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
if ($this->hasStaleResult($stats, $evaluationResult)) {
|
if ($this->hasStaleResult($stats, $evaluationResult)) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
@ -88,11 +148,17 @@ private function stateFamily(
|
|||||||
int $findingsVisibleCount,
|
int $findingsVisibleCount,
|
||||||
bool $positiveClaimAllowed,
|
bool $positiveClaimAllowed,
|
||||||
bool $isStale,
|
bool $isStale,
|
||||||
|
int $overdueOpenFindingsCount,
|
||||||
|
int $expiringGovernanceCount,
|
||||||
|
int $lapsedGovernanceCount,
|
||||||
): string {
|
): string {
|
||||||
return match (true) {
|
return match (true) {
|
||||||
$stats->state === 'comparing' => BaselineCompareSummaryAssessment::STATE_IN_PROGRESS,
|
$stats->state === 'comparing' => BaselineCompareSummaryAssessment::STATE_IN_PROGRESS,
|
||||||
$stats->state === 'failed',
|
$stats->state === 'failed',
|
||||||
$findingsVisibleCount > 0 => BaselineCompareSummaryAssessment::STATE_ACTION_REQUIRED,
|
$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,
|
in_array($stats->state, ['no_tenant', 'no_assignment', 'no_snapshot', 'idle'], true) => BaselineCompareSummaryAssessment::STATE_UNAVAILABLE,
|
||||||
$isStale => BaselineCompareSummaryAssessment::STATE_STALE,
|
$isStale => BaselineCompareSummaryAssessment::STATE_STALE,
|
||||||
$positiveClaimAllowed => BaselineCompareSummaryAssessment::STATE_POSITIVE,
|
$positiveClaimAllowed => BaselineCompareSummaryAssessment::STATE_POSITIVE,
|
||||||
@ -131,6 +197,9 @@ private function headline(
|
|||||||
int $findingsVisibleCount,
|
int $findingsVisibleCount,
|
||||||
int $highSeverityCount,
|
int $highSeverityCount,
|
||||||
string $evaluationResult,
|
string $evaluationResult,
|
||||||
|
int $overdueOpenFindingsCount,
|
||||||
|
int $expiringGovernanceCount,
|
||||||
|
int $lapsedGovernanceCount,
|
||||||
): string {
|
): string {
|
||||||
return match ($stateFamily) {
|
return match ($stateFamily) {
|
||||||
BaselineCompareSummaryAssessment::STATE_POSITIVE => 'No confirmed drift in the latest baseline compare.',
|
BaselineCompareSummaryAssessment::STATE_POSITIVE => 'No confirmed drift in the latest baseline compare.',
|
||||||
@ -143,6 +212,9 @@ private function headline(
|
|||||||
BaselineCompareSummaryAssessment::STATE_STALE => 'The latest baseline compare result is stale.',
|
BaselineCompareSummaryAssessment::STATE_STALE => 'The latest baseline compare result is stale.',
|
||||||
BaselineCompareSummaryAssessment::STATE_ACTION_REQUIRED => match (true) {
|
BaselineCompareSummaryAssessment::STATE_ACTION_REQUIRED => match (true) {
|
||||||
$stats->state === 'failed' || $evaluationResult === 'failed_result' => 'The latest baseline compare failed before it produced a usable result.',
|
$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'),
|
$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'),
|
default => sprintf('%d open drift finding%s need review.', $findingsVisibleCount, $findingsVisibleCount === 1 ? '' : 's'),
|
||||||
},
|
},
|
||||||
@ -161,6 +233,9 @@ private function supportingMessage(
|
|||||||
string $stateFamily,
|
string $stateFamily,
|
||||||
int $findingsVisibleCount,
|
int $findingsVisibleCount,
|
||||||
string $evaluationResult,
|
string $evaluationResult,
|
||||||
|
int $overdueOpenFindingsCount,
|
||||||
|
int $expiringGovernanceCount,
|
||||||
|
int $lapsedGovernanceCount,
|
||||||
): ?string {
|
): ?string {
|
||||||
return match ($stateFamily) {
|
return match ($stateFamily) {
|
||||||
BaselineCompareSummaryAssessment::STATE_POSITIVE => $stats->lastComparedHuman !== null
|
BaselineCompareSummaryAssessment::STATE_POSITIVE => $stats->lastComparedHuman !== null
|
||||||
@ -177,6 +252,9 @@ private function supportingMessage(
|
|||||||
: 'Refresh compare before relying on this posture.',
|
: 'Refresh compare before relying on this posture.',
|
||||||
BaselineCompareSummaryAssessment::STATE_ACTION_REQUIRED => match (true) {
|
BaselineCompareSummaryAssessment::STATE_ACTION_REQUIRED => match (true) {
|
||||||
$stats->state === 'failed' => $stats->failureReason,
|
$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.',
|
$findingsVisibleCount > 0 => 'Open findings remain on this tenant and need review.',
|
||||||
default => $stats->message,
|
default => $stats->message,
|
||||||
},
|
},
|
||||||
@ -204,8 +282,11 @@ private function nextAction(
|
|||||||
string $stateFamily,
|
string $stateFamily,
|
||||||
int $findingsVisibleCount,
|
int $findingsVisibleCount,
|
||||||
string $evaluationResult,
|
string $evaluationResult,
|
||||||
|
int $overdueOpenFindingsCount,
|
||||||
|
int $expiringGovernanceCount,
|
||||||
|
int $lapsedGovernanceCount,
|
||||||
): array {
|
): array {
|
||||||
if ($findingsVisibleCount > 0) {
|
if ($findingsVisibleCount > 0 || $overdueOpenFindingsCount > 0 || $expiringGovernanceCount > 0 || $lapsedGovernanceCount > 0) {
|
||||||
return [
|
return [
|
||||||
'label' => 'Open findings',
|
'label' => 'Open findings',
|
||||||
'target' => BaselineCompareSummaryAssessment::NEXT_TARGET_FINDINGS,
|
'target' => BaselineCompareSummaryAssessment::NEXT_TARGET_FINDINGS,
|
||||||
@ -260,6 +341,27 @@ private function nextAction(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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
|
private function hasStaleResult(BaselineCompareStats $stats, string $evaluationResult): bool
|
||||||
{
|
{
|
||||||
if ($stats->state !== 'ready') {
|
if ($stats->state !== 'ready') {
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -35,6 +35,9 @@
|
|||||||
use App\Support\References\ReferenceDescriptor;
|
use App\Support\References\ReferenceDescriptor;
|
||||||
use App\Support\References\ReferenceResolverRegistry;
|
use App\Support\References\ReferenceResolverRegistry;
|
||||||
use App\Support\References\RelatedContextReferenceAdapter;
|
use App\Support\References\RelatedContextReferenceAdapter;
|
||||||
|
use App\Support\Ui\DerivedState\DerivedStateFamily;
|
||||||
|
use App\Support\Ui\DerivedState\DerivedStateKey;
|
||||||
|
use App\Support\Ui\DerivedState\RequestScopedDerivedStateStore;
|
||||||
use Filament\Facades\Filament;
|
use Filament\Facades\Filament;
|
||||||
use Illuminate\Database\Eloquent\Model;
|
use Illuminate\Database\Eloquent\Model;
|
||||||
use Illuminate\Support\Arr;
|
use Illuminate\Support\Arr;
|
||||||
@ -48,6 +51,7 @@ public function __construct(
|
|||||||
private readonly WorkspaceCapabilityResolver $workspaceCapabilityResolver,
|
private readonly WorkspaceCapabilityResolver $workspaceCapabilityResolver,
|
||||||
private readonly ReferenceResolverRegistry $referenceResolverRegistry,
|
private readonly ReferenceResolverRegistry $referenceResolverRegistry,
|
||||||
private readonly RelatedContextReferenceAdapter $relatedContextReferenceAdapter,
|
private readonly RelatedContextReferenceAdapter $relatedContextReferenceAdapter,
|
||||||
|
private readonly RequestScopedDerivedStateStore $derivedStateStore,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -69,18 +73,41 @@ public function detailEntries(string $sourceType, Model $record): array
|
|||||||
{
|
{
|
||||||
return array_map(
|
return array_map(
|
||||||
static fn (RelatedContextEntry $entry): array => $entry->toArray(),
|
static fn (RelatedContextEntry $entry): array => $entry->toArray(),
|
||||||
$this->resolveEntries($sourceType, CrossResourceNavigationMatrix::SURFACE_DETAIL_SECTION, $record),
|
$this->detailEntryObjects($sourceType, $record),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return list<array{
|
||||||
|
* key: string,
|
||||||
|
* label: string,
|
||||||
|
* value: string,
|
||||||
|
* secondaryValue: ?string,
|
||||||
|
* targetUrl: ?string,
|
||||||
|
* targetKind: string,
|
||||||
|
* availability: string,
|
||||||
|
* unavailableReason: ?string,
|
||||||
|
* contextBadge: ?string,
|
||||||
|
* priority: int,
|
||||||
|
* actionLabel: string
|
||||||
|
* }>
|
||||||
|
*/
|
||||||
|
public function detailEntriesFresh(string $sourceType, Model $record): array
|
||||||
|
{
|
||||||
|
return array_map(
|
||||||
|
static fn (RelatedContextEntry $entry): array => $entry->toArray(),
|
||||||
|
$this->detailEntryObjects($sourceType, $record, fresh: true),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function primaryListAction(string $sourceType, Model $record): ?RelatedContextEntry
|
public function primaryListAction(string $sourceType, Model $record): ?RelatedContextEntry
|
||||||
{
|
{
|
||||||
$entries = array_values(array_filter(
|
return $this->resolvePrimaryListAction($sourceType, $record);
|
||||||
$this->resolveEntries($sourceType, CrossResourceNavigationMatrix::SURFACE_LIST_ROW, $record),
|
}
|
||||||
static fn (RelatedContextEntry $entry): bool => $entry->isAvailable(),
|
|
||||||
));
|
|
||||||
|
|
||||||
return $entries[0] ?? null;
|
public function primaryListActionFresh(string $sourceType, Model $record): ?RelatedContextEntry
|
||||||
|
{
|
||||||
|
return $this->resolvePrimaryListAction($sourceType, $record, fresh: true);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -89,7 +116,7 @@ public function primaryListAction(string $sourceType, Model $record): ?RelatedCo
|
|||||||
public function operationLinks(OperationRun $run, ?Tenant $tenant): array
|
public function operationLinks(OperationRun $run, ?Tenant $tenant): array
|
||||||
{
|
{
|
||||||
$entries = array_filter(
|
$entries = array_filter(
|
||||||
$this->resolveEntries(CrossResourceNavigationMatrix::SOURCE_OPERATION_RUN, CrossResourceNavigationMatrix::SURFACE_DETAIL_SECTION, $run),
|
$this->detailEntryObjects(CrossResourceNavigationMatrix::SOURCE_OPERATION_RUN, $run),
|
||||||
static fn (RelatedContextEntry $entry): bool => $entry->isAvailable(),
|
static fn (RelatedContextEntry $entry): bool => $entry->isAvailable(),
|
||||||
);
|
);
|
||||||
|
|
||||||
@ -100,20 +127,51 @@ public function operationLinks(OperationRun $run, ?Tenant $tenant): array
|
|||||||
}
|
}
|
||||||
|
|
||||||
if ($tenant instanceof Tenant) {
|
if ($tenant instanceof Tenant) {
|
||||||
$links = ['Open operations' => OperationRunLinks::index($tenant)] + $links;
|
$links = ['Operations' => OperationRunLinks::index($tenant)] + $links;
|
||||||
} else {
|
} else {
|
||||||
$links = ['Open operations' => OperationRunLinks::index()] + $links;
|
$links = ['Operations' => OperationRunLinks::index()] + $links;
|
||||||
}
|
}
|
||||||
|
|
||||||
return $links;
|
return $links;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, string>
|
||||||
|
*/
|
||||||
|
public function operationLinksFresh(OperationRun $run, ?Tenant $tenant): array
|
||||||
|
{
|
||||||
|
$entries = array_filter(
|
||||||
|
$this->detailEntryObjects(CrossResourceNavigationMatrix::SOURCE_OPERATION_RUN, $run, fresh: true),
|
||||||
|
static fn (RelatedContextEntry $entry): bool => $entry->isAvailable(),
|
||||||
|
);
|
||||||
|
|
||||||
|
$links = [];
|
||||||
|
|
||||||
|
foreach ($entries as $entry) {
|
||||||
|
$links[$entry->actionLabel] = (string) $entry->targetUrl;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($tenant instanceof Tenant) {
|
||||||
|
return ['Operations' => OperationRunLinks::index($tenant)] + $links;
|
||||||
|
}
|
||||||
|
|
||||||
|
return ['Operations' => OperationRunLinks::index()] + $links;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @return list<RelatedContextEntry>
|
* @return list<RelatedContextEntry>
|
||||||
*/
|
*/
|
||||||
public function headerEntries(string $sourceType, Model $record): array
|
public function headerEntries(string $sourceType, Model $record): array
|
||||||
{
|
{
|
||||||
return $this->resolveEntries($sourceType, CrossResourceNavigationMatrix::SURFACE_DETAIL_HEADER, $record);
|
return $this->headerEntryObjects($sourceType, $record);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return list<RelatedContextEntry>
|
||||||
|
*/
|
||||||
|
public function headerEntriesFresh(string $sourceType, Model $record): array
|
||||||
|
{
|
||||||
|
return $this->headerEntryObjects($sourceType, $record, fresh: true);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -247,6 +305,91 @@ private function resolveEntries(string $sourceType, string $surface, Model $reco
|
|||||||
return $entries;
|
return $entries;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return list<RelatedContextEntry>
|
||||||
|
*/
|
||||||
|
private function detailEntryObjects(string $sourceType, Model $record, bool $fresh = false): array
|
||||||
|
{
|
||||||
|
return $this->memoizedEntries(
|
||||||
|
family: DerivedStateFamily::RelatedNavigationDetail,
|
||||||
|
sourceType: $sourceType,
|
||||||
|
record: $record,
|
||||||
|
surface: CrossResourceNavigationMatrix::SURFACE_DETAIL_SECTION,
|
||||||
|
fresh: $fresh,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return list<RelatedContextEntry>
|
||||||
|
*/
|
||||||
|
private function headerEntryObjects(string $sourceType, Model $record, bool $fresh = false): array
|
||||||
|
{
|
||||||
|
return $this->memoizedEntries(
|
||||||
|
family: DerivedStateFamily::RelatedNavigationHeader,
|
||||||
|
sourceType: $sourceType,
|
||||||
|
record: $record,
|
||||||
|
surface: CrossResourceNavigationMatrix::SURFACE_DETAIL_HEADER,
|
||||||
|
fresh: $fresh,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function resolvePrimaryListAction(string $sourceType, Model $record, bool $fresh = false): ?RelatedContextEntry
|
||||||
|
{
|
||||||
|
$entries = array_values(array_filter(
|
||||||
|
$this->memoizedEntries(
|
||||||
|
family: DerivedStateFamily::RelatedNavigationPrimary,
|
||||||
|
sourceType: $sourceType,
|
||||||
|
record: $record,
|
||||||
|
surface: CrossResourceNavigationMatrix::SURFACE_LIST_ROW,
|
||||||
|
fresh: $fresh,
|
||||||
|
),
|
||||||
|
static fn (RelatedContextEntry $entry): bool => $entry->isAvailable(),
|
||||||
|
));
|
||||||
|
|
||||||
|
return $entries[0] ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return list<RelatedContextEntry>
|
||||||
|
*/
|
||||||
|
private function memoizedEntries(
|
||||||
|
DerivedStateFamily $family,
|
||||||
|
string $sourceType,
|
||||||
|
Model $record,
|
||||||
|
string $surface,
|
||||||
|
bool $fresh = false,
|
||||||
|
): array {
|
||||||
|
$key = DerivedStateKey::fromModel(
|
||||||
|
family: $family,
|
||||||
|
record: $record,
|
||||||
|
variant: $sourceType,
|
||||||
|
context: [
|
||||||
|
'source_type' => $sourceType,
|
||||||
|
'surface' => $surface,
|
||||||
|
'active_tenant_id' => $this->activeTenantId(),
|
||||||
|
'route_name' => request()?->route()?->getName(),
|
||||||
|
'user_id' => auth()->id(),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
/** @var list<RelatedContextEntry> $entries */
|
||||||
|
$entries = $fresh
|
||||||
|
? $this->derivedStateStore->resolveFresh(
|
||||||
|
$key,
|
||||||
|
fn (): array => $this->resolveEntries($sourceType, $surface, $record),
|
||||||
|
$family->defaultFreshnessPolicy(),
|
||||||
|
$family->allowsNegativeResultCache(),
|
||||||
|
)
|
||||||
|
: $this->derivedStateStore->resolve(
|
||||||
|
$key,
|
||||||
|
fn (): array => $this->resolveEntries($sourceType, $surface, $record),
|
||||||
|
$family->defaultFreshnessPolicy(),
|
||||||
|
$family->allowsNegativeResultCache(),
|
||||||
|
);
|
||||||
|
|
||||||
|
return $entries;
|
||||||
|
}
|
||||||
|
|
||||||
private function resolveRule(NavigationMatrixRule $rule, Model $record): ?RelatedContextEntry
|
private function resolveRule(NavigationMatrixRule $rule, Model $record): ?RelatedContextEntry
|
||||||
{
|
{
|
||||||
return match ($rule->sourceType) {
|
return match ($rule->sourceType) {
|
||||||
|
|||||||
@ -9,7 +9,11 @@
|
|||||||
use App\Support\OperationCatalog;
|
use App\Support\OperationCatalog;
|
||||||
use App\Support\Operations\OperationRunFreshnessState;
|
use App\Support\Operations\OperationRunFreshnessState;
|
||||||
use App\Support\ReasonTranslation\ReasonPresenter;
|
use App\Support\ReasonTranslation\ReasonPresenter;
|
||||||
|
use App\Support\ReasonTranslation\ReasonResolutionEnvelope;
|
||||||
use App\Support\RedactionIntegrity;
|
use App\Support\RedactionIntegrity;
|
||||||
|
use App\Support\Ui\DerivedState\DerivedStateFamily;
|
||||||
|
use App\Support\Ui\DerivedState\DerivedStateKey;
|
||||||
|
use App\Support\Ui\DerivedState\RequestScopedDerivedStateStore;
|
||||||
use App\Support\Ui\GovernanceArtifactTruth\ArtifactTruthPresenter;
|
use App\Support\Ui\GovernanceArtifactTruth\ArtifactTruthPresenter;
|
||||||
use App\Support\Ui\OperatorExplanation\OperatorExplanationPattern;
|
use App\Support\Ui\OperatorExplanation\OperatorExplanationPattern;
|
||||||
use Filament\Notifications\Notification as FilamentNotification;
|
use Filament\Notifications\Notification as FilamentNotification;
|
||||||
@ -97,6 +101,25 @@ public static function terminalDatabaseNotification(OperationRun $run, ?Tenant $
|
|||||||
}
|
}
|
||||||
|
|
||||||
public static function surfaceGuidance(OperationRun $run): ?string
|
public static function surfaceGuidance(OperationRun $run): ?string
|
||||||
|
{
|
||||||
|
return self::memoizeGuidance(
|
||||||
|
run: $run,
|
||||||
|
variant: 'surface_guidance',
|
||||||
|
resolver: fn (): ?string => self::buildSurfaceGuidance($run),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function surfaceGuidanceFresh(OperationRun $run): ?string
|
||||||
|
{
|
||||||
|
return self::memoizeGuidance(
|
||||||
|
run: $run,
|
||||||
|
variant: 'surface_guidance',
|
||||||
|
resolver: fn (): ?string => self::buildSurfaceGuidance($run),
|
||||||
|
fresh: true,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function buildSurfaceGuidance(OperationRun $run): ?string
|
||||||
{
|
{
|
||||||
$uxStatus = OperationStatusNormalizer::toUxStatus($run->status, $run->outcome);
|
$uxStatus = OperationStatusNormalizer::toUxStatus($run->status, $run->outcome);
|
||||||
$reasonEnvelope = self::reasonEnvelope($run);
|
$reasonEnvelope = self::reasonEnvelope($run);
|
||||||
@ -148,6 +171,25 @@ public static function surfaceGuidance(OperationRun $run): ?string
|
|||||||
}
|
}
|
||||||
|
|
||||||
public static function surfaceFailureDetail(OperationRun $run): ?string
|
public static function surfaceFailureDetail(OperationRun $run): ?string
|
||||||
|
{
|
||||||
|
return self::memoizeExplanation(
|
||||||
|
run: $run,
|
||||||
|
variant: 'surface_failure_detail',
|
||||||
|
resolver: fn (): ?string => self::buildSurfaceFailureDetail($run),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function surfaceFailureDetailFresh(OperationRun $run): ?string
|
||||||
|
{
|
||||||
|
return self::memoizeExplanation(
|
||||||
|
run: $run,
|
||||||
|
variant: 'surface_failure_detail',
|
||||||
|
resolver: fn (): ?string => self::buildSurfaceFailureDetail($run),
|
||||||
|
fresh: true,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function buildSurfaceFailureDetail(OperationRun $run): ?string
|
||||||
{
|
{
|
||||||
$operatorExplanation = self::governanceOperatorExplanation($run);
|
$operatorExplanation = self::governanceOperatorExplanation($run);
|
||||||
|
|
||||||
@ -181,6 +223,25 @@ public static function freshnessState(OperationRun $run): OperationRunFreshnessS
|
|||||||
}
|
}
|
||||||
|
|
||||||
public static function lifecycleAttentionSummary(OperationRun $run): ?string
|
public static function lifecycleAttentionSummary(OperationRun $run): ?string
|
||||||
|
{
|
||||||
|
return self::memoizeExplanation(
|
||||||
|
run: $run,
|
||||||
|
variant: 'lifecycle_attention_summary',
|
||||||
|
resolver: fn (): ?string => self::buildLifecycleAttentionSummary($run),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function lifecycleAttentionSummaryFresh(OperationRun $run): ?string
|
||||||
|
{
|
||||||
|
return self::memoizeExplanation(
|
||||||
|
run: $run,
|
||||||
|
variant: 'lifecycle_attention_summary',
|
||||||
|
resolver: fn (): ?string => self::buildLifecycleAttentionSummary($run),
|
||||||
|
fresh: true,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function buildLifecycleAttentionSummary(OperationRun $run): ?string
|
||||||
{
|
{
|
||||||
return match (self::freshnessState($run)) {
|
return match (self::freshnessState($run)) {
|
||||||
OperationRunFreshnessState::LikelyStale => 'Likely stale',
|
OperationRunFreshnessState::LikelyStale => 'Likely stale',
|
||||||
@ -189,6 +250,16 @@ public static function lifecycleAttentionSummary(OperationRun $run): ?string
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public static function governanceOperatorExplanation(OperationRun $run): ?OperatorExplanationPattern
|
||||||
|
{
|
||||||
|
return self::resolveGovernanceOperatorExplanation($run);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function governanceOperatorExplanationFresh(OperationRun $run): ?OperatorExplanationPattern
|
||||||
|
{
|
||||||
|
return self::resolveGovernanceOperatorExplanation($run, fresh: true);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @return array{titleSuffix: string, body: string, status: string}
|
* @return array{titleSuffix: string, body: string, status: string}
|
||||||
*/
|
*/
|
||||||
@ -282,14 +353,18 @@ private static function sanitizeFailureMessage(string $failureMessage): ?string
|
|||||||
return $failureMessage !== '' ? $failureMessage : null;
|
return $failureMessage !== '' ? $failureMessage : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
private static function reasonEnvelope(OperationRun $run): ?\App\Support\ReasonTranslation\ReasonResolutionEnvelope
|
private static function reasonEnvelope(OperationRun $run): ?ReasonResolutionEnvelope
|
||||||
{
|
{
|
||||||
return app(ReasonPresenter::class)->forOperationRun($run, 'notification');
|
return self::memoizeExplanation(
|
||||||
|
run: $run,
|
||||||
|
variant: 'reason_envelope_notification',
|
||||||
|
resolver: fn (): ?ReasonResolutionEnvelope => app(ReasonPresenter::class)->forOperationRun($run, 'notification'),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static function operatorExplanationGuidance(OperationRun $run): ?string
|
private static function operatorExplanationGuidance(OperationRun $run): ?string
|
||||||
{
|
{
|
||||||
$operatorExplanation = self::governanceOperatorExplanation($run);
|
$operatorExplanation = self::resolveGovernanceOperatorExplanation($run);
|
||||||
|
|
||||||
if (! is_string($operatorExplanation?->nextActionText) || trim($operatorExplanation->nextActionText) === '') {
|
if (! is_string($operatorExplanation?->nextActionText) || trim($operatorExplanation->nextActionText) === '') {
|
||||||
return null;
|
return null;
|
||||||
@ -306,12 +381,73 @@ private static function operatorExplanationGuidance(OperationRun $run): ?string
|
|||||||
: 'Next step: '.$text.'.';
|
: 'Next step: '.$text.'.';
|
||||||
}
|
}
|
||||||
|
|
||||||
private static function governanceOperatorExplanation(OperationRun $run): ?OperatorExplanationPattern
|
private static function resolveGovernanceOperatorExplanation(OperationRun $run, bool $fresh = false): ?OperatorExplanationPattern
|
||||||
{
|
{
|
||||||
if (! $run->supportsOperatorExplanation()) {
|
if (! $run->supportsOperatorExplanation()) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
return app(ArtifactTruthPresenter::class)->forOperationRun($run)?->operatorExplanation;
|
return self::memoizeExplanation(
|
||||||
|
run: $run,
|
||||||
|
variant: 'governance_operator_explanation',
|
||||||
|
resolver: fn (): ?OperatorExplanationPattern => $fresh
|
||||||
|
? app(ArtifactTruthPresenter::class)->forOperationRunFresh($run)?->operatorExplanation
|
||||||
|
: app(ArtifactTruthPresenter::class)->forOperationRun($run)?->operatorExplanation,
|
||||||
|
fresh: $fresh,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function memoizeGuidance(
|
||||||
|
OperationRun $run,
|
||||||
|
string $variant,
|
||||||
|
callable $resolver,
|
||||||
|
bool $fresh = false,
|
||||||
|
): ?string {
|
||||||
|
$key = DerivedStateKey::fromModel(DerivedStateFamily::OperationUxGuidance, $run, $variant);
|
||||||
|
|
||||||
|
/** @var ?string $value */
|
||||||
|
$value = $fresh
|
||||||
|
? self::derivedStateStore()->resolveFresh(
|
||||||
|
$key,
|
||||||
|
$resolver,
|
||||||
|
DerivedStateFamily::OperationUxGuidance->defaultFreshnessPolicy(),
|
||||||
|
DerivedStateFamily::OperationUxGuidance->allowsNegativeResultCache(),
|
||||||
|
)
|
||||||
|
: self::derivedStateStore()->resolve(
|
||||||
|
$key,
|
||||||
|
$resolver,
|
||||||
|
DerivedStateFamily::OperationUxGuidance->defaultFreshnessPolicy(),
|
||||||
|
DerivedStateFamily::OperationUxGuidance->allowsNegativeResultCache(),
|
||||||
|
);
|
||||||
|
|
||||||
|
return $value;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function memoizeExplanation(
|
||||||
|
OperationRun $run,
|
||||||
|
string $variant,
|
||||||
|
callable $resolver,
|
||||||
|
bool $fresh = false,
|
||||||
|
): mixed {
|
||||||
|
$key = DerivedStateKey::fromModel(DerivedStateFamily::OperationUxExplanation, $run, $variant);
|
||||||
|
|
||||||
|
return $fresh
|
||||||
|
? self::derivedStateStore()->resolveFresh(
|
||||||
|
$key,
|
||||||
|
$resolver,
|
||||||
|
DerivedStateFamily::OperationUxExplanation->defaultFreshnessPolicy(),
|
||||||
|
DerivedStateFamily::OperationUxExplanation->allowsNegativeResultCache(),
|
||||||
|
)
|
||||||
|
: self::derivedStateStore()->resolve(
|
||||||
|
$key,
|
||||||
|
$resolver,
|
||||||
|
DerivedStateFamily::OperationUxExplanation->defaultFreshnessPolicy(),
|
||||||
|
DerivedStateFamily::OperationUxExplanation->allowsNegativeResultCache(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function derivedStateStore(): RequestScopedDerivedStateStore
|
||||||
|
{
|
||||||
|
return app(RequestScopedDerivedStateStore::class);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
25
app/Support/Ui/DerivedState/DerivedStateFamily.php
Normal file
25
app/Support/Ui/DerivedState/DerivedStateFamily.php
Normal file
@ -0,0 +1,25 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Support\Ui\DerivedState;
|
||||||
|
|
||||||
|
enum DerivedStateFamily: string
|
||||||
|
{
|
||||||
|
case ArtifactTruth = 'artifact_truth';
|
||||||
|
case OperationUxGuidance = 'operation_ux_guidance';
|
||||||
|
case OperationUxExplanation = 'operation_ux_explanation';
|
||||||
|
case RelatedNavigationPrimary = 'related_navigation_primary';
|
||||||
|
case RelatedNavigationDetail = 'related_navigation_detail';
|
||||||
|
case RelatedNavigationHeader = 'related_navigation_header';
|
||||||
|
|
||||||
|
public function allowsNegativeResultCache(): bool
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function defaultFreshnessPolicy(): string
|
||||||
|
{
|
||||||
|
return RequestScopedDerivedStateStore::FRESHNESS_INVALIDATE_AFTER_MUTATION;
|
||||||
|
}
|
||||||
|
}
|
||||||
190
app/Support/Ui/DerivedState/DerivedStateKey.php
Normal file
190
app/Support/Ui/DerivedState/DerivedStateKey.php
Normal file
@ -0,0 +1,190 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Support\Ui\DerivedState;
|
||||||
|
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use JsonException;
|
||||||
|
|
||||||
|
final class DerivedStateKey
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
public readonly DerivedStateFamily $family,
|
||||||
|
public readonly string $recordClass,
|
||||||
|
public readonly string $recordKey,
|
||||||
|
public readonly string $variant,
|
||||||
|
public readonly ?int $workspaceId = null,
|
||||||
|
public readonly ?int $tenantId = null,
|
||||||
|
public readonly ?string $contextHash = null,
|
||||||
|
) {
|
||||||
|
if (trim($this->recordClass) === '') {
|
||||||
|
throw new \InvalidArgumentException('Derived state keys require a non-empty record class.');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (trim($this->recordKey) === '') {
|
||||||
|
throw new \InvalidArgumentException('Derived state keys require a non-empty record key.');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (trim($this->variant) === '') {
|
||||||
|
throw new \InvalidArgumentException('Derived state keys require a non-empty variant.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed>|string|null $context
|
||||||
|
*/
|
||||||
|
public static function fromModel(
|
||||||
|
DerivedStateFamily $family,
|
||||||
|
Model $record,
|
||||||
|
string $variant,
|
||||||
|
array|string|null $context = null,
|
||||||
|
?int $workspaceId = null,
|
||||||
|
?int $tenantId = null,
|
||||||
|
): self {
|
||||||
|
return new self(
|
||||||
|
family: $family,
|
||||||
|
recordClass: $record::class,
|
||||||
|
recordKey: (string) $record->getKey(),
|
||||||
|
variant: $variant,
|
||||||
|
workspaceId: $workspaceId ?? self::normalizeScopeId($record->getAttribute('workspace_id')),
|
||||||
|
tenantId: $tenantId ?? self::normalizeScopeId($record->getAttribute('tenant_id')),
|
||||||
|
contextHash: self::hashContext($context),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array{
|
||||||
|
* family: string,
|
||||||
|
* record_class: string,
|
||||||
|
* record_key: string,
|
||||||
|
* variant: string,
|
||||||
|
* workspace_id: ?int,
|
||||||
|
* tenant_id: ?int,
|
||||||
|
* context_hash: ?string
|
||||||
|
* }
|
||||||
|
*/
|
||||||
|
public function toArray(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'family' => $this->family->value,
|
||||||
|
'record_class' => $this->recordClass,
|
||||||
|
'record_key' => $this->recordKey,
|
||||||
|
'variant' => $this->variant,
|
||||||
|
'workspace_id' => $this->workspaceId,
|
||||||
|
'tenant_id' => $this->tenantId,
|
||||||
|
'context_hash' => $this->contextHash,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function fingerprint(): string
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
/** @var string $json */
|
||||||
|
$json = json_encode($this->toArray(), JSON_THROW_ON_ERROR);
|
||||||
|
} catch (JsonException $exception) {
|
||||||
|
throw new \RuntimeException('Unable to encode derived state key fingerprint.', previous: $exception);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $json;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function matches(
|
||||||
|
DerivedStateFamily $family,
|
||||||
|
?string $recordClass = null,
|
||||||
|
string|int|null $recordKey = null,
|
||||||
|
?string $variant = null,
|
||||||
|
?int $workspaceId = null,
|
||||||
|
?int $tenantId = null,
|
||||||
|
): bool {
|
||||||
|
if ($this->family !== $family) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($recordClass !== null && $this->recordClass !== $recordClass) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($recordKey !== null && $this->recordKey !== (string) $recordKey) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($variant !== null && $this->variant !== $variant) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($workspaceId !== null && $this->workspaceId !== $workspaceId) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($tenantId !== null && $this->tenantId !== $tenantId) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed>|string|null $context
|
||||||
|
*/
|
||||||
|
public static function hashContext(array|string|null $context): ?string
|
||||||
|
{
|
||||||
|
if ($context === null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (is_string($context)) {
|
||||||
|
$context = trim($context);
|
||||||
|
|
||||||
|
return $context === '' ? null : sha1($context);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($context === []) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$normalized = self::normalizeContext($context);
|
||||||
|
|
||||||
|
try {
|
||||||
|
/** @var string $json */
|
||||||
|
$json = json_encode($normalized, JSON_THROW_ON_ERROR);
|
||||||
|
} catch (JsonException $exception) {
|
||||||
|
throw new \RuntimeException('Unable to encode derived state context.', previous: $exception);
|
||||||
|
}
|
||||||
|
|
||||||
|
return sha1($json);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $context
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
private static function normalizeContext(array $context): array
|
||||||
|
{
|
||||||
|
ksort($context);
|
||||||
|
|
||||||
|
foreach ($context as $key => $value) {
|
||||||
|
if (is_array($value)) {
|
||||||
|
/** @var mixed $normalized */
|
||||||
|
$normalized = array_is_list($value)
|
||||||
|
? array_map(static fn (mixed $item): mixed => is_array($item) ? self::normalizeContext($item) : $item, $value)
|
||||||
|
: self::normalizeContext($value);
|
||||||
|
|
||||||
|
$context[$key] = $normalized;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $context;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function normalizeScopeId(mixed $value): ?int
|
||||||
|
{
|
||||||
|
if (! is_numeric($value)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$normalized = (int) $value;
|
||||||
|
|
||||||
|
return $normalized > 0 ? $normalized : null;
|
||||||
|
}
|
||||||
|
}
|
||||||
186
app/Support/Ui/DerivedState/RequestScopedDerivedStateStore.php
Normal file
186
app/Support/Ui/DerivedState/RequestScopedDerivedStateStore.php
Normal file
@ -0,0 +1,186 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Support\Ui\DerivedState;
|
||||||
|
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Support\Str;
|
||||||
|
|
||||||
|
final class RequestScopedDerivedStateStore
|
||||||
|
{
|
||||||
|
public const string FRESHNESS_REQUEST_STABLE = 'request_stable';
|
||||||
|
|
||||||
|
public const string FRESHNESS_INVALIDATE_AFTER_MUTATION = 'invalidate_after_mutation';
|
||||||
|
|
||||||
|
public const string FRESHNESS_NO_REUSE = 'no_reuse';
|
||||||
|
|
||||||
|
private string $requestScopeId;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @var array<string, array{
|
||||||
|
* key: DerivedStateKey,
|
||||||
|
* value: mixed,
|
||||||
|
* negative_result: bool,
|
||||||
|
* freshness_policy: string,
|
||||||
|
* resolved_at: int
|
||||||
|
* }>
|
||||||
|
*/
|
||||||
|
private array $entries = [];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @var list<string>
|
||||||
|
*/
|
||||||
|
private array $invalidations = [];
|
||||||
|
|
||||||
|
private int $resolutionSequence = 0;
|
||||||
|
|
||||||
|
public function __construct(?string $requestScopeId = null)
|
||||||
|
{
|
||||||
|
$this->requestScopeId = $requestScopeId ?? (string) Str::uuid();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function requestScopeId(): string
|
||||||
|
{
|
||||||
|
return $this->requestScopeId;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function resolve(
|
||||||
|
DerivedStateKey $key,
|
||||||
|
callable $resolver,
|
||||||
|
?string $freshnessPolicy = null,
|
||||||
|
?bool $allowNegativeResultCache = null,
|
||||||
|
): mixed {
|
||||||
|
$freshnessPolicy ??= $key->family->defaultFreshnessPolicy();
|
||||||
|
|
||||||
|
if ($freshnessPolicy === self::FRESHNESS_NO_REUSE) {
|
||||||
|
return $resolver();
|
||||||
|
}
|
||||||
|
|
||||||
|
$fingerprint = $key->fingerprint();
|
||||||
|
|
||||||
|
if (array_key_exists($fingerprint, $this->entries)) {
|
||||||
|
return $this->entries[$fingerprint]['value'];
|
||||||
|
}
|
||||||
|
|
||||||
|
$value = $resolver();
|
||||||
|
$negativeResult = $this->isNegativeResult($value);
|
||||||
|
$allowNegativeResultCache ??= $key->family->allowsNegativeResultCache();
|
||||||
|
|
||||||
|
if ($negativeResult && ! $allowNegativeResultCache) {
|
||||||
|
return $value;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->entries[$fingerprint] = [
|
||||||
|
'key' => $key,
|
||||||
|
'value' => $value,
|
||||||
|
'negative_result' => $negativeResult,
|
||||||
|
'freshness_policy' => $freshnessPolicy,
|
||||||
|
'resolved_at' => ++$this->resolutionSequence,
|
||||||
|
];
|
||||||
|
|
||||||
|
return $value;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function resolveFresh(
|
||||||
|
DerivedStateKey $key,
|
||||||
|
callable $resolver,
|
||||||
|
?string $freshnessPolicy = null,
|
||||||
|
?bool $allowNegativeResultCache = null,
|
||||||
|
): mixed {
|
||||||
|
$this->invalidateKey($key);
|
||||||
|
|
||||||
|
return $this->resolve($key, $resolver, $freshnessPolicy, $allowNegativeResultCache);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function invalidateKey(DerivedStateKey $key): int
|
||||||
|
{
|
||||||
|
$fingerprint = $key->fingerprint();
|
||||||
|
|
||||||
|
if (! array_key_exists($fingerprint, $this->entries)) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
unset($this->entries[$fingerprint]);
|
||||||
|
$this->invalidations[] = $fingerprint;
|
||||||
|
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function invalidateFamily(
|
||||||
|
DerivedStateFamily $family,
|
||||||
|
?string $recordClass = null,
|
||||||
|
string|int|null $recordKey = null,
|
||||||
|
?string $variant = null,
|
||||||
|
?int $workspaceId = null,
|
||||||
|
?int $tenantId = null,
|
||||||
|
): int {
|
||||||
|
$invalidated = 0;
|
||||||
|
|
||||||
|
foreach ($this->entries as $fingerprint => $record) {
|
||||||
|
if (! $record['key']->matches($family, $recordClass, $recordKey, $variant, $workspaceId, $tenantId)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
unset($this->entries[$fingerprint]);
|
||||||
|
$this->invalidations[] = $fingerprint;
|
||||||
|
$invalidated++;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $invalidated;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function invalidateModel(DerivedStateFamily $family, Model $record, ?string $variant = null): int
|
||||||
|
{
|
||||||
|
return $this->invalidateFamily(
|
||||||
|
family: $family,
|
||||||
|
recordClass: $record::class,
|
||||||
|
recordKey: $record->getKey(),
|
||||||
|
variant: $variant,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function entryCount(): int
|
||||||
|
{
|
||||||
|
return count($this->entries);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function countStored(
|
||||||
|
DerivedStateFamily $family,
|
||||||
|
?string $recordClass = null,
|
||||||
|
string|int|null $recordKey = null,
|
||||||
|
?string $variant = null,
|
||||||
|
): int {
|
||||||
|
return count(array_filter(
|
||||||
|
$this->entries,
|
||||||
|
static fn (array $record): bool => $record['key']->matches($family, $recordClass, $recordKey, $variant),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array{
|
||||||
|
* key: DerivedStateKey,
|
||||||
|
* value: mixed,
|
||||||
|
* negative_result: bool,
|
||||||
|
* freshness_policy: string,
|
||||||
|
* resolved_at: int
|
||||||
|
* }|null
|
||||||
|
*/
|
||||||
|
public function resolutionRecord(DerivedStateKey $key): ?array
|
||||||
|
{
|
||||||
|
return $this->entries[$key->fingerprint()] ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return list<string>
|
||||||
|
*/
|
||||||
|
public function invalidations(): array
|
||||||
|
{
|
||||||
|
return $this->invalidations;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function isNegativeResult(mixed $value): bool
|
||||||
|
{
|
||||||
|
return $value === null || $value === [];
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -27,8 +27,12 @@
|
|||||||
use App\Support\ReviewPackStatus;
|
use App\Support\ReviewPackStatus;
|
||||||
use App\Support\TenantReviewCompletenessState;
|
use App\Support\TenantReviewCompletenessState;
|
||||||
use App\Support\TenantReviewStatus;
|
use App\Support\TenantReviewStatus;
|
||||||
|
use App\Support\Ui\DerivedState\DerivedStateFamily;
|
||||||
|
use App\Support\Ui\DerivedState\DerivedStateKey;
|
||||||
|
use App\Support\Ui\DerivedState\RequestScopedDerivedStateStore;
|
||||||
use App\Support\Ui\OperatorExplanation\CountDescriptor;
|
use App\Support\Ui\OperatorExplanation\CountDescriptor;
|
||||||
use App\Support\Ui\OperatorExplanation\OperatorExplanationBuilder;
|
use App\Support\Ui\OperatorExplanation\OperatorExplanationBuilder;
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
use Illuminate\Support\Arr;
|
use Illuminate\Support\Arr;
|
||||||
|
|
||||||
final class ArtifactTruthPresenter
|
final class ArtifactTruthPresenter
|
||||||
@ -37,6 +41,7 @@ public function __construct(
|
|||||||
private readonly ReasonPresenter $reasonPresenter,
|
private readonly ReasonPresenter $reasonPresenter,
|
||||||
private readonly BaselineSnapshotTruthResolver $snapshotTruthResolver,
|
private readonly BaselineSnapshotTruthResolver $snapshotTruthResolver,
|
||||||
private readonly OperatorExplanationBuilder $operatorExplanationBuilder,
|
private readonly OperatorExplanationBuilder $operatorExplanationBuilder,
|
||||||
|
private readonly RequestScopedDerivedStateStore $derivedStateStore,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
public function for(mixed $record): ?ArtifactTruthEnvelope
|
public function for(mixed $record): ?ArtifactTruthEnvelope
|
||||||
@ -51,7 +56,38 @@ public function for(mixed $record): ?ArtifactTruthEnvelope
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function forFresh(mixed $record): ?ArtifactTruthEnvelope
|
||||||
|
{
|
||||||
|
return match (true) {
|
||||||
|
$record instanceof BaselineSnapshot => $this->forBaselineSnapshotFresh($record),
|
||||||
|
$record instanceof EvidenceSnapshot => $this->forEvidenceSnapshotFresh($record),
|
||||||
|
$record instanceof TenantReview => $this->forTenantReviewFresh($record),
|
||||||
|
$record instanceof ReviewPack => $this->forReviewPackFresh($record),
|
||||||
|
$record instanceof OperationRun => $this->forOperationRunFresh($record),
|
||||||
|
default => null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
public function forBaselineSnapshot(BaselineSnapshot $snapshot): ArtifactTruthEnvelope
|
public function forBaselineSnapshot(BaselineSnapshot $snapshot): ArtifactTruthEnvelope
|
||||||
|
{
|
||||||
|
return $this->resolveEnvelope(
|
||||||
|
record: $snapshot,
|
||||||
|
variant: 'baseline_snapshot',
|
||||||
|
resolver: fn (): ArtifactTruthEnvelope => $this->buildBaselineSnapshotEnvelope($snapshot),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function forBaselineSnapshotFresh(BaselineSnapshot $snapshot): ArtifactTruthEnvelope
|
||||||
|
{
|
||||||
|
return $this->resolveEnvelope(
|
||||||
|
record: $snapshot,
|
||||||
|
variant: 'baseline_snapshot',
|
||||||
|
resolver: fn (): ArtifactTruthEnvelope => $this->buildBaselineSnapshotEnvelope($snapshot),
|
||||||
|
fresh: true,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function buildBaselineSnapshotEnvelope(BaselineSnapshot $snapshot): ArtifactTruthEnvelope
|
||||||
{
|
{
|
||||||
$snapshot->loadMissing('baselineProfile');
|
$snapshot->loadMissing('baselineProfile');
|
||||||
|
|
||||||
@ -185,6 +221,25 @@ public function forBaselineSnapshot(BaselineSnapshot $snapshot): ArtifactTruthEn
|
|||||||
}
|
}
|
||||||
|
|
||||||
public function forEvidenceSnapshot(EvidenceSnapshot $snapshot): ArtifactTruthEnvelope
|
public function forEvidenceSnapshot(EvidenceSnapshot $snapshot): ArtifactTruthEnvelope
|
||||||
|
{
|
||||||
|
return $this->resolveEnvelope(
|
||||||
|
record: $snapshot,
|
||||||
|
variant: 'evidence_snapshot',
|
||||||
|
resolver: fn (): ArtifactTruthEnvelope => $this->buildEvidenceSnapshotEnvelope($snapshot),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function forEvidenceSnapshotFresh(EvidenceSnapshot $snapshot): ArtifactTruthEnvelope
|
||||||
|
{
|
||||||
|
return $this->resolveEnvelope(
|
||||||
|
record: $snapshot,
|
||||||
|
variant: 'evidence_snapshot',
|
||||||
|
resolver: fn (): ArtifactTruthEnvelope => $this->buildEvidenceSnapshotEnvelope($snapshot),
|
||||||
|
fresh: true,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function buildEvidenceSnapshotEnvelope(EvidenceSnapshot $snapshot): ArtifactTruthEnvelope
|
||||||
{
|
{
|
||||||
$snapshot->loadMissing('tenant');
|
$snapshot->loadMissing('tenant');
|
||||||
|
|
||||||
@ -327,6 +382,25 @@ public function forEvidenceSnapshot(EvidenceSnapshot $snapshot): ArtifactTruthEn
|
|||||||
}
|
}
|
||||||
|
|
||||||
public function forTenantReview(TenantReview $review): ArtifactTruthEnvelope
|
public function forTenantReview(TenantReview $review): ArtifactTruthEnvelope
|
||||||
|
{
|
||||||
|
return $this->resolveEnvelope(
|
||||||
|
record: $review,
|
||||||
|
variant: 'tenant_review',
|
||||||
|
resolver: fn (): ArtifactTruthEnvelope => $this->buildTenantReviewEnvelope($review),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function forTenantReviewFresh(TenantReview $review): ArtifactTruthEnvelope
|
||||||
|
{
|
||||||
|
return $this->resolveEnvelope(
|
||||||
|
record: $review,
|
||||||
|
variant: 'tenant_review',
|
||||||
|
resolver: fn (): ArtifactTruthEnvelope => $this->buildTenantReviewEnvelope($review),
|
||||||
|
fresh: true,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function buildTenantReviewEnvelope(TenantReview $review): ArtifactTruthEnvelope
|
||||||
{
|
{
|
||||||
$review->loadMissing(['tenant', 'currentExportReviewPack']);
|
$review->loadMissing(['tenant', 'currentExportReviewPack']);
|
||||||
|
|
||||||
@ -474,6 +548,25 @@ public function forTenantReview(TenantReview $review): ArtifactTruthEnvelope
|
|||||||
}
|
}
|
||||||
|
|
||||||
public function forReviewPack(ReviewPack $pack): ArtifactTruthEnvelope
|
public function forReviewPack(ReviewPack $pack): ArtifactTruthEnvelope
|
||||||
|
{
|
||||||
|
return $this->resolveEnvelope(
|
||||||
|
record: $pack,
|
||||||
|
variant: 'review_pack',
|
||||||
|
resolver: fn (): ArtifactTruthEnvelope => $this->buildReviewPackEnvelope($pack),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function forReviewPackFresh(ReviewPack $pack): ArtifactTruthEnvelope
|
||||||
|
{
|
||||||
|
return $this->resolveEnvelope(
|
||||||
|
record: $pack,
|
||||||
|
variant: 'review_pack',
|
||||||
|
resolver: fn (): ArtifactTruthEnvelope => $this->buildReviewPackEnvelope($pack),
|
||||||
|
fresh: true,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function buildReviewPackEnvelope(ReviewPack $pack): ArtifactTruthEnvelope
|
||||||
{
|
{
|
||||||
$pack->loadMissing(['tenant', 'tenantReview']);
|
$pack->loadMissing(['tenant', 'tenantReview']);
|
||||||
|
|
||||||
@ -612,6 +705,25 @@ public function forReviewPack(ReviewPack $pack): ArtifactTruthEnvelope
|
|||||||
}
|
}
|
||||||
|
|
||||||
public function forOperationRun(OperationRun $run): ArtifactTruthEnvelope
|
public function forOperationRun(OperationRun $run): ArtifactTruthEnvelope
|
||||||
|
{
|
||||||
|
return $this->resolveEnvelope(
|
||||||
|
record: $run,
|
||||||
|
variant: 'operation_run',
|
||||||
|
resolver: fn (): ArtifactTruthEnvelope => $this->buildOperationRunEnvelope($run),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function forOperationRunFresh(OperationRun $run): ArtifactTruthEnvelope
|
||||||
|
{
|
||||||
|
return $this->resolveEnvelope(
|
||||||
|
record: $run,
|
||||||
|
variant: 'operation_run',
|
||||||
|
resolver: fn (): ArtifactTruthEnvelope => $this->buildOperationRunEnvelope($run),
|
||||||
|
fresh: true,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function buildOperationRunEnvelope(OperationRun $run): ArtifactTruthEnvelope
|
||||||
{
|
{
|
||||||
$artifact = $this->resolveArtifactForRun($run);
|
$artifact = $this->resolveArtifactForRun($run);
|
||||||
$reason = $this->reasonPresenter->forOperationRun($run, 'run_detail');
|
$reason = $this->reasonPresenter->forOperationRun($run, 'run_detail');
|
||||||
@ -720,6 +832,32 @@ private function resolveArtifactForRun(OperationRun $run): BaselineSnapshot|Evid
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private function resolveEnvelope(
|
||||||
|
Model $record,
|
||||||
|
string $variant,
|
||||||
|
callable $resolver,
|
||||||
|
bool $fresh = false,
|
||||||
|
): ArtifactTruthEnvelope {
|
||||||
|
$key = DerivedStateKey::fromModel(DerivedStateFamily::ArtifactTruth, $record, $variant);
|
||||||
|
|
||||||
|
/** @var ArtifactTruthEnvelope $envelope */
|
||||||
|
$envelope = $fresh
|
||||||
|
? $this->derivedStateStore->resolveFresh(
|
||||||
|
$key,
|
||||||
|
$resolver,
|
||||||
|
DerivedStateFamily::ArtifactTruth->defaultFreshnessPolicy(),
|
||||||
|
DerivedStateFamily::ArtifactTruth->allowsNegativeResultCache(),
|
||||||
|
)
|
||||||
|
: $this->derivedStateStore->resolve(
|
||||||
|
$key,
|
||||||
|
$resolver,
|
||||||
|
DerivedStateFamily::ArtifactTruth->defaultFreshnessPolicy(),
|
||||||
|
DerivedStateFamily::ArtifactTruth->allowsNegativeResultCache(),
|
||||||
|
);
|
||||||
|
|
||||||
|
return $envelope;
|
||||||
|
}
|
||||||
|
|
||||||
private function contentExplanation(string $contentState): string
|
private function contentExplanation(string $contentState): string
|
||||||
{
|
{
|
||||||
return match ($contentState) {
|
return match ($contentState) {
|
||||||
|
|||||||
@ -120,7 +120,7 @@ ### Missing (no code, no spec beyond brainstorming)
|
|||||||
|
|
||||||
## Architecture & Principles (Non-Negotiables)
|
## Architecture & Principles (Non-Negotiables)
|
||||||
|
|
||||||
Source: [.specify/memory/constitution.md](.specify/memory/constitution.md) (v1.13.0)
|
Source: [.specify/memory/constitution.md](.specify/memory/constitution.md) (v1.14.0)
|
||||||
|
|
||||||
### Core Principles
|
### Core Principles
|
||||||
|
|
||||||
@ -132,6 +132,8 @@ ### Core Principles
|
|||||||
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.
|
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
|
||||||
|
|
||||||
|
|||||||
@ -3,7 +3,7 @@ # Product Principles
|
|||||||
> Permanent product principles that govern every spec, every UI decision, and every architectural choice.
|
> Permanent product principles that govern every spec, every UI decision, and every architectural choice.
|
||||||
> New specs must align with these. If a principle needs to change, update this file first.
|
> New specs must align with these. If a principle needs to change, update this file first.
|
||||||
|
|
||||||
**Last reviewed**: 2026-03-26
|
**Last reviewed**: 2026-03-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
|
||||||
@ -98,6 +118,10 @@ ### Filament-native first, no ad-hoc styling
|
|||||||
No hand-built status chips, alert cards, or local semantic color/border styling when Filament or a central primitive already expresses the meaning.
|
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.
|
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.
|
||||||
@ -122,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.
|
||||||
|
|||||||
@ -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 Baseline Compare Scope Guardrails & Ambiguity Guidance candidate)
|
**Last reviewed**: 2026-03-28 (added request-scoped performance foundation candidates for derived state, governance aggregates, and workspace access context)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@ -44,6 +44,89 @@ ## Qualified
|
|||||||
|
|
||||||
> Problem + Nutzen klar. Scope noch offen. Braucht noch Priorisierung.
|
> Problem + Nutzen klar. Scope noch offen. Braucht noch Priorisierung.
|
||||||
|
|
||||||
|
### Request-Scoped Derived State and Resolver Memoization
|
||||||
|
- **Type**: foundation
|
||||||
|
- **Source**: cross-cutting Filament render-path performance analysis 2026-03-28 — repeated derived-state resolution across `ArtifactTruthPresenter`, `OperationUxPresenter`, and `RelatedNavigationResolver`
|
||||||
|
- **Problem**: TenantPilot's presenter / resolver layer is architecturally correct but render-path chatty. The same derived truth, guidance, or related-navigation state is recomputed multiple times per record, per surface, and per request: list row badges, descriptions, tooltips, visibility checks, detail entries, and widgets ask for the same deterministic answer through separate closures. This is broader than one local N+1 query; it is a repeated-cost shape that now spans baseline, evidence, review, operation, and navigation surfaces.
|
||||||
|
- **Why it matters**: If each page is locally optimized in isolation, the underlying cost pattern remains and spreads into tenant reviews, review packs, evidence surfaces, baseline snapshots, and future portfolio/MSP views. The product needs a shared contract for deterministic request-local reuse, not another generation of ad hoc static caches and page-specific memoization helpers.
|
||||||
|
- **Proposed direction**:
|
||||||
|
- Introduce a canonical request-scoped derived-state store for deterministic presenter / resolver outputs
|
||||||
|
- Define explicit keying rules around presenter family, record type, record identity, variant / surface mode, and any relevant view-policy context
|
||||||
|
- Route `ArtifactTruthPresenter::for*()` paths, `OperationUxPresenter` surface guidance paths, and `RelatedNavigationResolver` primary/detail-entry paths through the shared store
|
||||||
|
- Separate raw domain state, derived surface state, and navigation/context state so memoization boundaries are explicit instead of accidental
|
||||||
|
- Provide a row-safe consumption pattern for Filament tables so label / tooltip / description / visible / url closures share the same derived state instead of recomputing it independently
|
||||||
|
- Define an explicit freshness rule for mutating action flows so request-local reuse never masks state that must be recomputed within the same action request
|
||||||
|
- **Scope boundaries**:
|
||||||
|
- **In scope**: request-scoped memoization contract, derived-state store infrastructure, keying contract, presenter/resolver adoption for at least Artifact Truth, Operation UX, and Related Navigation, and Filament list/detail/widget guardrails for row-safe consumption
|
||||||
|
- **Out of scope**: Redis or cross-request caching, aggressive query redesign, semantic changes to truth/guidance/navigation rules, widget redesign, and one-off page caches that bypass the shared contract
|
||||||
|
- **Acceptance points**:
|
||||||
|
- The same artifact truth for the same scope is fully derived at most once per request
|
||||||
|
- The same operation guidance for the same run/surface scope is fully derived at most once per request
|
||||||
|
- The same related primary/detail navigation entry for the same record is fully resolved at most once per request
|
||||||
|
- At least three currently affected surface families adopt the same shared contract
|
||||||
|
- Existing truth / guidance / navigation semantics stay unchanged from the operator's perspective
|
||||||
|
- Regression tests prove request-local reuse and mutation-path freshness
|
||||||
|
- **Risks / open questions**:
|
||||||
|
- Incorrect cache keys could cause invalid reuse across surfaces or variants
|
||||||
|
- Static ad hoc caches would hide the problem rather than solve it
|
||||||
|
- Mutation flows need an explicit invalidation or fresh-recompute rule where state can legitimately change mid-request
|
||||||
|
- **Suggested order**: first. This is the shared performance foundation the two follow-up candidates build on.
|
||||||
|
- **Priority**: high
|
||||||
|
|
||||||
|
### Tenant Governance Aggregate Contract
|
||||||
|
- **Type**: foundation
|
||||||
|
- **Source**: tenant governance surface overlap analysis 2026-03-28 — shared summary state duplicated across Baseline Compare Landing, Needs Attention, Coverage Banner, Compare Now, and related tenant-governance cards
|
||||||
|
- **Problem**: TenantPilot currently recomposes overlapping tenant-governance summaries on multiple surfaces in parallel instead of treating them as one tenant-scoped aggregate. Baseline compare freshness, compare outcome, open finding counts, overdue findings, expiring governance, lapsed governance, and related drift/coverage signals are recalculated or re-queried per widget/page, leaving ownership fragmented and making each new governance card more expensive than it needs to be.
|
||||||
|
- **Why it matters**: Release 1 and 2 continue expanding governance, review, evidence, and dashboard surfaces. If these tenant-level summaries keep growing surface-by-surface, new widgets will multiply redundant count queries, presentation-specific helper code, and semantic drift around what exactly qualifies as an attention count or governance posture.
|
||||||
|
- **Proposed direction**:
|
||||||
|
- Promote the current baseline/governance summary path into an explicit tenant-scoped aggregate contract, either by hardening `BaselineCompareStats` into that role or by introducing a clearly named `TenantGovernanceAggregate`
|
||||||
|
- Define one shared tenant summary that covers compare freshness, compare outcome, confidence/coverage/suppression where relevant, open findings summary, overdue findings, expiring/lapsed governance counts, and compare-related drift posture
|
||||||
|
- Make dashboard widgets and attention cards consume the aggregate and keep only presentation mapping local to the surface
|
||||||
|
- Prohibit parallel count queries for states that are already part of the shared aggregate contract
|
||||||
|
- Ensure repeated reads of the same tenant aggregate on one page are request-scoped and reusable instead of recomputed per widget
|
||||||
|
- **Scope boundaries**:
|
||||||
|
- **In scope**: tenant-scoped governance aggregate contract, shared ownership of attention counts, dashboard/landing/banner/card consumption for the same aggregate family, and request-local reuse across those surfaces
|
||||||
|
- **Out of scope**: cross-tenant portfolio aggregation, cross-request persistence caching, new drift semantics, new findings workflow semantics, full dashboard redesign, and broader evidence/review aggregation beyond the current tenant-governance summary family
|
||||||
|
- **Acceptance points**:
|
||||||
|
- At least three tenant-governance widgets/pages consume the same aggregate contract
|
||||||
|
- No widget re-queries overdue, lapsed, or expiring counts that are already part of the shared aggregate
|
||||||
|
- Dashboard, landing, and banner surfaces present semantically consistent values for the same tenant state
|
||||||
|
- Request-local reuse for the tenant aggregate is demonstrably testable
|
||||||
|
- No visible business-semantics regression is introduced while consolidating the summary source
|
||||||
|
- **Risks / open questions**:
|
||||||
|
- An overly broad aggregate could become a single oversized payload instead of a crisp contract
|
||||||
|
- If attention semantics are not clearly separated from presentation mapping, widgets will continue to smuggle business logic back into the UI layer
|
||||||
|
- If `BaselineCompareStats` stays helper-shaped rather than becoming an explicit contract, ownership ambiguity will persist even after partial consolidation
|
||||||
|
- **Suggested order**: second, ideally immediately after or alongside the request-scoped derived-state foundation.
|
||||||
|
- **Priority**: high
|
||||||
|
|
||||||
|
### Workspace Access Context and Navigation Cost Hardening
|
||||||
|
- **Type**: hardening
|
||||||
|
- **Source**: admin/workspace access-path analysis 2026-03-28 — repeated current-workspace, membership, navigation-visibility, and policy-adjacent access resolution across admin requests
|
||||||
|
- **Problem**: TenantPilot already has request-local caching in some capability resolvers, but the wider workspace/admin access path still pays a repeated request tax. Current workspace resolution, workspace membership lookups, navigation visibility checks, page access checks, and policy-adjacent access helpers can rebuild overlapping context multiple times before the actual screen content has even rendered. The issue is not one slow page; it is a hidden cost shape spread across many admin requests.
|
||||||
|
- **Why it matters**: As admin, monitoring, review, evidence, and future portfolio/workspace surfaces grow, this hidden context tax will compound across almost every workspace-scoped request. Left unbounded, it also increases the risk of access logic drifting into scattered local helpers instead of one explicit request-level contract.
|
||||||
|
- **Proposed direction**:
|
||||||
|
- Introduce an explicit request-scoped workspace access context that carries the current workspace ID/model, the membership decision, and any capability-access snapshot needed for repeated checks
|
||||||
|
- Harden `currentWorkspace()` or equivalent paths so the active workspace model is request-stable instead of repeatedly reloaded
|
||||||
|
- Make navigation visibility, resource visibility, page access helpers, and similar admin-panel checks consume the shared access context rather than rebuilding workspace/membership state locally
|
||||||
|
- Reuse the same context in policy-side or policy-adjacent workspace access decisions where repeated lookup is currently common
|
||||||
|
- Keep cross-panel workspace-aware transitions aligned with the same context contract rather than introducing special-case handoff helpers
|
||||||
|
- **Scope boundaries**:
|
||||||
|
- **In scope**: request-scoped workspace access context, current-workspace reuse, membership reuse, navigation/page-access context reuse, and migration of at least a subset of admin-sensitive helpers to the shared path
|
||||||
|
- **Out of scope**: RBAC redesign, capability-semantic changes, navigation IA restructuring, tenant-panel RBAC rewrite, or product-model changes to workspace-first behavior
|
||||||
|
- **Acceptance points**:
|
||||||
|
- The current workspace is not separately loaded multiple times within the same request path
|
||||||
|
- Repeated workspace-scoped access checks reuse the same membership/access context instead of rebuilding it
|
||||||
|
- At least two admin-sensitive request paths are migrated to the shared access context
|
||||||
|
- Navigation visibility uses request-wide reusable workspace context rather than repeated local lookups
|
||||||
|
- Access semantics remain unchanged while the request-path cost is hardened
|
||||||
|
- **Risks / open questions**:
|
||||||
|
- A wrong or overly broad shared context could create subtle access bugs
|
||||||
|
- The boundary between session-persisted workspace choice and request-scoped access context must stay explicit
|
||||||
|
- Over-centralization could hide legitimate special cases if exceptions are not consciously modeled
|
||||||
|
- **Suggested order**: third, after the derived-state and tenant-aggregate foundation work has clarified the shared request-scoped patterns.
|
||||||
|
- **Priority**: medium
|
||||||
|
|
||||||
### Tenant Draft Discard Lifecycle and Orphaned Draft Visibility
|
### Tenant Draft Discard Lifecycle and Orphaned Draft Visibility
|
||||||
- **Type**: hardening
|
- **Type**: hardening
|
||||||
- **Source**: domain architecture analysis 2026-03-16 — tenant lifecycle vs onboarding workflow lifecycle review
|
- **Source**: domain architecture analysis 2026-03-16 — tenant lifecycle vs onboarding workflow lifecycle review
|
||||||
|
|||||||
@ -4,7 +4,7 @@ # Product Standards
|
|||||||
> Specs reference these standards; they do not redefine them.
|
> Specs reference these standards; they do not redefine them.
|
||||||
> Guard tests enforce critical constraints automatically.
|
> Guard tests enforce critical constraints automatically.
|
||||||
|
|
||||||
**Last reviewed**: 2026-03-26
|
**Last reviewed**: 2026-03-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, UI-FIL-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) |
|
||||||
|
|||||||
@ -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)
|
||||||
|
|||||||
@ -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');
|
||||||
|
|||||||
@ -0,0 +1,37 @@
|
|||||||
|
# Specification Quality Checklist: Finding Governance Health & Resolution Semantics Surface Hardening
|
||||||
|
|
||||||
|
**Purpose**: Validate specification completeness and quality before proceeding to planning
|
||||||
|
**Created**: 2026-03-27
|
||||||
|
**Feature**: [spec.md](/Users/ahmeddarrazi/Documents/projects/TenantAtlas/specs/166-finding-governance-health/spec.md)
|
||||||
|
|
||||||
|
## Content Quality
|
||||||
|
|
||||||
|
- [x] No code-level implementation mechanics (new classes, schema, jobs, or algorithms) are prescribed
|
||||||
|
- [x] Focused on user value and business needs
|
||||||
|
- [x] Written for product, design, and implementation stakeholders in repo-native spec language
|
||||||
|
- [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 code-level implementation mechanics leak into the specification beyond required route, RBAC, operator-surface contract detail, and UI Action Matrix location references
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
- Validation pass 1 completed against the finished spec.
|
||||||
|
- No open clarification markers remain.
|
||||||
|
- Proportionality review completed: the spec explicitly records that it adds no new source of truth, persistence, abstraction, state family, or cross-domain taxonomy.
|
||||||
|
- The spec intentionally references existing route surfaces and shared semantic primitives because this repo's spec template requires operator-surface and constitution alignment details; it does not prescribe implementation mechanics.
|
||||||
@ -0,0 +1,329 @@
|
|||||||
|
openapi: 3.1.0
|
||||||
|
info:
|
||||||
|
title: Finding Governance Health Internal Contract
|
||||||
|
version: 0.1.0
|
||||||
|
description: |
|
||||||
|
Internal admin-plane contract for hardened finding governance and resolution semantics.
|
||||||
|
These endpoints represent the server-side data contract backing Filament and Livewire surfaces,
|
||||||
|
not a new public API.
|
||||||
|
servers:
|
||||||
|
- url: /api/internal
|
||||||
|
paths:
|
||||||
|
/tenants/{tenantId}/findings:
|
||||||
|
get:
|
||||||
|
summary: List findings with workflow, governance, and urgency semantics
|
||||||
|
operationId: listTenantFindingsWithGovernanceHealth
|
||||||
|
tags: [Findings]
|
||||||
|
parameters:
|
||||||
|
- $ref: '#/components/parameters/TenantId'
|
||||||
|
- name: status
|
||||||
|
in: query
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
enum: [new, acknowledged, triaged, in_progress, reopened, resolved, closed, risk_accepted]
|
||||||
|
- name: governanceValidity
|
||||||
|
in: query
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
enum: [valid, expiring, expired, revoked, rejected, missing_support]
|
||||||
|
- name: overdue
|
||||||
|
in: query
|
||||||
|
schema:
|
||||||
|
type: boolean
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: Findings list rows with operator-facing lifecycle and governance semantics
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
data:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
$ref: '#/components/schemas/FindingListItem'
|
||||||
|
'403':
|
||||||
|
$ref: '#/components/responses/Forbidden'
|
||||||
|
'404':
|
||||||
|
$ref: '#/components/responses/NotFound'
|
||||||
|
/tenants/{tenantId}/findings/{findingId}:
|
||||||
|
get:
|
||||||
|
summary: View finding detail with leading status and governance zone
|
||||||
|
operationId: showFindingWithGovernanceHealth
|
||||||
|
tags: [Findings]
|
||||||
|
parameters:
|
||||||
|
- $ref: '#/components/parameters/TenantId'
|
||||||
|
- $ref: '#/components/parameters/FindingId'
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: Finding detail payload with primary operator summary and secondary diagnostics
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/FindingDetail'
|
||||||
|
'403':
|
||||||
|
$ref: '#/components/responses/Forbidden'
|
||||||
|
'404':
|
||||||
|
$ref: '#/components/responses/NotFound'
|
||||||
|
/tenants/{tenantId}/finding-exceptions:
|
||||||
|
get:
|
||||||
|
summary: List tenant exception records aligned with finding governance semantics
|
||||||
|
operationId: listTenantFindingExceptionsForGovernanceHealth
|
||||||
|
tags: [Finding Exceptions]
|
||||||
|
parameters:
|
||||||
|
- $ref: '#/components/parameters/TenantId'
|
||||||
|
- name: status
|
||||||
|
in: query
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
enum: [pending, active, expiring, expired, rejected, revoked, superseded]
|
||||||
|
- name: validity
|
||||||
|
in: query
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
enum: [valid, expiring, expired, revoked, rejected, missing_support]
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: Tenant exception register rows with governance validity and warnings
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
data:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
$ref: '#/components/schemas/FindingExceptionSurfaceItem'
|
||||||
|
'403':
|
||||||
|
$ref: '#/components/responses/Forbidden'
|
||||||
|
'404':
|
||||||
|
$ref: '#/components/responses/NotFound'
|
||||||
|
/workspaces/{workspaceId}/finding-exceptions/queue:
|
||||||
|
get:
|
||||||
|
summary: Canonical exception queue with tenant-safe governance attention signals
|
||||||
|
operationId: listCanonicalFindingExceptionQueueForGovernanceHealth
|
||||||
|
tags: [Finding Exceptions]
|
||||||
|
parameters:
|
||||||
|
- $ref: '#/components/parameters/WorkspaceId'
|
||||||
|
- name: tenantId
|
||||||
|
in: query
|
||||||
|
schema:
|
||||||
|
type: integer
|
||||||
|
- name: validity
|
||||||
|
in: query
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
enum: [valid, expiring, expired, revoked, rejected, missing_support]
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: Canonical queue rows filtered to entitled tenants only
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
data:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
$ref: '#/components/schemas/FindingExceptionSurfaceItem'
|
||||||
|
'403':
|
||||||
|
$ref: '#/components/responses/Forbidden'
|
||||||
|
'404':
|
||||||
|
$ref: '#/components/responses/NotFound'
|
||||||
|
/tenants/{tenantId}/dashboard/needs-attention:
|
||||||
|
get:
|
||||||
|
summary: Tenant dashboard needs-attention summary including overdue and governance attention
|
||||||
|
operationId: showTenantNeedsAttentionSummary
|
||||||
|
tags: [Dashboard]
|
||||||
|
parameters:
|
||||||
|
- $ref: '#/components/parameters/TenantId'
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: Read-only summary items for tenant dashboard attention state
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/NeedsAttentionSummary'
|
||||||
|
'403':
|
||||||
|
$ref: '#/components/responses/Forbidden'
|
||||||
|
'404':
|
||||||
|
$ref: '#/components/responses/NotFound'
|
||||||
|
/tenants/{tenantId}/baseline-compare/summary:
|
||||||
|
get:
|
||||||
|
summary: Baseline compare summary with honest findings and governance attention cues
|
||||||
|
operationId: showBaselineCompareSummaryWithFindingGovernanceContext
|
||||||
|
tags: [Baseline Compare]
|
||||||
|
parameters:
|
||||||
|
- $ref: '#/components/parameters/TenantId'
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: Baseline compare summary payload with findings and governance attention context
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/BaselineCompareFindingSummary'
|
||||||
|
'403':
|
||||||
|
$ref: '#/components/responses/Forbidden'
|
||||||
|
'404':
|
||||||
|
$ref: '#/components/responses/NotFound'
|
||||||
|
components:
|
||||||
|
parameters:
|
||||||
|
TenantId:
|
||||||
|
name: tenantId
|
||||||
|
in: path
|
||||||
|
required: true
|
||||||
|
schema:
|
||||||
|
type: integer
|
||||||
|
FindingId:
|
||||||
|
name: findingId
|
||||||
|
in: path
|
||||||
|
required: true
|
||||||
|
schema:
|
||||||
|
type: integer
|
||||||
|
WorkspaceId:
|
||||||
|
name: workspaceId
|
||||||
|
in: path
|
||||||
|
required: true
|
||||||
|
schema:
|
||||||
|
type: integer
|
||||||
|
responses:
|
||||||
|
Forbidden:
|
||||||
|
description: Member lacks required capability in the current scope
|
||||||
|
NotFound:
|
||||||
|
description: Workspace or tenant scope is not entitled
|
||||||
|
schemas:
|
||||||
|
FindingListItem:
|
||||||
|
type: object
|
||||||
|
required: [id, workflowStatus, workflowFamily, severity, dueAttention, governance]
|
||||||
|
properties:
|
||||||
|
id:
|
||||||
|
type: integer
|
||||||
|
workflowStatus:
|
||||||
|
type: string
|
||||||
|
enum: [new, acknowledged, triaged, in_progress, reopened, resolved, closed, risk_accepted]
|
||||||
|
workflowFamily:
|
||||||
|
type: string
|
||||||
|
enum: [active, accepted_risk, historical]
|
||||||
|
severity:
|
||||||
|
type: string
|
||||||
|
enum: [low, medium, high, critical]
|
||||||
|
dueAttention:
|
||||||
|
type: string
|
||||||
|
enum: [none, due_soon, overdue]
|
||||||
|
ownerName:
|
||||||
|
type: string
|
||||||
|
nullable: true
|
||||||
|
assigneeName:
|
||||||
|
type: string
|
||||||
|
nullable: true
|
||||||
|
governance:
|
||||||
|
$ref: '#/components/schemas/GovernanceSignal'
|
||||||
|
FindingDetail:
|
||||||
|
allOf:
|
||||||
|
- $ref: '#/components/schemas/FindingListItem'
|
||||||
|
- type: object
|
||||||
|
properties:
|
||||||
|
primaryNextAction:
|
||||||
|
type: string
|
||||||
|
nullable: true
|
||||||
|
governanceWarning:
|
||||||
|
type: string
|
||||||
|
nullable: true
|
||||||
|
resolutionContext:
|
||||||
|
type: string
|
||||||
|
nullable: true
|
||||||
|
diagnostics:
|
||||||
|
type: object
|
||||||
|
description: Secondary diagnostic context such as run ids, fingerprints, and evidence sections
|
||||||
|
FindingExceptionSurfaceItem:
|
||||||
|
type: object
|
||||||
|
required: [id, status, validityState, findingId]
|
||||||
|
properties:
|
||||||
|
id:
|
||||||
|
type: integer
|
||||||
|
findingId:
|
||||||
|
type: integer
|
||||||
|
tenantId:
|
||||||
|
type: integer
|
||||||
|
nullable: true
|
||||||
|
status:
|
||||||
|
type: string
|
||||||
|
enum: [pending, active, expiring, expired, rejected, revoked, superseded]
|
||||||
|
validityState:
|
||||||
|
type: string
|
||||||
|
enum: [valid, expiring, expired, revoked, rejected, missing_support]
|
||||||
|
governanceWarning:
|
||||||
|
type: string
|
||||||
|
nullable: true
|
||||||
|
reviewDueAt:
|
||||||
|
type: string
|
||||||
|
format: date-time
|
||||||
|
nullable: true
|
||||||
|
expiresAt:
|
||||||
|
type: string
|
||||||
|
format: date-time
|
||||||
|
nullable: true
|
||||||
|
GovernanceSignal:
|
||||||
|
type: object
|
||||||
|
required: [attentionState]
|
||||||
|
properties:
|
||||||
|
validityState:
|
||||||
|
type: string
|
||||||
|
enum: [valid, expiring, expired, revoked, rejected, missing_support]
|
||||||
|
nullable: true
|
||||||
|
exceptionStatus:
|
||||||
|
type: string
|
||||||
|
enum: [pending, active, expiring, expired, rejected, revoked, superseded]
|
||||||
|
nullable: true
|
||||||
|
attentionState:
|
||||||
|
type: string
|
||||||
|
enum: [healthy, attention_needed, not_applicable]
|
||||||
|
warning:
|
||||||
|
type: string
|
||||||
|
nullable: true
|
||||||
|
NeedsAttentionSummary:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
items:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
type: object
|
||||||
|
required: [title, body, badge]
|
||||||
|
properties:
|
||||||
|
title:
|
||||||
|
type: string
|
||||||
|
body:
|
||||||
|
type: string
|
||||||
|
badge:
|
||||||
|
type: string
|
||||||
|
badgeColor:
|
||||||
|
type: string
|
||||||
|
nextStep:
|
||||||
|
type: string
|
||||||
|
nullable: true
|
||||||
|
healthyChecks:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
title:
|
||||||
|
type: string
|
||||||
|
body:
|
||||||
|
type: string
|
||||||
|
BaselineCompareFindingSummary:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
findingsCount:
|
||||||
|
type: integer
|
||||||
|
summaryHeadline:
|
||||||
|
type: string
|
||||||
|
supportingMessage:
|
||||||
|
type: string
|
||||||
|
nullable: true
|
||||||
|
governanceAttentionPresent:
|
||||||
|
type: boolean
|
||||||
|
overdueFindingsPresent:
|
||||||
|
type: boolean
|
||||||
|
nextActionLabel:
|
||||||
|
type: string
|
||||||
|
nullable: true
|
||||||
149
specs/166-finding-governance-health/data-model.md
Normal file
149
specs/166-finding-governance-health/data-model.md
Normal file
@ -0,0 +1,149 @@
|
|||||||
|
# Data Model: Finding Governance Health & Resolution Semantics Surface Hardening
|
||||||
|
|
||||||
|
## 1. Finding
|
||||||
|
|
||||||
|
- **Purpose**: Tenant-owned issue record that carries workflow lifecycle, severity, due state, ownership, and evidence-backed diagnostic context.
|
||||||
|
- **Ownership**: Tenant-owned (`workspace_id` + `tenant_id` already exist).
|
||||||
|
- **Relevant existing fields**:
|
||||||
|
- `id`
|
||||||
|
- `workspace_id`
|
||||||
|
- `tenant_id`
|
||||||
|
- `finding_type`
|
||||||
|
- `status`: `new`, `acknowledged`, `triaged`, `in_progress`, `reopened`, `resolved`, `closed`, `risk_accepted`
|
||||||
|
- `severity`
|
||||||
|
- `owner_user_id`
|
||||||
|
- `assignee_user_id`
|
||||||
|
- `due_at`
|
||||||
|
- `sla_days`
|
||||||
|
- `resolved_at`, `resolved_reason`
|
||||||
|
- `closed_at`, `closed_reason`, `closed_by_user_id`
|
||||||
|
- `reopened_at`, `triaged_at`, `in_progress_at`
|
||||||
|
- `current_operation_run_id`, `baseline_operation_run_id`
|
||||||
|
- `evidence_jsonb`
|
||||||
|
- **Relationships**:
|
||||||
|
- belongs to `Tenant`
|
||||||
|
- has one current `FindingException`
|
||||||
|
- belongs to owner, assignee, acknowledged-by, and closed-by users
|
||||||
|
- **Existing invariants preserved**:
|
||||||
|
- Workflow status remains the source of lifecycle truth.
|
||||||
|
- `risk_accepted` remains a finding workflow state, not proof of healthy governance by itself.
|
||||||
|
- Open-status and terminal-status helper behavior does not change in this slice.
|
||||||
|
|
||||||
|
## 2. FindingException
|
||||||
|
|
||||||
|
- **Purpose**: Tenant-owned governance record that determines whether accepted risk is currently backed by valid, expiring, expired, revoked, rejected, or missing-support governance.
|
||||||
|
- **Ownership**: Tenant-owned (`workspace_id` + `tenant_id` already exist).
|
||||||
|
- **Relevant existing fields**:
|
||||||
|
- `id`
|
||||||
|
- `workspace_id`
|
||||||
|
- `tenant_id`
|
||||||
|
- `finding_id`
|
||||||
|
- `status`: `pending`, `active`, `expiring`, `expired`, `rejected`, `revoked`, `superseded`
|
||||||
|
- `current_validity_state`: `valid`, `expiring`, `expired`, `revoked`, `rejected`, `missing_support`
|
||||||
|
- `requested_by_user_id`
|
||||||
|
- `owner_user_id`
|
||||||
|
- `approved_by_user_id`
|
||||||
|
- `request_reason`, `approval_reason`, `rejection_reason`, `revocation_reason`
|
||||||
|
- `requested_at`, `approved_at`, `rejected_at`, `revoked_at`
|
||||||
|
- `effective_from`, `review_due_at`, `expires_at`
|
||||||
|
- `current_decision_id`
|
||||||
|
- `evidence_summary`
|
||||||
|
- **Relationships**:
|
||||||
|
- belongs to `Finding`
|
||||||
|
- belongs to requester, owner, approver, and current decision
|
||||||
|
- has many decisions and evidence references
|
||||||
|
- **Existing invariants preserved**:
|
||||||
|
- Governance validity remains derived from exception state and timing.
|
||||||
|
- The exception remains the authoritative source for healthy versus lapsed governance.
|
||||||
|
|
||||||
|
## 3. Derived Surface Projection: Finding Governance Surface State
|
||||||
|
|
||||||
|
- **Purpose**: Non-persisted operator-facing projection used by findings list, finding detail, exception surfaces, and summary widgets.
|
||||||
|
- **Derived from**:
|
||||||
|
- `Finding.status`
|
||||||
|
- `Finding.severity`
|
||||||
|
- `Finding.owner_user_id`, `Finding.assignee_user_id`, `Finding.due_at`
|
||||||
|
- linked `FindingException`
|
||||||
|
- `FindingRiskGovernanceResolver::resolveFindingState()`
|
||||||
|
- `FindingRiskGovernanceResolver::resolveWarningMessage()`
|
||||||
|
- **Proposed derived fields**:
|
||||||
|
- `workflow_status`: current finding status value
|
||||||
|
- `workflow_family`: `active`, `accepted_risk`, or `historical`
|
||||||
|
- `governance_validity`: `valid`, `expiring`, `expired`, `revoked`, `rejected`, `missing_support`, or `null`
|
||||||
|
- `governance_attention`: `healthy`, `attention_needed`, or `not_applicable`
|
||||||
|
- `governance_warning`: nullable operator-readable warning message
|
||||||
|
- `due_attention`: `overdue`, `due_soon`, `none`
|
||||||
|
- `ownership_attention`: `assigned`, `owner_missing`, `assignee_missing`, or `both_missing` where relevant
|
||||||
|
- `resolution_context`: nullable secondary text such as `no longer observed` when derivable
|
||||||
|
- `primary_next_action`: derived operator guidance such as inspect exception, renew governance, review overdue finding, or review historical closure
|
||||||
|
- **Invariant**:
|
||||||
|
- This projection is derived only. It must not become a new stored truth or a replacement status enum.
|
||||||
|
|
||||||
|
## 4. Derived Surface Projection: Finding Detail Status Zone
|
||||||
|
|
||||||
|
- **Purpose**: Non-persisted grouping for the leading zone on finding detail.
|
||||||
|
- **Derived from**:
|
||||||
|
- the Finding Governance Surface State
|
||||||
|
- existing finding severity and related owner or assignee relationships
|
||||||
|
- existing exception owner or approver details when relevant
|
||||||
|
- **Required visible fields**:
|
||||||
|
- lifecycle status
|
||||||
|
- severity or priority
|
||||||
|
- governance validity and warning when applicable
|
||||||
|
- owner and assignee context
|
||||||
|
- due or SLA urgency
|
||||||
|
- next-step guidance
|
||||||
|
- **Invariant**:
|
||||||
|
- This zone reorganizes existing truth only; it does not add a new domain layer.
|
||||||
|
|
||||||
|
## 5. Derived Surface Projection: Tenant Governance Attention Summary
|
||||||
|
|
||||||
|
- **Purpose**: Non-persisted aggregate for dashboard and baseline-compare summary surfaces.
|
||||||
|
- **Derived from**:
|
||||||
|
- findings with open or terminal statuses
|
||||||
|
- overdue findings in open workflow states
|
||||||
|
- accepted-risk findings whose governance projection resolves to expiring, expired, revoked, rejected, or missing-support states
|
||||||
|
- existing compare summary state from `BaselineCompareStats`
|
||||||
|
- **Proposed aggregate values**:
|
||||||
|
- `overdue_open_findings_count`
|
||||||
|
- `expiring_governance_count`
|
||||||
|
- `lapsed_governance_count`
|
||||||
|
- `active_non_new_findings_count`
|
||||||
|
- `high_severity_active_findings_count`
|
||||||
|
- `healthy_checks` fallback only when none of the above require attention
|
||||||
|
- **Invariant**:
|
||||||
|
- Summary surfaces remain glance-first and DB-only. They surface operator-critical truth without becoming a new reporting system.
|
||||||
|
|
||||||
|
## Surface State Families
|
||||||
|
|
||||||
|
### Finding workflow families
|
||||||
|
|
||||||
|
- `active`: `new`, `acknowledged`, `triaged`, `in_progress`, `reopened`
|
||||||
|
- `accepted_risk`: `risk_accepted`
|
||||||
|
- `historical`: `resolved`, `closed`
|
||||||
|
|
||||||
|
### Governance-health families
|
||||||
|
|
||||||
|
- `healthy`: accepted risk with `valid` governance
|
||||||
|
- `attention_needed`: accepted risk with `expiring`, `expired`, `revoked`, `rejected`, or `missing_support` governance
|
||||||
|
- `not_applicable`: active or historical findings without current governance relevance
|
||||||
|
|
||||||
|
### Urgency families
|
||||||
|
|
||||||
|
- `overdue`: open finding with due date in the past
|
||||||
|
- `due_soon`: open finding approaching due threshold if current UI supports it
|
||||||
|
- `none`: no immediate due urgency signal
|
||||||
|
|
||||||
|
## Relationship Rules
|
||||||
|
|
||||||
|
- A finding can exist without an exception.
|
||||||
|
- A finding in `risk_accepted` status without a valid linked exception must project as governance attention, not as healthy accepted risk.
|
||||||
|
- A finding outside `risk_accepted` may still have historical exception context, but workflow lifecycle remains the primary status dimension.
|
||||||
|
- Exception surfaces and finding surfaces must render the same governance truth for the same finding or exception combination.
|
||||||
|
|
||||||
|
## Behavioral Invariants For This Spec
|
||||||
|
|
||||||
|
- No new persisted entity, table, enum, or status family is introduced.
|
||||||
|
- `resolved` and `closed` remain workflow states and do not gain implicit technical-remediation meaning.
|
||||||
|
- Governance validity remains derived from exception truth, not from finding status alone.
|
||||||
|
- Summary attention must not count only `new` findings when overdue or lapsed-governance conditions already exist.
|
||||||
134
specs/166-finding-governance-health/plan.md
Normal file
134
specs/166-finding-governance-health/plan.md
Normal file
@ -0,0 +1,134 @@
|
|||||||
|
# Implementation Plan: Finding Governance Health & Resolution Semantics Surface Hardening
|
||||||
|
|
||||||
|
**Branch**: `166-finding-governance-health` | **Date**: 2026-03-27 | **Spec**: [/Users/ahmeddarrazi/Documents/projects/TenantAtlas/specs/166-finding-governance-health/spec.md](/Users/ahmeddarrazi/Documents/projects/TenantAtlas/specs/166-finding-governance-health/spec.md)
|
||||||
|
**Input**: Feature specification from `/specs/166-finding-governance-health/spec.md`
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
Harden findings, exception, and tenant-summary surfaces so operators can quickly distinguish active findings, accepted risk with healthy governance, accepted risk with governance attention needed, historical resolved or closed workflow states, and overdue work. The implementation stays deliberately narrow: it reuses existing `Finding`, `FindingException`, `FindingRiskGovernanceResolver`, centralized badge and filter catalogs, and current Filament surfaces rather than adding schema, new status families, or a presenter framework.
|
||||||
|
|
||||||
|
The work is primarily surface hardening across the tenant findings list, finding detail, tenant exception register, canonical exception queue, tenant dashboard attention widget, and baseline-compare summary surfaces. The design keeps workflow lifecycle and governance validity as separate visible dimensions, promotes due and ownership urgency ahead of diagnostics, and adds cross-surface tests that enforce semantic consistency.
|
||||||
|
|
||||||
|
## Technical Context
|
||||||
|
|
||||||
|
**Language/Version**: PHP 8.4.15
|
||||||
|
**Primary Dependencies**: Laravel 12, Filament v5, Livewire v4, Pest v4, Tailwind CSS v4, existing `Finding`, `FindingException`, `FindingRiskGovernanceResolver`, `BadgeCatalog`, `BadgeRenderer`, `FilterOptionCatalog`, and tenant dashboard widgets
|
||||||
|
**Storage**: PostgreSQL using existing `findings`, `finding_exceptions`, related decision tables, and existing DB-backed summary sources; no schema changes required
|
||||||
|
**Testing**: Pest feature tests, Pest unit tests, and Livewire or Filament component tests
|
||||||
|
**Target Platform**: Laravel Sail web application on PostgreSQL
|
||||||
|
**Project Type**: Web application monolith
|
||||||
|
**Performance Goals**: Findings list, exception list, dashboard widgets, and baseline-compare summary remain DB-only at render time; added governance cues must preserve scan-first list behavior and avoid disproportionate query cost; no new background processing is introduced
|
||||||
|
**Constraints**: No new schema or enum; no new Microsoft Graph calls; no new `OperationRun`; no competing governance truth; destructive actions keep existing confirmation and audit behavior; tenant and workspace isolation semantics must remain unchanged; avoid new presenter or semantic-framework layers
|
||||||
|
**Scale/Scope**: One tenant findings resource, one finding detail, one tenant exception register, one canonical exception queue, one tenant attention widget, and one baseline-compare summary surface, plus shared badge, filter, and resolver semantics with focused regression coverage
|
||||||
|
|
||||||
|
## Constitution Check
|
||||||
|
|
||||||
|
*GATE: Must pass before Phase 0 research. Re-check after Phase 1 design.*
|
||||||
|
|
||||||
|
- **Pre-Phase-0 Gate: PASS**
|
||||||
|
- Inventory-first: PASS. The feature changes how existing findings and exception truth is surfaced; it does not redefine inventory, snapshots, or backup truth.
|
||||||
|
- Read/write separation: PASS. Existing writes stay unchanged and confirmation-gated where already required; this slice is mostly read-surface hardening.
|
||||||
|
- Graph contract path: PASS. No Graph calls are introduced.
|
||||||
|
- Deterministic capabilities: PASS. Existing capability registry and RBAC helpers remain the sole authorization source.
|
||||||
|
- RBAC-UX / workspace / tenant isolation: PASS. Tenant findings and exception surfaces remain tenant-scoped, canonical queue remains entitlement-filtered, non-members remain 404, and in-scope capability denials remain 403.
|
||||||
|
- Global search: PASS. This slice does not expand global-search exposure.
|
||||||
|
- Run observability: PASS by non-applicability. No new long-running or remote work is introduced.
|
||||||
|
- Ops-UX 3-surface feedback: PASS by non-applicability. No new `OperationRun` flow is added.
|
||||||
|
- Data minimization: PASS. Surface hardening reuses current DB-backed truth and does not add new stored payloads.
|
||||||
|
- Proportionality / no premature abstraction / persisted truth / state / UI semantics / few layers: PASS. The feature explicitly avoids new persistence, new state families, and a new semantic framework. The narrow implementation is to tighten existing list, detail, queue, and summary mappings.
|
||||||
|
- BADGE-001: PASS. The plan reuses existing centralized badge domains for finding status, severity, exception status, and governance validity.
|
||||||
|
- UI-FIL-001: PASS. Native Filament resources, infolists, tables, widgets, and existing shared primitives remain the implementation path.
|
||||||
|
- UI-NAMING-001: PASS. The work sharpens existing operator language instead of inventing new labels.
|
||||||
|
- OPSURF-001: PASS. The plan explicitly separates workflow lifecycle, governance validity, and due urgency, and keeps diagnostics secondary.
|
||||||
|
- Filament UI Action Surface Contract: PASS. Existing inspection affordances and action groups remain; the main change is semantic emphasis, not new mutation inventory.
|
||||||
|
- Filament UI UX-001: PASS. The design promotes a clearer leading status zone on view pages, retains searchable and filterable tables, and keeps summary widgets scan-first.
|
||||||
|
|
||||||
|
**Post-Phase-1 Re-check: PASS**
|
||||||
|
- The design introduces no new stored truth, no new resolver layer, and no new status family.
|
||||||
|
- All proposed contracts and data models remain derived from existing `Finding`, `FindingException`, centralized badge semantics, and current tenant or workspace queries.
|
||||||
|
- The testing strategy protects business truth across findings list, finding detail, exception surfaces, and summary widgets instead of snapshotting a new presentation framework.
|
||||||
|
|
||||||
|
## Project Structure
|
||||||
|
|
||||||
|
### Documentation (this feature)
|
||||||
|
|
||||||
|
```text
|
||||||
|
specs/166-finding-governance-health/
|
||||||
|
├── checklists/
|
||||||
|
│ └── requirements.md
|
||||||
|
├── spec.md
|
||||||
|
├── plan.md
|
||||||
|
├── research.md
|
||||||
|
├── data-model.md
|
||||||
|
├── quickstart.md
|
||||||
|
├── contracts/
|
||||||
|
└── tasks.md
|
||||||
|
```
|
||||||
|
|
||||||
|
### Source Code (repository root)
|
||||||
|
|
||||||
|
```text
|
||||||
|
app/
|
||||||
|
├── Filament/
|
||||||
|
│ ├── Pages/
|
||||||
|
│ │ ├── Monitoring/
|
||||||
|
│ │ └── TenantDashboard.php
|
||||||
|
│ ├── Resources/
|
||||||
|
│ │ ├── FindingResource.php
|
||||||
|
│ │ ├── FindingResource/
|
||||||
|
│ │ └── FindingExceptionResource/
|
||||||
|
│ └── Widgets/
|
||||||
|
│ ├── Dashboard/
|
||||||
|
│ ├── Tenant/
|
||||||
|
│ └── Workspace/
|
||||||
|
├── Models/
|
||||||
|
├── Services/
|
||||||
|
│ └── Findings/
|
||||||
|
└── Support/
|
||||||
|
├── Badges/
|
||||||
|
└── Filament/
|
||||||
|
|
||||||
|
resources/
|
||||||
|
└── views/
|
||||||
|
└── filament/
|
||||||
|
├── pages/
|
||||||
|
└── widgets/
|
||||||
|
|
||||||
|
tests/
|
||||||
|
├── Feature/
|
||||||
|
│ ├── Findings/
|
||||||
|
│ ├── Filament/
|
||||||
|
│ ├── Authorization/
|
||||||
|
│ └── Evidence/
|
||||||
|
└── Unit/
|
||||||
|
└── Badges/
|
||||||
|
```
|
||||||
|
|
||||||
|
**Structure Decision**: Keep the existing Laravel monolith structure. The implementation should stay inside the current findings and exception resources, current dashboard and baseline-compare surfaces, the existing findings governance resolver, and centralized badge or filter helpers. Regression coverage should extend current Findings, Filament, Authorization, Evidence, and Badge test suites rather than creating a new surface framework or test package.
|
||||||
|
|
||||||
|
## Complexity Tracking
|
||||||
|
|
||||||
|
No constitution violations require justification. The plan intentionally avoids adding persistence, new abstractions, or new state families.
|
||||||
|
|
||||||
|
Implementation confirmation (2026-03-27): delivery stayed within the planned narrow slice. No additional semantic gap was discovered that would require a new resolver layer, persistence field, or extra follow-up spec beyond the already documented resolution-origin candidate.
|
||||||
|
|
||||||
|
## Phase 0 — Research Output
|
||||||
|
|
||||||
|
- [research.md](/Users/ahmeddarrazi/Documents/projects/TenantAtlas/specs/166-finding-governance-health/research.md)
|
||||||
|
|
||||||
|
## Phase 1 — Design Output
|
||||||
|
|
||||||
|
- [data-model.md](/Users/ahmeddarrazi/Documents/projects/TenantAtlas/specs/166-finding-governance-health/data-model.md)
|
||||||
|
- [quickstart.md](/Users/ahmeddarrazi/Documents/projects/TenantAtlas/specs/166-finding-governance-health/quickstart.md)
|
||||||
|
- [contracts/finding-governance-health.openapi.yaml](/Users/ahmeddarrazi/Documents/projects/TenantAtlas/specs/166-finding-governance-health/contracts/finding-governance-health.openapi.yaml)
|
||||||
|
|
||||||
|
## Phase 2 — Implementation Planning
|
||||||
|
|
||||||
|
The implementation task plan covers these execution slices:
|
||||||
|
|
||||||
|
1. Findings list hardening so lifecycle state, governance validity, overdue urgency, and ownership become scan-first without degrading filterability or list density.
|
||||||
|
2. Finding detail restructuring so an operator-first status and governance zone appears above diagnostics, and resolved or closed copy becomes semantically cautious.
|
||||||
|
3. Exception-surface alignment so tenant register and canonical queue carry the same healthy versus attention-needed governance signals as finding surfaces.
|
||||||
|
4. Tenant-summary propagation so `NeedsAttention`, `BaselineCompareNow`, and baseline-compare landing surfaces include overdue and unhealthy governance truth instead of relying only on `new` findings.
|
||||||
|
5. Shared semantics tightening so `FindingRiskGovernanceResolver`, `BadgeCatalog`, `BadgeRenderer`, and `FilterOptionCatalog` remain the sole truth sources for status and governance vocabulary.
|
||||||
|
6. Cross-surface regression coverage spanning findings list, finding detail, exception register, canonical queue, dashboard attention, baseline-compare summary, and badge-domain semantics.
|
||||||
88
specs/166-finding-governance-health/quickstart.md
Normal file
88
specs/166-finding-governance-health/quickstart.md
Normal file
@ -0,0 +1,88 @@
|
|||||||
|
# Quickstart: Finding Governance Health & Resolution Semantics Surface Hardening
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
Verify that findings, exceptions, and tenant summary surfaces clearly distinguish active work, healthy accepted risk, lapsed or expiring governance, overdue work, and historical resolved or closed workflow states.
|
||||||
|
|
||||||
|
## Prerequisites
|
||||||
|
|
||||||
|
1. Start Sail and ensure the application database is available.
|
||||||
|
2. Have at least one workspace and one tenant with visible findings in tenant context.
|
||||||
|
3. Use a seeded dataset that includes at minimum:
|
||||||
|
- one active open finding
|
||||||
|
- one overdue open finding
|
||||||
|
- one `risk_accepted` finding with valid governance
|
||||||
|
- one `risk_accepted` finding with expiring governance
|
||||||
|
- one `risk_accepted` finding with expired governance
|
||||||
|
- one `risk_accepted` finding with revoked governance
|
||||||
|
- one `risk_accepted` finding with rejected governance where that state is operator-visible
|
||||||
|
- one `risk_accepted` finding with missing-support governance
|
||||||
|
- one `resolved` finding
|
||||||
|
- one `closed` finding
|
||||||
|
4. Have two user roles available for validation:
|
||||||
|
- tenant operator or manager who can inspect findings and exceptions
|
||||||
|
- readonly tenant member for capability-safe inspection checks
|
||||||
|
- workspace approver with exception-approval scope for canonical queue validation
|
||||||
|
|
||||||
|
## Findings List Validation
|
||||||
|
|
||||||
|
1. Open the tenant findings list.
|
||||||
|
2. Confirm that the active open finding is visibly distinct from the healthy accepted-risk finding.
|
||||||
|
3. Confirm that accepted-risk findings with expiring, expired, revoked, rejected where operator-visible, or missing-support governance are visibly distinct from the healthy accepted-risk finding.
|
||||||
|
4. Confirm that the overdue open finding is visually prioritized relative to non-overdue active work.
|
||||||
|
5. Confirm that the historical resolved or closed finding does not read like a healthy accepted-risk state.
|
||||||
|
6. Confirm that list filters and scanability still work while the added governance cues are present.
|
||||||
|
|
||||||
|
## Finding Detail Validation
|
||||||
|
|
||||||
|
1. Open the active finding detail page.
|
||||||
|
2. Confirm that the first visible zone shows lifecycle state, severity, owner or assignee, due urgency, and next-action context before IDs or raw diagnostics.
|
||||||
|
3. Open the healthy accepted-risk finding detail page.
|
||||||
|
4. Confirm that accepted risk remains visible as accepted risk and includes a healthy governance cue.
|
||||||
|
5. Open the lapsed-governance accepted-risk finding detail page.
|
||||||
|
6. Confirm that the leading zone surfaces the governance warning before fingerprints, run links, raw evidence, or diff sections.
|
||||||
|
7. Open the resolved finding detail page.
|
||||||
|
8. Confirm that the copy reads as workflow or historical state and does not imply technically verified permanent remediation.
|
||||||
|
9. Confirm that any relevant historical governance warning from the exception trail remains visible as secondary context instead of being erased by the resolved state.
|
||||||
|
10. Open the closed finding detail page.
|
||||||
|
11. Confirm that the copy reads as workflow or historical state and does not imply technically verified permanent remediation.
|
||||||
|
12. Confirm that any relevant historical governance warning from the exception trail remains visible as secondary context instead of being erased by the closed state.
|
||||||
|
|
||||||
|
## Exception Surface Parity Validation
|
||||||
|
|
||||||
|
1. Open the tenant risk-exception register.
|
||||||
|
2. Confirm that valid, expiring, expired, revoked, rejected where operator-visible, and missing-support governance states remain visibly distinguishable.
|
||||||
|
3. Navigate from a tenant finding or tenant exception record into the canonical exception queue.
|
||||||
|
4. Confirm that the canonical queue opens prefiltered to the active tenant.
|
||||||
|
5. Confirm that the operator can only broaden filters within their authorized tenant set and cannot use filter changes to infer out-of-scope tenants.
|
||||||
|
6. Open the same records through the canonical exception queue when entitled.
|
||||||
|
7. Confirm that the queue and the tenant register communicate the same governance truth as the linked finding surfaces.
|
||||||
|
|
||||||
|
## Summary Surface Validation
|
||||||
|
|
||||||
|
1. Open the tenant dashboard.
|
||||||
|
2. Confirm that `NeedsAttention` surfaces overdue findings or unhealthy governance even when the tenant has no `new` findings.
|
||||||
|
3. Open the baseline-compare landing page.
|
||||||
|
4. Confirm that the findings summary and no-findings language do not imply an all-clear when overdue findings or unhealthy governance still exist.
|
||||||
|
5. Confirm that healthy fallback copy appears only when no operator-critical finding or governance attention remains.
|
||||||
|
|
||||||
|
## Authorization Checks
|
||||||
|
|
||||||
|
1. Verify that a readonly tenant member can inspect surfaces they are entitled to see but cannot execute disabled mutation actions.
|
||||||
|
2. Verify that a non-member or wrong-tenant user receives deny-as-not-found behavior for tenant findings, tenant exceptions, and tenant summary surfaces.
|
||||||
|
3. Verify that the canonical exception queue remains accessible only to appropriately entitled workspace approvers.
|
||||||
|
|
||||||
|
## Suggested Focused Test Runs
|
||||||
|
|
||||||
|
1. `vendor/bin/sail artisan test --compact tests/Feature/Findings/FindingsListDefaultsTest.php`
|
||||||
|
2. `vendor/bin/sail artisan test --compact tests/Feature/Findings/FindingsListFiltersTest.php`
|
||||||
|
3. `vendor/bin/sail artisan test --compact tests/Feature/Findings/FindingRiskGovernanceProjectionTest.php`
|
||||||
|
4. `vendor/bin/sail artisan test --compact tests/Feature/Findings/FindingExceptionRegisterTest.php`
|
||||||
|
5. `vendor/bin/sail artisan test --compact tests/Feature/Filament/NeedsAttentionWidgetTest.php`
|
||||||
|
6. `vendor/bin/sail artisan test --compact tests/Feature/Filament/BaselineCompareLandingWhyNoFindingsTest.php`
|
||||||
|
7. `vendor/bin/sail artisan test --compact tests/Unit/Badges/FindingBadgesTest.php`
|
||||||
|
|
||||||
|
## Timed Acceptance Checks
|
||||||
|
|
||||||
|
1. Time SC-166-001 by opening the tenant findings list on a seeded tenant and measuring how long it takes a reviewer to correctly classify healthy accepted risk, lapsed accepted risk, active work, and historical workflow state.
|
||||||
|
2. Time SC-166-004 by opening finding detail and measuring how long it takes the reviewer to identify lifecycle state, governance health, due urgency, owner or assignee, and next action before interacting with lower diagnostic sections.
|
||||||
61
specs/166-finding-governance-health/research.md
Normal file
61
specs/166-finding-governance-health/research.md
Normal file
@ -0,0 +1,61 @@
|
|||||||
|
# Research: Finding Governance Health & Resolution Semantics Surface Hardening
|
||||||
|
|
||||||
|
## Decision 1: Reuse existing governance truth instead of introducing a new semantic layer
|
||||||
|
|
||||||
|
**Decision**: Drive surface hardening from existing `Finding.status`, `FindingException.current_validity_state`, `FindingRiskGovernanceResolver`, and centralized badge domains rather than introducing a new persisted governance-health field or a presenter framework.
|
||||||
|
|
||||||
|
**Rationale**: The codebase already distinguishes valid, expiring, expired, revoked, rejected, and missing-support governance states, and it already exposes warning messages through `FindingRiskGovernanceResolver`. The operator problem is primarily under-communication on major surfaces, not missing underlying truth. Reusing the current truth sources satisfies the constitution's proportionality and no-new-layer constraints.
|
||||||
|
|
||||||
|
**Alternatives considered**:
|
||||||
|
- Add a new stored `governance_health` field on findings: rejected because it would duplicate exception truth and require sync logic.
|
||||||
|
- Introduce a new presenter or explanation framework: rejected because this slice needs clearer surfaces, not another interpretation layer.
|
||||||
|
|
||||||
|
## Decision 2: Keep workflow lifecycle and governance validity as separate visible dimensions
|
||||||
|
|
||||||
|
**Decision**: Findings surfaces should show workflow lifecycle and governance validity as distinct operator-visible dimensions instead of collapsing them into a single synthetic super-status.
|
||||||
|
|
||||||
|
**Rationale**: A finding can be `risk_accepted` while governance is healthy, expiring, expired, revoked, or unsupported. A finding can also be `resolved` without proving permanent remediation. Separating lifecycle from governance validity prevents false calm and matches the constitution rule that distinct status dimensions should stay distinct when they matter.
|
||||||
|
|
||||||
|
**Alternatives considered**:
|
||||||
|
- Replace current states with one combined surface enum such as `accepted-risk-warning`: rejected because it would blur workflow truth and governance truth together.
|
||||||
|
- Leave lifecycle badges unchanged and hide governance only in detail: rejected because the list and summary surfaces are where prioritization happens.
|
||||||
|
|
||||||
|
## Decision 3: Harden finding detail by reordering existing infolist truth into a leading status zone
|
||||||
|
|
||||||
|
**Decision**: Implement the detail-page change as a leading status and governance zone built from existing infolist data, leaving raw identifiers, run links, diffs, and evidence in secondary sections.
|
||||||
|
|
||||||
|
**Rationale**: The current finding detail already contains most of the required truth, including status, severity, due date, owner, assignee, and risk-governance entries. The main change needed is information hierarchy. Reordering existing truth is narrower and safer than introducing a new detail-specific read model.
|
||||||
|
|
||||||
|
**Alternatives considered**:
|
||||||
|
- Build a separate read model or DTO for the view page: rejected because the current infolist already has the required fields.
|
||||||
|
- Leave the current section order and add more warnings lower on the page: rejected because it does not solve the 5 to 10 second operator-read requirement.
|
||||||
|
|
||||||
|
## Decision 4: Propagate governance attention to summary surfaces through existing DB-backed widget queries
|
||||||
|
|
||||||
|
**Decision**: Extend current tenant summary surfaces such as `NeedsAttention` and baseline-compare summary outputs with DB-backed aggregates for overdue findings, lapsed governance, and expiring governance instead of adding background jobs or a new tenant-governance dashboard.
|
||||||
|
|
||||||
|
**Rationale**: The dashboard widget and baseline-compare landing already compute DB-backed summaries without external calls. Adding a small set of operator-critical aggregates keeps these surfaces honest while preserving their glance-first character and avoiding a new subsystem.
|
||||||
|
|
||||||
|
**Alternatives considered**:
|
||||||
|
- Add scheduled reminder jobs or alerting in this slice: rejected because the spec explicitly leaves proactive automation to a follow-up spec.
|
||||||
|
- Create a dedicated governance-health dashboard: rejected because the current scope is surface hardening, not a new reporting domain.
|
||||||
|
|
||||||
|
## Decision 5: Communicate `resolved` and `closed` cautiously, with optional secondary observation context
|
||||||
|
|
||||||
|
**Decision**: Treat `resolved` and `closed` as historical workflow states in primary UI language, and only show `no longer observed` or similar context as secondary explanatory information when it can be derived from current truth.
|
||||||
|
|
||||||
|
**Rationale**: The spec's central risk is that operators may read `resolved` as technical proof. The safest narrow fix is copy and layout: make workflow meaning explicit and reserve any observation-based explanation for clearly secondary context.
|
||||||
|
|
||||||
|
**Alternatives considered**:
|
||||||
|
- Reinterpret `resolved` as `no longer observed` everywhere: rejected because it rewrites current workflow truth.
|
||||||
|
- Add a new persistence field to separate resolution origin immediately: rejected because the spec explicitly defers that to a follow-up if needed.
|
||||||
|
|
||||||
|
## Decision 6: Anchor testing in existing Livewire, Filament, Findings, and Badge suites
|
||||||
|
|
||||||
|
**Decision**: Extend the current Pest suites for findings list, finding detail, exception register, exception queue, dashboard widgets, baseline-compare summary, evidence integration, and badge semantics rather than creating a new presentation-only test framework.
|
||||||
|
|
||||||
|
**Rationale**: The repository already has strong coverage anchors for findings workflow, governance projection, exception lifecycle, needs-attention widgets, and baseline-compare explanation surfaces. Cross-surface consistency can be enforced most efficiently by extending those suites with operator-truth assertions.
|
||||||
|
|
||||||
|
**Alternatives considered**:
|
||||||
|
- Add screenshot-heavy UI snapshot coverage as the main protection: rejected because the business truth is semantic distinction, not pixel fidelity.
|
||||||
|
- Test a new intermediate presenter layer: rejected because the plan intentionally avoids creating one.
|
||||||
254
specs/166-finding-governance-health/spec.md
Normal file
254
specs/166-finding-governance-health/spec.md
Normal file
@ -0,0 +1,254 @@
|
|||||||
|
# Feature Specification: Finding Governance Health & Resolution Semantics Surface Hardening
|
||||||
|
|
||||||
|
**Feature Branch**: `166-finding-governance-health`
|
||||||
|
**Created**: 2026-03-27
|
||||||
|
**Status**: Draft
|
||||||
|
**Input**: User description: "Spec 166 — Finding Governance Health & Resolution Semantics Surface Hardening"
|
||||||
|
|
||||||
|
## Spec Scope Fields *(mandatory)*
|
||||||
|
|
||||||
|
- **Scope**: tenant + canonical-view
|
||||||
|
- **Primary Routes**:
|
||||||
|
- `/admin/t/{tenant}/findings` as the tenant-context findings queue where operators scan active work, accepted risk, and governance health
|
||||||
|
- `/admin/t/{tenant}/findings/{finding}` as the tenant-context finding detail surface where status, governance health, ownership, due context, and next action must become operator-first
|
||||||
|
- `/admin/t/{tenant}/exceptions` as the tenant-context risk-exception register that must stay semantically aligned with finding-level governance truth
|
||||||
|
- `/admin/finding-exceptions/queue` as the canonical workspace approval and review queue for finding exceptions across entitled tenants
|
||||||
|
- `/admin/t/{tenant}` and `/admin/t/{tenant}/baseline-compare-landing` as existing tenant summary surfaces that currently carry finding-related attention and must become more honest about overdue and governance-risk states
|
||||||
|
- **Data Ownership**:
|
||||||
|
- Tenant-owned: findings, finding workflow metadata, ownership, SLA and due state, linked tenant-scoped exception state, and surface-visible governance warnings derived for one tenant's findings
|
||||||
|
- Workspace-owned but tenant-filtered: canonical approval queue visibility, workspace review counts, and summary surfaces that aggregate finding governance state across entitled tenants without changing tenant ownership of the underlying findings or exceptions
|
||||||
|
- Existing audit history, evidence summaries, review-pack outputs, and compare summaries remain downstream consumers of the same findings and governance truth and are not redefined here
|
||||||
|
- **RBAC**:
|
||||||
|
- Workspace membership remains required for all findings and exception surfaces
|
||||||
|
- Tenant entitlement remains required for tenant-context findings, finding detail, tenant exception register, and tenant dashboard surfaces
|
||||||
|
- Existing finding and finding-exception capabilities remain the enforcement source for inspection and mutation actions
|
||||||
|
- The canonical exception queue remains available only to entitled workspace members with exception-approval scope
|
||||||
|
- Non-members or out-of-scope users remain deny-as-not-found, while in-scope members lacking the relevant capability remain forbidden
|
||||||
|
|
||||||
|
For canonical-view specs, the spec MUST define:
|
||||||
|
|
||||||
|
- **Default filter behavior when tenant-context is active**: When an operator reaches the canonical exception queue from a tenant finding or tenant exception register, the canonical queue opens prefiltered to the active tenant. The operator may only broaden filters within their authorized tenant set.
|
||||||
|
- **Explicit entitlement checks preventing cross-tenant leakage**: Canonical queue rows, counts, tenant labels, filter options, related finding labels, governance-warning summaries, and drill-down links must only be assembled after workspace membership and tenant entitlement checks. Unauthorized users must not learn whether another tenant has expired governance, pending approvals, or overdue findings.
|
||||||
|
|
||||||
|
## 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 |
|
||||||
|
|---|---|---|---|---|---|---|---|---|---|
|
||||||
|
| Tenant findings list | Tenant operator | List | What needs action right now, and which accepted risks are no longer safe to ignore? | Finding status family, severity, governance health for accepted risk, overdue or due-soon state, owner or assignee, top-level warning context | Fingerprints, raw evidence provenance, run identifiers, deep diff payloads | workflow lifecycle, governance validity, due urgency, ownership completeness | TenantPilot workflow and governance actions for one tenant | View finding, filter, scan workflow state, request or inspect exception | Existing workflow actions such as resolve, close, reopen, and exception decisions remain confirmation-gated where applicable |
|
||||||
|
| Tenant finding detail | Tenant operator | Detail | What is the current state, why does it matter, and what should happen next? | Leading status and governance zone with lifecycle state, severity or priority, governance health, due or SLA, owner or assignee, and next-action guidance | IDs, fingerprints, run links, diff sections, raw evidence JSON, historical low-level metadata | workflow lifecycle, governance validity, due urgency, ownership completeness, secondary resolution context | TenantPilot workflow and governance actions for one tenant | Open related record, inspect exception, request or renew exception, workflow actions | Existing revoke, renew, reject, close, resolve, and similar destructive-like actions remain confirmation-gated and capability-gated |
|
||||||
|
| Tenant risk-exception register | Tenant governance operator | List/detail | Which exceptions are healthy, expiring, expired, revoked, or otherwise problematic for this tenant? | Exception status, validity family, governance warning, owner, approver, review due, expiry timing, linked finding summary | Decision payload detail, evidence-reference payloads, low-level IDs | exception lifecycle, governance validity, due urgency | TenantPilot exception workflow for one tenant | View exception, renew exception, revoke exception | Revoke remains destructive and requires confirmation |
|
||||||
|
| Canonical finding-exceptions queue | Workspace approver or auditor | Queue/detail | Which tenants have pending or unhealthy governance that requires workspace attention? | Pending approvals, expired or expiring governance, selected exception detail, tenant filter context, related finding access | Full decision history payloads and deep evidence metadata | exception lifecycle, governance validity, tenant review scope | TenantPilot governance review across entitled tenants | Approve exception, reject exception, open tenant register, open finding | Approve and reject remain per-record and confirmation-gated |
|
||||||
|
| Tenant dashboard needs-attention widget | Tenant operator | Summary widget | Is there governance or overdue work that needs immediate attention? | Overdue findings, lapsed governance, expiring governance, severe active findings, baseline posture attention | Diagnostic cause chains and low-level evidence gaps beyond the top message | attention state, governance health, due urgency, active issue severity | Read-only summary with drill-down navigation | Open dashboard destinations and related lists | None |
|
||||||
|
| Baseline compare landing finding summary | Tenant operator | Summary panel | Does the tenant currently have actionable finding risk, including governance debt? | Findings count, honest no-findings language, governance-attention cues, route into findings list | Run internals and raw compare payloads | compare summary state, active findings attention, governance attention | Read-only summary with drill-down navigation | View findings | None |
|
||||||
|
|
||||||
|
## Proportionality Review *(mandatory when structural complexity is introduced)*
|
||||||
|
|
||||||
|
- **New source of truth?**: No. The spec reuses existing findings workflow truth, finding-exception truth, and governance-validity derivation.
|
||||||
|
- **New persisted entity/table/artifact?**: No. This spec explicitly avoids schema additions and new durable artifacts.
|
||||||
|
- **New abstraction?**: No. It should be implemented by tightening existing surface mappings and shared badge/governance presentation, not by introducing a new resolver, presenter stack, or framework.
|
||||||
|
- **New enum/state/reason family?**: No. The work relies on existing workflow and governance families and makes them more visible and less misleading.
|
||||||
|
- **New cross-domain UI framework/taxonomy?**: No. The hardening stays local to findings and exceptions surfaces and must not turn governance semantics into a reusable interpretive framework.
|
||||||
|
- **Current operator problem**: Operators can currently misread accepted-risk findings as safe terminal states when governance has lapsed, and can miss overdue or governance-problematic work on summary surfaces.
|
||||||
|
- **Existing structure is insufficient because**: Current surfaces under-express distinctions that already exist in the underlying truth, especially the difference between healthy accepted risk and lapsed governance, and they can overstate what `resolved` or `closed` means.
|
||||||
|
- **Narrowest correct implementation**: Reorder and harden existing list, detail, queue, and summary surfaces so they expose existing governance and workflow truth directly, using current badge domains and existing data sources rather than adding new persistence or new semantic layers.
|
||||||
|
- **Ownership cost**: The main cost is cross-surface copy, layout, and regression-test maintenance to keep findings, exception, and summary surfaces semantically aligned.
|
||||||
|
- **Alternative intentionally rejected**: Introducing a new governance-health persistence field, new workflow enum, or new presenter/taxonomy framework was rejected because the needed distinctions are already derivable from existing truth and a narrower surface hardening solves the current-release problem.
|
||||||
|
- **Release truth**: Current-release truth. The problem already affects the shipped operator workflow and does not depend on speculative future domains.
|
||||||
|
|
||||||
|
## User Scenarios & Testing *(mandatory)*
|
||||||
|
|
||||||
|
### User Story 1 - Distinguish safe accepted risk from governance drift (Priority: P1)
|
||||||
|
|
||||||
|
As a tenant operator, I want the findings list and finding detail to tell me immediately whether an accepted risk is still backed by valid governance, so that I do not mistake an expired or revoked exception for a safe terminal state.
|
||||||
|
|
||||||
|
**Why this priority**: The highest product risk is false calm. A `risk_accepted` finding without valid governance is governance debt, not closure.
|
||||||
|
|
||||||
|
**Independent Test**: Can be fully tested by comparing findings with valid, expiring, expired, revoked, and missing-support governance states across list and detail surfaces and verifying that each state remains visibly distinct.
|
||||||
|
|
||||||
|
**Acceptance Scenarios**:
|
||||||
|
|
||||||
|
1. **Given** a finding is `risk_accepted` and backed by valid exception governance, **When** an authorized operator opens the list or detail surface, **Then** the UI shows accepted risk with a healthy governance signal rather than a warning state.
|
||||||
|
2. **Given** a finding is `risk_accepted` but its exception governance is expired, revoked, or unsupported, **When** an authorized operator opens the list or detail surface, **Then** the UI shows a warning or problem state that is visibly different from healthy accepted risk.
|
||||||
|
3. **Given** two accepted-risk findings differ only in governance validity, **When** the operator scans the findings list, **Then** the list makes that difference visible without requiring a click into deep detail.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### User Story 2 - Read resolved and closed states without false reassurance (Priority: P1)
|
||||||
|
|
||||||
|
As an operator reviewing historical findings, I want `resolved` and `closed` to read as workflow outcomes rather than automatic proof of technical remediation, so that I do not overstate the current technical truth.
|
||||||
|
|
||||||
|
**Why this priority**: The second major product risk is semantic overclaim. A workflow state must not imply more certainty than the underlying observation supports.
|
||||||
|
|
||||||
|
**Independent Test**: Can be fully tested by rendering resolved and closed findings on list, detail, and summary surfaces and verifying that the UI does not describe them as permanently fixed or technically guaranteed absent.
|
||||||
|
|
||||||
|
**Acceptance Scenarios**:
|
||||||
|
|
||||||
|
1. **Given** a finding is `resolved`, **When** an authorized operator views it, **Then** the UI frames the state as workflow completion or historical closure and avoids language that implies verified permanent remediation.
|
||||||
|
2. **Given** the system can infer that a finding is no longer observed, **When** an authorized operator views a resolved finding, **Then** that information appears as secondary context rather than replacing the workflow meaning of `resolved`.
|
||||||
|
3. **Given** a dashboard or summary surface references resolved findings, **When** the operator scans the summary, **Then** the summary does not present them as equivalent to healthy governance or absence of risk.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### User Story 3 - See governance and overdue problems in summary surfaces (Priority: P2)
|
||||||
|
|
||||||
|
As a tenant operator or workspace approver, I want dashboard and attention surfaces to surface overdue findings and unhealthy governance, so that critical review work is not hidden just because no finding is currently `new`.
|
||||||
|
|
||||||
|
**Why this priority**: Summary surfaces are where operators decide whether a tenant needs attention. If they hide overdue or lapsed governance states, the product underreports operational risk.
|
||||||
|
|
||||||
|
**Independent Test**: Can be fully tested by seeding tenants with overdue findings, expiring governance, and lapsed accepted risk, then verifying those states appear on at least one summary or attention surface without requiring detail-page inspection.
|
||||||
|
|
||||||
|
**Acceptance Scenarios**:
|
||||||
|
|
||||||
|
1. **Given** a tenant has no `new` findings but has overdue active findings, **When** the operator opens the dashboard, **Then** a summary surface still marks the tenant as requiring attention.
|
||||||
|
2. **Given** a tenant has accepted-risk findings with lapsed governance, **When** the operator opens summary and attention surfaces, **Then** those surfaces show governance attention rather than a healthy state.
|
||||||
|
3. **Given** a tenant has expiring governance that remains valid for now, **When** the operator scans attention surfaces, **Then** the UI distinguishes it from both healthy governance and already-lapsed governance.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### User Story 4 - Get operator-first detail before diagnostics (Priority: P2)
|
||||||
|
|
||||||
|
As an operator opening a finding, I want the page to start with status, governance, ownership, due state, and next action before raw identifiers and deep diagnostics, so that I can decide what to do within seconds.
|
||||||
|
|
||||||
|
**Why this priority**: Detail surfaces are currently rich in truth but not yet ordered around operator actionability.
|
||||||
|
|
||||||
|
**Independent Test**: Can be fully tested by opening a finding detail page and verifying that the first visible zone communicates operator state, ownership, and next-step guidance before raw IDs, evidence payloads, or diff internals.
|
||||||
|
|
||||||
|
**Acceptance Scenarios**:
|
||||||
|
|
||||||
|
1. **Given** a finding is open or in progress, **When** the operator opens detail, **Then** ownership, due context, severity, and next-action guidance appear before IDs and raw evidence.
|
||||||
|
2. **Given** a finding is `risk_accepted` with governance attention needed, **When** the operator opens detail, **Then** the warning appears in the leading zone rather than only in a lower governance section.
|
||||||
|
3. **Given** a finding is historical, **When** the operator opens detail, **Then** the detail still shows the lifecycle meaning clearly without hiding governance or recurrence context behind diagnostics.
|
||||||
|
|
||||||
|
### Edge Cases
|
||||||
|
|
||||||
|
- A finding remains in `risk_accepted` status after its governing exception expired, was revoked, or is otherwise unsupported; the UI must not render it as calm or completed.
|
||||||
|
- A finding has no current exception record but its status is `risk_accepted`; the surface must treat this as missing-support governance rather than as a healthy accepted risk.
|
||||||
|
- A tenant has no `new` findings, but has overdue `triaged`, `in_progress`, or `reopened` findings; summary surfaces must still mark the tenant as needing attention.
|
||||||
|
- A finding is `resolved` or `closed` while historical governance warnings still exist in the exception trail; the detail surface must not erase that context.
|
||||||
|
- A workspace approver sees a tenant-filtered canonical queue; queue counts and filters must not reveal other tenants' governance states.
|
||||||
|
- Existing workflow, assign, due, renew, revoke, approve, and reject actions must keep their current capability and confirmation behavior while the surfaces around them become semantically clearer.
|
||||||
|
|
||||||
|
## Requirements *(mandatory)*
|
||||||
|
|
||||||
|
**Constitution alignment (required):** This feature introduces no new Microsoft Graph calls, no new long-running jobs, and no required schema changes. It hardens operator-facing findings and exception surfaces by propagating existing workflow and governance truth more clearly. Existing findings, exceptions, and governance resolver outputs remain the source of truth. The feature must describe how unhealthy governance, overdue work, and historical workflow states become visible across list, detail, queue, and summary surfaces without inventing a competing state model.
|
||||||
|
|
||||||
|
**Constitution alignment (PROP-001 / ABSTR-001 / PERSIST-001 / STATE-001 / BLOAT-001):** This feature is intentionally narrow. It introduces no new persistence, no new abstraction layer, no new state family, and no new cross-domain taxonomy. The implementation must make existing truth easier to read on operator surfaces and must not solve the problem by adding a new stored governance field, a presenter stack, or a secondary semantic framework.
|
||||||
|
|
||||||
|
**Constitution alignment (OPS-UX):** No new `OperationRun` type is introduced and the Ops-UX 3-surface feedback contract is unchanged. This feature is about existing list, detail, queue, and dashboard semantics rather than new background operations.
|
||||||
|
|
||||||
|
**Constitution alignment (RBAC-UX):** The feature affects the tenant/admin plane for findings, finding detail, tenant exception register, tenant dashboard, and baseline-compare summary, plus the workspace-admin plane for the canonical finding-exceptions queue. Cross-plane access remains deny-as-not-found. Non-members and out-of-scope users receive `404`. In-scope members lacking the relevant capability continue to receive `403`. Server-side authorization remains mandatory for workflow actions and exception decisions surfaced from these pages. Existing destructive-like actions such as approve, reject, revoke, resolve, close, and similar mutations remain confirmation-gated.
|
||||||
|
|
||||||
|
**Constitution alignment (OPS-EX-AUTH-001):** Not applicable. No authentication handshake behavior is changed.
|
||||||
|
|
||||||
|
**Constitution alignment (BADGE-001):** Finding status, finding severity, finding-exception status, and finding risk-governance validity remain centralized badge domains. This feature may change where and how often these badge families appear, but must not introduce page-local mappings or duplicate status vocabularies. Tests must cover the newly emphasized healthy, expiring, expired, revoked, rejected, and missing-support governance states where they become operator-visible.
|
||||||
|
|
||||||
|
**Constitution alignment (UI-FIL-001):** The feature reuses native Filament sections, infolist entries, badges, notifications, widgets, and existing shared badge primitives for status and governance communication. Local replacement markup for core status language should be avoided. Any special emphasis for governance warnings must still derive from shared badge domains or existing warning primitives rather than page-local color language.
|
||||||
|
|
||||||
|
**Constitution alignment (UI-NAMING-001):** The target objects are findings and finding exceptions. Primary operator-facing terms remain `Findings`, `Risk exceptions`, `Approve exception`, `Reject exception`, `Renew exception`, `Revoke exception`, `Resolve`, `Close`, and `Reopen`. New or revised summary copy must preserve the distinction between `accepted risk`, `governance warning`, `resolved`, `closed`, and `no longer observed` context, and must avoid implementation-first phrases such as `resolver state`, `status enum`, or `unsupported row`.
|
||||||
|
|
||||||
|
**Constitution alignment (OPSURF-001):** This feature materially refactors operator-facing findings, exception, and summary surfaces. Default-visible content must remain operator-first: lifecycle state, governance health, due urgency, ownership, and next action appear before IDs, fingerprints, run references, or raw evidence detail. Diagnostics remain secondary. Status dimensions must stay distinct: workflow lifecycle is not governance validity, and governance validity is not the same as compare posture or technical absence. Existing mutations keep their current scope semantics and confirmation patterns.
|
||||||
|
|
||||||
|
**Constitution alignment (UI-SEM-001 / LAYER-001 / TEST-TRUTH-001):** This feature must not introduce an explanation-builder layer, a new governance presenter framework, or duplicate truth across findings, exceptions, summaries, and helper objects. Surfaces should map existing domain truth directly into operator-visible cues. Tests must focus on the business consequence that operators can distinguish healthy accepted risk, lapsed governance, overdue work, and historical workflow states rather than snapshotting thin presentation indirection for its own sake.
|
||||||
|
|
||||||
|
**Constitution alignment (Filament Action Surfaces):** This feature modifies the tenant Findings Resource, tenant Finding Exception Resource, workspace Finding Exceptions Queue, and dashboard-related Filament surfaces. The Action Surface Contract remains satisfied because primary inspection and mutation affordances stay explicit, confirmation-gated where required, and capability-aware. Dashboard and summary widgets remain read-only surfaces with drill-down navigation, not mutation surfaces.
|
||||||
|
|
||||||
|
**Constitution alignment (UX-001 — Layout & Information Architecture):** Findings and exception detail surfaces must remain sectioned, operator-first inspection pages. The finding detail page specifically must promote a leading status and governance zone before diagnostics. Existing tables must remain searchable, sortable, and filterable for core workflow and governance dimensions. Summary surfaces should add attention truth without becoming full diagnostic dashboards.
|
||||||
|
|
||||||
|
### Functional Requirements
|
||||||
|
|
||||||
|
- **FR-166-001**: A finding in `risk_accepted` status MUST never be shown on relevant operator surfaces without an accompanying governance-validity signal.
|
||||||
|
- **FR-166-002**: Governance-validity presentation for accepted risk MUST distinguish at minimum healthy valid governance, expiring governance, expired governance, revoked governance, rejected governance where relevant, and missing or otherwise unsupported governance.
|
||||||
|
- **FR-166-003**: A `risk_accepted` finding with lapsed, revoked, rejected, or missing-support governance MUST be rendered as an attention or warning condition rather than as a calm terminal state.
|
||||||
|
- **FR-166-004**: The tenant findings list MUST make these operator-relevant state families visibly distinguishable: active issue, accepted risk with healthy governance, accepted risk with governance attention needed, and historical resolved or closed workflow state.
|
||||||
|
- **FR-166-005**: The tenant findings list MUST remain scan-friendly and filterable while exposing the extra governance-health distinction.
|
||||||
|
- **FR-166-006**: The finding detail page MUST include a leading status and governance zone that makes visible the current finding lifecycle state, severity or priority, governance health when relevant, owner or assignee, due or SLA urgency, and primary next-action guidance.
|
||||||
|
- **FR-166-007**: The leading finding-detail zone MUST appear before IDs, fingerprints, run references, diff payloads, raw evidence JSON, and other diagnostic-first material.
|
||||||
|
- **FR-166-008**: If the governance resolver or linked exception truth indicates warning, unguided, lapsed, or fresh-decision-needed status, the finding detail surface MUST surface that warning above purely diagnostic sections.
|
||||||
|
- **FR-166-009**: `resolved` and `closed` findings MUST be communicated as workflow or historical states and MUST NOT be presented as implicit proof of permanent remediation, compliance clearance, or currently confirmed technical absence.
|
||||||
|
- **FR-166-010**: If existing data can support a secondary `no longer observed` or equivalent context, the UI SHOULD show it as secondary explanatory context without changing the primary meaning of the workflow status.
|
||||||
|
- **FR-166-011**: Ownership, assignee, due date, and overdue state MUST be visually promoted on relevant findings surfaces when a finding is active or governance-problematic.
|
||||||
|
- **FR-166-012**: Overdue findings MUST be visibly prioritized on at least the findings list and at least one summary or attention surface.
|
||||||
|
- **FR-166-013**: At least one tenant summary or attention surface MUST reflect overdue findings, accepted-risk findings with lapsed governance, and expiring governance rather than only counting `new` findings.
|
||||||
|
- **FR-166-014**: Summary surfaces MUST NOT imply that a tenant is healthy merely because it lacks `new` findings when overdue or governance-problematic findings still exist.
|
||||||
|
- **FR-166-015**: The tenant dashboard needs-attention surface MUST treat lapsed governance, expiring governance, and overdue findings as attention-worthy conditions when present.
|
||||||
|
- **FR-166-016**: The baseline-compare landing or equivalent tenant summary surface MUST use honest copy when findings attention remains due to overdue or unhealthy governance states.
|
||||||
|
- **FR-166-017**: The tenant exception register, canonical exception queue, findings list, finding detail, and summary surfaces MUST not contradict one another about the same underlying governance truth.
|
||||||
|
- **FR-166-018**: The canonical exception queue and tenant exception register MUST continue to expose exception lifecycle and governance validity in ways that support the same healthy vs attention-needed distinction used on findings surfaces.
|
||||||
|
- **FR-166-019**: Surface-level hardening MUST rely on existing findings workflow truth, finding-exception truth, and governance-resolver outputs rather than requiring a new workflow enum, new persistence field, or competing resolver layer.
|
||||||
|
- **FR-166-020**: If a required semantic distinction cannot be stably derived from existing truth, the implementation MUST document the gap as follow-up foundation work rather than silently flattening the state.
|
||||||
|
- **FR-166-021**: Existing finding and exception mutation actions, capability gates, and confirmation behavior MUST continue to function without regression.
|
||||||
|
- **FR-166-022**: Existing centralized badge domains and filter catalogs MUST remain the semantic source for status and governance vocabulary so surfaces do not drift into contradictory local language.
|
||||||
|
- **FR-166-023**: Cross-surface tests MUST verify that healthy accepted risk, expiring governance, lapsed governance, active findings, overdue findings, and historical resolved findings remain distinguishable where intended.
|
||||||
|
|
||||||
|
## 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 |
|
||||||
|
|---|---|---|---|---|---|---|---|---|---|---|
|
||||||
|
| Findings list | `app/Filament/Resources/FindingResource.php` and `app/Filament/Resources/FindingResource/Pages/ListFindings.php` | Existing tenant findings actions such as `Backfill findings lifecycle` and `Triage all matching`; no new header mutation required for this spec | Clickable row into finding detail | `View` plus existing workflow entry points | Existing grouped workflow bulk actions remain; no new bulk governance action is introduced | Existing no-create empty state remains because findings are generated, not manually created | N/A | N/A | Existing workflow mutations remain audited where already defined | Spec 166 changes surface semantics and prioritization, not the action inventory |
|
||||||
|
| Finding detail | `app/Filament/Resources/FindingResource/Pages/ViewFinding.php` | `Open related record` and existing `Actions` workflow group | N/A | N/A | None | N/A | Existing workflow actions and related-record navigation remain | N/A | Existing workflow and exception actions remain audited where already defined | The main change is reordering and emphasis of status, governance, due, and ownership context |
|
||||||
|
| Tenant risk-exception register | `app/Filament/Resources/FindingExceptionResource.php` | Existing header navigation back to findings origins | Clickable row into exception detail | Direct record actions such as renew or revoke remain state-aware | Bulk actions remain intentionally omitted in v1 | Existing empty state explains that requests start from finding detail | Existing detail actions remain state-aware | N/A | Yes for existing exception decisions | No new bulk governance concept is introduced |
|
||||||
|
| Canonical finding-exceptions queue | `app/Filament/Pages/Monitoring/FindingExceptionsQueue.php` | `Clear filters`, `View tenant register`, selected-record actions such as `Approve exception`, `Reject exception`, `Open tenant detail`, `Open finding` | View action and selected-record detail | `Approve exception`, `Reject exception` via selected-record workflow | None | Existing queue empty state remains | Selected detail actions remain in header | N/A | Yes for approve and reject decisions | Spec 166 aligns queue semantics with findings surfaces; it does not expand mutation scope |
|
||||||
|
| Tenant dashboard attention widgets | `app/Filament/Widgets/Dashboard/NeedsAttention.php` and related tenant dashboard composition | None | Drill-down via existing linked destinations where available | None | None | Healthy state remains read-only | N/A | N/A | No new writes | Read-only operator summary surface; included because semantics materially change |
|
||||||
|
|
||||||
|
### Key Entities *(include if feature involves data)*
|
||||||
|
|
||||||
|
- **Finding**: A tenant-scoped governance or drift issue with workflow lifecycle, severity, ownership, due state, and history.
|
||||||
|
- **Finding exception**: A tenant-scoped risk-governance record that can make accepted risk currently valid, expiring, expired, revoked, rejected, or unsupported.
|
||||||
|
- **Governance validity**: The surface-visible truth of whether accepted risk is currently backed by healthy governance, nearing review, or already problematic.
|
||||||
|
- **Finding surface state family**: The normalized operator-facing distinction between active work, accepted risk with healthy governance, accepted risk needing attention, historical workflow closure, and overdue or ownership attention.
|
||||||
|
- **Summary attention state**: The dashboard or summary-level signal that tells an operator whether a tenant still needs action because of overdue work, unhealthy governance, or severe active findings.
|
||||||
|
|
||||||
|
## Success Criteria *(mandatory)*
|
||||||
|
|
||||||
|
### Measurable Outcomes
|
||||||
|
|
||||||
|
- **SC-166-001**: In usability review on a seeded tenant, an operator can distinguish within 10 seconds between active issue, accepted risk with healthy governance, accepted risk with governance attention needed, and historical resolved or closed state on the findings list.
|
||||||
|
- **SC-166-002**: In regression coverage, healthy accepted risk and lapsed or unsupported accepted risk render as different operator-visible states on both finding list and finding detail surfaces in 100% of tested cases.
|
||||||
|
- **SC-166-003**: In regression coverage, at least one tenant summary or attention surface surfaces overdue findings and at least one unhealthy-governance condition even when the tenant has zero `new` findings.
|
||||||
|
- **SC-166-004**: In acceptance review, a finding detail page allows an operator to identify status, governance health, due urgency, owner or assignee, and next action before reading diagnostics in under 15 seconds.
|
||||||
|
- **SC-166-005**: In regression coverage, `resolved` findings are not labeled or described as technically fixed, permanently absent, or compliance-cleared on the hardened surfaces.
|
||||||
|
- **SC-166-006**: Cross-surface tests show no contradiction between finding list, finding detail, tenant exception surfaces, and canonical exception queue for the same governance-validity condition.
|
||||||
|
- **SC-166-007**: The feature ships without requiring a new schema field, new workflow enum, or mandatory persistence migration.
|
||||||
|
|
||||||
|
## Assumptions
|
||||||
|
|
||||||
|
- Existing findings workflow semantics from the findings workflow specs remain the source of truth for lifecycle status names and allowed transitions.
|
||||||
|
- Existing finding-exception lifecycle and governance-validity semantics from the risk-acceptance specs remain the source of truth for valid, expiring, expired, revoked, rejected, and missing-support governance states.
|
||||||
|
- Existing dashboard and baseline-compare summary infrastructure can carry additional attention truth without becoming a new standalone governance dashboard.
|
||||||
|
- Any deeper distinction between workflow resolution and strong technical absence may require a later foundation spec if the current data model cannot reliably support it on every surface.
|
||||||
|
|
||||||
|
**Implementation note (2026-03-27)**: This implementation did not uncover any new semantic distinction that required a new persisted truth source or a new foundation abstraction. Existing `Finding`, `FindingException`, and baseline summary truth were sufficient for this slice, so the previously identified resolution-origin follow-up candidate remains unchanged.
|
||||||
|
|
||||||
|
## Non-Goals
|
||||||
|
|
||||||
|
- Introducing a new database table, new required schema field, or new workflow enum for findings or exceptions
|
||||||
|
- Replacing the findings workflow model with a new resolution-origin model in this slice
|
||||||
|
- Redesigning exception scope beyond finding-level risk governance
|
||||||
|
- Introducing proactive email, notification, or alert automation as a new subsystem
|
||||||
|
- Building a full tenant-overview governance program beyond the targeted findings and exception surfaces
|
||||||
|
- Rewriting recurrence or reopen engine internals beyond what existing surface truth already exposes
|
||||||
|
|
||||||
|
## Dependencies
|
||||||
|
|
||||||
|
- Existing Findings Resource, Finding detail, and findings workflow surfaces
|
||||||
|
- Existing Finding Exception Resource and canonical Finding Exceptions Queue
|
||||||
|
- Existing finding risk governance resolver and centralized badge domains
|
||||||
|
- Existing tenant dashboard attention infrastructure and baseline-compare summary surfaces
|
||||||
|
- Existing RBAC and tenant-isolation enforcement for tenant-context and canonical workspace views
|
||||||
|
|
||||||
|
## Follow-up Spec Candidates
|
||||||
|
|
||||||
|
- **Spec 167 — Finding Resolution Origin & Workflow Truth Foundation** if the current data model cannot stably distinguish workflow resolution from no-longer-observed truth on all required surfaces
|
||||||
|
- **Spec 168 — Exception Expiry Alerts & Governance Notifications** for proactive alerting around expiring or expired governance
|
||||||
|
- **Spec 169 — Finding Detail Workflow History & Reopen Context** for deeper recurrence, prior-resolution, and historical workflow storytelling
|
||||||
|
|
||||||
|
## Definition of Done
|
||||||
|
|
||||||
|
Spec 166 is complete when:
|
||||||
|
|
||||||
|
- accepted-risk findings without valid governance no longer render as calm terminal states,
|
||||||
|
- finding list and finding detail visibly carry governance health,
|
||||||
|
- at least one summary or attention surface surfaces overdue findings and unhealthy governance even without `new` findings,
|
||||||
|
- `resolved` and `closed` are communicated as workflow or historical states rather than implicit technical proof,
|
||||||
|
- list, detail, exception, and summary surfaces do not contradict one another about governance validity,
|
||||||
|
- and the hardening is achievable with existing truth sources and without a mandatory model or schema refactor.
|
||||||
258
specs/166-finding-governance-health/tasks.md
Normal file
258
specs/166-finding-governance-health/tasks.md
Normal file
@ -0,0 +1,258 @@
|
|||||||
|
# Tasks: Finding Governance Health & Resolution Semantics Surface Hardening
|
||||||
|
|
||||||
|
**Input**: Design documents from `/specs/166-finding-governance-health/`
|
||||||
|
**Prerequisites**: plan.md, spec.md, research.md, data-model.md, contracts/, quickstart.md
|
||||||
|
|
||||||
|
**Tests**: Tests are REQUIRED for this feature because it changes runtime operator behavior across Filament resources, widgets, authorization-sensitive surfaces, and shared badge semantics.
|
||||||
|
**Operations**: This feature introduces no new `OperationRun`; tasks focus on DB-backed surface hardening, existing confirmation behavior, and semantic consistency across findings, exceptions, and summary surfaces.
|
||||||
|
**RBAC**: This feature changes tenant and canonical read surfaces, so tasks must preserve capability-registry enforcement, `404` vs `403` semantics, tenant-safe filtering, and cross-tenant leakage guards.
|
||||||
|
**Filament UI**: This feature modifies Filament resources, pages, and widgets, so tasks include Action Surface Contract, BADGE-001, OPSURF-001, and UX-001 regression coverage.
|
||||||
|
|
||||||
|
## Phase 1: Setup (Shared Infrastructure)
|
||||||
|
|
||||||
|
**Purpose**: Prepare shared seeded scenarios and reusable verification entry points for all stories.
|
||||||
|
|
||||||
|
- [ ] T001 Extend shared seeded findings scenarios for overdue, historical, healthy accepted-risk, and lapsed-governance cases, including expiring, expired, revoked, rejected where operator-visible, and missing-support governance states, in `database/factories/FindingFactory.php` and `tests/Feature/Findings/FindingRiskGovernanceProjectionTest.php`
|
||||||
|
- [ ] T002 [P] Refresh shared dashboard and baseline-compare verification fixtures for governance-attention scenarios in `tests/Feature/Filament/NeedsAttentionWidgetTest.php`, `tests/Feature/Filament/BaselineCompareNowWidgetTest.php`, and `tests/Feature/Filament/BaselineCompareLandingWhyNoFindingsTest.php`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 2: Foundational (Blocking Prerequisites)
|
||||||
|
|
||||||
|
**Purpose**: Establish the shared semantic primitives that every story depends on before surface-specific work begins.
|
||||||
|
|
||||||
|
**⚠️ CRITICAL**: No user story work should begin until this phase is complete.
|
||||||
|
|
||||||
|
- [X] T003 Normalize derived governance-attention, warning-message, and resolution-context behavior in `app/Services/Findings/FindingRiskGovernanceResolver.php`
|
||||||
|
- [X] T004 [P] Centralize the hardened governance and lifecycle vocabulary in `app/Support/Badges/BadgeCatalog.php`, `app/Support/Badges/BadgeRenderer.php`, `app/Support/Badges/Domains/FindingRiskGovernanceValidityBadge.php`, and `app/Support/Badges/Domains/FindingStatusBadge.php`
|
||||||
|
- [X] T005 [P] Extend shared lifecycle and governance filter definitions for findings and exception surfaces in `app/Support/Filament/FilterOptionCatalog.php`, `app/Filament/Resources/FindingResource.php`, and `app/Filament/Resources/FindingExceptionResource.php`
|
||||||
|
- [ ] T006 Add foundational semantic regression coverage for resolver, badges, and filter truth in `tests/Feature/Findings/FindingRiskGovernanceProjectionTest.php`, `tests/Unit/Badges/FindingBadgesTest.php`, and `tests/Unit/Badges/BadgeCatalogTest.php`
|
||||||
|
|
||||||
|
**Checkpoint**: Shared governance semantics are ready. User story work can now proceed.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 3: User Story 1 - Distinguish Safe Accepted Risk From Governance Drift (Priority: P1) 🎯 MVP
|
||||||
|
|
||||||
|
**Goal**: Make tenant findings and exception surfaces visibly distinguish healthy accepted risk from accepted risk that has governance attention or drift.
|
||||||
|
|
||||||
|
**Independent Test**: Seed healthy, expiring, expired, revoked, rejected where operator-visible, and missing-support accepted-risk findings and verify that findings list, finding detail, tenant exception register, and canonical queue distinguish the relevant states without contradictory semantics.
|
||||||
|
|
||||||
|
### Tests for User Story 1
|
||||||
|
|
||||||
|
- [X] T007 [P] [US1] Add findings-list coverage for healthy versus expiring, expired, revoked, rejected where operator-visible, or missing-support accepted risk, overdue prioritization, owner or assignee promotion, and governance-aware filters in `tests/Feature/Findings/FindingsListDefaultsTest.php` and `tests/Feature/Findings/FindingsListFiltersTest.php`
|
||||||
|
- [X] T008 [P] [US1] Add tenant-register and canonical-queue parity coverage for governance-validity visibility across expiring, expired, revoked, rejected where operator-visible, and missing-support states, plus tenant-prefilter handoff and authorized tenant-filter broadening in `tests/Feature/Findings/FindingExceptionRegisterTest.php`, `tests/Feature/Monitoring/FindingExceptionsQueueTest.php`, and `tests/Feature/Findings/FindingRelatedNavigationTest.php`
|
||||||
|
- [ ] T009 [P] [US1] Add positive and negative authorization coverage for tenant findings and canonical exception governance states, including `404` versus `403` outcomes and no-regression capability gating for existing mutation actions in `tests/Feature/Findings/FindingRbacTest.php` and `tests/Feature/Findings/FindingExceptionAuthorizationTest.php`
|
||||||
|
|
||||||
|
### Implementation for User Story 1
|
||||||
|
|
||||||
|
- [X] T010 [US1] Harden tenant findings list lifecycle badges, governance cues, overdue emphasis, owner or assignee promotion, and related filters in `app/Filament/Resources/FindingResource.php` and `app/Filament/Resources/FindingResource/Pages/ListFindings.php`
|
||||||
|
- [X] T011 [US1] Align finding detail governance warnings and healthy accepted-risk messaging with shared resolver truth in `app/Filament/Resources/FindingResource.php` and `app/Filament/Resources/FindingResource/Pages/ViewFinding.php`
|
||||||
|
- [X] T012 [US1] Align tenant exception register and canonical exception queue governance semantics, tenant-prefilter entry behavior, and authorized tenant-filter broadening with finding surfaces in `app/Filament/Resources/FindingExceptionResource.php`, `app/Filament/Resources/FindingExceptionResource/Pages/ListFindingExceptions.php`, `app/Filament/Resources/FindingExceptionResource/Pages/ViewFindingException.php`, `app/Filament/Pages/Monitoring/FindingExceptionsQueue.php`, and `app/Support/Navigation/RelatedNavigationResolver.php`
|
||||||
|
|
||||||
|
**Checkpoint**: User Story 1 is independently functional and closes the primary false-calm risk.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 4: User Story 2 - Read Resolved And Closed States Without False Reassurance (Priority: P1)
|
||||||
|
|
||||||
|
**Goal**: Present `resolved` and `closed` as historical workflow states rather than implicit proof of permanent remediation.
|
||||||
|
|
||||||
|
**Independent Test**: Render resolved and closed findings on list, detail, and baseline-compare summary surfaces and verify that copy stays historically accurate, with any `no longer observed` context remaining secondary.
|
||||||
|
|
||||||
|
### Tests for User Story 2
|
||||||
|
|
||||||
|
- [ ] T013 [P] [US2] Add list and detail coverage that `resolved` and `closed` remain historical workflow states instead of technical proof while preserving relevant historical governance warnings as secondary context in `tests/Feature/Findings/FindingWorkflowViewActionsTest.php` and `tests/Feature/Filament/FindingViewRbacEvidenceTest.php`
|
||||||
|
- [ ] T014 [P] [US2] Add baseline-compare and summary-copy coverage for historical findings without false all-clear language in `tests/Feature/Filament/BaselineCompareLandingWhyNoFindingsTest.php` and `tests/Feature/Filament/BaselineCompareSummaryConsistencyTest.php`
|
||||||
|
|
||||||
|
### Implementation for User Story 2
|
||||||
|
|
||||||
|
- [X] T015 [US2] Revise resolved and closed list/detail copy plus secondary `no longer observed` context while preserving relevant historical governance warnings from the exception trail in `app/Filament/Resources/FindingResource.php` and `app/Filament/Resources/FindingResource/Pages/ViewFinding.php`
|
||||||
|
- [X] T016 [US2] Carry cautious historical semantics into baseline-compare assessment and explanation logic in `app/Support/Baselines/BaselineCompareSummaryAssessor.php`, `app/Support/Baselines/BaselineCompareSummaryAssessment.php`, and `app/Support/Baselines/BaselineCompareExplanationRegistry.php`
|
||||||
|
- [X] T017 [US2] Update baseline-compare landing presentation so historical findings never read as healthy governance or technical clearance in `app/Filament/Pages/BaselineCompareLanding.php` and `resources/views/filament/pages/baseline-compare-landing.blade.php`
|
||||||
|
|
||||||
|
**Checkpoint**: User Story 2 is independently functional and removes semantic overclaim from historical findings.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 5: User Story 3 - See Governance And Overdue Problems In Summary Surfaces (Priority: P2)
|
||||||
|
|
||||||
|
**Goal**: Make tenant dashboard and baseline-compare summary surfaces surface overdue findings and unhealthy governance even when no findings are `new`.
|
||||||
|
|
||||||
|
**Independent Test**: Seed a tenant with overdue open findings, expiring governance, and lapsed governance, then verify that dashboard and baseline-compare surfaces still show attention-required states and route operators into the right lists.
|
||||||
|
|
||||||
|
### Tests for User Story 3
|
||||||
|
|
||||||
|
- [ ] T018 [P] [US3] Add tenant-dashboard coverage for overdue findings plus expiring and lapsed governance attention in `tests/Feature/Filament/NeedsAttentionWidgetTest.php` and `tests/Feature/Filament/TenantDashboardDbOnlyTest.php`
|
||||||
|
- [ ] T019 [P] [US3] Add baseline-compare widget and landing parity coverage for overdue and governance-attention summaries in `tests/Feature/Filament/BaselineCompareNowWidgetTest.php`, `tests/Feature/Baselines/BaselineCompareFindingsTest.php`, and `tests/Feature/Filament/BaselineCompareLandingAdminTenantParityTest.php`
|
||||||
|
|
||||||
|
### Implementation for User Story 3
|
||||||
|
|
||||||
|
- [X] T020 [US3] Extend tenant attention aggregates for overdue findings and unhealthy governance in `app/Filament/Widgets/Dashboard/NeedsAttention.php`, `resources/views/filament/widgets/dashboard/needs-attention.blade.php`, and `app/Filament/Pages/TenantDashboard.php`
|
||||||
|
- [X] T021 [US3] Extend baseline-compare stats and assessment logic for overdue and governance-attention conditions in `app/Support/Baselines/BaselineCompareStats.php`, `app/Support/Baselines/BaselineCompareSummaryAssessor.php`, and `app/Support/Baselines/BaselineCompareReasonCode.php`
|
||||||
|
- [X] T022 [US3] Surface the new attention states in baseline-compare widgets and landing UI in `app/Filament/Widgets/Dashboard/BaselineCompareNow.php`, `resources/views/filament/widgets/dashboard/baseline-compare-now.blade.php`, `app/Filament/Widgets/Tenant/BaselineCompareCoverageBanner.php`, `resources/views/filament/widgets/tenant/baseline-compare-coverage-banner.blade.php`, `app/Filament/Pages/BaselineCompareLanding.php`, and `resources/views/filament/pages/baseline-compare-landing.blade.php`
|
||||||
|
|
||||||
|
**Checkpoint**: User Story 3 is independently functional and summary surfaces no longer underreport overdue or governance-risk work.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 6: User Story 4 - Get Operator-First Detail Before Diagnostics (Priority: P2)
|
||||||
|
|
||||||
|
**Goal**: Reorder finding detail so status, governance, ownership, due context, and next action appear before IDs, evidence payloads, and lower-level diagnostics.
|
||||||
|
|
||||||
|
**Independent Test**: Open an active finding, a healthy accepted-risk finding, and a lapsed-governance accepted-risk finding and verify that the first visible zone communicates lifecycle, governance, due urgency, ownership, and next-action guidance before diagnostic sections.
|
||||||
|
|
||||||
|
### Tests for User Story 4
|
||||||
|
|
||||||
|
- [ ] T023 [P] [US4] Add finding-detail information-order and leading-zone coverage in `tests/Feature/Filament/FindingViewRbacEvidenceTest.php` and `tests/Feature/Findings/FindingRelatedNavigationTest.php`
|
||||||
|
- [ ] T024 [P] [US4] Add action-surface and table-contract regression coverage for the hardened finding and exception screens, including no-regression confirmation requirements for existing destructive actions, in `tests/Feature/Guards/ActionSurfaceContractTest.php` and `tests/Feature/Guards/FilamentTableRiskExceptionsGuardTest.php`
|
||||||
|
|
||||||
|
### Implementation for User Story 4
|
||||||
|
|
||||||
|
- [X] T025 [US4] Reorder finding detail into an operator-first status and governance zone ahead of diagnostics in `app/Filament/Resources/FindingResource.php` and `app/Filament/Resources/FindingResource/Pages/ViewFinding.php`
|
||||||
|
- [X] T026 [US4] Tighten next-action guidance and related-record navigation from the leading finding-detail zone in `app/Filament/Resources/FindingResource/Pages/ViewFinding.php`, `app/Filament/Pages/Monitoring/FindingExceptionsQueue.php`, and `app/Support/Filament/FilterOptionCatalog.php`
|
||||||
|
|
||||||
|
**Checkpoint**: User Story 4 is independently functional and the detail page becomes operator-first instead of diagnostics-first.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 6b: Queue Surface Hardening — Exception Register Enterprise UX (Priority: P2)
|
||||||
|
|
||||||
|
**Goal**: Make the tenant risk-exception register (Finding Exceptions list) enterprise-grade by adding severity visibility, actionable finding titles, relative expiry context, KPI stats, and segmented quick-tabs.
|
||||||
|
|
||||||
|
**Independent Test**: Open the tenant exception register and verify that severity badges, descriptive finding titles, relative time descriptions, stats overview, and quick-tab segmentation are visible and functional.
|
||||||
|
|
||||||
|
### Implementation for Queue Surface Hardening
|
||||||
|
|
||||||
|
- [X] T032 [US1+US3] Add finding severity badge column to exception register table using centralized `FindingSeverity` badge domain in `app/Filament/Resources/FindingExceptionResource.php`
|
||||||
|
- [X] T033 [US1] Improve finding summary in exception register to show subject display name plus finding type instead of bare `Finding #ID` in `app/Filament/Resources/FindingExceptionResource.php`
|
||||||
|
- [X] T034 [US3] Add relative time descriptions (e.g. "In 3 days", "2 days ago") to review_due_at and expires_at columns in `app/Filament/Resources/FindingExceptionResource.php`
|
||||||
|
- [X] T035 [US3] Create `FindingExceptionStatsOverview` widget showing Active/Expiring/Expired/Pending counts above the exception register table in `app/Filament/Widgets/Tenant/FindingExceptionStatsOverview.php` and `app/Filament/Resources/FindingExceptionResource/Pages/ListFindingExceptions.php`
|
||||||
|
- [X] T036 [US1+US3] Add segmented quick-tabs (All / Needs action / Active / Historical) to exception register list page with badge count on Needs action tab in `app/Filament/Resources/FindingExceptionResource/Pages/ListFindingExceptions.php`
|
||||||
|
- [X] T037 Add finding severity filter option catalog entry in `app/Support/Filament/FilterOptionCatalog.php`
|
||||||
|
- [X] T038 [P] Add exception register enterprise-UX coverage for severity column, finding title, relative time, stats widget, and tab segmentation in `tests/Feature/Findings/FindingExceptionRegisterTest.php`
|
||||||
|
|
||||||
|
**Checkpoint**: Exception register is enterprise-grade with severity, titles, relative timing, stats, and segmentation.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 7: Polish & Cross-Cutting Concerns
|
||||||
|
|
||||||
|
**Purpose**: Lock in cross-surface consistency, tenant isolation, and final verification.
|
||||||
|
|
||||||
|
- [ ] T027 [P] Add cross-surface consistency coverage for the same governance state across findings list, finding detail, exception register, canonical queue, tenant dashboard attention, and baseline-compare summary surfaces in `tests/Feature/Findings/FindingRiskGovernanceProjectionTest.php`, `tests/Feature/Findings/FindingExceptionRegisterTest.php`, `tests/Feature/Monitoring/FindingExceptionsQueueTest.php`, `tests/Feature/Filament/NeedsAttentionWidgetTest.php`, and `tests/Feature/Filament/BaselineCompareSummaryConsistencyTest.php`
|
||||||
|
- [X] T028 [P] Refresh tenant-owned query and cross-tenant leakage guards for hardened findings, summaries, and exception surfaces in `tests/Feature/Guards/TenantOwnedQueryGuardTest.php`, `tests/Feature/Findings/FindingAdminTenantParityTest.php`, and `tests/Feature/Filament/TenantDashboardTenantScopeTest.php`
|
||||||
|
- [X] T029 [P] Refresh no-ad-hoc status and warning badge guards for the new governance-attention states in `tests/Feature/Guards/NoAdHocStatusBadgesTest.php` and `tests/Feature/Guards/NoDiagnosticWarningBadgesTest.php`
|
||||||
|
- [X] T030 Validate the Spec 166 quickstart scenarios and focused Pest pack in `specs/166-finding-governance-health/quickstart.md`, `tests/Feature/Findings/FindingsListDefaultsTest.php`, `tests/Feature/Findings/FindingExceptionRegisterTest.php`, `tests/Feature/Filament/NeedsAttentionWidgetTest.php`, and `tests/Feature/Filament/BaselineCompareLandingWhyNoFindingsTest.php`
|
||||||
|
- [X] T031 Document any semantic distinction that cannot be stably derived from existing truth as follow-up foundation work in `specs/166-finding-governance-health/spec.md` and `specs/166-finding-governance-health/plan.md`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Dependencies & Execution Order
|
||||||
|
|
||||||
|
### Phase Dependencies
|
||||||
|
|
||||||
|
- **Setup (Phase 1)**: No dependencies; can start immediately.
|
||||||
|
- **Foundational (Phase 2)**: Depends on Setup completion; blocks all user stories.
|
||||||
|
- **User Story 1 (Phase 3)**: Depends on Foundational completion; recommended MVP slice.
|
||||||
|
- **User Story 2 (Phase 4)**: Depends on Foundational completion and benefits from User Story 1 because hardened list/detail semantics are reused.
|
||||||
|
- **User Story 3 (Phase 5)**: Depends on Foundational completion and benefits from User Story 1 and User Story 2 because summary attention logic should reuse the same governance and historical semantics.
|
||||||
|
- **User Story 4 (Phase 6)**: Depends on Foundational completion and benefits from User Story 1 because the leading zone reuses the hardened governance cues.
|
||||||
|
- **Polish (Phase 7)**: Depends on all desired user stories being complete.
|
||||||
|
|
||||||
|
### User Story Dependencies
|
||||||
|
|
||||||
|
- **US1**: No dependency on other stories after Foundational; this is the MVP.
|
||||||
|
- **US2**: Can start after Foundational, but should land after or alongside US1 so findings surfaces reuse the same accepted-risk semantics.
|
||||||
|
- **US3**: Can start after Foundational, but should follow US1 and US2 for consistent governance and historical messaging on summaries.
|
||||||
|
- **US4**: Can start after Foundational, but is safest after US1 because the detail leading zone depends on the hardened governance cues.
|
||||||
|
|
||||||
|
### Within Each User Story
|
||||||
|
|
||||||
|
- Tests MUST be written and confirmed failing before implementation tasks begin.
|
||||||
|
- Shared semantic helpers and badges must be updated before surface-specific copy or layout work closes.
|
||||||
|
- Resource and page behavior should land before final guard and parity coverage is refreshed.
|
||||||
|
|
||||||
|
### Parallel Opportunities
|
||||||
|
|
||||||
|
- `T002`, `T004`, and `T005` can run in parallel once `T001` and `T003` establish the seeded scenarios and semantic baseline.
|
||||||
|
- In each user story, all `[P]` test tasks can run in parallel.
|
||||||
|
- `T020` and `T021` can run in parallel during US3 once summary test expectations are in place.
|
||||||
|
- `T027`, `T028`, and `T029` can run in parallel in the polish phase.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Parallel Execution Examples
|
||||||
|
|
||||||
|
### User Story 1
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Run the User Story 1 test tasks in parallel:
|
||||||
|
T007 Add findings-list coverage for healthy versus expiring or lapsed accepted risk.
|
||||||
|
T008 Add tenant-register and canonical-queue parity coverage for governance-validity visibility.
|
||||||
|
T009 Add positive and negative authorization coverage for tenant findings and canonical exception governance states.
|
||||||
|
|
||||||
|
# Then implement the shared surface hardening in sequence:
|
||||||
|
T010 Harden tenant findings list lifecycle badges, governance cues, overdue emphasis, and filters.
|
||||||
|
T011 Align finding detail governance warnings and healthy accepted-risk messaging.
|
||||||
|
T012 Align tenant exception register and canonical exception queue governance semantics.
|
||||||
|
```
|
||||||
|
|
||||||
|
### User Story 2
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Run the User Story 2 test tasks in parallel:
|
||||||
|
T013 Add list and detail coverage for cautious resolved and closed semantics.
|
||||||
|
T014 Add baseline-compare and summary-copy coverage for historical findings.
|
||||||
|
|
||||||
|
# Then implement the historical-state hardening in sequence:
|
||||||
|
T015 Revise resolved and closed list/detail copy.
|
||||||
|
T016 Carry cautious historical semantics into baseline-compare assessment logic.
|
||||||
|
T017 Update baseline-compare landing presentation.
|
||||||
|
```
|
||||||
|
|
||||||
|
### User Story 3
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Run the User Story 3 test tasks in parallel:
|
||||||
|
T018 Add tenant-dashboard attention coverage.
|
||||||
|
T019 Add baseline-compare widget and landing parity coverage.
|
||||||
|
|
||||||
|
# Then implement the summary-surface work in sequence:
|
||||||
|
T020 Extend tenant attention aggregates.
|
||||||
|
T021 Extend baseline-compare stats and assessment logic.
|
||||||
|
T022 Surface the new attention states in widgets and landing UI.
|
||||||
|
```
|
||||||
|
|
||||||
|
### User Story 4
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Run the User Story 4 test tasks in parallel:
|
||||||
|
T023 Add finding-detail information-order and leading-zone coverage.
|
||||||
|
T024 Add action-surface and table-contract regression coverage.
|
||||||
|
|
||||||
|
# Then implement the detail-page reordering in sequence:
|
||||||
|
T025 Reorder finding detail into an operator-first status and governance zone.
|
||||||
|
T026 Tighten next-action guidance and related-record navigation.
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Implementation Strategy
|
||||||
|
|
||||||
|
### MVP First
|
||||||
|
|
||||||
|
1. Complete Phase 1 and Phase 2.
|
||||||
|
2. Deliver User Story 1 as the MVP so accepted risk no longer hides governance drift.
|
||||||
|
3. Confirm the main false-calm risk is closed before broadening summary and detail refinements.
|
||||||
|
|
||||||
|
### Incremental Delivery
|
||||||
|
|
||||||
|
1. Add User Story 2 to remove false reassurance from `resolved` and `closed` surfaces.
|
||||||
|
2. Add User Story 3 so dashboard and baseline-compare summaries report overdue and unhealthy governance honestly.
|
||||||
|
3. Add User Story 4 to make the detail page operator-first and reduce time-to-decision.
|
||||||
|
|
||||||
|
### Suggested MVP Scope
|
||||||
|
|
||||||
|
- **Recommended MVP**: Phases 1 through 3 only.
|
||||||
|
- **Why**: This delivers the highest-risk business correction first by making governance drift immediately visible on the primary findings workflow surfaces.
|
||||||
@ -0,0 +1,36 @@
|
|||||||
|
# Specification Quality Checklist: Request-Scoped Derived State and Resolver Memoization
|
||||||
|
|
||||||
|
**Purpose**: Validate specification completeness and quality before proceeding to planning
|
||||||
|
**Created**: 2026-03-28
|
||||||
|
**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
|
||||||
|
|
||||||
|
- First-pass validation completed on 2026-03-28.
|
||||||
|
- No clarification markers remain.
|
||||||
|
- Spec is ready for `/speckit.plan`.
|
||||||
@ -0,0 +1,72 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||||
|
"$id": "https://tenantpilot.local/contracts/request-scoped-derived-state-key.schema.json",
|
||||||
|
"title": "RequestScopedDerivedStateKey",
|
||||||
|
"description": "Deterministic key used to identify one reusable derived-state result inside a single request. This schema describes the internal runtime key shape using snake_case field names; the logical OpenAPI contract documents an equivalent camelCase transport form that must normalize back to this structure.",
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": false,
|
||||||
|
"required": [
|
||||||
|
"family",
|
||||||
|
"record_class",
|
||||||
|
"record_key",
|
||||||
|
"variant"
|
||||||
|
],
|
||||||
|
"properties": {
|
||||||
|
"family": {
|
||||||
|
"type": "string",
|
||||||
|
"enum": [
|
||||||
|
"artifact_truth",
|
||||||
|
"operation_ux_guidance",
|
||||||
|
"operation_ux_explanation",
|
||||||
|
"related_navigation_primary",
|
||||||
|
"related_navigation_detail",
|
||||||
|
"related_navigation_header"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"record_class": {
|
||||||
|
"type": "string",
|
||||||
|
"minLength": 1,
|
||||||
|
"examples": [
|
||||||
|
"App\\Models\\TenantReview"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"record_key": {
|
||||||
|
"type": "string",
|
||||||
|
"minLength": 1,
|
||||||
|
"examples": [
|
||||||
|
"42"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"variant": {
|
||||||
|
"type": "string",
|
||||||
|
"minLength": 1,
|
||||||
|
"examples": [
|
||||||
|
"list_row",
|
||||||
|
"detail_page",
|
||||||
|
"header_action"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"workspace_id": {
|
||||||
|
"type": [
|
||||||
|
"integer",
|
||||||
|
"null"
|
||||||
|
],
|
||||||
|
"minimum": 1
|
||||||
|
},
|
||||||
|
"tenant_id": {
|
||||||
|
"type": [
|
||||||
|
"integer",
|
||||||
|
"null"
|
||||||
|
],
|
||||||
|
"minimum": 1
|
||||||
|
},
|
||||||
|
"context_hash": {
|
||||||
|
"type": [
|
||||||
|
"string",
|
||||||
|
"null"
|
||||||
|
],
|
||||||
|
"minLength": 1,
|
||||||
|
"description": "Stable hash of additional scope-sensitive or capability-sensitive inputs required to distinguish the result."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,571 @@
|
|||||||
|
openapi: 3.1.0
|
||||||
|
info:
|
||||||
|
title: Request-Scoped Derived State Logical Contract
|
||||||
|
version: 0.1.0
|
||||||
|
summary: Logical contract for resolving, reusing, and invalidating deterministic derived state within one request.
|
||||||
|
description: |
|
||||||
|
This contract is logical rather than transport-prescriptive. It documents the
|
||||||
|
expected behavior of the internal request-scoped derived-state store used by
|
||||||
|
existing presenter and resolver families. It does not add new external APIs
|
||||||
|
and does not imply cross-request caching.
|
||||||
|
Resolution and invalidation payloads use camelCase transport keys in this
|
||||||
|
contract, while the runtime data model and JSON schema keep their internal
|
||||||
|
snake_case field names. Implementations must normalize between the two
|
||||||
|
shapes rather than treating them as separate contracts.
|
||||||
|
Every future presenter or resolver family that wants to use the shared store
|
||||||
|
must document its family key, scope-sensitive inputs, access pattern, and
|
||||||
|
freshness policy through the consumer-validation contract before adoption.
|
||||||
|
servers:
|
||||||
|
- url: https://tenantpilot.local
|
||||||
|
x-derived-state-consumers:
|
||||||
|
- surface: reviews.register.table
|
||||||
|
family: artifact_truth
|
||||||
|
variant: tenant_review
|
||||||
|
accessPattern: row_safe
|
||||||
|
scopeInputs:
|
||||||
|
- record_class
|
||||||
|
- record_key
|
||||||
|
- workspace_id
|
||||||
|
freshnessPolicy: invalidate_after_mutation
|
||||||
|
guardScope:
|
||||||
|
- app/Filament/Pages/Reviews/ReviewRegister.php
|
||||||
|
requiredMarkers:
|
||||||
|
- 'private function reviewTruth(TenantReview $record, bool $fresh = false): ArtifactTruthEnvelope'
|
||||||
|
- '$this->reviewTruth($record)'
|
||||||
|
maxOccurrences:
|
||||||
|
- needle: '->forTenantReview('
|
||||||
|
max: 1
|
||||||
|
- needle: '->forTenantReviewFresh('
|
||||||
|
max: 1
|
||||||
|
- surface: monitoring.evidence_overview.table
|
||||||
|
family: artifact_truth
|
||||||
|
variant: evidence_snapshot
|
||||||
|
accessPattern: row_safe
|
||||||
|
scopeInputs:
|
||||||
|
- record_class
|
||||||
|
- record_key
|
||||||
|
- workspace_id
|
||||||
|
- tenant_id
|
||||||
|
freshnessPolicy: invalidate_after_mutation
|
||||||
|
guardScope:
|
||||||
|
- app/Filament/Pages/Monitoring/EvidenceOverview.php
|
||||||
|
requiredMarkers:
|
||||||
|
- 'private function snapshotTruth(EvidenceSnapshot $snapshot, bool $fresh = false): ArtifactTruthEnvelope'
|
||||||
|
- '$this->snapshotTruth($snapshot)'
|
||||||
|
maxOccurrences:
|
||||||
|
- needle: '->forEvidenceSnapshot('
|
||||||
|
max: 1
|
||||||
|
- needle: '->forEvidenceSnapshotFresh('
|
||||||
|
max: 1
|
||||||
|
- surface: tenant.evidence_snapshots.table
|
||||||
|
family: artifact_truth
|
||||||
|
variant: evidence_snapshot
|
||||||
|
accessPattern: row_safe
|
||||||
|
scopeInputs:
|
||||||
|
- record_class
|
||||||
|
- record_key
|
||||||
|
- workspace_id
|
||||||
|
- tenant_id
|
||||||
|
freshnessPolicy: invalidate_after_mutation
|
||||||
|
guardScope:
|
||||||
|
- app/Filament/Resources/EvidenceSnapshotResource.php
|
||||||
|
requiredMarkers:
|
||||||
|
- 'private static function truthEnvelope(EvidenceSnapshot $record, bool $fresh = false): ArtifactTruthEnvelope'
|
||||||
|
- 'static::truthEnvelope($record)'
|
||||||
|
- 'fresh: true'
|
||||||
|
maxOccurrences:
|
||||||
|
- needle: '->forEvidenceSnapshot('
|
||||||
|
max: 1
|
||||||
|
- needle: '->forEvidenceSnapshotFresh('
|
||||||
|
max: 1
|
||||||
|
- surface: tenant.tenant_reviews.table
|
||||||
|
family: artifact_truth
|
||||||
|
variant: tenant_review
|
||||||
|
accessPattern: row_safe
|
||||||
|
scopeInputs:
|
||||||
|
- record_class
|
||||||
|
- record_key
|
||||||
|
- workspace_id
|
||||||
|
- tenant_id
|
||||||
|
freshnessPolicy: invalidate_after_mutation
|
||||||
|
guardScope:
|
||||||
|
- app/Filament/Resources/TenantReviewResource.php
|
||||||
|
requiredMarkers:
|
||||||
|
- 'private static function truthEnvelope(TenantReview $record, bool $fresh = false): ArtifactTruthEnvelope'
|
||||||
|
- 'static::truthEnvelope($record)'
|
||||||
|
- 'static::truthEnvelope($review->refresh(), fresh: true);'
|
||||||
|
maxOccurrences:
|
||||||
|
- needle: '->forTenantReview('
|
||||||
|
max: 1
|
||||||
|
- needle: '->forTenantReviewFresh('
|
||||||
|
max: 1
|
||||||
|
- surface: tenant.review_packs.table
|
||||||
|
family: artifact_truth
|
||||||
|
variant: review_pack
|
||||||
|
accessPattern: row_safe
|
||||||
|
scopeInputs:
|
||||||
|
- record_class
|
||||||
|
- record_key
|
||||||
|
- workspace_id
|
||||||
|
- tenant_id
|
||||||
|
freshnessPolicy: invalidate_after_mutation
|
||||||
|
guardScope:
|
||||||
|
- app/Filament/Resources/ReviewPackResource.php
|
||||||
|
requiredMarkers:
|
||||||
|
- 'private static function truthEnvelope(ReviewPack $record, bool $fresh = false): ArtifactTruthEnvelope'
|
||||||
|
- 'static::truthEnvelope($record)'
|
||||||
|
- 'static::truthEnvelope($reviewPack->refresh(), fresh: true);'
|
||||||
|
maxOccurrences:
|
||||||
|
- needle: '->forReviewPack('
|
||||||
|
max: 1
|
||||||
|
- needle: '->forReviewPackFresh('
|
||||||
|
max: 1
|
||||||
|
- surface: admin.baseline_snapshots.truth
|
||||||
|
family: artifact_truth
|
||||||
|
variant: baseline_snapshot
|
||||||
|
accessPattern: row_safe
|
||||||
|
scopeInputs:
|
||||||
|
- record_class
|
||||||
|
- record_key
|
||||||
|
- workspace_id
|
||||||
|
freshnessPolicy: invalidate_after_mutation
|
||||||
|
guardScope:
|
||||||
|
- app/Filament/Resources/BaselineSnapshotResource.php
|
||||||
|
requiredMarkers:
|
||||||
|
- 'private static function truthEnvelope(BaselineSnapshot $snapshot, bool $fresh = false): ArtifactTruthEnvelope'
|
||||||
|
- 'self::truthEnvelope($record)'
|
||||||
|
maxOccurrences:
|
||||||
|
- needle: '->forBaselineSnapshot('
|
||||||
|
max: 1
|
||||||
|
- needle: '->forBaselineSnapshotFresh('
|
||||||
|
max: 1
|
||||||
|
- surface: admin.baseline_snapshots.primary_navigation
|
||||||
|
family: related_navigation_primary
|
||||||
|
variant: baseline_snapshot
|
||||||
|
accessPattern: row_safe
|
||||||
|
scopeInputs:
|
||||||
|
- record_class
|
||||||
|
- record_key
|
||||||
|
- workspace_id
|
||||||
|
- user_id
|
||||||
|
freshnessPolicy: invalidate_after_mutation
|
||||||
|
guardScope:
|
||||||
|
- app/Filament/Resources/BaselineSnapshotResource.php
|
||||||
|
requiredMarkers:
|
||||||
|
- 'private static function primaryRelatedEntry(BaselineSnapshot $record): ?RelatedContextEntry'
|
||||||
|
- 'static::primaryRelatedEntry($record)'
|
||||||
|
maxOccurrences:
|
||||||
|
- needle: '->primaryListAction(CrossResourceNavigationMatrix::SOURCE_BASELINE_SNAPSHOT, $record)'
|
||||||
|
max: 1
|
||||||
|
- surface: tenant.findings.primary_navigation
|
||||||
|
family: related_navigation_primary
|
||||||
|
variant: finding
|
||||||
|
accessPattern: row_safe
|
||||||
|
scopeInputs:
|
||||||
|
- record_class
|
||||||
|
- record_key
|
||||||
|
- workspace_id
|
||||||
|
- tenant_id
|
||||||
|
- active_tenant_id
|
||||||
|
- user_id
|
||||||
|
- route_name
|
||||||
|
freshnessPolicy: invalidate_after_mutation
|
||||||
|
guardScope:
|
||||||
|
- app/Filament/Resources/FindingResource.php
|
||||||
|
requiredMarkers:
|
||||||
|
- 'private static function primaryRelatedEntry(Finding $record, bool $fresh = false): ?RelatedContextEntry'
|
||||||
|
- 'static::primaryRelatedEntry($record)'
|
||||||
|
maxOccurrences:
|
||||||
|
- needle: '->primaryListAction(CrossResourceNavigationMatrix::SOURCE_FINDING, $record)'
|
||||||
|
max: 1
|
||||||
|
- needle: '->primaryListActionFresh(CrossResourceNavigationMatrix::SOURCE_FINDING, $record)'
|
||||||
|
max: 1
|
||||||
|
- needle: 'primaryRelatedEntryCache'
|
||||||
|
max: 0
|
||||||
|
- surface: tenant.policy_versions.header_navigation
|
||||||
|
family: related_navigation_primary
|
||||||
|
variant: policy_version
|
||||||
|
accessPattern: page_safe
|
||||||
|
scopeInputs:
|
||||||
|
- record_class
|
||||||
|
- record_key
|
||||||
|
- workspace_id
|
||||||
|
- tenant_id
|
||||||
|
- active_tenant_id
|
||||||
|
- user_id
|
||||||
|
- route_name
|
||||||
|
freshnessPolicy: invalidate_after_mutation
|
||||||
|
guardScope:
|
||||||
|
- app/Filament/Resources/PolicyVersionResource/Pages/ViewPolicyVersion.php
|
||||||
|
requiredMarkers:
|
||||||
|
- 'private function primaryRelatedEntry(bool $fresh = false): ?RelatedContextEntry'
|
||||||
|
- '$this->primaryRelatedEntry()'
|
||||||
|
maxOccurrences:
|
||||||
|
- needle: '->primaryListAction(CrossResourceNavigationMatrix::SOURCE_POLICY_VERSION, $this->getRecord())'
|
||||||
|
max: 1
|
||||||
|
- needle: '->primaryListActionFresh(CrossResourceNavigationMatrix::SOURCE_POLICY_VERSION, $this->getRecord())'
|
||||||
|
max: 1
|
||||||
|
- surface: admin.operations.table_guidance
|
||||||
|
family: operation_ux_guidance
|
||||||
|
variant: surface_guidance
|
||||||
|
accessPattern: row_safe
|
||||||
|
scopeInputs:
|
||||||
|
- record_class
|
||||||
|
- record_key
|
||||||
|
- workspace_id
|
||||||
|
- tenant_id
|
||||||
|
freshnessPolicy: invalidate_after_mutation
|
||||||
|
guardScope:
|
||||||
|
- app/Filament/Resources/OperationRunResource.php
|
||||||
|
requiredMarkers:
|
||||||
|
- 'private static function surfaceGuidance(OperationRun $record, bool $fresh = false): ?string'
|
||||||
|
- 'private static function lifecycleAttentionSummary(OperationRun $record, bool $fresh = false): ?string'
|
||||||
|
- 'static::surfaceGuidance($record)'
|
||||||
|
maxOccurrences:
|
||||||
|
- needle: 'OperationUxPresenter::surfaceGuidance('
|
||||||
|
max: 1
|
||||||
|
- needle: 'OperationUxPresenter::surfaceGuidanceFresh('
|
||||||
|
max: 1
|
||||||
|
- needle: 'OperationUxPresenter::lifecycleAttentionSummary('
|
||||||
|
max: 1
|
||||||
|
- needle: 'OperationUxPresenter::lifecycleAttentionSummaryFresh('
|
||||||
|
max: 1
|
||||||
|
- surface: admin.operations.detail_related_context
|
||||||
|
family: related_navigation_detail
|
||||||
|
variant: operation_run
|
||||||
|
accessPattern: page_safe
|
||||||
|
scopeInputs:
|
||||||
|
- record_class
|
||||||
|
- record_key
|
||||||
|
- workspace_id
|
||||||
|
- tenant_id
|
||||||
|
- active_tenant_id
|
||||||
|
- user_id
|
||||||
|
- route_name
|
||||||
|
freshnessPolicy: invalidate_after_mutation
|
||||||
|
guardScope:
|
||||||
|
- app/Filament/Resources/OperationRunResource.php
|
||||||
|
requiredMarkers:
|
||||||
|
- 'private static function relatedContextEntries(OperationRun $record, bool $fresh = false): array'
|
||||||
|
- 'CrossResourceNavigationMatrix::SOURCE_OPERATION_RUN'
|
||||||
|
maxOccurrences:
|
||||||
|
- needle: '->detailEntries(CrossResourceNavigationMatrix::SOURCE_OPERATION_RUN, $record)'
|
||||||
|
max: 1
|
||||||
|
- needle: '->detailEntriesFresh(CrossResourceNavigationMatrix::SOURCE_OPERATION_RUN, $record)'
|
||||||
|
max: 1
|
||||||
|
- surface: admin.operations.viewer_explanation
|
||||||
|
family: operation_ux_explanation
|
||||||
|
variant: governance_operator_explanation
|
||||||
|
accessPattern: page_safe
|
||||||
|
scopeInputs:
|
||||||
|
- record_class
|
||||||
|
- record_key
|
||||||
|
- workspace_id
|
||||||
|
- tenant_id
|
||||||
|
freshnessPolicy: invalidate_after_mutation
|
||||||
|
guardScope:
|
||||||
|
- app/Filament/Pages/Operations/TenantlessOperationRunViewer.php
|
||||||
|
requiredMarkers:
|
||||||
|
- 'private function governanceOperatorExplanation(): ?OperatorExplanationPattern'
|
||||||
|
- 'OperationUxPresenter::governanceOperatorExplanation($this->run);'
|
||||||
|
maxOccurrences:
|
||||||
|
- needle: 'OperationUxPresenter::governanceOperatorExplanation('
|
||||||
|
max: 1
|
||||||
|
- needle: 'ArtifactTruthPresenter::class)->forOperationRun('
|
||||||
|
max: 0
|
||||||
|
- surface: admin.operations.viewer_related_links
|
||||||
|
family: related_navigation_detail
|
||||||
|
variant: operation_run
|
||||||
|
accessPattern: page_safe
|
||||||
|
scopeInputs:
|
||||||
|
- record_class
|
||||||
|
- record_key
|
||||||
|
- workspace_id
|
||||||
|
- tenant_id
|
||||||
|
- active_tenant_id
|
||||||
|
- user_id
|
||||||
|
- route_name
|
||||||
|
freshnessPolicy: invalidate_after_mutation
|
||||||
|
guardScope:
|
||||||
|
- app/Filament/Pages/Operations/TenantlessOperationRunViewer.php
|
||||||
|
requiredMarkers:
|
||||||
|
- 'private function relatedLinks(bool $fresh = false): array'
|
||||||
|
- '$resolver->operationLinks($this->run, $this->relatedLinksTenant())'
|
||||||
|
maxOccurrences:
|
||||||
|
- needle: '->operationLinks($this->run, $this->relatedLinksTenant())'
|
||||||
|
max: 1
|
||||||
|
- needle: '->operationLinksFresh($this->run, $this->relatedLinksTenant())'
|
||||||
|
max: 1
|
||||||
|
paths:
|
||||||
|
/contracts/derived-state/resolve:
|
||||||
|
post:
|
||||||
|
summary: Resolve or reuse one deterministic derived-state result within the current request
|
||||||
|
operationId: resolveDerivedState
|
||||||
|
requestBody:
|
||||||
|
required: true
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/DerivedStateResolutionRequest'
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: Derived-state value resolved, reused, or intentionally bypassed
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/DerivedStateResolutionResponse'
|
||||||
|
/contracts/derived-state/invalidate:
|
||||||
|
post:
|
||||||
|
summary: Invalidate one or more request-local derived-state entries after a covered mutation
|
||||||
|
operationId: invalidateDerivedState
|
||||||
|
requestBody:
|
||||||
|
required: true
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/DerivedStateInvalidationRequest'
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: Matching request-local entries invalidated
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/DerivedStateInvalidationResponse'
|
||||||
|
/contracts/derived-state/validate-consumer:
|
||||||
|
post:
|
||||||
|
summary: Validate one UI consumer against the supported family, keying, and freshness rules
|
||||||
|
description: |
|
||||||
|
Use this logical validation step before onboarding a new presenter or
|
||||||
|
resolver family or before replacing an existing local cache pattern.
|
||||||
|
The consumer must declare its access pattern, scope inputs, and
|
||||||
|
freshness policy so unsupported reuse never becomes implicit.
|
||||||
|
The automated Pest guard for derived-state adoption should report
|
||||||
|
violations from this validation step with file and snippet context.
|
||||||
|
operationId: validateDerivedStateConsumer
|
||||||
|
requestBody:
|
||||||
|
required: true
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/DerivedStateConsumerValidationRequest'
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: Consumer validation result
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/DerivedStateConsumerValidationResponse'
|
||||||
|
components:
|
||||||
|
schemas:
|
||||||
|
DerivedStateResolutionRequest:
|
||||||
|
type: object
|
||||||
|
additionalProperties: false
|
||||||
|
required:
|
||||||
|
- family
|
||||||
|
- recordClass
|
||||||
|
- recordKey
|
||||||
|
- variant
|
||||||
|
properties:
|
||||||
|
family:
|
||||||
|
type: string
|
||||||
|
enum:
|
||||||
|
- artifact_truth
|
||||||
|
- operation_ux_guidance
|
||||||
|
- operation_ux_explanation
|
||||||
|
- related_navigation_primary
|
||||||
|
- related_navigation_detail
|
||||||
|
- related_navigation_header
|
||||||
|
recordClass:
|
||||||
|
type: string
|
||||||
|
example: App\\Models\\TenantReview
|
||||||
|
recordKey:
|
||||||
|
type: string
|
||||||
|
example: '42'
|
||||||
|
variant:
|
||||||
|
type: string
|
||||||
|
example: list_row
|
||||||
|
workspaceId:
|
||||||
|
type:
|
||||||
|
- integer
|
||||||
|
- 'null'
|
||||||
|
tenantId:
|
||||||
|
type:
|
||||||
|
- integer
|
||||||
|
- 'null'
|
||||||
|
contextHash:
|
||||||
|
type:
|
||||||
|
- string
|
||||||
|
- 'null'
|
||||||
|
allowNegativeResultCache:
|
||||||
|
type: boolean
|
||||||
|
default: true
|
||||||
|
freshnessPolicy:
|
||||||
|
type: string
|
||||||
|
enum:
|
||||||
|
- request_stable
|
||||||
|
- invalidate_after_mutation
|
||||||
|
- no_reuse
|
||||||
|
default: request_stable
|
||||||
|
DerivedStateResolutionResponse:
|
||||||
|
type: object
|
||||||
|
additionalProperties: false
|
||||||
|
required:
|
||||||
|
- cacheStatus
|
||||||
|
- family
|
||||||
|
- variant
|
||||||
|
- negativeResult
|
||||||
|
- freshnessPolicy
|
||||||
|
properties:
|
||||||
|
cacheStatus:
|
||||||
|
type: string
|
||||||
|
enum:
|
||||||
|
- miss_resolved
|
||||||
|
- hit_reused
|
||||||
|
- bypassed
|
||||||
|
family:
|
||||||
|
type: string
|
||||||
|
variant:
|
||||||
|
type: string
|
||||||
|
negativeResult:
|
||||||
|
type: boolean
|
||||||
|
freshnessPolicy:
|
||||||
|
type: string
|
||||||
|
enum:
|
||||||
|
- request_stable
|
||||||
|
- invalidate_after_mutation
|
||||||
|
- no_reuse
|
||||||
|
scopeFingerprint:
|
||||||
|
type:
|
||||||
|
- string
|
||||||
|
- 'null'
|
||||||
|
notes:
|
||||||
|
type:
|
||||||
|
- string
|
||||||
|
- 'null'
|
||||||
|
DerivedStateInvalidationRequest:
|
||||||
|
type: object
|
||||||
|
additionalProperties: false
|
||||||
|
properties:
|
||||||
|
family:
|
||||||
|
type:
|
||||||
|
- string
|
||||||
|
- 'null'
|
||||||
|
recordClass:
|
||||||
|
type:
|
||||||
|
- string
|
||||||
|
- 'null'
|
||||||
|
recordKey:
|
||||||
|
type:
|
||||||
|
- string
|
||||||
|
- 'null'
|
||||||
|
variant:
|
||||||
|
type:
|
||||||
|
- string
|
||||||
|
- 'null'
|
||||||
|
workspaceId:
|
||||||
|
type:
|
||||||
|
- integer
|
||||||
|
- 'null'
|
||||||
|
tenantId:
|
||||||
|
type:
|
||||||
|
- integer
|
||||||
|
- 'null'
|
||||||
|
reason:
|
||||||
|
type: string
|
||||||
|
required:
|
||||||
|
- reason
|
||||||
|
DerivedStateInvalidationResponse:
|
||||||
|
type: object
|
||||||
|
additionalProperties: false
|
||||||
|
required:
|
||||||
|
- invalidatedCount
|
||||||
|
properties:
|
||||||
|
invalidatedCount:
|
||||||
|
type: integer
|
||||||
|
minimum: 0
|
||||||
|
DerivedStateConsumerValidationRequest:
|
||||||
|
type: object
|
||||||
|
description: |
|
||||||
|
Adoption request for one UI consumer. New consumers should not use the
|
||||||
|
shared store until family support, scope-sensitive inputs, access
|
||||||
|
pattern, and freshness behavior are all explicit.
|
||||||
|
additionalProperties: false
|
||||||
|
required:
|
||||||
|
- surface
|
||||||
|
- family
|
||||||
|
- variant
|
||||||
|
- accessPattern
|
||||||
|
- scopeInputs
|
||||||
|
- freshnessPolicy
|
||||||
|
- guardScope
|
||||||
|
properties:
|
||||||
|
surface:
|
||||||
|
type: string
|
||||||
|
example: reviews.register.table
|
||||||
|
family:
|
||||||
|
type: string
|
||||||
|
description: Supported derived-state family name, or a proposed family under review for adoption.
|
||||||
|
variant:
|
||||||
|
type: string
|
||||||
|
description: Stable variant identifier for the consumer path, such as `list_row` or `detail_page`.
|
||||||
|
accessPattern:
|
||||||
|
type: string
|
||||||
|
enum:
|
||||||
|
- row_safe
|
||||||
|
- page_safe
|
||||||
|
- direct_once
|
||||||
|
scopeInputs:
|
||||||
|
type: array
|
||||||
|
description: Scope or capability inputs that affect the result for this consumer.
|
||||||
|
items:
|
||||||
|
type: string
|
||||||
|
guardScope:
|
||||||
|
type: array
|
||||||
|
description: Source paths or helper seams the automated guard scans when validating this consumer.
|
||||||
|
items:
|
||||||
|
type: string
|
||||||
|
mutationSensitive:
|
||||||
|
type: boolean
|
||||||
|
description: Advisory hint for the guard when post-mutation state changes require explicit freshness handling; does not replace `freshnessPolicy`.
|
||||||
|
default: false
|
||||||
|
capabilitySensitive:
|
||||||
|
type: boolean
|
||||||
|
description: Advisory hint for the guard when capability context changes the result; does not replace `scopeInputs`.
|
||||||
|
default: false
|
||||||
|
freshnessPolicy:
|
||||||
|
type: string
|
||||||
|
enum:
|
||||||
|
- request_stable
|
||||||
|
- invalidate_after_mutation
|
||||||
|
- no_reuse
|
||||||
|
default: request_stable
|
||||||
|
DerivedStateConsumerValidationResponse:
|
||||||
|
type: object
|
||||||
|
additionalProperties: false
|
||||||
|
required:
|
||||||
|
- valid
|
||||||
|
- violations
|
||||||
|
properties:
|
||||||
|
valid:
|
||||||
|
type: boolean
|
||||||
|
violations:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
type: object
|
||||||
|
additionalProperties: false
|
||||||
|
required:
|
||||||
|
- code
|
||||||
|
- message
|
||||||
|
properties:
|
||||||
|
code:
|
||||||
|
type: string
|
||||||
|
enum:
|
||||||
|
- missing_scope_context
|
||||||
|
- unsupported_family
|
||||||
|
- mutation_freshness_gap
|
||||||
|
- ad_hoc_local_cache
|
||||||
|
- unstable_variant_key
|
||||||
|
- missing_guard_scope
|
||||||
|
- missing_freshness_policy
|
||||||
|
message:
|
||||||
|
type: string
|
||||||
134
specs/167-derived-state-memoization/data-model.md
Normal file
134
specs/167-derived-state-memoization/data-model.md
Normal file
@ -0,0 +1,134 @@
|
|||||||
|
# Data Model: Request-Scoped Derived State and Resolver Memoization
|
||||||
|
|
||||||
|
This feature does not introduce persistent storage. It defines runtime-only entities that govern how deterministic presenter and resolver results are reused inside one HTTP or Livewire request.
|
||||||
|
|
||||||
|
## Entities
|
||||||
|
|
||||||
|
### RequestScopedDerivedStateStore
|
||||||
|
|
||||||
|
- **Purpose**: Holds resolved derived-state values for the lifetime of one request so repeated consumers can reuse deterministic results.
|
||||||
|
- **Lifecycle**: Created at request start through the Laravel container and discarded at request end.
|
||||||
|
|
||||||
|
#### Fields
|
||||||
|
|
||||||
|
| Field | Type | Required | Description |
|
||||||
|
|-------|------|----------|-------------|
|
||||||
|
| `request_scope_id` | string | yes | Internal identifier for the active request-local store instance |
|
||||||
|
| `entries` | map<string, DerivedStateResolutionRecord> | yes | Resolved entries indexed by deterministic derived-state key |
|
||||||
|
| `invalidations` | list<string> | no | Keys or family scopes explicitly invalidated during the request |
|
||||||
|
|
||||||
|
#### Validation Rules
|
||||||
|
|
||||||
|
- The store must never be serialized or persisted.
|
||||||
|
- The store must never survive beyond the current request lifecycle.
|
||||||
|
- Each key in `entries` must be unique within the request.
|
||||||
|
|
||||||
|
### DerivedStateKey
|
||||||
|
|
||||||
|
- **Purpose**: Defines what counts as “the same derivation” for request-local reuse.
|
||||||
|
- **Contract naming note**: The runtime model uses internal snake_case field names. The logical OpenAPI contract uses camelCase transport names for request and response payloads and must normalize back to this runtime key shape.
|
||||||
|
|
||||||
|
#### Fields
|
||||||
|
|
||||||
|
| Field | Type | Required | Description |
|
||||||
|
|-------|------|----------|-------------|
|
||||||
|
| `family` | enum | yes | Covered family such as `artifact_truth`, `operation_ux_guidance`, `operation_ux_explanation`, `related_navigation_primary`, `related_navigation_detail`, or `related_navigation_header` |
|
||||||
|
| `record_class` | string | yes | Concrete model class or logical source type used by the family |
|
||||||
|
| `record_key` | string | yes | Stable string form of the source record identity |
|
||||||
|
| `variant` | string | yes | Surface mode or derivation variant such as `list_row`, `detail_page`, `expanded`, or `header_action` |
|
||||||
|
| `workspace_id` | int nullable | no | Workspace scope when relevant to the derivation |
|
||||||
|
| `tenant_id` | int nullable | no | Tenant scope when relevant to the derivation |
|
||||||
|
| `context_hash` | string nullable | no | Stable hash of any additional scope-sensitive inputs such as capability-sensitive visibility or consumer options |
|
||||||
|
|
||||||
|
#### Validation Rules
|
||||||
|
|
||||||
|
- `family` must be one of the explicitly supported family identifiers.
|
||||||
|
- `record_class` and `record_key` must be non-empty.
|
||||||
|
- `variant` must be non-empty and stable for the consumer path.
|
||||||
|
- `workspace_id`, `tenant_id`, and `context_hash` must be included whenever omitting them could change the result.
|
||||||
|
|
||||||
|
### DerivedStateResolutionRecord
|
||||||
|
|
||||||
|
- **Purpose**: Represents one resolved request-local entry stored under a `DerivedStateKey`.
|
||||||
|
|
||||||
|
#### Fields
|
||||||
|
|
||||||
|
| Field | Type | Required | Description |
|
||||||
|
|-------|------|----------|-------------|
|
||||||
|
| `key` | DerivedStateKey | yes | Deterministic key for the stored result |
|
||||||
|
| `value` | mixed | yes | The resolved presenter or resolver result |
|
||||||
|
| `negative_result` | bool | yes | Whether the stored value represents a stable negative result such as `null`, no entry, or no next action |
|
||||||
|
| `freshness_policy` | enum(`request_stable`,`invalidate_after_mutation`,`no_reuse`) | yes | Freshness behavior for the stored result |
|
||||||
|
| `resolved_at` | string | yes | Internal timestamp or sequence marker for testable store behavior |
|
||||||
|
|
||||||
|
#### Validation Rules
|
||||||
|
|
||||||
|
- `negative_result = true` is allowed only when the result is deterministic for the current scope.
|
||||||
|
- `freshness_policy = no_reuse` means the record must not be stored or reused.
|
||||||
|
- `freshness_policy = invalidate_after_mutation` requires an explicit invalidation path on covered mutation flows.
|
||||||
|
|
||||||
|
### DerivedStateFamilyContract
|
||||||
|
|
||||||
|
- **Purpose**: Documents the supported family-level rules for key composition, negative-result reuse, and freshness.
|
||||||
|
|
||||||
|
#### Fields
|
||||||
|
|
||||||
|
| Field | Type | Required | Description |
|
||||||
|
|-------|------|----------|-------------|
|
||||||
|
| `family` | enum | yes | Covered family identifier |
|
||||||
|
| `source_method` | string | yes | Existing presenter or resolver entry point used to resolve the family |
|
||||||
|
| `allows_negative_result_cache` | bool | yes | Whether deterministic negative results may be reused within the request |
|
||||||
|
| `default_freshness_policy` | enum | yes | Default freshness behavior for the family |
|
||||||
|
| `required_scope_inputs` | list<string> | yes | Key fields that must be present when the family depends on scope or capability context |
|
||||||
|
|
||||||
|
### DerivedStateConsumerDeclaration
|
||||||
|
|
||||||
|
- **Purpose**: Declares how one UI consumer is allowed to adopt the shared request-scoped contract and provides the metadata used by the automated guardrail.
|
||||||
|
- **Contract naming note**: This declaration uses the same camelCase field names as the logical consumer-validation contract because the automated guard and onboarding path treat that contract as the published declaration surface.
|
||||||
|
|
||||||
|
#### Fields
|
||||||
|
|
||||||
|
| Field | Type | Required | Description |
|
||||||
|
|-------|------|----------|-------------|
|
||||||
|
| `surface` | string | yes | Stable surface identifier such as `reviews.register.table` or `operations.run.detail` |
|
||||||
|
| `family` | enum | yes | Covered family identifier used by the consumer |
|
||||||
|
| `variant` | string | yes | Stable variant identifier such as `list_row`, `detail_page`, or `header_action` used by the consumer |
|
||||||
|
| `accessPattern` | enum(`row_safe`,`page_safe`,`direct_once`) | yes | Supported consumer access pattern |
|
||||||
|
| `scopeInputs` | list<string> | yes | Scope or capability inputs that must be declared for the consumer |
|
||||||
|
| `freshnessPolicy` | enum(`request_stable`,`invalidate_after_mutation`,`no_reuse`) | yes | Freshness behavior required for this consumer |
|
||||||
|
| `guardScope` | list<string> | yes | Source paths or helper seams the automated guard uses when validating adoption |
|
||||||
|
| `mutationSensitive` | bool | no | Advisory flag used when the consumer's visible result changes after in-request mutation and requires explicit freshness handling |
|
||||||
|
| `capabilitySensitive` | bool | no | Advisory flag used when capability context changes the result and the guard should require explicit scope inputs |
|
||||||
|
|
||||||
|
#### Validation Rules
|
||||||
|
|
||||||
|
- `family` must exist in a supported `DerivedStateFamilyContract`.
|
||||||
|
- `variant` must be explicit and stable for the guarded consumer path.
|
||||||
|
- `accessPattern` must be one of the supported consumer patterns.
|
||||||
|
- `scopeInputs` must be explicit when capability, workspace, tenant, or route context can affect the result.
|
||||||
|
- `guardScope` must be narrow enough to produce actionable failures with file and snippet output.
|
||||||
|
- `mutationSensitive` and `capabilitySensitive` are advisory guard hints and must never replace explicit `freshnessPolicy` or `scopeInputs` declaration.
|
||||||
|
|
||||||
|
## Relationships
|
||||||
|
|
||||||
|
- One `RequestScopedDerivedStateStore` contains many `DerivedStateResolutionRecord` objects.
|
||||||
|
- Each `DerivedStateResolutionRecord` is uniquely identified by one `DerivedStateKey`.
|
||||||
|
- Each `DerivedStateKey` belongs to one `DerivedStateFamilyContract`.
|
||||||
|
- Each `DerivedStateConsumerDeclaration` references one `DerivedStateFamilyContract` and is validated by the automated adoption guard before new consumers rely on the shared store.
|
||||||
|
|
||||||
|
## State Transitions
|
||||||
|
|
||||||
|
### Derived State Lifecycle
|
||||||
|
|
||||||
|
1. **Miss**: No `DerivedStateResolutionRecord` exists for the requested `DerivedStateKey`.
|
||||||
|
2. **Resolved**: The existing presenter or resolver computes the result and stores one `DerivedStateResolutionRecord` when reuse is allowed.
|
||||||
|
3. **Reused**: Additional consumers in the same request retrieve the same stored record without a new full derivation.
|
||||||
|
4. **Invalidated**: A covered mutation explicitly invalidates affected keys or family scopes when business truth changes.
|
||||||
|
5. **Recomputed**: The next access after invalidation resolves a fresh record under the same or updated key.
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
- No database migrations, model tables, or persisted read models are introduced.
|
||||||
|
- The feature must not add a generic cross-request cache abstraction.
|
||||||
|
- Existing presenter envelopes and navigation entry objects remain the business-visible payloads; the new model only governs reuse of those outputs within one request.
|
||||||
|
- The automated guardrail uses `DerivedStateConsumerDeclaration` metadata to block undeclared or unsupported adoption patterns in CI.
|
||||||
271
specs/167-derived-state-memoization/plan.md
Normal file
271
specs/167-derived-state-memoization/plan.md
Normal file
@ -0,0 +1,271 @@
|
|||||||
|
# Implementation Plan: Request-Scoped Derived State and Resolver Memoization
|
||||||
|
|
||||||
|
**Branch**: `167-derived-state-memoization` | **Date**: 2026-03-28 | **Spec**: `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/specs/167-derived-state-memoization/spec.md`
|
||||||
|
**Input**: Feature specification from `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/specs/167-derived-state-memoization/spec.md`
|
||||||
|
|
||||||
|
**Note**: This template is filled in by the `/speckit.plan` command. See `.specify/scripts/` for helper scripts.
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
Introduce one explicit request-scoped derived-state contract for deterministic presenter and resolver outputs that are currently recalculated multiple times per record and per surface during one HTTP or Livewire request. The first implementation slice will bind a per-request in-memory store inside the Laravel container, define a stable key contract, adopt the store behind `ArtifactTruthPresenter`, `OperationUxPresenter`, and `RelatedNavigationResolver`, converge existing local cache behavior where appropriate, and protect freshness with focused mutation-path and scope-safety tests.
|
||||||
|
|
||||||
|
## Technical Context
|
||||||
|
|
||||||
|
**Language/Version**: PHP 8.4.15
|
||||||
|
**Primary Dependencies**: Laravel 12, Filament v5, Livewire v4, Pest v4, Tailwind CSS v4, existing `ArtifactTruthPresenter`, `OperationUxPresenter`, `RelatedNavigationResolver`, `AppServiceProvider`, `BadgeCatalog`, `BadgeRenderer`, and current Filament resource/page seams
|
||||||
|
**Storage**: PostgreSQL unchanged; feature adds no persistence and relies on request-local in-memory state only
|
||||||
|
**Testing**: Pest 4 unit and feature tests, including focused Filament page/component coverage and mutation-path regression tests run through Laravel Sail
|
||||||
|
**Target Platform**: Laravel monolith web application in Sail locally and containerized Linux deployment in staging/production
|
||||||
|
**Project Type**: web application
|
||||||
|
**Performance Goals**: Covered deterministic derived-state families resolve at most once per request for the same family + record + variant + scope tuple; covered list/detail/widget pages keep DB-only render behavior and avoid repeated presenter/resolver fan-out in one render pass
|
||||||
|
**Constraints**: No cross-request caching, no new persistent summaries, no business-semantic changes, no RBAC drift, request-local reuse must work for both HTTP and Livewire requests, mutation freshness must be explicit, and covered surfaces must preserve current operator-visible meaning
|
||||||
|
**Scale/Scope**: One cross-cutting runtime contract, three covered derived-state families, representative adoption across review register, evidence overview, baseline snapshot, operation-run detail/list, and related-context surfaces serving dozens of rows and multi-section pages per request
|
||||||
|
|
||||||
|
## Constitution Check
|
||||||
|
|
||||||
|
*GATE: Passed before Phase 0 research. Re-checked after Phase 1 design and still passing.*
|
||||||
|
|
||||||
|
| Principle | Pre-Research | Post-Design | Notes |
|
||||||
|
|-----------|--------------|-------------|-------|
|
||||||
|
| Inventory-first / snapshots-second | PASS | PASS | No inventory or snapshot ownership semantics change; reuse only affects request-time derivation. |
|
||||||
|
| Read/write separation | PASS | PASS | Default path is read-only. Mutation-path freshness is a guardrail, not a new write flow. |
|
||||||
|
| Graph contract path | N/A | N/A | No Graph calls or `config/graph_contracts.php` changes. |
|
||||||
|
| Deterministic capabilities | PASS | PASS | Capability semantics remain unchanged; any capability-sensitive derivation must key scope explicitly or bypass reuse. |
|
||||||
|
| Workspace + tenant isolation | PASS | PASS | Request-local reuse is explicitly scoped and must not cross workspace, tenant, or request boundaries. |
|
||||||
|
| RBAC-UX authorization semantics | PASS | PASS | No new policies or capabilities; tests must prove no 404/403 leakage through reused derived values. |
|
||||||
|
| Run observability / Ops-UX | PASS | PASS | No new `OperationRun` family or feedback path; covered operation surfaces reuse guidance only. |
|
||||||
|
| Data minimization | PASS | PASS | No additional persistence or log payloads; cached state lives only inside the current request. |
|
||||||
|
| Proportionality / no premature abstraction | PASS WITH JUSTIFIED ABSTRACTION | PASS WITH JUSTIFIED ABSTRACTION | One new abstraction is introduced because three existing families already share the same repeated-cost shape and page-local caches are insufficient. |
|
||||||
|
| Persisted truth / behavioral state | PASS | PASS | No new table, artifact, status, or reason family. |
|
||||||
|
| UI semantics / few layers | PASS | PASS | The design sits below existing presenters and resolvers and must not create a second interpretation layer. |
|
||||||
|
| Badge semantics (BADGE-001) | PASS | PASS | Existing badge catalogs and renderers remain the single source for visible state mappings. |
|
||||||
|
| Filament Action Surface Contract | PASS | PASS | No action inventory or inspect-affordance change; only repeated derivation behind existing surfaces changes. |
|
||||||
|
| Filament UX-001 | PASS | PASS | No layout redesign; current list/detail/widget structures stay intact. |
|
||||||
|
| Filament v5 / Livewire v4 compliance | PASS | PASS | The design remains inside the existing Filament v5 + Livewire v4 stack with no legacy API introduction. |
|
||||||
|
| Provider registration location | PASS | PASS | No panel or provider change; Laravel 11+ provider registration remains in `bootstrap/providers.php`. |
|
||||||
|
| Global-search hard rule | PASS | PASS | No global-search behavior changes are proposed. |
|
||||||
|
| Asset strategy | PASS | PASS | No new assets, no shared asset registration, and no new `filament:assets` requirement. |
|
||||||
|
|
||||||
|
## Phase 0 Research
|
||||||
|
|
||||||
|
Research outcomes are captured in `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/specs/167-derived-state-memoization/research.md`.
|
||||||
|
|
||||||
|
Key decisions:
|
||||||
|
|
||||||
|
- Use a dedicated per-request in-memory store bound through the Laravel container rather than static arrays or persistent caches.
|
||||||
|
- Define one stable key contract around family, record identity, variant, and scope context rather than relying on model-object identity or page-local assumptions.
|
||||||
|
- Integrate through the existing `ArtifactTruthPresenter`, `OperationUxPresenter`, and `RelatedNavigationResolver` entry points instead of introducing a new presentation framework.
|
||||||
|
- Standardize row-safe and page-safe consumer seams on covered Filament surfaces so multiple closures can share one resolved family result.
|
||||||
|
- Treat mutation freshness as an explicit invalidation or fresh-access rule rather than as an accidental side effect of current code order.
|
||||||
|
- Validate behavior with focused derivation-count, scope-safety, and mutation-path tests instead of relying on ad hoc microbenchmarks.
|
||||||
|
|
||||||
|
## Phase 1 Design
|
||||||
|
|
||||||
|
Design artifacts are created under `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/specs/167-derived-state-memoization/`:
|
||||||
|
|
||||||
|
- `data-model.md`: runtime entities for the request-scoped store, key contract, supported family contract, and freshness policy
|
||||||
|
- `contracts/request-scoped-derived-state.logical.openapi.yaml`: logical service contract for resolve, invalidate, and consumer-validation behavior
|
||||||
|
- `contracts/request-scoped-derived-state-key.schema.json`: structural schema for deterministic key composition
|
||||||
|
- `quickstart.md`: focused implementation and verification workflow
|
||||||
|
|
||||||
|
Design decisions:
|
||||||
|
|
||||||
|
- The request-scoped store is a single narrow abstraction, not a generic caching platform.
|
||||||
|
- The store is bound in the Laravel container per request and not persisted to cache stores or the database.
|
||||||
|
- Existing family entry points remain the only place where covered derivations are resolved; adoption happens behind those seams.
|
||||||
|
- Existing local caches such as `FindingResource::$primaryRelatedEntryCache` become convergence points, not a parallel long-term pattern.
|
||||||
|
- Covered mutation flows must either invalidate affected keys or force a fresh derivation path after business-state changes.
|
||||||
|
- Regression protection focuses on repeated-read elimination, cross-scope safety, and post-mutation freshness rather than on implementation trivia.
|
||||||
|
|
||||||
|
## Project Structure
|
||||||
|
|
||||||
|
### Documentation (this feature)
|
||||||
|
|
||||||
|
```text
|
||||||
|
specs/167-derived-state-memoization/
|
||||||
|
├── spec.md
|
||||||
|
├── plan.md
|
||||||
|
├── research.md
|
||||||
|
├── data-model.md
|
||||||
|
├── quickstart.md
|
||||||
|
├── contracts/
|
||||||
|
│ ├── request-scoped-derived-state.logical.openapi.yaml
|
||||||
|
│ └── request-scoped-derived-state-key.schema.json
|
||||||
|
├── checklists/
|
||||||
|
│ └── requirements.md
|
||||||
|
└── tasks.md
|
||||||
|
```
|
||||||
|
|
||||||
|
### Source Code (repository root)
|
||||||
|
|
||||||
|
```text
|
||||||
|
app/
|
||||||
|
├── Filament/
|
||||||
|
│ ├── Pages/
|
||||||
|
│ │ ├── Monitoring/
|
||||||
|
│ │ │ └── EvidenceOverview.php
|
||||||
|
│ │ ├── Operations/
|
||||||
|
│ │ │ └── TenantlessOperationRunViewer.php
|
||||||
|
│ │ └── Reviews/
|
||||||
|
│ │ └── ReviewRegister.php
|
||||||
|
│ └── Resources/
|
||||||
|
│ ├── BaselineSnapshotResource.php
|
||||||
|
│ ├── EvidenceSnapshotResource.php
|
||||||
|
│ ├── FindingResource.php
|
||||||
|
│ ├── OperationRunResource.php
|
||||||
|
│ ├── ReviewPackResource.php
|
||||||
|
│ ├── TenantReviewResource.php
|
||||||
|
│ └── PolicyVersionResource/
|
||||||
|
│ └── Pages/
|
||||||
|
│ └── ViewPolicyVersion.php
|
||||||
|
├── Providers/
|
||||||
|
│ └── AppServiceProvider.php
|
||||||
|
└── Support/
|
||||||
|
├── Navigation/
|
||||||
|
│ └── RelatedNavigationResolver.php
|
||||||
|
├── OpsUx/
|
||||||
|
│ └── OperationUxPresenter.php
|
||||||
|
└── Ui/
|
||||||
|
├── DerivedState/
|
||||||
|
│ ├── DerivedStateKey.php
|
||||||
|
│ ├── DerivedStateFamily.php
|
||||||
|
│ └── RequestScopedDerivedStateStore.php
|
||||||
|
└── GovernanceArtifactTruth/
|
||||||
|
└── ArtifactTruthPresenter.php
|
||||||
|
|
||||||
|
tests/
|
||||||
|
├── Feature/
|
||||||
|
│ ├── Filament/
|
||||||
|
│ │ ├── ReviewRegisterDerivedStateMemoizationTest.php
|
||||||
|
│ │ ├── EvidenceOverviewDerivedStateMemoizationTest.php
|
||||||
|
│ │ ├── OperationRunDerivedStateMemoizationTest.php
|
||||||
|
│ │ └── DerivedStateMutationFreshnessTest.php
|
||||||
|
│ ├── Guards/
|
||||||
|
│ │ └── DerivedStateConsumerAdoptionGuardTest.php
|
||||||
|
│ └── Navigation/
|
||||||
|
│ └── RelatedNavigationResolverMemoizationTest.php
|
||||||
|
└── Unit/
|
||||||
|
└── Support/
|
||||||
|
└── Ui/
|
||||||
|
└── DerivedState/
|
||||||
|
└── RequestScopedDerivedStateStoreTest.php
|
||||||
|
```
|
||||||
|
|
||||||
|
**Structure Decision**: Keep the existing Laravel monolith structure. Introduce the new runtime support types as a narrow support layer near the covered presenter/resolver families, bind them in `AppServiceProvider`, and adopt them through current Filament resources/pages rather than introducing new base directories or a broader platform package.
|
||||||
|
|
||||||
|
## Implementation Strategy
|
||||||
|
|
||||||
|
### Phase A — Introduce the Request-Scoped Store and Key Contract
|
||||||
|
|
||||||
|
**Goal**: Add one explicit per-request store with deterministic keys and no persistence.
|
||||||
|
|
||||||
|
| Step | File | Change |
|
||||||
|
|------|------|--------|
|
||||||
|
| A.1 | `app/Providers/AppServiceProvider.php` | Bind the new derived-state store with request-local lifecycle semantics and no cross-request persistence |
|
||||||
|
| A.2 | `app/Support/Ui/DerivedState/*` | Add the narrow store, key, and supported-family support types needed for resolve and invalidate behavior |
|
||||||
|
| A.3 | Covered unit tests | Verify hit/miss, negative-result reuse, distinct-variant separation, and explicit invalidation behavior |
|
||||||
|
|
||||||
|
### Phase B — Adopt Artifact Truth on Representative Surfaces
|
||||||
|
|
||||||
|
**Goal**: Route repeated artifact-truth resolution through the shared contract and expose row-safe/page-safe access on representative surfaces.
|
||||||
|
|
||||||
|
| Step | File | Change |
|
||||||
|
|------|------|--------|
|
||||||
|
| B.1 | `app/Support/Ui/GovernanceArtifactTruth/ArtifactTruthPresenter.php` | Route `forBaselineSnapshot()`, `forEvidenceSnapshot()`, `forTenantReview()`, `forReviewPack()`, and `forOperationRun()` through the request-scoped store while preserving existing envelope semantics |
|
||||||
|
| B.2 | `app/Filament/Pages/Reviews/ReviewRegister.php` | Replace repeated per-closure `forTenantReview()` calls with one row-safe access path |
|
||||||
|
| B.3 | `app/Filament/Pages/Monitoring/EvidenceOverview.php` | Reuse one artifact-truth resolution per active snapshot row in the canonical evidence overview |
|
||||||
|
| B.4 | `app/Filament/Resources/BaselineSnapshotResource.php`, `app/Filament/Resources/BaselineSnapshotResource/Pages/ViewBaselineSnapshot.php`, `app/Filament/Resources/EvidenceSnapshotResource.php`, `app/Filament/Resources/TenantReviewResource.php`, `app/Filament/Resources/ReviewPackResource.php`, and `app/Filament/Resources/OperationRunResource.php` | Keep the first-slice baseline snapshot, evidence snapshot, tenant review, review pack, and operation-run helper consumers aligned to the shared presenter contract without changing visible meaning |
|
||||||
|
|
||||||
|
### Phase C — Adopt Operation UX and Related Navigation
|
||||||
|
|
||||||
|
**Goal**: Reuse operation guidance and related-context resolution through the same contract while converging existing hidden caches.
|
||||||
|
|
||||||
|
| Step | File | Change |
|
||||||
|
|------|------|--------|
|
||||||
|
| C.1 | `app/Support/OpsUx/OperationUxPresenter.php` | Route covered guidance/explanation reads through the request-scoped store |
|
||||||
|
| C.2 | `app/Support/Navigation/RelatedNavigationResolver.php` | Route primary, detail, and header entry resolution through the request-scoped store |
|
||||||
|
| C.3 | `app/Filament/Resources/OperationRunResource.php` and `app/Filament/Pages/Operations/TenantlessOperationRunViewer.php` | Reuse operation guidance and related context on run list/detail surfaces |
|
||||||
|
| C.4 | `app/Filament/Resources/FindingResource.php` and `app/Filament/Resources/PolicyVersionResource/Pages/ViewPolicyVersion.php` | Replace page-local related-navigation repetition and align the existing finding-specific cache behavior with the shared contract |
|
||||||
|
|
||||||
|
### Phase D — Freshness Rules and Consumer Guardrails
|
||||||
|
|
||||||
|
**Goal**: Make post-mutation freshness explicit and prevent future heavy closures from bypassing the shared contract.
|
||||||
|
|
||||||
|
| Step | File | Change |
|
||||||
|
|------|------|--------|
|
||||||
|
| D.1 | Covered mutating resource/page actions | Document and implement invalidation or forced-fresh access where visible truth, guidance, or related navigation changes within the same request |
|
||||||
|
| D.2 | Covered resources/pages | Introduce clearly named row-safe or page-safe helper seams for repeated derived-state reads |
|
||||||
|
| D.3 | `specs/167-derived-state-memoization/quickstart.md`, `specs/167-derived-state-memoization/contracts/request-scoped-derived-state.logical.openapi.yaml`, and `tests/Feature/Guards/DerivedStateConsumerAdoptionGuardTest.php` | Document the future-family adoption path and add an automated guard that locks the supported-family, keying, access-pattern, and freshness rules so future surfaces cannot drift back to ad hoc local caches |
|
||||||
|
|
||||||
|
### Phase E — Regression Protection and Verification
|
||||||
|
|
||||||
|
**Goal**: Prove the first slice delivers one-derivation-per-request behavior without leaks or stale post-mutation state.
|
||||||
|
|
||||||
|
| Step | File | Change |
|
||||||
|
|------|------|--------|
|
||||||
|
| E.1 | `tests/Unit/Support/Ui/DerivedState/RequestScopedDerivedStateStoreTest.php` | Add core store and key behavior coverage |
|
||||||
|
| E.2 | `tests/Feature/Filament/ReviewRegisterDerivedStateMemoizationTest.php` | Prove repeated artifact-truth reads on one row resolve once per request |
|
||||||
|
| E.3 | `tests/Feature/Filament/EvidenceOverviewDerivedStateMemoizationTest.php` | Prove canonical evidence overview reuses one artifact-truth result per row without scope leakage |
|
||||||
|
| E.4 | `tests/Feature/Filament/OperationRunDerivedStateMemoizationTest.php` and `tests/Feature/Navigation/RelatedNavigationResolverMemoizationTest.php` | Prove operation-guidance and related-navigation reuse plus authorization safety |
|
||||||
|
| E.5 | `tests/Feature/Filament/DerivedStateMutationFreshnessTest.php` | Prove covered post-mutation truth and navigation follow explicit fresh-derivation rules within the same request |
|
||||||
|
| E.6 | `tests/Feature/Guards/DerivedStateConsumerAdoptionGuardTest.php` | Fail CI with actionable output if a covered family introduces an ad hoc local cache or adopts the shared store without explicit declaration metadata |
|
||||||
|
| E.7 | `vendor/bin/sail bin pint --dirty --format agent` and focused Pest runs | Required formatting and targeted verification before implementation completion |
|
||||||
|
|
||||||
|
## Key Design Decisions
|
||||||
|
|
||||||
|
### D-001 — Request-local reuse must be explicit, not hidden in page-local static arrays
|
||||||
|
|
||||||
|
The repo already shows local cache behavior in isolated places, but the repeated-cost problem spans three families and multiple surfaces. One explicit request-scoped contract is narrower and safer than multiplying hidden static caches.
|
||||||
|
|
||||||
|
### D-002 — Keys must include scope context, not just record identity
|
||||||
|
|
||||||
|
Model ID alone is insufficient because some derived values depend on route context, surface variant, or capability-sensitive visibility. The key contract must include scope-sensitive inputs or the family must bypass reuse.
|
||||||
|
|
||||||
|
### D-003 — Adoption should happen behind existing presenters and resolvers, not above them
|
||||||
|
|
||||||
|
The feature exists to reduce duplicate work beneath already-correct semantics. Replacing the current presenters or adding a new presentation meta-framework would violate the spec's bounded intent.
|
||||||
|
|
||||||
|
### D-004 — Row-safe and page-safe helper seams are the UI-level adoption point
|
||||||
|
|
||||||
|
Most repeated work comes from multiple closures on the same list row or detail section. The consumer-side seam should therefore be one named row-safe or page-safe accessor rather than another layer of inline app() calls.
|
||||||
|
|
||||||
|
### D-005 — Mutation freshness is part of the contract, not an afterthought
|
||||||
|
|
||||||
|
Request-local reuse is only safe if covered post-action flows either invalidate affected keys or force a fresh derivation path. Blanket caching to request end would create stale-state ambiguity.
|
||||||
|
|
||||||
|
## Risk Assessment
|
||||||
|
|
||||||
|
| Risk | Impact | Likelihood | Mitigation |
|
||||||
|
|------|--------|------------|------------|
|
||||||
|
| Incorrect key composition leaks state across variants or scopes | High | Medium | Include scope/context fields in the key contract and add explicit cross-tenant/workspace regression tests |
|
||||||
|
| Overbroad first slice creates a large diff with mixed concerns | Medium | Medium | Keep adoption to representative list/detail/canonical surfaces in the three covered families |
|
||||||
|
| Mutation flows accidentally reuse stale pre-action values | High | Medium | Define family-specific freshness policy and add at least one mutation-path regression per covered family |
|
||||||
|
| Existing local caches continue living in parallel with the shared contract | Medium | Medium | Converge finding-specific cache behavior into the shared contract and document page-local cache exceptions as temporary only |
|
||||||
|
| New contract becomes a general cache framework over time | Medium | Low | Keep support types narrow, document non-goals, and require future families to prove deterministic fit before adoption |
|
||||||
|
|
||||||
|
## Test Strategy
|
||||||
|
|
||||||
|
- Add one narrow unit suite for the store and key contract instead of snapshotting every consuming presenter branch.
|
||||||
|
- Add focused Filament feature tests for representative list/detail/canonical surfaces where repeated closure calls exist today.
|
||||||
|
- Prove business-visible non-regression by asserting current labels, badge properties, next-action text, and related URLs remain unchanged on covered examples.
|
||||||
|
- Add scope-safety tests that exercise tenant-context and canonical workspace-context reuse without leaking unauthorized tenant state, including at least one explicit deny-as-not-found regression for non-members or wrong-scope users and one explicit forbidden regression for in-scope users lacking capability.
|
||||||
|
- Add a dedicated post-mutation freshness suite in `tests/Feature/Filament/DerivedStateMutationFreshnessTest.php` to prove stale pre-action truth, guidance, or related navigation is not reused after business state changes in the same request.
|
||||||
|
- Keep the future-family adoption path documented in `quickstart.md` and the logical contract so new presenter or resolver families declare supported family, scope inputs, access pattern, and freshness behavior before using the shared store.
|
||||||
|
- Add a lightweight guard test in `tests/Feature/Guards/DerivedStateConsumerAdoptionGuardTest.php` that scans covered source paths and fails with file-and-snippet output when new ad hoc local caches or undeclared adoption patterns appear.
|
||||||
|
- Keep Livewire v4-compatible page tests for covered pages and use the minimum focused Sail test set needed for implementation verification.
|
||||||
|
|
||||||
|
## Complexity Tracking
|
||||||
|
|
||||||
|
| Violation | Why Needed | Simpler Alternative Rejected Because |
|
||||||
|
|-----------|------------|-------------------------------------|
|
||||||
|
| New request-scoped derived-state abstraction | Three existing families already exhibit the same deterministic repeated-cost pattern across shipped surfaces, and the contract must enforce scope safety plus freshness rules consistently | Page-local static caches and one-off helper memoization hide scope boundaries, duplicate logic, and cannot provide one enforceable adoption path |
|
||||||
|
|
||||||
|
## Proportionality Review
|
||||||
|
|
||||||
|
- **Current operator problem**: Covered pages recompute the same deterministic truth, guidance, and related-navigation state multiple times in one request, increasing render cost and making consistency across closures harder to defend.
|
||||||
|
- **Existing structure is insufficient because**: Existing presenters and resolvers are correct but have no explicit request-local reuse boundary, so every closure and page fragment can trigger a full derivation again.
|
||||||
|
- **Narrowest correct implementation**: One request-scoped store plus one stable key contract beneath the existing families is enough to remove repeated work without persistence, without semantic changes, and without a new UI meta-framework.
|
||||||
|
- **Ownership cost created**: The codebase gains one bounded runtime abstraction, adoption work on covered consumers, and focused tests for keying, scope safety, and freshness.
|
||||||
|
- **Alternative intentionally rejected**: Static arrays, ad hoc per-page caches, and persistent cache stores were rejected because they either obscure scope semantics or solve the wrong problem layer.
|
||||||
|
- **Release truth**: Current-release truth. The hotspot exists today on Review Register, Evidence Overview, Baseline Snapshot, Operation Run, and related navigation surfaces.
|
||||||
|
|
||||||
52
specs/167-derived-state-memoization/quickstart.md
Normal file
52
specs/167-derived-state-memoization/quickstart.md
Normal file
@ -0,0 +1,52 @@
|
|||||||
|
# Quickstart: Request-Scoped Derived State and Resolver Memoization
|
||||||
|
|
||||||
|
Implement Spec 167 by adding one explicit request-scoped derived-state store beneath the existing presenter and resolver families, then adopt it on representative list, detail, and canonical surfaces without changing operator-visible semantics.
|
||||||
|
|
||||||
|
## Implementation Steps
|
||||||
|
|
||||||
|
1. Add the narrow request-scoped derived-state support types under the existing support layer and bind the store in `app/Providers/AppServiceProvider.php` with request-local lifecycle semantics.
|
||||||
|
2. Define the deterministic key contract for family, record identity, variant, and scope-sensitive context, plus an explicit invalidation path for mutation-sensitive derivations.
|
||||||
|
3. Route `ArtifactTruthPresenter::forBaselineSnapshot()`, `forEvidenceSnapshot()`, `forTenantReview()`, `forReviewPack()`, and `forOperationRun()` through the shared store.
|
||||||
|
4. Refactor repeated consumer seams on `ReviewRegister`, `EvidenceOverview`, `BaselineSnapshotResource`, `BaselineSnapshotResource/Pages/ViewBaselineSnapshot.php`, `EvidenceSnapshotResource`, `TenantReviewResource`, `ReviewPackResource`, and `OperationRunResource` so the first-slice badge, description, next-step, and helper consumers share one per-record derivation.
|
||||||
|
5. Route covered `OperationUxPresenter` guidance/explanation and `RelatedNavigationResolver` primary/detail/header entry paths through the same store.
|
||||||
|
6. Converge the existing finding-specific related-entry cache and other repeated navigation consumers toward the shared contract instead of leaving multiple local cache patterns in place.
|
||||||
|
7. Add focused unit and feature tests for derivation counts, negative-result reuse, mutation freshness, and cross-scope safety, including `tests/Feature/Filament/DerivedStateMutationFreshnessTest.php` as the dedicated freshness suite.
|
||||||
|
8. Add `tests/Feature/Guards/DerivedStateConsumerAdoptionGuardTest.php` so CI fails with actionable output if covered families reintroduce ad hoc local caches or adopt the shared store without explicit consumer declaration metadata.
|
||||||
|
|
||||||
|
## Future Family Adoption
|
||||||
|
|
||||||
|
1. Confirm the candidate family is deterministic for the proposed access path and that adopting the shared store does not introduce a second semantic layer.
|
||||||
|
2. Declare the full consumer metadata set under the top-level `x-derived-state-consumers` extension in `contracts/request-scoped-derived-state.logical.openapi.yaml` before adding the consumer: `surface`, `family`, `variant`, `accessPattern`, `scopeInputs`, `freshnessPolicy`, and `guardScope`. Add `requiredMarkers` and `maxOccurrences` guard metadata so the adoption guard can point to the intended helper seam and reject bypasses or resurrected local caches. Advisory hints such as `mutationSensitive` or `capabilitySensitive` may be added when they help review, but they do not replace the required declaration fields.
|
||||||
|
3. Choose one supported `accessPattern` per surface: `row_safe`, `page_safe`, or `direct_once`; do not introduce a new page-local static cache for a covered family.
|
||||||
|
4. Add or update the focused Pest coverage that proves repeated reads collapse to one derivation, scope boundaries remain intact, and any mutation-sensitive path is fresh after state changes.
|
||||||
|
5. If a family cannot satisfy deterministic keying or freshness rules, use the explicit no-reuse path instead of weakening the shared contract.
|
||||||
|
6. Run `tests/Feature/Guards/DerivedStateConsumerAdoptionGuardTest.php` after adding or widening adoption so undeclared scope inputs, freshness gaps, missing guard scope, and ad hoc local caches fail before merge.
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
|
||||||
|
### Automated
|
||||||
|
|
||||||
|
```bash
|
||||||
|
vendor/bin/sail up -d
|
||||||
|
vendor/bin/sail artisan test --compact tests/Unit/Support/Ui/DerivedState/RequestScopedDerivedStateStoreTest.php
|
||||||
|
vendor/bin/sail artisan test --compact tests/Feature/Filament/ReviewRegisterDerivedStateMemoizationTest.php
|
||||||
|
vendor/bin/sail artisan test --compact tests/Feature/Filament/EvidenceOverviewDerivedStateMemoizationTest.php
|
||||||
|
vendor/bin/sail artisan test --compact tests/Feature/Filament/OperationRunDerivedStateMemoizationTest.php
|
||||||
|
vendor/bin/sail artisan test --compact tests/Feature/Filament/DerivedStateMutationFreshnessTest.php
|
||||||
|
vendor/bin/sail artisan test --compact tests/Feature/Navigation/RelatedNavigationResolverMemoizationTest.php
|
||||||
|
vendor/bin/sail artisan test --compact tests/Feature/Guards/DerivedStateConsumerAdoptionGuardTest.php
|
||||||
|
vendor/bin/sail bin pint --dirty --format agent
|
||||||
|
```
|
||||||
|
|
||||||
|
### Manual
|
||||||
|
|
||||||
|
1. Open the review register and verify artifact-truth label, publication state, and next-step text remain unchanged while repeated presenter calls are eliminated.
|
||||||
|
2. Open the evidence overview and verify one active snapshot row per tenant still renders the same truth and freshness messaging, then confirm one canonical authorization regression still behaves correctly: non-member or wrong-scope access remains deny-as-not-found and an in-scope user lacking capability remains forbidden.
|
||||||
|
3. Open the tenantless operation-run viewer and verify related context, guidance, and artifact-truth details remain consistent.
|
||||||
|
4. Exercise one covered mutating flow and verify any post-action truth or related navigation shown in the same request reflects the updated business state.
|
||||||
|
|
||||||
|
## Expected Outcome
|
||||||
|
|
||||||
|
- Covered presenter and resolver families resolve deterministic results once per request for the same key.
|
||||||
|
- Covered surfaces retain the same operator-visible semantics and navigation destinations.
|
||||||
|
- No persistent caches, no new semantic state families, and no cross-tenant or cross-workspace reuse leakage are introduced.
|
||||||
49
specs/167-derived-state-memoization/research.md
Normal file
49
specs/167-derived-state-memoization/research.md
Normal file
@ -0,0 +1,49 @@
|
|||||||
|
# Research: Request-Scoped Derived State and Resolver Memoization
|
||||||
|
|
||||||
|
## Decision 1: Use a dedicated request-scoped in-memory store bound through the Laravel container
|
||||||
|
|
||||||
|
- **Decision**: Introduce one dedicated request-scoped derived-state store with request-local lifecycle semantics instead of static arrays or persistent cache stores.
|
||||||
|
- **Rationale**: The feature needs explicit reuse within one HTTP or Livewire request and explicit isolation across requests. A request-local container binding makes that boundary visible and testable while avoiding new persistence and avoiding cross-request staleness.
|
||||||
|
- **Alternatives considered**:
|
||||||
|
- Static caches inside presenters or resources: rejected because they hide scope boundaries, duplicate behavior across families, and make invalidation inconsistent.
|
||||||
|
- `Cache::remember()` or Redis-backed caching: rejected because the spec explicitly excludes cross-request caching and because stale semantic reuse would become much harder to reason about.
|
||||||
|
|
||||||
|
## Decision 2: Key derivations by family, record identity, variant, and scope-sensitive context
|
||||||
|
|
||||||
|
- **Decision**: Define one deterministic key contract that includes the derived-state family, stable record identity, variant or surface mode, and any workspace, tenant, or visibility-sensitive context required to produce the correct result.
|
||||||
|
- **Rationale**: Existing repeated work happens because the same deterministic question is asked multiple times. Correct reuse therefore depends on a stable definition of “same question.” Model-object identity alone is insufficient because the same record can appear through different model instances or under different scopes.
|
||||||
|
- **Alternatives considered**:
|
||||||
|
- Model ID only: rejected because it cannot distinguish list vs detail variants or capability-sensitive outputs.
|
||||||
|
- `spl_object_hash()` only: rejected because it prevents convergence across separate model instances representing the same record.
|
||||||
|
|
||||||
|
## Decision 3: Integrate through the existing family entry points, not by adding a new presentation framework
|
||||||
|
|
||||||
|
- **Decision**: Route reuse through `ArtifactTruthPresenter`, `OperationUxPresenter`, and `RelatedNavigationResolver` entry points instead of creating a generic presenter base class, a universal decorator, or a new UI taxonomy layer.
|
||||||
|
- **Rationale**: The business semantics already live in these families. The feature's goal is to reduce repeated deterministic work beneath them, not to redesign how operator meaning is modeled.
|
||||||
|
- **Alternatives considered**:
|
||||||
|
- New cross-domain presentation framework: rejected because it would layer new semantics on top of already-correct families and violate the spec's narrow foundation intent.
|
||||||
|
- Surface-only fixes per page: rejected because the same repeated-cost pattern already spans multiple domains and would continue to reappear elsewhere.
|
||||||
|
|
||||||
|
## Decision 4: Converge existing hidden caches into the shared contract and keep negative results reusable
|
||||||
|
|
||||||
|
- **Decision**: Standardize existing local request-like caches, such as the finding primary related-entry cache, behind the shared contract and allow deterministic negative results like “no related entry” or “no next action” to be reused within one request.
|
||||||
|
- **Rationale**: The repo already contains evidence that request-local reuse is useful, but it is unevenly applied. Converging on one contract avoids parallel caching patterns and still prevents repeated work when the correct result is the absence of a link or action.
|
||||||
|
- **Alternatives considered**:
|
||||||
|
- Leave existing local caches untouched and optimize only the worst pages: rejected because it would preserve multiple hidden patterns and make future adoption harder.
|
||||||
|
- Cache only positive results: rejected because deterministic negative results can also drive repeated work and should be equally reusable when scope-stable.
|
||||||
|
|
||||||
|
## Decision 5: Treat mutation freshness as an explicit family rule
|
||||||
|
|
||||||
|
- **Decision**: Covered mutating flows must explicitly invalidate or bypass request-local reuse after business state changes within the same request.
|
||||||
|
- **Rationale**: The spec requires “no stale within request ambiguity.” A clear family-level freshness rule is safer than assuming the existing code path order will always avoid stale derived values.
|
||||||
|
- **Alternatives considered**:
|
||||||
|
- Cache for the full request without exceptions: rejected because post-action state could become stale and misleading.
|
||||||
|
- Disable reuse on every Livewire action: rejected because many actions still have deterministic pre- and post-action read phases where request-local reuse remains valuable.
|
||||||
|
|
||||||
|
## Decision 6: Test derivation-count behavior directly instead of proxying everything through query-count assertions
|
||||||
|
|
||||||
|
- **Decision**: Validate the feature with focused unit and feature tests that prove one full derivation per request for representative covered families, plus explicit scope-safety and mutation-path tests.
|
||||||
|
- **Rationale**: The repeated-cost problem is not just SQL chatter. It is repeated presenter and resolver work across closures and page fragments. Query-count assertions alone would miss important non-query work and would not prove freshness rules.
|
||||||
|
- **Alternatives considered**:
|
||||||
|
- Measure only query counts: rejected because the problem is broader than SQL and includes repeated in-memory translation and navigation assembly.
|
||||||
|
- Rely on manual profiling only: rejected because this feature needs regression protection against future local cache drift.
|
||||||
219
specs/167-derived-state-memoization/spec.md
Normal file
219
specs/167-derived-state-memoization/spec.md
Normal file
@ -0,0 +1,219 @@
|
|||||||
|
# Feature Specification: Request-Scoped Derived State and Resolver Memoization
|
||||||
|
|
||||||
|
**Feature Branch**: `167-derived-state-memoization`
|
||||||
|
**Created**: 2026-03-28
|
||||||
|
**Status**: Draft
|
||||||
|
**Input**: User description: "Request-scoped derived state and resolver memoization foundation"
|
||||||
|
|
||||||
|
## Spec Scope Fields *(mandatory)*
|
||||||
|
|
||||||
|
- **Scope**: workspace + tenant + canonical-view
|
||||||
|
- **Primary Routes**:
|
||||||
|
- Workspace-scoped baseline snapshot list and detail surfaces
|
||||||
|
- Tenant-scoped evidence snapshot list and detail surfaces
|
||||||
|
- Workspace-scoped evidence overview and review register surfaces
|
||||||
|
- Tenant-scoped tenant review and review-pack list and detail surfaces
|
||||||
|
- Workspace-scoped operation run list and tenantless operation-run detail surfaces
|
||||||
|
- Existing detail and list surfaces that build related-context entries through the shared navigation resolver, especially baseline snapshot, policy version, and finding inspection surfaces
|
||||||
|
- **Data Ownership**:
|
||||||
|
- Workspace-owned records affected: baseline snapshots, operation runs, workspace-scoped review register rows, and related workspace-scoped records whose operator-facing state is repeatedly derived during one request
|
||||||
|
- Tenant-owned records affected: evidence snapshots, tenant reviews, review packs, and tenant-owned records that contribute related navigation entries or operator guidance on covered surfaces
|
||||||
|
- This feature does not change ownership boundaries and does not introduce any new persisted artifact; it only changes how already-owned truth is reused inside one request
|
||||||
|
- **RBAC**:
|
||||||
|
- Existing workspace membership, tenant entitlement, and capability checks remain unchanged across all covered surfaces
|
||||||
|
- Non-members and out-of-scope users remain deny-as-not-found
|
||||||
|
- In-scope users missing required capability remain forbidden
|
||||||
|
- Request-local reuse must never cross workspace, tenant, request, or authorization boundaries
|
||||||
|
|
||||||
|
For canonical-view specs, the spec MUST define:
|
||||||
|
|
||||||
|
- **Default filter behavior when tenant-context is active**: Canonical evidence, review, and operation-run surfaces continue to open in the currently active tenant context or existing prefiltered scope when entered from tenant context. Request-local reuse must respect that existing scope instead of broadening a derivation to workspace-wide results.
|
||||||
|
- **Explicit entitlement checks preventing cross-tenant leakage**: Any memoized derived state used on canonical views must remain isolated to the current request and must include scope-sensitive inputs whenever the output depends on tenant context, workspace context, visibility rules, or capability-sensitive presentation. A derived label, next action, or related entry computed for one tenant or one entitlement context must never be reused for another.
|
||||||
|
|
||||||
|
## 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 |
|
||||||
|
|---|---|---|---|---|---|---|---|---|---|
|
||||||
|
| Baseline snapshot list and detail | Workspace governance operator | List/detail | Is this snapshot trustworthy, and where should I go next? | Existing artifact-truth label, explanation, status badges, related context, next action | Raw payloads, low-level counters, internal reason fragments | artifact truth, freshness, readiness, operator actionability | Existing actions only | View snapshot, inspect related context, open related run | Existing destructive actions unchanged |
|
||||||
|
| Evidence snapshot list/detail and evidence overview | Tenant or workspace governance operator | List/detail/overview | What evidence exists, how complete is it, and what should I inspect? | Existing truth badges, completeness explanation, next action, related navigation | Raw evidence payloads, internal detail fragments | artifact truth, completeness, freshness, actionability | Existing actions only | View snapshot, open related review or run | Existing destructive actions unchanged |
|
||||||
|
| Tenant review list/detail and review register | Tenant or workspace review operator | List/detail/register | Is this review ready, and what is blocking it if not? | Existing primary label, publication-readiness state, next action, related navigation | Raw section payloads, internal readiness details | artifact truth, publication readiness, actionability | Existing actions only | View review, open related pack or run | Existing destructive actions unchanged |
|
||||||
|
| Review-pack list/detail | Tenant review operator | List/detail | Is this pack usable, and what is its source context? | Existing primary truth state, operator explanation, related navigation | Internal generation details, raw export payloads | artifact truth, readiness, actionability | Existing actions only | View pack, open source review | Existing destructive actions unchanged |
|
||||||
|
| Operation run list/detail and related-context surfaces | Governance or operations operator | List/detail | What happened, what does it mean, and where should I navigate? | Existing surface guidance, operator explanation, related entries, existing run metadata | Raw context JSON, low-level execution fields | execution outcome, artifact truth where relevant, actionability | Existing actions only | View run, retry where already allowed, open related record | Existing dangerous rerun or mutation actions unchanged |
|
||||||
|
|
||||||
|
## Proportionality Review *(mandatory when structural complexity is introduced)*
|
||||||
|
|
||||||
|
- **New source of truth?**: No. The feature reuses existing artifact truth, operation UX, and related-navigation truth.
|
||||||
|
- **New persisted entity/table/artifact?**: No. All reuse remains request-local and in-memory only.
|
||||||
|
- **New abstraction?**: Yes. A single request-scoped derived-state contract is introduced so deterministic presenter and resolver outputs can be reused explicitly within one request.
|
||||||
|
- **New enum/state/reason family?**: No. This feature must not introduce a new semantic family.
|
||||||
|
- **New cross-domain UI framework/taxonomy?**: No. The feature is a runtime reuse contract, not a new interpretation or badge framework.
|
||||||
|
- **Current operator problem**: Governance-heavy list, detail, and widget surfaces repeatedly recompute the same truth, guidance, and related-navigation state, making each additional closure or surface fragment more expensive than it should be and increasing the risk that related values diverge in timing or freshness.
|
||||||
|
- **Existing structure is insufficient because**: Current presenters and resolvers are semantically correct but are called independently from badge, description, tooltip, visibility, URL, detail-entry, and widget closures without one explicit request-local reuse boundary.
|
||||||
|
- **Narrowest correct implementation**: A request-scoped deterministic reuse contract for explicitly covered presenter and resolver families is the smallest viable fix. It addresses the repeated-cost shape without adding persistence, without introducing cross-request caching, and without redefining business semantics.
|
||||||
|
- **Ownership cost**: The feature adds key-discipline rules, adoption work on covered surfaces, freshness testing for mutation flows, and ongoing review discipline so new heavy closures do not bypass the shared contract.
|
||||||
|
- **Alternative intentionally rejected**: Page-local static caches, ad hoc helper memoization, cross-request cache stores, and broad query redesign were rejected because they either hide scope boundaries, create stale-state ambiguity, or solve a different layer of the problem.
|
||||||
|
- **Release truth**: Current-release truth. The affected hotspots already exist on shipped artifact, review, navigation, and operation surfaces.
|
||||||
|
|
||||||
|
## User Scenarios & Testing *(mandatory)*
|
||||||
|
|
||||||
|
### User Story 1 - Reuse One Derived Truth Per Record (Priority: P1)
|
||||||
|
|
||||||
|
As an operator viewing governance-heavy lists and detail surfaces, I want repeated labels, explanations, icons, colors, next actions, and related links for the same record to come from one request-local derivation, so that the page remains responsive and internally consistent.
|
||||||
|
|
||||||
|
**Why this priority**: This is the immediate product-cost problem. The same row or detail page currently asks for the same deterministic answer multiple times through separate UI closures.
|
||||||
|
|
||||||
|
**Independent Test**: Can be fully tested by instrumenting a covered surface where the same presenter or resolver output is read multiple times for one record and proving that the full derivation occurs once per request while all visible values stay unchanged.
|
||||||
|
|
||||||
|
**Acceptance Scenarios**:
|
||||||
|
|
||||||
|
1. **Given** a covered list row renders a primary label, badge color, badge icon, description, and next action from the same artifact-truth state, **When** the row is built within one request, **Then** the underlying full truth derivation occurs once and the visible outputs remain identical to the current semantics.
|
||||||
|
2. **Given** a covered detail page renders related-context entries and operator explanation from the same underlying record, **When** the page is built within one request, **Then** repeated reads reuse the same request-local derived state instead of recomputing it separately.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### User Story 2 - Reuse Derived State Across Surface Fragments In One Request (Priority: P1)
|
||||||
|
|
||||||
|
As an operator using pages that combine tables, summary sections, and related-context fragments, I want the same deterministic truth to be reused across those fragments, so that multi-part pages do not multiply the same derivation cost.
|
||||||
|
|
||||||
|
**Why this priority**: The repeated-cost shape is not limited to one table cell. It also appears when one request renders widgets, detail sections, and navigation context for the same records.
|
||||||
|
|
||||||
|
**Independent Test**: Can be fully tested by instrumenting a covered page where the same record contributes to more than one surface fragment during one request and proving that the derivation is reused while the page still renders the same operator-facing meaning.
|
||||||
|
|
||||||
|
**Acceptance Scenarios**:
|
||||||
|
|
||||||
|
1. **Given** a canonical or workspace surface renders a table plus a supporting detail or related-context fragment for the same record set, **When** the request completes, **Then** the shared presenter or resolver family computes each deterministic record-scope result once.
|
||||||
|
2. **Given** a covered page uses both a presenter-backed explanation and a resolver-backed related link for the same record in multiple places, **When** the page renders, **Then** each covered family reuses its own request-local result instead of repeating full derivation work per consumer.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### User Story 3 - Preserve Freshness In Mutation Flows (Priority: P2)
|
||||||
|
|
||||||
|
As an operator triggering an action that changes a record's visible truth, guidance, or related navigation, I want any post-action state shown within the same request to be freshly determined when needed, so that the interface never reuses stale pre-mutation derived state.
|
||||||
|
|
||||||
|
**Why this priority**: Request-local reuse is only safe if mutation flows define where reuse stops and fresh determination must resume.
|
||||||
|
|
||||||
|
**Independent Test**: Can be fully tested by executing a covered mutating action that changes operator-visible truth or related navigation in the same request and proving that the post-action state is not taken from the stale pre-action derivation.
|
||||||
|
|
||||||
|
**Acceptance Scenarios**:
|
||||||
|
|
||||||
|
1. **Given** a covered action changes the business state used by a presenter or resolver, **When** the action finishes within the same request, **Then** any post-action truth or navigation shown to the operator is freshly determined according to the explicit freshness rule.
|
||||||
|
2. **Given** a covered derivation is intentionally non-deterministic or capability-sensitive, **When** the request path cannot safely reuse a prior result, **Then** the feature bypasses memoization or uses a distinct key rather than returning a stale or cross-scope result.
|
||||||
|
|
||||||
|
### Edge Cases
|
||||||
|
|
||||||
|
- The same underlying record is referenced through more than one model instance during one request; identity-based keying must still converge when the business inputs are the same.
|
||||||
|
- Two derivation variants for the same record require different outputs, such as list-surface primary state versus detail-surface related entries; the key contract must keep those variants separate.
|
||||||
|
- A covered path returns a stable negative result such as no related entry or no next action; that absence must be reusable within the request instead of triggering repeated resolver work.
|
||||||
|
- A derivation depends on capability, visibility, or current-scope context; memoization must include those inputs or explicitly opt out.
|
||||||
|
- A mutating action changes the state used by a covered derivation inside the same request; the pre-mutation result must not be reused after the state change.
|
||||||
|
- Canonical views entered from tenant context must not reuse derived state outside the active tenant or workspace scope.
|
||||||
|
|
||||||
|
## Requirements *(mandatory)*
|
||||||
|
|
||||||
|
**Constitution alignment (required):** This feature introduces no new Microsoft Graph call path, no new persistence, and no new long-running operation family. It hardens the request-time read path for existing presenter and resolver outputs. Existing business truth, existing safety gates, existing audit semantics, and existing operation observability remain unchanged. If a covered mutating action needs fresh post-action truth, the implementation must define that freshness path explicitly rather than relying on accidental stale reuse.
|
||||||
|
|
||||||
|
**Constitution alignment (PROP-001 / ABSTR-001 / PERSIST-001 / STATE-001 / BLOAT-001):** The feature introduces one new abstraction and no new persistence or state family. The abstraction is justified because repeated deterministic derivation is already a current-release cross-surface cost problem and existing local call-site discipline is insufficient. The feature follows the default bias by deriving before persisting, keeping reuse request-local, and avoiding a broader cache platform.
|
||||||
|
|
||||||
|
**Constitution alignment (OPS-UX):** This feature does not create a new `OperationRun` family and does not change the three-surface feedback contract. If operation-run list or detail surfaces adopt request-local guidance reuse, `OperationRun.status` and `OperationRun.outcome` remain service-owned and all existing notification behavior stays unchanged.
|
||||||
|
|
||||||
|
**Constitution alignment (RBAC-UX):** This feature does not change the authorization model, but it must not become an accidental authorization layer. Non-members and wrong-scope users remain `404`. In-scope users lacking capability remain `403`. Any derived state that depends on visibility or capability context must include that context in its reuse boundary or bypass reuse. At least one positive and one negative scope-safety regression test must prove that request-local reuse cannot leak unauthorized tenant or workspace state.
|
||||||
|
|
||||||
|
**Constitution alignment (OPS-EX-AUTH-001):** Not applicable. No authentication handshake behavior is changed.
|
||||||
|
|
||||||
|
**Constitution alignment (BADGE-001):** This feature must not introduce new page-local badge mappings. If covered surfaces reuse the same derived truth for multiple badge properties, they must continue to consume centralized badge semantics with unchanged visible meaning.
|
||||||
|
|
||||||
|
**Constitution alignment (UI-FIL-001):** Covered admin and operator surfaces continue to use existing Filament tables, infolists, widgets, and shared UI primitives. The feature should consolidate how existing derived values are obtained, not introduce custom replacement markup or a new local status language.
|
||||||
|
|
||||||
|
**Constitution alignment (UI-NAMING-001):** This feature does not introduce new operator-facing action vocabulary. Existing labels, helper copy, next-action text, run titles, and related-link labels must remain semantically unchanged unless an existing covered surface is already inconsistent and the change is explicitly called out by a follow-up spec.
|
||||||
|
|
||||||
|
**Constitution alignment (OPSURF-001):** This feature materially affects operator-facing surfaces but must not change what those surfaces primarily communicate. Default-visible information, diagnostic hierarchy, mutation scope messaging, and inspect affordances stay the same. The change is that repeated deterministic state is obtained once per request and reused consistently.
|
||||||
|
|
||||||
|
**Constitution alignment (UI-SEM-001 / LAYER-001 / TEST-TRUTH-001):** The feature must not create a second semantic layer above artifact truth, operation UX, or related navigation. It reuses existing layers and adds one request-local reuse boundary beneath them. Tests must focus on business-visible consistency, scope safety, and freshness consequences rather than treating memoization as a goal by itself.
|
||||||
|
|
||||||
|
**Constitution alignment (Filament Action Surfaces):** Covered Filament Resources and Pages keep their current action inventories and inspect affordances. The Action Surface Contract remains satisfied because this feature changes derivation reuse behind existing actions rather than introducing new mutation behavior. No new exemptions are needed.
|
||||||
|
|
||||||
|
**Constitution alignment (UX-001 — Layout & Information Architecture):** No new Filament layout pattern is introduced. Existing view-page, list-page, widget, and summary structures remain intact. Any row-safe or page-safe consumption helper introduced by implementation must preserve current table filters, sortability, inspection flow, empty states, and detail-section hierarchy.
|
||||||
|
|
||||||
|
### Functional Requirements
|
||||||
|
|
||||||
|
- **FR-167-001**: The system MUST provide one explicit request-scoped contract for deterministic derived presentation and navigation state used repeatedly within one HTTP or Livewire request.
|
||||||
|
- **FR-167-002**: The contract MUST be isolated per request and MUST NOT persist derived state across requests.
|
||||||
|
- **FR-167-003**: The contract MUST use deterministic keys that can distinguish at minimum derivation family, record type, record identity, surface variant or mode, and any scope-sensitive input that changes the result.
|
||||||
|
- **FR-167-004**: The contract MUST support an explicit opt-out or distinct-key path for derivations that are non-deterministic, mutation-sensitive, or capability-sensitive.
|
||||||
|
- **FR-167-005**: The first implementation slice MUST route the covered `ArtifactTruthPresenter::for*()` paths through the request-scoped contract for baseline snapshots, evidence snapshots, tenant reviews, review packs, and operation runs.
|
||||||
|
- **FR-167-006**: The first implementation slice MUST route covered `OperationUxPresenter` surface-guidance and operator-explanation paths through the same request-scoped contract.
|
||||||
|
- **FR-167-007**: The first implementation slice MUST route covered `RelatedNavigationResolver` primary-entry, detail-entry, and header-entry paths through the same request-scoped contract where repeated resolution currently occurs.
|
||||||
|
- **FR-167-008**: The first implementation slice MUST cover at least one list surface, one detail surface, and one widget or canonical surface across the three target families.
|
||||||
|
- **FR-167-009**: Covered surfaces MUST be able to read the same deterministic derived value from multiple UI consumers in one request without triggering a second full derivation.
|
||||||
|
- **FR-167-010**: Covered list surfaces MUST define a row-safe consumption pattern so label, description, tooltip, icon, color, URL, and visibility consumers can share one derived-state result per row when they rely on the same family and scope.
|
||||||
|
- **FR-167-011**: Covered detail surfaces MUST define an equivalent page-safe or section-safe consumption pattern when the same deterministic result is read more than once during one request.
|
||||||
|
- **FR-167-012**: The contract MUST support stable reuse of negative results such as no related entry, no next action, or no operator explanation when those results are deterministic for the current scope.
|
||||||
|
- **FR-167-013**: Request-local reuse MUST NOT change operator-visible semantics, labels, readiness states, navigation destinations, or next-action text on covered surfaces.
|
||||||
|
- **FR-167-014**: Request-local reuse MUST NOT cross workspace boundaries, tenant boundaries, request boundaries, or authorization contexts.
|
||||||
|
- **FR-167-015**: If a derived result depends on capability or visibility context, that context MUST be part of the reuse boundary or the derivation MUST bypass request-local reuse.
|
||||||
|
- **FR-167-016**: Covered mutating action flows MUST define an explicit freshness rule so post-mutation truth, guidance, or navigation is recomputed when the underlying business state changes within the same request.
|
||||||
|
- **FR-167-017**: The feature MUST forbid new ad hoc page-local static caches for covered families once the shared contract exists, unless an explicit, bounded exemption is documented.
|
||||||
|
- **FR-167-018**: The feature MUST include focused regression coverage proving one full derivation per request for representative `ArtifactTruthPresenter`, `OperationUxPresenter`, and `RelatedNavigationResolver` paths.
|
||||||
|
- **FR-167-019**: The feature MUST include focused regression coverage proving that request-local reuse cannot leak derived state across tenant, workspace, or entitlement boundaries.
|
||||||
|
- **FR-167-020**: The feature MUST include at least one mutation-path regression proving that covered post-action state is freshly determined when business truth changes within the same request.
|
||||||
|
- **FR-167-021**: The feature MUST provide a documented adoption path and an automated guardrail for future presenter and resolver families so new heavy derived-state surfaces do not bypass the contract by default.
|
||||||
|
- **FR-167-022**: The feature MUST explicitly exclude cross-request caching, persistent cache stores, new persisted summaries, and business-semantic changes to artifact truth, operation guidance, or related navigation.
|
||||||
|
|
||||||
|
## 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 snapshots | `app/Filament/Resources/BaselineSnapshotResource.php` and related view page | Existing actions unchanged | Existing clickable inspection remains primary | Existing row actions unchanged | Existing bulk actions unchanged | Existing empty state unchanged | Existing view actions unchanged | N/A | Existing audit model unchanged | This spec changes only how truth and related context are reused during one request |
|
||||||
|
| Evidence snapshots and evidence overview | `app/Filament/Resources/EvidenceSnapshotResource.php` and `app/Filament/Pages/Monitoring/EvidenceOverview.php` | Existing actions unchanged | Existing clickable inspection remains primary | Existing row actions unchanged | Existing bulk actions unchanged | Existing empty states unchanged | Existing view actions unchanged | N/A | Existing audit model unchanged | No action inventory change; only repeated truth derivation is consolidated |
|
||||||
|
| Tenant reviews and review register | `app/Filament/Resources/TenantReviewResource.php` and `app/Filament/Pages/Reviews/ReviewRegister.php` | Existing actions unchanged | Existing clickable inspection remains primary | Existing row actions unchanged | Existing bulk actions unchanged | Existing empty states unchanged | Existing view actions unchanged | N/A | Existing audit model unchanged | Review truth, readiness, and next-action reads should reuse one request-local result per record |
|
||||||
|
| Review packs | `app/Filament/Resources/ReviewPackResource.php` | Existing actions unchanged | Existing clickable inspection remains primary | Existing row actions unchanged | Existing bulk actions unchanged | Existing empty states unchanged | Existing view actions unchanged | N/A | Existing audit model unchanged | No new actions, semantics, or audit events are added |
|
||||||
|
| Operation runs and related-context pages | `app/Filament/Resources/OperationRunResource.php`, `app/Filament/Pages/Operations/TenantlessOperationRunViewer.php`, and covered related-context pages using `RelatedNavigationResolver` | Existing actions unchanged | Existing inspection patterns unchanged | Existing retry or navigation actions unchanged | Existing bulk actions unchanged | Existing empty states unchanged | Existing detail actions unchanged | N/A | Existing audit model unchanged | The purpose is request-local reuse of guidance and related navigation, not action redesign |
|
||||||
|
|
||||||
|
### Key Entities *(include if feature involves data)*
|
||||||
|
|
||||||
|
- **Request-Scoped Derived State Contract**: The explicit per-request reuse boundary for deterministic presenter and resolver outputs.
|
||||||
|
- **Derived State Key**: The stable identifier that combines derivation family, record identity, variant, and any scope-sensitive inputs needed to return the correct result.
|
||||||
|
- **Row-Safe Surface State**: The once-derived per-record state reused by multiple list-row consumers in one request.
|
||||||
|
- **Freshness Boundary**: The point at which a mutating action must recompute truth instead of reusing a previously derived result from the same request.
|
||||||
|
- **Derived State Family**: One covered presenter or resolver family, such as artifact truth, operation UX guidance, or related navigation.
|
||||||
|
|
||||||
|
## Success Criteria *(mandatory)*
|
||||||
|
|
||||||
|
### Measurable Outcomes
|
||||||
|
|
||||||
|
- **SC-167-001**: On the first implementation slice, 100% of covered repeated-read examples derive a deterministic record-scope result at most once per request for the covered family.
|
||||||
|
- **SC-167-002**: At least three currently affected surface families adopt the same request-scoped contract without changing the operator-visible meaning of their labels, explanations, or related destinations.
|
||||||
|
- **SC-167-003**: In focused regression coverage, 100% of covered rows and detail pages keep badge properties, descriptions, next-action text, and related-navigation outputs internally consistent when those values depend on the same derived-state family.
|
||||||
|
- **SC-167-004**: In mutation-path regression coverage, 100% of covered post-action flows show fresh post-mutation truth or navigation when the underlying business state changes inside the request.
|
||||||
|
- **SC-167-005**: In focused scope-safety regression coverage, 100% of covered canonical and tenant-context examples prevent derived-state reuse across unauthorized workspace, tenant, or entitlement boundaries.
|
||||||
|
- **SC-167-006**: The first implementation slice ships without introducing any new persisted cache, any new business-semantic state family, or any visible operator-language regression on the covered surfaces.
|
||||||
|
- **SC-167-007**: An automated derived-state adoption guard fails with actionable output when a covered consumer introduces an ad hoc local cache, bypasses the supported access patterns, or adopts the shared store without explicit scope and freshness declaration.
|
||||||
|
|
||||||
|
## Assumptions
|
||||||
|
|
||||||
|
- Existing `ArtifactTruthPresenter`, `OperationUxPresenter`, and `RelatedNavigationResolver` outputs are already semantically correct for the covered first slice; the problem is repeated request-time computation, not incorrect business meaning.
|
||||||
|
- Request scope is defined by one HTTP or Livewire request and is sufficiently narrow to avoid cross-request staleness while still removing repeated deterministic work.
|
||||||
|
- Some surfaces already contain local request-like caches, and the long-term direction is to converge those local caches into one explicit contract instead of allowing multiple hidden patterns to coexist.
|
||||||
|
- The first implementation slice should reuse existing presenter and resolver families rather than replacing them with a broader semantic or caching platform.
|
||||||
|
|
||||||
|
## Dependencies
|
||||||
|
|
||||||
|
- Spec 131 - Cross-Resource Navigation
|
||||||
|
- Spec 144 - Canonical Operation Viewer Context Decoupling
|
||||||
|
- Spec 156 - Operator Outcome Taxonomy and Cross-Domain State Separation
|
||||||
|
- Spec 157 - Operator Reason Code Translation and Humanization Contract
|
||||||
|
- Spec 158 - Governance Artifact Truthful Outcomes and Fidelity Semantics
|
||||||
|
- Existing baseline snapshot, evidence snapshot, tenant review, review-pack, review register, evidence overview, and operation-run surfaces already in the admin panel
|
||||||
|
|
||||||
|
## Non-Goals
|
||||||
|
|
||||||
|
- Introducing cross-request caching, Redis, or any persistent cache store
|
||||||
|
- Redesigning the underlying database query layer or treating this spec as a general query-optimization initiative
|
||||||
|
- Changing the business semantics of artifact truth, operation guidance, or related navigation
|
||||||
|
- Replacing existing presenter or resolver families with a new taxonomy or UI framework
|
||||||
|
- Performing the tenant-governance aggregate consolidation tracked by the adjacent aggregate candidate
|
||||||
|
- Performing the workspace access-context hardening tracked by the adjacent workspace-context candidate
|
||||||
|
|
||||||
|
## Final Direction
|
||||||
|
|
||||||
|
This spec establishes one explicit request-scoped reuse contract for deterministic derived state that is already being asked for repeatedly across artifact, review, navigation, and operation surfaces. It is intentionally narrow: no new persistence, no new meaning system, and no speculative cache platform. The first slice should prove that Artifact Truth, Operation UX, and Related Navigation can share one request-local boundary, that mutating flows keep their freshness guarantees, and that future heavy surfaces have a clear adoption path instead of inventing one more local cache.
|
||||||
208
specs/167-derived-state-memoization/tasks.md
Normal file
208
specs/167-derived-state-memoization/tasks.md
Normal file
@ -0,0 +1,208 @@
|
|||||||
|
# Tasks: Request-Scoped Derived State and Resolver Memoization
|
||||||
|
|
||||||
|
**Input**: Design documents from `/specs/167-derived-state-memoization/`
|
||||||
|
**Prerequisites**: `plan.md` (required), `spec.md` (required for user stories), `research.md`, `data-model.md`, `contracts/`, `quickstart.md`
|
||||||
|
|
||||||
|
**Tests**: Tests are REQUIRED for this feature. Use Pest coverage in `tests/Unit/Support/Ui/DerivedState/RequestScopedDerivedStateStoreTest.php`, `tests/Feature/Filament/ReviewRegisterDerivedStateMemoizationTest.php`, `tests/Feature/Filament/EvidenceOverviewDerivedStateMemoizationTest.php`, `tests/Feature/Filament/OperationRunDerivedStateMemoizationTest.php`, `tests/Feature/Navigation/RelatedNavigationResolverMemoizationTest.php`, `tests/Feature/Filament/DerivedStateMutationFreshnessTest.php`, and `tests/Feature/Guards/DerivedStateConsumerAdoptionGuardTest.php`.
|
||||||
|
**Operations**: This feature touches existing `OperationRun` list/detail surfaces and `OperationUxPresenter`, but it does not create a new run type, does not change run lifecycle ownership, and does not add a new Ops-UX feedback surface.
|
||||||
|
**RBAC**: Existing workspace membership, tenant entitlement, and 404 vs 403 semantics remain unchanged. Tasks must preserve tenant-safe and workspace-safe derived-state reuse and add focused scope-safety regression coverage.
|
||||||
|
**Operator Surfaces**: Covered list, detail, and canonical surfaces must keep their current operator-visible meaning while sharing one request-local derived-state result per family where appropriate.
|
||||||
|
**Filament UI Action Surfaces**: No new actions or inspect affordances are added. Existing action inventories, confirmations, and detail/list interaction patterns must remain intact.
|
||||||
|
**Filament UI UX-001**: No screen layout redesign is introduced. Existing table, detail, and widget structures remain intact while consumer seams are refactored to reuse derived-state results.
|
||||||
|
**Badges**: Existing badge semantics must continue to flow through `BadgeCatalog` / `BadgeRenderer`; no page-local mappings are introduced as part of memoization work.
|
||||||
|
|
||||||
|
**Organization**: Tasks are grouped by user story so each story can be implemented and verified independently after the shared runtime contract is in place.
|
||||||
|
|
||||||
|
## Phase 1: Setup (Shared Runtime Scaffolding)
|
||||||
|
|
||||||
|
**Purpose**: Create the narrow runtime and test scaffolding required for the request-scoped contract.
|
||||||
|
|
||||||
|
- [X] T001 [P] Create the request-scoped derived-state support files in `app/Support/Ui/DerivedState/DerivedStateFamily.php`, `app/Support/Ui/DerivedState/DerivedStateKey.php`, and `app/Support/Ui/DerivedState/RequestScopedDerivedStateStore.php`
|
||||||
|
- [X] T002 [P] Create the focused test files in `tests/Unit/Support/Ui/DerivedState/RequestScopedDerivedStateStoreTest.php`, `tests/Feature/Filament/ReviewRegisterDerivedStateMemoizationTest.php`, `tests/Feature/Filament/EvidenceOverviewDerivedStateMemoizationTest.php`, `tests/Feature/Filament/OperationRunDerivedStateMemoizationTest.php`, `tests/Feature/Navigation/RelatedNavigationResolverMemoizationTest.php`, `tests/Feature/Filament/DerivedStateMutationFreshnessTest.php`, and `tests/Feature/Guards/DerivedStateConsumerAdoptionGuardTest.php`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 2: Foundational (Blocking Runtime Contract)
|
||||||
|
|
||||||
|
**Purpose**: Build the core request-scoped store and binding that all user stories depend on.
|
||||||
|
|
||||||
|
**⚠️ CRITICAL**: No user story work should begin until this phase is complete.
|
||||||
|
|
||||||
|
- [X] T003 [P] Add unit coverage for key composition, hit/miss behavior, negative-result reuse, variant separation, and invalidation in `tests/Unit/Support/Ui/DerivedState/RequestScopedDerivedStateStoreTest.php`
|
||||||
|
- [X] T004 [P] Implement the derived-state family and key value objects in `app/Support/Ui/DerivedState/DerivedStateFamily.php` and `app/Support/Ui/DerivedState/DerivedStateKey.php`
|
||||||
|
- [X] T005 Implement request-local resolve, reuse, and invalidation behavior in `app/Support/Ui/DerivedState/RequestScopedDerivedStateStore.php`
|
||||||
|
- [X] T006 Register the request-scoped derived-state store binding in `app/Providers/AppServiceProvider.php`
|
||||||
|
|
||||||
|
**Checkpoint**: The request-scoped derived-state runtime contract exists and can be adopted by presenter and resolver families.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 3: User Story 1 - Reuse One Derived Truth Per Record (Priority: P1)
|
||||||
|
|
||||||
|
**Goal**: Reuse one artifact-truth derivation per covered record on representative list and canonical surfaces.
|
||||||
|
|
||||||
|
**Independent Test**: Review Register and Evidence Overview render the same labels, explanations, and next-step text as before while each covered artifact-truth result resolves once per request for the same record and variant.
|
||||||
|
|
||||||
|
### Tests for User Story 1
|
||||||
|
|
||||||
|
- [X] T007 [P] [US1] Add per-row artifact-truth reuse assertions for `ReviewRegister` in `tests/Feature/Filament/ReviewRegisterDerivedStateMemoizationTest.php`
|
||||||
|
- [X] T008 [P] [US1] Add canonical per-row artifact-truth reuse assertions plus one explicit entitlement-boundary regression for `EvidenceOverview` in `tests/Feature/Filament/EvidenceOverviewDerivedStateMemoizationTest.php`
|
||||||
|
|
||||||
|
### Implementation for User Story 1
|
||||||
|
|
||||||
|
- [X] T009 [US1] Route `ArtifactTruthPresenter::forBaselineSnapshot()`, `forEvidenceSnapshot()`, `forTenantReview()`, `forReviewPack()`, and `forOperationRun()` through the request-scoped store in `app/Support/Ui/GovernanceArtifactTruth/ArtifactTruthPresenter.php`
|
||||||
|
- [X] T010 [US1] Replace repeated `forTenantReview()` closure calls with a row-safe artifact-truth access path in `app/Filament/Pages/Reviews/ReviewRegister.php`
|
||||||
|
- [X] T011 [US1] Reuse a single artifact-truth resolution per active snapshot row in `app/Filament/Pages/Monitoring/EvidenceOverview.php`
|
||||||
|
- [X] T012 [US1] Keep the first-slice baseline snapshot, evidence snapshot, tenant review, review pack, and operation-run helper consumers aligned to the shared artifact-truth contract in `app/Filament/Resources/BaselineSnapshotResource.php`, `app/Filament/Resources/BaselineSnapshotResource/Pages/ViewBaselineSnapshot.php`, `app/Filament/Resources/EvidenceSnapshotResource.php`, `app/Filament/Resources/TenantReviewResource.php`, `app/Filament/Resources/ReviewPackResource.php`, and `app/Filament/Resources/OperationRunResource.php`
|
||||||
|
- [X] T013 [US1] Run the focused artifact-truth memoization pack in `tests/Feature/Filament/ReviewRegisterDerivedStateMemoizationTest.php` and `tests/Feature/Filament/EvidenceOverviewDerivedStateMemoizationTest.php`
|
||||||
|
|
||||||
|
**Checkpoint**: Covered artifact-truth list and canonical surfaces now reuse one deterministic truth result per record and request.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 4: User Story 2 - Reuse Derived State Across Surface Fragments In One Request (Priority: P1)
|
||||||
|
|
||||||
|
**Goal**: Reuse operation guidance and related-navigation state across list/detail fragments and converge existing hidden local caches.
|
||||||
|
|
||||||
|
**Independent Test**: Operation run list/detail surfaces and representative related-navigation consumers keep the same URLs and guidance text while each covered family resolves once per request for the same scope and also safely reuses deterministic negative results.
|
||||||
|
|
||||||
|
### Tests for User Story 2
|
||||||
|
|
||||||
|
- [X] T014 [P] [US2] Add operation-guidance and operator-explanation reuse assertions for list/detail surfaces in `tests/Feature/Filament/OperationRunDerivedStateMemoizationTest.php`
|
||||||
|
- [X] T015 [P] [US2] Add related-navigation reuse, negative-result caching, deny-as-not-found regressions for non-members or wrong-scope users, forbidden regressions for in-scope users lacking capability, and tenant/workspace entitlement-boundary assertions in `tests/Feature/Navigation/RelatedNavigationResolverMemoizationTest.php`
|
||||||
|
|
||||||
|
### Implementation for User Story 2
|
||||||
|
|
||||||
|
- [X] T016 [US2] Route covered guidance and explanation reads through the request-scoped store in `app/Support/OpsUx/OperationUxPresenter.php`
|
||||||
|
- [X] T017 [US2] Route primary, detail, and header related-navigation resolution through the request-scoped store in `app/Support/Navigation/RelatedNavigationResolver.php`
|
||||||
|
- [X] T018 [US2] Reuse operation guidance and related-context state on run list/detail surfaces in `app/Filament/Resources/OperationRunResource.php` and `app/Filament/Pages/Operations/TenantlessOperationRunViewer.php`
|
||||||
|
- [X] T019 [US2] Converge page-local related-entry consumers with the shared contract in `app/Filament/Resources/FindingResource.php` and `app/Filament/Resources/PolicyVersionResource/Pages/ViewPolicyVersion.php`
|
||||||
|
- [X] T020 [US2] Run the focused guidance and navigation memoization pack in `tests/Feature/Filament/OperationRunDerivedStateMemoizationTest.php` and `tests/Feature/Navigation/RelatedNavigationResolverMemoizationTest.php`
|
||||||
|
|
||||||
|
**Checkpoint**: Covered operation-guidance and related-navigation surfaces now share one request-local derived-state contract instead of separate local cache patterns.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 5: User Story 3 - Preserve Freshness In Mutation Flows (Priority: P2)
|
||||||
|
|
||||||
|
**Goal**: Make post-mutation freshness explicit so request-local reuse never returns stale artifact truth or navigation after business state changes.
|
||||||
|
|
||||||
|
**Independent Test**: A covered mutating action changes business truth within the same request and the subsequent visible artifact-truth or related state is freshly determined instead of reusing stale pre-action results.
|
||||||
|
|
||||||
|
### Tests for User Story 3
|
||||||
|
|
||||||
|
- [X] T021 [P] [US3] Add post-mutation freshness regression coverage for covered truth-, guidance-, or navigation-affecting generation or review actions in `tests/Feature/Filament/DerivedStateMutationFreshnessTest.php`
|
||||||
|
- [X] T022 [P] [US3] Add no-reuse and invalidate-after-mutation assertions to `tests/Unit/Support/Ui/DerivedState/RequestScopedDerivedStateStoreTest.php`
|
||||||
|
|
||||||
|
### Implementation for User Story 3
|
||||||
|
|
||||||
|
- [X] T023 [US3] Add family freshness-policy and invalidation APIs in `app/Support/Ui/DerivedState/DerivedStateFamily.php` and `app/Support/Ui/DerivedState/RequestScopedDerivedStateStore.php`
|
||||||
|
- [X] T024 [US3] Invalidate or bypass stale artifact-truth entries after covered tenant review, evidence snapshot, and review pack mutations in `app/Filament/Resources/TenantReviewResource.php`, `app/Filament/Resources/EvidenceSnapshotResource.php`, and `app/Filament/Resources/ReviewPackResource.php`
|
||||||
|
- [X] T025 [US3] Update post-action truth, guidance, and related-navigation helper access to use explicit fresh-access paths in `app/Filament/Resources/TenantReviewResource.php`, `app/Filament/Resources/EvidenceSnapshotResource.php`, `app/Filament/Resources/ReviewPackResource.php`, `app/Filament/Resources/OperationRunResource.php`, and `app/Filament/Resources/PolicyVersionResource/Pages/ViewPolicyVersion.php`
|
||||||
|
- [X] T026 [US3] Run the freshness regression pack in `tests/Feature/Filament/DerivedStateMutationFreshnessTest.php` and `tests/Unit/Support/Ui/DerivedState/RequestScopedDerivedStateStoreTest.php`
|
||||||
|
|
||||||
|
**Checkpoint**: Covered mutation flows now have explicit freshness behavior and cannot accidentally reuse stale request-local derived state.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 6: Polish & Cross-Cutting Concerns
|
||||||
|
|
||||||
|
**Purpose**: Add the automated future-family guard, remove superseded local patterns, format touched files, and run the full focused verification pack.
|
||||||
|
|
||||||
|
- [X] T027 Implement the automated derived-state adoption guard in `tests/Feature/Guards/DerivedStateConsumerAdoptionGuardTest.php` using existing guard-test scanning patterns plus declared `surface`, `family`, `variant`, `accessPattern`, `scopeInputs`, `freshnessPolicy`, and `guardScope` metadata to fail with actionable file-and-snippet output when covered families reintroduce ad hoc local caches or undeclared adoption paths
|
||||||
|
- [X] T028 Remove or collapse superseded ad hoc local derived-state caches and document future-family adoption guardrails in `app/Filament/Pages/Reviews/ReviewRegister.php`, `app/Filament/Pages/Monitoring/EvidenceOverview.php`, `app/Filament/Resources/FindingResource.php`, `app/Filament/Resources/PolicyVersionResource/Pages/ViewPolicyVersion.php`, `specs/167-derived-state-memoization/quickstart.md`, and `specs/167-derived-state-memoization/contracts/request-scoped-derived-state.logical.openapi.yaml`
|
||||||
|
- [X] T029 Run formatting for touched implementation files with `vendor/bin/sail bin pint --dirty --format agent` using `specs/167-derived-state-memoization/quickstart.md`
|
||||||
|
- [X] T030 Run the final focused verification pack from `specs/167-derived-state-memoization/quickstart.md` against `tests/Unit/Support/Ui/DerivedState/RequestScopedDerivedStateStoreTest.php`, `tests/Feature/Filament/ReviewRegisterDerivedStateMemoizationTest.php`, `tests/Feature/Filament/EvidenceOverviewDerivedStateMemoizationTest.php`, `tests/Feature/Filament/OperationRunDerivedStateMemoizationTest.php`, `tests/Feature/Navigation/RelatedNavigationResolverMemoizationTest.php`, `tests/Feature/Filament/DerivedStateMutationFreshnessTest.php`, and `tests/Feature/Guards/DerivedStateConsumerAdoptionGuardTest.php`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Dependencies & Execution Order
|
||||||
|
|
||||||
|
### Phase Dependencies
|
||||||
|
|
||||||
|
- **Setup (Phase 1)**: Starts immediately and creates the narrow runtime and test scaffolding.
|
||||||
|
- **Foundational (Phase 2)**: Depends on Setup and blocks all user stories until the request-scoped store and binding exist.
|
||||||
|
- **User Story 1 (Phase 3)**: Starts after Foundational and delivers the MVP artifact-truth reuse slice.
|
||||||
|
- **User Story 2 (Phase 4)**: Starts after Foundational and can overlap with User Story 1 after the shared store is available, but it is safest after artifact-truth adoption confirms the key contract.
|
||||||
|
- **User Story 3 (Phase 5)**: Starts after User Stories 1 and 2 have established the family adoption seams because freshness rules depend on the shared contract being in use.
|
||||||
|
- **Polish (Phase 6)**: Starts after all desired user stories are complete and ends with the automated adoption guard plus focused verification pack passing.
|
||||||
|
|
||||||
|
### User Story Dependencies
|
||||||
|
|
||||||
|
- **User Story 1 (P1)**: Depends only on the request-scoped store, key contract, and binding from Phase 2.
|
||||||
|
- **User Story 2 (P1)**: Depends on the same foundational contract and can proceed independently of US1 at the store level, but shares adoption patterns and should follow once the artifact-truth slice proves the consumer seam.
|
||||||
|
- **User Story 3 (P2)**: Depends on the adoption work from US1 and US2 because mutation freshness only matters after reuse is in place.
|
||||||
|
|
||||||
|
### Within Each User Story
|
||||||
|
|
||||||
|
- Tests should be added before or alongside implementation and must fail before the story is considered complete.
|
||||||
|
- Family entry-point changes should land before consumer refactors in the same story.
|
||||||
|
- Consumer refactors should land before the focused story-level regression run.
|
||||||
|
|
||||||
|
### Parallel Opportunities
|
||||||
|
|
||||||
|
- `T001` and `T002` can run in parallel during Setup.
|
||||||
|
- `T003` and `T004` can run in parallel during Foundational work.
|
||||||
|
- `T007` and `T008` can run in parallel for User Story 1.
|
||||||
|
- `T014` and `T015` can run in parallel for User Story 2.
|
||||||
|
- `T021` and `T022` can run in parallel for User Story 3.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Parallel Example: User Story 1
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# User Story 1 tests in parallel:
|
||||||
|
Task: T007 tests/Feature/Filament/ReviewRegisterDerivedStateMemoizationTest.php
|
||||||
|
Task: T008 tests/Feature/Filament/EvidenceOverviewDerivedStateMemoizationTest.php
|
||||||
|
|
||||||
|
# User Story 1 implementation split after test expectations are clear:
|
||||||
|
Task: T010 app/Filament/Pages/Reviews/ReviewRegister.php
|
||||||
|
Task: T011 app/Filament/Pages/Monitoring/EvidenceOverview.php
|
||||||
|
```
|
||||||
|
|
||||||
|
## Parallel Example: User Story 2
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# User Story 2 tests in parallel:
|
||||||
|
Task: T014 tests/Feature/Filament/OperationRunDerivedStateMemoizationTest.php
|
||||||
|
Task: T015 tests/Feature/Navigation/RelatedNavigationResolverMemoizationTest.php
|
||||||
|
|
||||||
|
# User Story 2 implementation split after family entry-point work lands:
|
||||||
|
Task: T018 app/Filament/Resources/OperationRunResource.php and app/Filament/Pages/Operations/TenantlessOperationRunViewer.php
|
||||||
|
Task: T019 app/Filament/Resources/FindingResource.php and app/Filament/Resources/PolicyVersionResource/Pages/ViewPolicyVersion.php
|
||||||
|
```
|
||||||
|
|
||||||
|
## Parallel Example: User Story 3
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# User Story 3 freshness coverage in parallel:
|
||||||
|
Task: T021 tests/Feature/Filament/DerivedStateMutationFreshnessTest.php
|
||||||
|
Task: T022 tests/Unit/Support/Ui/DerivedState/RequestScopedDerivedStateStoreTest.php
|
||||||
|
|
||||||
|
# User Story 3 implementation split after freshness rules are fixed:
|
||||||
|
Task: T024 app/Filament/Resources/TenantReviewResource.php, app/Filament/Resources/EvidenceSnapshotResource.php, and app/Filament/Resources/ReviewPackResource.php
|
||||||
|
Task: T025 app/Filament/Resources/TenantReviewResource.php, app/Filament/Resources/EvidenceSnapshotResource.php, and app/Filament/Resources/ReviewPackResource.php
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Implementation Strategy
|
||||||
|
|
||||||
|
### MVP First
|
||||||
|
|
||||||
|
- Complete Phase 1 and Phase 2.
|
||||||
|
- Deliver User Story 1 as the MVP slice.
|
||||||
|
- Verify that representative artifact-truth surfaces now reuse one deterministic result per request without changing visible meaning.
|
||||||
|
|
||||||
|
### Incremental Delivery
|
||||||
|
|
||||||
|
- Add User Story 2 next to cover operation guidance and related-navigation reuse across multi-fragment surfaces.
|
||||||
|
- Add User Story 3 last to make mutation freshness explicit once the reuse seams are in place.
|
||||||
|
|
||||||
|
### Verification Finish
|
||||||
|
|
||||||
|
- Run the derived-state adoption guard test from `quickstart.md`.
|
||||||
|
- Run Pint on touched files.
|
||||||
|
- Run the focused verification pack from `quickstart.md`.
|
||||||
|
- If broader confidence is needed after focused verification, run the wider suite separately.
|
||||||
@ -3,10 +3,10 @@
|
|||||||
declare(strict_types=1);
|
declare(strict_types=1);
|
||||||
|
|
||||||
use App\Filament\Resources\InventoryItemResource;
|
use App\Filament\Resources\InventoryItemResource;
|
||||||
|
use App\Filament\Resources\RestoreRunResource;
|
||||||
use App\Models\OperationRun;
|
use App\Models\OperationRun;
|
||||||
use App\Models\RestoreRun;
|
use App\Models\RestoreRun;
|
||||||
use App\Models\Tenant;
|
use App\Models\Tenant;
|
||||||
use App\Support\OperationRunLinks;
|
|
||||||
use App\Support\Workspaces\WorkspaceContext;
|
use App\Support\Workspaces\WorkspaceContext;
|
||||||
use Filament\Facades\Filament;
|
use Filament\Facades\Filament;
|
||||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
@ -33,16 +33,15 @@ public function test_shows_restore_related_links_on_canonical_detail_for_restore
|
|||||||
],
|
],
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$expectedUrl = OperationRunLinks::related($run->loadMissing('tenant'), $tenant)['Restore Run'] ?? null;
|
$expectedUrl = RestoreRunResource::getUrl('view', ['record' => $restoreRun], tenant: $tenant);
|
||||||
|
|
||||||
$response = $this->actingAs($user)
|
$response = $this->actingAs($user)
|
||||||
->withSession([WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id])
|
->withSession([WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id])
|
||||||
->get(route('admin.operations.view', ['run' => (int) $run->getKey()]))
|
->get(route('admin.operations.view', ['run' => (int) $run->getKey()]))
|
||||||
->assertOk()
|
->assertOk()
|
||||||
->assertSee('Open')
|
->assertSee('Open')
|
||||||
->assertSee('Restore Run');
|
->assertSee('View restore run');
|
||||||
|
|
||||||
$this->assertIsString($expectedUrl);
|
|
||||||
$response->assertSee((string) $expectedUrl, false);
|
$response->assertSee((string) $expectedUrl, false);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -65,7 +64,7 @@ public function test_shows_only_generic_links_for_tenantless_runs_on_canonical_d
|
|||||||
->assertOk()
|
->assertOk()
|
||||||
->assertSee('Operations')
|
->assertSee('Operations')
|
||||||
->assertSee(route('admin.operations.index'), false)
|
->assertSee(route('admin.operations.index'), false)
|
||||||
->assertDontSee('Restore Run');
|
->assertDontSee('View restore run');
|
||||||
}
|
}
|
||||||
|
|
||||||
public function test_does_not_show_legacy_admin_details_cta_and_keeps_canonical_view_run_label(): void
|
public function test_does_not_show_legacy_admin_details_cta_and_keeps_canonical_view_run_label(): void
|
||||||
|
|||||||
@ -199,3 +199,50 @@ function createAssignedBaselineTenant(): array
|
|||||||
->and($assessment->findingsVisibleCount)->toBe(1)
|
->and($assessment->findingsVisibleCount)->toBe(1)
|
||||||
->and($assessment->nextActionTarget())->toBe(BaselineCompareSummaryAssessment::NEXT_TARGET_FINDINGS);
|
->and($assessment->nextActionTarget())->toBe(BaselineCompareSummaryAssessment::NEXT_TARGET_FINDINGS);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('treats overdue workflow and lapsed governance as action required even with zero compare findings', function (): void {
|
||||||
|
[$tenant, $profile, $snapshot] = createAssignedBaselineTenant();
|
||||||
|
|
||||||
|
OperationRun::factory()->create([
|
||||||
|
'tenant_id' => (int) $tenant->getKey(),
|
||||||
|
'workspace_id' => (int) $tenant->workspace_id,
|
||||||
|
'type' => OperationRunType::BaselineCompare->value,
|
||||||
|
'status' => OperationRunStatus::Completed->value,
|
||||||
|
'outcome' => OperationRunOutcome::Succeeded->value,
|
||||||
|
'completed_at' => now(),
|
||||||
|
'context' => [
|
||||||
|
'baseline_profile_id' => (int) $profile->getKey(),
|
||||||
|
'baseline_snapshot_id' => (int) $snapshot->getKey(),
|
||||||
|
'baseline_compare' => [
|
||||||
|
'reason_code' => BaselineCompareReasonCode::NoDriftDetected->value,
|
||||||
|
'coverage' => [
|
||||||
|
'effective_types' => ['deviceConfiguration'],
|
||||||
|
'covered_types' => ['deviceConfiguration'],
|
||||||
|
'uncovered_types' => [],
|
||||||
|
'proof' => true,
|
||||||
|
],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
|
||||||
|
Finding::factory()->create([
|
||||||
|
'workspace_id' => (int) $tenant->workspace_id,
|
||||||
|
'tenant_id' => (int) $tenant->getKey(),
|
||||||
|
'status' => Finding::STATUS_TRIAGED,
|
||||||
|
'due_at' => now()->subDay(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
Finding::factory()->create([
|
||||||
|
'workspace_id' => (int) $tenant->workspace_id,
|
||||||
|
'tenant_id' => (int) $tenant->getKey(),
|
||||||
|
'status' => Finding::STATUS_RISK_ACCEPTED,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$assessment = BaselineCompareStats::forTenant($tenant)->summaryAssessment();
|
||||||
|
|
||||||
|
expect($assessment->stateFamily)->toBe(BaselineCompareSummaryAssessment::STATE_ACTION_REQUIRED)
|
||||||
|
->and($assessment->overdueOpenFindingsCount)->toBe(1)
|
||||||
|
->and($assessment->lapsedGovernanceCount)->toBe(1)
|
||||||
|
->and($assessment->headline)->toContain('Accepted-risk governance has lapsed')
|
||||||
|
->and($assessment->nextActionTarget())->toBe(BaselineCompareSummaryAssessment::NEXT_TARGET_FINDINGS);
|
||||||
|
});
|
||||||
|
|||||||
198
tests/Feature/Filament/DerivedStateMutationFreshnessTest.php
Normal file
198
tests/Feature/Filament/DerivedStateMutationFreshnessTest.php
Normal file
@ -0,0 +1,198 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
use App\Filament\Resources\EvidenceSnapshotResource\Pages\ListEvidenceSnapshots;
|
||||||
|
use App\Filament\Resources\ReviewPackResource\Pages\ListReviewPacks;
|
||||||
|
use App\Models\EvidenceSnapshot;
|
||||||
|
use App\Models\Finding;
|
||||||
|
use App\Models\OperationRun;
|
||||||
|
use App\Models\Policy;
|
||||||
|
use App\Models\PolicyVersion;
|
||||||
|
use App\Models\ReviewPack;
|
||||||
|
use App\Support\Evidence\EvidenceCompletenessState;
|
||||||
|
use App\Support\Evidence\EvidenceSnapshotStatus;
|
||||||
|
use App\Support\Navigation\CrossResourceNavigationMatrix;
|
||||||
|
use App\Support\Navigation\RelatedNavigationResolver;
|
||||||
|
use App\Support\OperationRunOutcome;
|
||||||
|
use App\Support\OperationRunStatus;
|
||||||
|
use App\Support\OpsUx\OperationUxPresenter;
|
||||||
|
use App\Support\ReviewPackStatus;
|
||||||
|
use App\Support\Ui\DerivedState\DerivedStateFamily;
|
||||||
|
use App\Support\Ui\DerivedState\RequestScopedDerivedStateStore;
|
||||||
|
use App\Support\Ui\GovernanceArtifactTruth\ArtifactTruthPresenter;
|
||||||
|
use Filament\Facades\Filament;
|
||||||
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
use Livewire\Livewire;
|
||||||
|
|
||||||
|
uses(RefreshDatabase::class);
|
||||||
|
|
||||||
|
it('refreshes evidence artifact truth after expiring a snapshot in the same request', function (): void {
|
||||||
|
$tenant = \App\Models\Tenant::factory()->create();
|
||||||
|
[$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'owner');
|
||||||
|
|
||||||
|
$snapshot = EvidenceSnapshot::query()->create([
|
||||||
|
'tenant_id' => (int) $tenant->getKey(),
|
||||||
|
'workspace_id' => (int) $tenant->workspace_id,
|
||||||
|
'status' => EvidenceSnapshotStatus::Active->value,
|
||||||
|
'completeness_state' => EvidenceCompletenessState::Complete->value,
|
||||||
|
'summary' => [
|
||||||
|
'dimension_count' => 5,
|
||||||
|
'missing_dimensions' => 0,
|
||||||
|
'stale_dimensions' => 0,
|
||||||
|
],
|
||||||
|
'generated_at' => now(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
Filament::setTenant($tenant, true);
|
||||||
|
|
||||||
|
Livewire::actingAs($user)
|
||||||
|
->test(ListEvidenceSnapshots::class)
|
||||||
|
->callTableAction('expire', $snapshot);
|
||||||
|
|
||||||
|
$snapshot->refresh();
|
||||||
|
$truth = app(ArtifactTruthPresenter::class)->forEvidenceSnapshot($snapshot);
|
||||||
|
|
||||||
|
expect((string) $snapshot->status)->toBe(EvidenceSnapshotStatus::Expired->value)
|
||||||
|
->and($truth->freshnessState)->toBe('stale')
|
||||||
|
->and(app(RequestScopedDerivedStateStore::class)->countStored(
|
||||||
|
DerivedStateFamily::ArtifactTruth,
|
||||||
|
EvidenceSnapshot::class,
|
||||||
|
(string) $snapshot->getKey(),
|
||||||
|
'evidence_snapshot',
|
||||||
|
))->toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('refreshes review-pack artifact truth after expiring a pack in the same request', function (): void {
|
||||||
|
$tenant = \App\Models\Tenant::factory()->create();
|
||||||
|
[$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'owner');
|
||||||
|
|
||||||
|
$pack = ReviewPack::factory()->ready()->create([
|
||||||
|
'tenant_id' => (int) $tenant->getKey(),
|
||||||
|
'workspace_id' => (int) $tenant->workspace_id,
|
||||||
|
'initiated_by_user_id' => (int) $user->getKey(),
|
||||||
|
'file_disk' => 'exports',
|
||||||
|
'file_path' => 'review-packs/freshness.zip',
|
||||||
|
]);
|
||||||
|
|
||||||
|
\Illuminate\Support\Facades\Storage::fake('exports');
|
||||||
|
\Illuminate\Support\Facades\Storage::disk('exports')->put('review-packs/freshness.zip', 'PK-fake');
|
||||||
|
|
||||||
|
Filament::setTenant($tenant, true);
|
||||||
|
|
||||||
|
Livewire::actingAs($user)
|
||||||
|
->test(ListReviewPacks::class)
|
||||||
|
->callTableAction('expire', $pack);
|
||||||
|
|
||||||
|
$pack->refresh();
|
||||||
|
$truth = app(ArtifactTruthPresenter::class)->forReviewPack($pack);
|
||||||
|
|
||||||
|
expect((string) $pack->status)->toBe(ReviewPackStatus::Expired->value)
|
||||||
|
->and($truth->freshnessState)->toBe('stale')
|
||||||
|
->and(app(RequestScopedDerivedStateStore::class)->countStored(
|
||||||
|
DerivedStateFamily::ArtifactTruth,
|
||||||
|
ReviewPack::class,
|
||||||
|
(string) $pack->getKey(),
|
||||||
|
'review_pack',
|
||||||
|
))->toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns fresh operation guidance after run state changes within the same request', function (): void {
|
||||||
|
$tenant = \App\Models\Tenant::factory()->create();
|
||||||
|
createUserWithTenant(tenant: $tenant, role: 'owner');
|
||||||
|
|
||||||
|
$run = OperationRun::factory()->create([
|
||||||
|
'tenant_id' => (int) $tenant->getKey(),
|
||||||
|
'workspace_id' => (int) $tenant->workspace_id,
|
||||||
|
'type' => 'inventory_sync',
|
||||||
|
'status' => OperationRunStatus::Queued->value,
|
||||||
|
'outcome' => OperationRunOutcome::Pending->value,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$queuedGuidance = OperationUxPresenter::surfaceGuidance($run);
|
||||||
|
|
||||||
|
$run->update([
|
||||||
|
'status' => OperationRunStatus::Completed->value,
|
||||||
|
'outcome' => OperationRunOutcome::Blocked->value,
|
||||||
|
'context' => [
|
||||||
|
'reason_code' => 'missing_capability',
|
||||||
|
'execution_legitimacy' => [
|
||||||
|
'reason_code' => 'missing_capability',
|
||||||
|
],
|
||||||
|
],
|
||||||
|
'failure_summary' => [[
|
||||||
|
'reason_code' => 'missing_capability',
|
||||||
|
'message' => 'Missing capability prevented execution.',
|
||||||
|
]],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$staleGuidance = OperationUxPresenter::surfaceGuidance($run->fresh());
|
||||||
|
$freshGuidance = OperationUxPresenter::surfaceGuidanceFresh($run->fresh());
|
||||||
|
|
||||||
|
expect($staleGuidance)->toBe($queuedGuidance)
|
||||||
|
->and($freshGuidance)->not->toBe($staleGuidance)
|
||||||
|
->and($freshGuidance)->toBe('Review the blocked prerequisite before retrying.')
|
||||||
|
->and(app(RequestScopedDerivedStateStore::class)->countStored(
|
||||||
|
DerivedStateFamily::OperationUxGuidance,
|
||||||
|
OperationRun::class,
|
||||||
|
(string) $run->getKey(),
|
||||||
|
'surface_guidance',
|
||||||
|
))->toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns fresh related navigation after a finding changes its related policy version', function (): void {
|
||||||
|
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||||
|
$this->actingAs($user);
|
||||||
|
Filament::setTenant($tenant, true);
|
||||||
|
|
||||||
|
$policy = Policy::factory()->create([
|
||||||
|
'tenant_id' => (int) $tenant->getKey(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$versionA = PolicyVersion::factory()->create([
|
||||||
|
'tenant_id' => (int) $tenant->getKey(),
|
||||||
|
'policy_id' => (int) $policy->getKey(),
|
||||||
|
'version_number' => 1,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$versionB = PolicyVersion::factory()->create([
|
||||||
|
'tenant_id' => (int) $tenant->getKey(),
|
||||||
|
'policy_id' => (int) $policy->getKey(),
|
||||||
|
'version_number' => 2,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$finding = Finding::factory()->for($tenant)->create([
|
||||||
|
'evidence_jsonb' => [
|
||||||
|
'current' => [
|
||||||
|
'policy_version_id' => (int) $versionA->getKey(),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$resolver = app(RelatedNavigationResolver::class);
|
||||||
|
$firstEntries = collect($resolver->detailEntries(CrossResourceNavigationMatrix::SOURCE_FINDING, $finding));
|
||||||
|
$first = $firstEntries->firstWhere('key', 'current_policy_version');
|
||||||
|
|
||||||
|
$finding->update([
|
||||||
|
'evidence_jsonb' => [
|
||||||
|
'current' => [
|
||||||
|
'policy_version_id' => (int) $versionB->getKey(),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$staleEntries = collect($resolver->detailEntries(CrossResourceNavigationMatrix::SOURCE_FINDING, $finding->fresh()));
|
||||||
|
$freshEntries = collect($resolver->detailEntriesFresh(CrossResourceNavigationMatrix::SOURCE_FINDING, $finding->fresh()));
|
||||||
|
$stale = $staleEntries->firstWhere('key', 'current_policy_version');
|
||||||
|
$fresh = $freshEntries->firstWhere('key', 'current_policy_version');
|
||||||
|
|
||||||
|
expect($first['targetUrl'] ?? null)->toBe($stale['targetUrl'] ?? null)
|
||||||
|
->and($fresh['targetUrl'] ?? null)->not->toBe($stale['targetUrl'] ?? null)
|
||||||
|
->and($fresh['targetUrl'] ?? null)->toContain((string) $versionB->getKey())
|
||||||
|
->and(app(RequestScopedDerivedStateStore::class)->countStored(
|
||||||
|
DerivedStateFamily::RelatedNavigationDetail,
|
||||||
|
Finding::class,
|
||||||
|
(string) $finding->getKey(),
|
||||||
|
CrossResourceNavigationMatrix::SOURCE_FINDING,
|
||||||
|
))->toBe(1);
|
||||||
|
});
|
||||||
@ -0,0 +1,68 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
use App\Filament\Pages\Monitoring\EvidenceOverview;
|
||||||
|
use App\Models\EvidenceSnapshot;
|
||||||
|
use App\Support\Evidence\EvidenceCompletenessState;
|
||||||
|
use App\Support\Evidence\EvidenceSnapshotStatus;
|
||||||
|
use App\Support\Ui\DerivedState\DerivedStateFamily;
|
||||||
|
use App\Support\Ui\DerivedState\RequestScopedDerivedStateStore;
|
||||||
|
use App\Support\Ui\GovernanceArtifactTruth\ArtifactTruthPresenter;
|
||||||
|
use App\Support\Workspaces\WorkspaceContext;
|
||||||
|
use Filament\Facades\Filament;
|
||||||
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
use Livewire\Livewire;
|
||||||
|
|
||||||
|
uses(RefreshDatabase::class);
|
||||||
|
|
||||||
|
it('reuses one artifact-truth resolution per active snapshot row on the evidence overview', function (): void {
|
||||||
|
$tenant = \App\Models\Tenant::factory()->create();
|
||||||
|
[$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'owner');
|
||||||
|
|
||||||
|
$snapshot = EvidenceSnapshot::query()->create([
|
||||||
|
'tenant_id' => (int) $tenant->getKey(),
|
||||||
|
'workspace_id' => (int) $tenant->workspace_id,
|
||||||
|
'status' => EvidenceSnapshotStatus::Active->value,
|
||||||
|
'completeness_state' => EvidenceCompletenessState::Partial->value,
|
||||||
|
'summary' => [
|
||||||
|
'dimension_count' => 5,
|
||||||
|
'missing_dimensions' => 2,
|
||||||
|
'stale_dimensions' => 0,
|
||||||
|
],
|
||||||
|
'generated_at' => now(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
setAdminPanelContext();
|
||||||
|
Filament::setTenant(null, true);
|
||||||
|
$this->actingAs($user);
|
||||||
|
session()->put(WorkspaceContext::SESSION_KEY, (int) $tenant->workspace_id);
|
||||||
|
|
||||||
|
Livewire::actingAs($user)
|
||||||
|
->test(EvidenceOverview::class)
|
||||||
|
->assertSee($tenant->name)
|
||||||
|
->assertSee('Artifact truth');
|
||||||
|
|
||||||
|
$truth = app(ArtifactTruthPresenter::class)->forEvidenceSnapshot($snapshot->fresh());
|
||||||
|
$store = app(RequestScopedDerivedStateStore::class);
|
||||||
|
|
||||||
|
expect($store->countStored(
|
||||||
|
DerivedStateFamily::ArtifactTruth,
|
||||||
|
EvidenceSnapshot::class,
|
||||||
|
(string) $snapshot->getKey(),
|
||||||
|
'evidence_snapshot',
|
||||||
|
))->toBe(1)
|
||||||
|
->and($truth->primaryLabel)->not->toBe('');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps the evidence overview deny-as-not-found for users outside the workspace boundary', function (): void {
|
||||||
|
$workspaceTenant = \App\Models\Tenant::factory()->create();
|
||||||
|
$user = \App\Models\User::factory()->create();
|
||||||
|
|
||||||
|
Filament::setTenant(null, true);
|
||||||
|
|
||||||
|
$this->actingAs($user)
|
||||||
|
->withSession([WorkspaceContext::SESSION_KEY => (int) $workspaceTenant->workspace_id])
|
||||||
|
->get(route('admin.evidence.overview'))
|
||||||
|
->assertNotFound();
|
||||||
|
});
|
||||||
@ -6,6 +6,7 @@
|
|||||||
use App\Models\BaselineProfile;
|
use App\Models\BaselineProfile;
|
||||||
use App\Models\BaselineSnapshot;
|
use App\Models\BaselineSnapshot;
|
||||||
use App\Models\BaselineTenantAssignment;
|
use App\Models\BaselineTenantAssignment;
|
||||||
|
use App\Models\Finding;
|
||||||
use App\Models\OperationRun;
|
use App\Models\OperationRun;
|
||||||
use App\Support\Baselines\BaselineCompareReasonCode;
|
use App\Support\Baselines\BaselineCompareReasonCode;
|
||||||
use App\Support\OperationRunOutcome;
|
use App\Support\OperationRunOutcome;
|
||||||
@ -170,3 +171,25 @@ function createNeedsAttentionTenant(): array
|
|||||||
->assertSee('Open Baseline Compare')
|
->assertSee('Open Baseline Compare')
|
||||||
->assertDontSee('Current dashboard signals look trustworthy.');
|
->assertDontSee('Current dashboard signals look trustworthy.');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('surfaces overdue and lapsed-governance findings even when there are no new findings', function (): void {
|
||||||
|
[$user, $tenant] = createNeedsAttentionTenant();
|
||||||
|
$this->actingAs($user);
|
||||||
|
|
||||||
|
Finding::factory()->for($tenant)->create([
|
||||||
|
'status' => Finding::STATUS_TRIAGED,
|
||||||
|
'due_at' => now()->subDay(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
Finding::factory()->for($tenant)->create([
|
||||||
|
'status' => Finding::STATUS_RISK_ACCEPTED,
|
||||||
|
]);
|
||||||
|
|
||||||
|
Filament::setCurrentPanel(Filament::getPanel('tenant'));
|
||||||
|
Filament::setTenant($tenant, true);
|
||||||
|
|
||||||
|
Livewire::test(NeedsAttention::class)
|
||||||
|
->assertSee('Overdue findings')
|
||||||
|
->assertSee('Lapsed accepted-risk governance')
|
||||||
|
->assertDontSee('Current dashboard signals look trustworthy.');
|
||||||
|
});
|
||||||
|
|||||||
@ -0,0 +1,71 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
use App\Filament\Pages\Operations\TenantlessOperationRunViewer;
|
||||||
|
use App\Models\OperationRun;
|
||||||
|
use App\Support\OperationRunOutcome;
|
||||||
|
use App\Support\OperationRunStatus;
|
||||||
|
use App\Support\OpsUx\OperationUxPresenter;
|
||||||
|
use App\Support\Ui\DerivedState\DerivedStateFamily;
|
||||||
|
use App\Support\Ui\DerivedState\RequestScopedDerivedStateStore;
|
||||||
|
use App\Support\Workspaces\WorkspaceContext;
|
||||||
|
use Filament\Facades\Filament;
|
||||||
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
use Livewire\Livewire;
|
||||||
|
|
||||||
|
uses(RefreshDatabase::class);
|
||||||
|
|
||||||
|
it('reuses operation guidance and explanation state on the canonical run detail surface', function (): void {
|
||||||
|
$tenant = \App\Models\Tenant::factory()->create();
|
||||||
|
[$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'owner');
|
||||||
|
|
||||||
|
$run = OperationRun::factory()->create([
|
||||||
|
'tenant_id' => (int) $tenant->getKey(),
|
||||||
|
'workspace_id' => (int) $tenant->workspace_id,
|
||||||
|
'user_id' => (int) $user->getKey(),
|
||||||
|
'type' => 'baseline_compare',
|
||||||
|
'status' => OperationRunStatus::Completed->value,
|
||||||
|
'outcome' => OperationRunOutcome::Blocked->value,
|
||||||
|
'context' => [
|
||||||
|
'reason_code' => 'missing_capability',
|
||||||
|
'blocked_by' => 'queued_execution_legitimacy',
|
||||||
|
'execution_legitimacy' => [
|
||||||
|
'reason_code' => 'missing_capability',
|
||||||
|
],
|
||||||
|
],
|
||||||
|
'failure_summary' => [[
|
||||||
|
'code' => 'operation.blocked',
|
||||||
|
'reason_code' => 'missing_capability',
|
||||||
|
'message' => 'Operation blocked because the initiating actor no longer has the required capability.',
|
||||||
|
]],
|
||||||
|
]);
|
||||||
|
|
||||||
|
Filament::setTenant(null, true);
|
||||||
|
$this->actingAs($user)->withSession([WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id]);
|
||||||
|
session([WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id]);
|
||||||
|
|
||||||
|
Livewire::actingAs($user)
|
||||||
|
->test(TenantlessOperationRunViewer::class, ['run' => $run])
|
||||||
|
->assertSee('Blocked by prerequisite')
|
||||||
|
->assertSee('Execution legitimacy revalidation');
|
||||||
|
|
||||||
|
$guidance = OperationUxPresenter::surfaceGuidance($run->fresh());
|
||||||
|
$operatorExplanation = OperationUxPresenter::governanceOperatorExplanation($run->fresh());
|
||||||
|
$store = app(RequestScopedDerivedStateStore::class);
|
||||||
|
|
||||||
|
expect($store->countStored(
|
||||||
|
DerivedStateFamily::OperationUxGuidance,
|
||||||
|
OperationRun::class,
|
||||||
|
(string) $run->getKey(),
|
||||||
|
'surface_guidance',
|
||||||
|
))->toBe(1)
|
||||||
|
->and($store->countStored(
|
||||||
|
DerivedStateFamily::OperationUxExplanation,
|
||||||
|
OperationRun::class,
|
||||||
|
(string) $run->getKey(),
|
||||||
|
'governance_operator_explanation',
|
||||||
|
))->toBe(1)
|
||||||
|
->and($guidance)->toBe('Review workspace or tenant access before retrying.')
|
||||||
|
->and($operatorExplanation?->headline)->not->toBe('');
|
||||||
|
});
|
||||||
@ -0,0 +1,41 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
use App\Filament\Pages\Reviews\ReviewRegister;
|
||||||
|
use App\Models\TenantReview;
|
||||||
|
use App\Support\Ui\DerivedState\DerivedStateFamily;
|
||||||
|
use App\Support\Ui\DerivedState\RequestScopedDerivedStateStore;
|
||||||
|
use App\Support\Ui\GovernanceArtifactTruth\ArtifactTruthPresenter;
|
||||||
|
use App\Support\Workspaces\WorkspaceContext;
|
||||||
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
use Livewire\Livewire;
|
||||||
|
|
||||||
|
uses(RefreshDatabase::class);
|
||||||
|
|
||||||
|
it('reuses one artifact-truth resolution per row on the canonical review register', function (): void {
|
||||||
|
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||||
|
$review = composeTenantReviewForTest($tenant, $user);
|
||||||
|
|
||||||
|
setAdminPanelContext();
|
||||||
|
$this->actingAs($user);
|
||||||
|
session()->put(WorkspaceContext::SESSION_KEY, (int) $tenant->workspace_id);
|
||||||
|
|
||||||
|
Livewire::actingAs($user)
|
||||||
|
->test(ReviewRegister::class)
|
||||||
|
->assertCanSeeTableRecords([$review])
|
||||||
|
->assertSee('Artifact truth')
|
||||||
|
->assertSee('Next step');
|
||||||
|
|
||||||
|
$truth = app(ArtifactTruthPresenter::class)->forTenantReview($review->fresh());
|
||||||
|
$store = app(RequestScopedDerivedStateStore::class);
|
||||||
|
|
||||||
|
expect($store->countStored(
|
||||||
|
DerivedStateFamily::ArtifactTruth,
|
||||||
|
TenantReview::class,
|
||||||
|
(string) $review->getKey(),
|
||||||
|
'tenant_review',
|
||||||
|
))->toBe(1)
|
||||||
|
->and($truth->primaryLabel)->not->toBe('')
|
||||||
|
->and($truth->nextStepText())->not->toBe('');
|
||||||
|
});
|
||||||
@ -209,7 +209,7 @@ function spec125CriticalTenantContext(bool $ensureDefaultMicrosoftProviderConnec
|
|||||||
expect($table->getColumn('subject_external_id')?->isToggledHiddenByDefault())->toBeTrue();
|
expect($table->getColumn('subject_external_id')?->isToggledHiddenByDefault())->toBeTrue();
|
||||||
expect($table->getColumn('scope_key')?->isToggleable())->toBeTrue();
|
expect($table->getColumn('scope_key')?->isToggleable())->toBeTrue();
|
||||||
expect($table->getColumn('scope_key')?->isToggledHiddenByDefault())->toBeTrue();
|
expect($table->getColumn('scope_key')?->isToggledHiddenByDefault())->toBeTrue();
|
||||||
expect(count($table->getVisibleColumns()))->toBeLessThanOrEqual(7);
|
expect(count($table->getVisibleColumns()))->toBeLessThanOrEqual(8);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('standardizes the monitoring operations view through the operation-run resource table contract', function (): void {
|
it('standardizes the monitoring operations view through the operation-run resource table contract', function (): void {
|
||||||
|
|||||||
@ -58,8 +58,9 @@
|
|||||||
->withSession([WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id])
|
->withSession([WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id])
|
||||||
->get(route('admin.operations.view', ['run' => (int) $run->getKey()]))
|
->get(route('admin.operations.view', ['run' => (int) $run->getKey()]))
|
||||||
->assertOk()
|
->assertOk()
|
||||||
->assertSee('Status')
|
->assertSee('Execution state')
|
||||||
->assertSee('Completed')
|
->assertSee('Run finished')
|
||||||
|
->assertSee('Outcome')
|
||||||
->assertSee('Tenant lifecycle')
|
->assertSee('Tenant lifecycle')
|
||||||
->assertSee('Onboarding')
|
->assertSee('Onboarding')
|
||||||
->assertSee('Tenant selector context')
|
->assertSee('Tenant selector context')
|
||||||
|
|||||||
@ -2,12 +2,16 @@
|
|||||||
|
|
||||||
declare(strict_types=1);
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
use App\Filament\Resources\FindingExceptionResource;
|
||||||
use App\Filament\Resources\FindingExceptionResource\Pages\ListFindingExceptions;
|
use App\Filament\Resources\FindingExceptionResource\Pages\ListFindingExceptions;
|
||||||
use App\Filament\Resources\FindingExceptionResource\Pages\ViewFindingException;
|
use App\Filament\Resources\FindingExceptionResource\Pages\ViewFindingException;
|
||||||
|
use App\Filament\Widgets\Tenant\FindingExceptionStatsOverview;
|
||||||
use App\Models\Finding;
|
use App\Models\Finding;
|
||||||
use App\Models\FindingException;
|
use App\Models\FindingException;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
|
use App\Models\Workspace;
|
||||||
use App\Services\Findings\FindingRiskGovernanceResolver;
|
use App\Services\Findings\FindingRiskGovernanceResolver;
|
||||||
|
use App\Support\Workspaces\WorkspaceContext;
|
||||||
use Filament\Facades\Filament;
|
use Filament\Facades\Filament;
|
||||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
use Livewire\Livewire;
|
use Livewire\Livewire;
|
||||||
@ -124,3 +128,221 @@
|
|||||||
->assertSee('No exceptions match this view')
|
->assertSee('No exceptions match this view')
|
||||||
->assertTableEmptyStateActionsExistInOrder(['open_findings']);
|
->assertTableEmptyStateActionsExistInOrder(['open_findings']);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('bridges tenant approval queue links into the admin workspace context', function (): void {
|
||||||
|
[$viewer, $tenant] = createUserWithTenant(role: 'owner', workspaceRole: 'owner');
|
||||||
|
|
||||||
|
$otherWorkspace = Workspace::factory()->create();
|
||||||
|
|
||||||
|
$this->actingAs($viewer)
|
||||||
|
->withSession([WorkspaceContext::SESSION_KEY => (int) $otherWorkspace->getKey()])
|
||||||
|
->get(route('admin.finding-exceptions.open-queue', ['tenant' => (string) $tenant->external_id]))
|
||||||
|
->assertRedirect(
|
||||||
|
\App\Filament\Pages\Monitoring\FindingExceptionsQueue::getUrl([
|
||||||
|
'tenant' => (string) $tenant->external_id,
|
||||||
|
], panel: 'admin')
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(session(WorkspaceContext::SESSION_KEY))->toBe((int) $tenant->workspace_id)
|
||||||
|
->and(session(WorkspaceContext::LAST_TENANT_IDS_SESSION_KEY, []))
|
||||||
|
->toHaveKey((string) $tenant->workspace_id, (int) $tenant->getKey());
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- Enterprise UX Hardening (Spec 166 Phase 6b) ---
|
||||||
|
|
||||||
|
it('shows finding severity badge in exception register table', function (): void {
|
||||||
|
[$viewer, $tenant] = createUserWithTenant(role: 'readonly');
|
||||||
|
[$requester] = createUserWithTenant(tenant: $tenant, role: 'owner');
|
||||||
|
|
||||||
|
$finding = Finding::factory()->for($tenant)->create(['severity' => Finding::SEVERITY_HIGH]);
|
||||||
|
|
||||||
|
$exception = FindingException::query()->create([
|
||||||
|
'workspace_id' => (int) $tenant->workspace_id,
|
||||||
|
'tenant_id' => (int) $tenant->getKey(),
|
||||||
|
'finding_id' => (int) $finding->getKey(),
|
||||||
|
'requested_by_user_id' => (int) $requester->getKey(),
|
||||||
|
'owner_user_id' => (int) $requester->getKey(),
|
||||||
|
'status' => FindingException::STATUS_ACTIVE,
|
||||||
|
'current_validity_state' => FindingException::VALIDITY_VALID,
|
||||||
|
'request_reason' => 'Test severity badge',
|
||||||
|
'requested_at' => now(),
|
||||||
|
'review_due_at' => now()->addDays(14),
|
||||||
|
'evidence_summary' => ['reference_count' => 0],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->actingAs($viewer);
|
||||||
|
$tenant->makeCurrent();
|
||||||
|
Filament::setTenant($tenant, true);
|
||||||
|
|
||||||
|
Livewire::test(ListFindingExceptions::class)
|
||||||
|
->assertCanSeeTableRecords([$exception])
|
||||||
|
->assertTableColumnExists('finding.severity')
|
||||||
|
->assertSee('High');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows descriptive finding title instead of bare Finding #ID', function (): void {
|
||||||
|
[$viewer, $tenant] = createUserWithTenant(role: 'readonly');
|
||||||
|
[$requester] = createUserWithTenant(tenant: $tenant, role: 'owner');
|
||||||
|
|
||||||
|
$finding = Finding::factory()->for($tenant)->create([
|
||||||
|
'finding_type' => Finding::FINDING_TYPE_DRIFT,
|
||||||
|
'subject_external_id' => 'test-policy-id',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$exception = FindingException::query()->create([
|
||||||
|
'workspace_id' => (int) $tenant->workspace_id,
|
||||||
|
'tenant_id' => (int) $tenant->getKey(),
|
||||||
|
'finding_id' => (int) $finding->getKey(),
|
||||||
|
'requested_by_user_id' => (int) $requester->getKey(),
|
||||||
|
'owner_user_id' => (int) $requester->getKey(),
|
||||||
|
'status' => FindingException::STATUS_ACTIVE,
|
||||||
|
'current_validity_state' => FindingException::VALIDITY_VALID,
|
||||||
|
'request_reason' => 'Test finding title',
|
||||||
|
'requested_at' => now(),
|
||||||
|
'review_due_at' => now()->addDays(14),
|
||||||
|
'evidence_summary' => ['reference_count' => 0],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->actingAs($viewer);
|
||||||
|
$tenant->makeCurrent();
|
||||||
|
Filament::setTenant($tenant, true);
|
||||||
|
|
||||||
|
Livewire::test(ListFindingExceptions::class)
|
||||||
|
->assertCanSeeTableRecords([$exception])
|
||||||
|
->assertSee('Drift');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows expires_at column with relative time description', function (): void {
|
||||||
|
[$viewer, $tenant] = createUserWithTenant(role: 'readonly');
|
||||||
|
[$requester] = createUserWithTenant(tenant: $tenant, role: 'owner');
|
||||||
|
|
||||||
|
$finding = Finding::factory()->for($tenant)->create();
|
||||||
|
|
||||||
|
$exception = FindingException::query()->create([
|
||||||
|
'workspace_id' => (int) $tenant->workspace_id,
|
||||||
|
'tenant_id' => (int) $tenant->getKey(),
|
||||||
|
'finding_id' => (int) $finding->getKey(),
|
||||||
|
'requested_by_user_id' => (int) $requester->getKey(),
|
||||||
|
'owner_user_id' => (int) $requester->getKey(),
|
||||||
|
'status' => FindingException::STATUS_EXPIRING,
|
||||||
|
'current_validity_state' => FindingException::VALIDITY_EXPIRING,
|
||||||
|
'request_reason' => 'Test relative time',
|
||||||
|
'requested_at' => now(),
|
||||||
|
'review_due_at' => now()->addDays(3),
|
||||||
|
'expires_at' => now()->addDays(5),
|
||||||
|
'evidence_summary' => ['reference_count' => 0],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->actingAs($viewer);
|
||||||
|
$tenant->makeCurrent();
|
||||||
|
Filament::setTenant($tenant, true);
|
||||||
|
|
||||||
|
Livewire::test(ListFindingExceptions::class)
|
||||||
|
->assertCanSeeTableRecords([$exception])
|
||||||
|
->assertTableColumnExists('expires_at');
|
||||||
|
|
||||||
|
// Verify the relative time helper directly (column descriptions aren't in Livewire test HTML)
|
||||||
|
expect(FindingExceptionResource::relativeTimeDescription(now()))->toBe('Today');
|
||||||
|
expect(FindingExceptionResource::relativeTimeDescription(now()->addDay()))->toBe('Tomorrow');
|
||||||
|
expect(FindingExceptionResource::relativeTimeDescription(now()->addDays(3)))->toBe('In 3 days');
|
||||||
|
expect(FindingExceptionResource::relativeTimeDescription(now()->addDays(14)))->toBe('In 14 days');
|
||||||
|
expect(FindingExceptionResource::relativeTimeDescription(null))->toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders stats overview widget above exception register table', function (): void {
|
||||||
|
[$viewer, $tenant] = createUserWithTenant(role: 'readonly');
|
||||||
|
|
||||||
|
$this->actingAs($viewer);
|
||||||
|
$tenant->makeCurrent();
|
||||||
|
Filament::setTenant($tenant, true);
|
||||||
|
|
||||||
|
Livewire::test(ListFindingExceptions::class)
|
||||||
|
->assertOk()
|
||||||
|
->assertSeeLivewire(FindingExceptionStatsOverview::class);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns correct stats counts for current tenant', function (): void {
|
||||||
|
[$viewer, $tenant] = createUserWithTenant(role: 'readonly');
|
||||||
|
[$requester] = createUserWithTenant(tenant: $tenant, role: 'owner');
|
||||||
|
|
||||||
|
$createException = fn (string $status, string $validity) => FindingException::query()->create([
|
||||||
|
'workspace_id' => (int) $tenant->workspace_id,
|
||||||
|
'tenant_id' => (int) $tenant->getKey(),
|
||||||
|
'finding_id' => (int) Finding::factory()->for($tenant)->create()->getKey(),
|
||||||
|
'requested_by_user_id' => (int) $requester->getKey(),
|
||||||
|
'owner_user_id' => (int) $requester->getKey(),
|
||||||
|
'status' => $status,
|
||||||
|
'current_validity_state' => $validity,
|
||||||
|
'request_reason' => 'Stats test',
|
||||||
|
'requested_at' => now(),
|
||||||
|
'review_due_at' => now()->addDays(14),
|
||||||
|
'evidence_summary' => ['reference_count' => 0],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$createException(FindingException::STATUS_ACTIVE, FindingException::VALIDITY_VALID);
|
||||||
|
$createException(FindingException::STATUS_ACTIVE, FindingException::VALIDITY_VALID);
|
||||||
|
$createException(FindingException::STATUS_EXPIRING, FindingException::VALIDITY_EXPIRING);
|
||||||
|
$createException(FindingException::STATUS_EXPIRED, FindingException::VALIDITY_EXPIRED);
|
||||||
|
$createException(FindingException::STATUS_PENDING, FindingException::VALIDITY_MISSING_SUPPORT);
|
||||||
|
|
||||||
|
$this->actingAs($viewer);
|
||||||
|
$tenant->makeCurrent();
|
||||||
|
Filament::setTenant($tenant, true);
|
||||||
|
|
||||||
|
$stats = FindingExceptionResource::exceptionStatsForCurrentTenant();
|
||||||
|
|
||||||
|
expect($stats)->toMatchArray([
|
||||||
|
'active' => 2,
|
||||||
|
'expiring' => 1,
|
||||||
|
'expired' => 1,
|
||||||
|
'pending' => 1,
|
||||||
|
'total' => 5,
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('segments exception register with quick-tabs for needs-action, active, and historical', function (): void {
|
||||||
|
[$viewer, $tenant] = createUserWithTenant(role: 'readonly');
|
||||||
|
[$requester] = createUserWithTenant(tenant: $tenant, role: 'owner');
|
||||||
|
|
||||||
|
$createException = fn (string $status, string $validity) => FindingException::query()->create([
|
||||||
|
'workspace_id' => (int) $tenant->workspace_id,
|
||||||
|
'tenant_id' => (int) $tenant->getKey(),
|
||||||
|
'finding_id' => (int) Finding::factory()->for($tenant)->create()->getKey(),
|
||||||
|
'requested_by_user_id' => (int) $requester->getKey(),
|
||||||
|
'owner_user_id' => (int) $requester->getKey(),
|
||||||
|
'status' => $status,
|
||||||
|
'current_validity_state' => $validity,
|
||||||
|
'request_reason' => 'Tab test',
|
||||||
|
'requested_at' => now(),
|
||||||
|
'review_due_at' => now()->addDays(14),
|
||||||
|
'evidence_summary' => ['reference_count' => 0],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$active = $createException(FindingException::STATUS_ACTIVE, FindingException::VALIDITY_VALID);
|
||||||
|
$expiring = $createException(FindingException::STATUS_EXPIRING, FindingException::VALIDITY_EXPIRING);
|
||||||
|
$rejected = $createException(FindingException::STATUS_REJECTED, FindingException::VALIDITY_REJECTED);
|
||||||
|
|
||||||
|
$this->actingAs($viewer);
|
||||||
|
$tenant->makeCurrent();
|
||||||
|
Filament::setTenant($tenant, true);
|
||||||
|
|
||||||
|
$list = Livewire::test(ListFindingExceptions::class);
|
||||||
|
|
||||||
|
// All tab shows everything
|
||||||
|
$list->assertCanSeeTableRecords([$active, $expiring, $rejected]);
|
||||||
|
|
||||||
|
// Needs action tab shows expiring (pending, expiring, expired)
|
||||||
|
$list->set('activeTab', 'needs_action')
|
||||||
|
->assertCanSeeTableRecords([$expiring])
|
||||||
|
->assertCanNotSeeTableRecords([$active, $rejected]);
|
||||||
|
|
||||||
|
// Active tab shows only active
|
||||||
|
$list->set('activeTab', 'active')
|
||||||
|
->assertCanSeeTableRecords([$active])
|
||||||
|
->assertCanNotSeeTableRecords([$expiring, $rejected]);
|
||||||
|
|
||||||
|
// Historical tab shows rejected/revoked/superseded
|
||||||
|
$list->set('activeTab', 'historical')
|
||||||
|
->assertCanSeeTableRecords([$rejected])
|
||||||
|
->assertCanNotSeeTableRecords([$active, $expiring]);
|
||||||
|
});
|
||||||
|
|||||||
@ -18,7 +18,7 @@ function findingsDefaultIndicatorLabels($component): array
|
|||||||
->all();
|
->all();
|
||||||
}
|
}
|
||||||
|
|
||||||
it('defaults to open findings across all finding types', function (): void {
|
it('defaults to a cross-lifecycle findings view across all finding types', function (): void {
|
||||||
[$user, $tenant] = createUserWithTenant(role: 'manager');
|
[$user, $tenant] = createUserWithTenant(role: 'manager');
|
||||||
$this->actingAs($user);
|
$this->actingAs($user);
|
||||||
Filament::setTenant($tenant, true);
|
Filament::setTenant($tenant, true);
|
||||||
@ -54,8 +54,7 @@ function findingsDefaultIndicatorLabels($component): array
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
Livewire::test(ListFindings::class)
|
Livewire::test(ListFindings::class)
|
||||||
->assertCanSeeTableRecords([$openDrift, $openPermission, $openEntra, $reopened])
|
->assertCanSeeTableRecords([$openDrift, $openPermission, $openEntra, $reopened, $resolved, $closed, $riskAccepted]);
|
||||||
->assertCanNotSeeTableRecords([$resolved, $closed, $riskAccepted]);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('keeps findings list defaults calm with explicit sortability and hidden forensic detail', function (): void {
|
it('keeps findings list defaults calm with explicit sortability and hidden forensic detail', function (): void {
|
||||||
@ -71,12 +70,15 @@ function findingsDefaultIndicatorLabels($component): array
|
|||||||
expect($table->getDefaultSortDirection())->toBe('desc');
|
expect($table->getDefaultSortDirection())->toBe('desc');
|
||||||
expect($table->getEmptyStateHeading())->toBe('No findings match this view');
|
expect($table->getEmptyStateHeading())->toBe('No findings match this view');
|
||||||
expect($table->getColumn('subject_display_name')?->isSearchable())->toBeTrue();
|
expect($table->getColumn('subject_display_name')?->isSearchable())->toBeTrue();
|
||||||
|
expect($table->getColumn('governance_validity'))->not->toBeNull();
|
||||||
expect($table->getColumn('due_at')?->isSortable())->toBeTrue();
|
expect($table->getColumn('due_at')?->isSortable())->toBeTrue();
|
||||||
expect($table->getColumn('evidence_fidelity')?->isToggledHiddenByDefault())->toBeTrue();
|
expect($table->getColumn('evidence_fidelity')?->isToggledHiddenByDefault())->toBeTrue();
|
||||||
expect($table->getColumn('subject_type')?->isToggledHiddenByDefault())->toBeTrue();
|
expect($table->getColumn('subject_type')?->isToggledHiddenByDefault())->toBeTrue();
|
||||||
expect($table->getColumn('subject_external_id')?->isToggledHiddenByDefault())->toBeTrue();
|
expect($table->getColumn('subject_external_id')?->isToggledHiddenByDefault())->toBeTrue();
|
||||||
expect($table->getColumn('scope_key')?->isToggledHiddenByDefault())->toBeTrue();
|
expect($table->getColumn('scope_key')?->isToggledHiddenByDefault())->toBeTrue();
|
||||||
expect(count($table->getVisibleColumns()))->toBeLessThanOrEqual(7);
|
expect(count($table->getVisibleColumns()))->toBeLessThanOrEqual(8);
|
||||||
|
expect($component->instance()->getTable()->getFilter('workflow_family'))->not->toBeNull();
|
||||||
|
expect($component->instance()->getTable()->getFilter('governance_validity'))->not->toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('defines created date-range narrowing with active indicators on the findings table', function (): void {
|
it('defines created date-range narrowing with active indicators on the findings table', function (): void {
|
||||||
|
|||||||
149
tests/Feature/Findings/FindingsListEnterpriseUxTest.php
Normal file
149
tests/Feature/Findings/FindingsListEnterpriseUxTest.php
Normal file
@ -0,0 +1,149 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
use App\Filament\Resources\FindingResource;
|
||||||
|
use App\Filament\Resources\FindingResource\Pages\ListFindings;
|
||||||
|
use App\Filament\Widgets\Tenant\FindingStatsOverview;
|
||||||
|
use App\Models\Finding;
|
||||||
|
use Filament\Facades\Filament;
|
||||||
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
use Livewire\Livewire;
|
||||||
|
|
||||||
|
uses(RefreshDatabase::class);
|
||||||
|
|
||||||
|
it('uses badge-formatted finding type labels instead of raw enum values', function (): void {
|
||||||
|
[$user, $tenant] = createUserWithTenant(role: 'manager');
|
||||||
|
$this->actingAs($user);
|
||||||
|
Filament::setTenant($tenant, true);
|
||||||
|
|
||||||
|
Finding::factory()->for($tenant)->create([
|
||||||
|
'finding_type' => Finding::FINDING_TYPE_PERMISSION_POSTURE,
|
||||||
|
'status' => Finding::STATUS_NEW,
|
||||||
|
]);
|
||||||
|
|
||||||
|
Livewire::test(ListFindings::class)
|
||||||
|
->assertSee('Permission posture');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows subject display name as an early visible column', function (): void {
|
||||||
|
[$user, $tenant] = createUserWithTenant(role: 'manager');
|
||||||
|
$this->actingAs($user);
|
||||||
|
Filament::setTenant($tenant, true);
|
||||||
|
|
||||||
|
$component = Livewire::test(ListFindings::class);
|
||||||
|
$table = $component->instance()->getTable();
|
||||||
|
$columns = array_keys($table->getVisibleColumns());
|
||||||
|
|
||||||
|
$subjectIndex = array_search('subject_display_name', $columns);
|
||||||
|
$statusIndex = array_search('status', $columns);
|
||||||
|
|
||||||
|
expect($subjectIndex)->toBeLessThan($statusIndex);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('places severity column before governance in column order', function (): void {
|
||||||
|
[$user, $tenant] = createUserWithTenant(role: 'manager');
|
||||||
|
$this->actingAs($user);
|
||||||
|
Filament::setTenant($tenant, true);
|
||||||
|
|
||||||
|
$component = Livewire::test(ListFindings::class);
|
||||||
|
$columns = array_keys($component->instance()->getTable()->getVisibleColumns());
|
||||||
|
|
||||||
|
$severityIndex = array_search('severity', $columns);
|
||||||
|
$governanceIndex = array_search('governance_validity', $columns);
|
||||||
|
|
||||||
|
expect($severityIndex)->toBeLessThan($governanceIndex);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders the finding stats overview widget above the findings table', function (): void {
|
||||||
|
[$user, $tenant] = createUserWithTenant(role: 'manager');
|
||||||
|
$this->actingAs($user);
|
||||||
|
Filament::setTenant($tenant, true);
|
||||||
|
|
||||||
|
Livewire::test(ListFindings::class)
|
||||||
|
->assertOk()
|
||||||
|
->assertSeeLivewire(FindingStatsOverview::class);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns correct finding stats counts for current tenant', function (): void {
|
||||||
|
[$user, $tenant] = createUserWithTenant(role: 'manager');
|
||||||
|
$this->actingAs($user);
|
||||||
|
Filament::setTenant($tenant, true);
|
||||||
|
|
||||||
|
Finding::factory()->for($tenant)->create([
|
||||||
|
'status' => Finding::STATUS_NEW,
|
||||||
|
'severity' => Finding::SEVERITY_LOW,
|
||||||
|
]);
|
||||||
|
|
||||||
|
Finding::factory()->for($tenant)->create([
|
||||||
|
'status' => Finding::STATUS_IN_PROGRESS,
|
||||||
|
'severity' => Finding::SEVERITY_HIGH,
|
||||||
|
'due_at' => now()->subDay(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
Finding::factory()->for($tenant)->create([
|
||||||
|
'status' => Finding::STATUS_RISK_ACCEPTED,
|
||||||
|
'severity' => Finding::SEVERITY_MEDIUM,
|
||||||
|
]);
|
||||||
|
|
||||||
|
Finding::factory()->for($tenant)->create([
|
||||||
|
'status' => Finding::STATUS_RESOLVED,
|
||||||
|
'severity' => Finding::SEVERITY_LOW,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$stats = FindingResource::findingStatsForCurrentTenant();
|
||||||
|
|
||||||
|
expect($stats['open'])->toBe(2);
|
||||||
|
expect($stats['overdue'])->toBe(1);
|
||||||
|
expect($stats['high_severity'])->toBe(1);
|
||||||
|
expect($stats['risk_accepted'])->toBe(1);
|
||||||
|
expect($stats['total'])->toBe(4);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('segments findings with quick-tabs for needs-action, overdue, risk-accepted, and resolved', function (): void {
|
||||||
|
[$user, $tenant] = createUserWithTenant(role: 'manager');
|
||||||
|
$this->actingAs($user);
|
||||||
|
Filament::setTenant($tenant, true);
|
||||||
|
|
||||||
|
$open = Finding::factory()->for($tenant)->create([
|
||||||
|
'status' => Finding::STATUS_NEW,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$overdue = Finding::factory()->for($tenant)->create([
|
||||||
|
'status' => Finding::STATUS_TRIAGED,
|
||||||
|
'due_at' => now()->subDays(2),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$riskAccepted = Finding::factory()->for($tenant)->create([
|
||||||
|
'status' => Finding::STATUS_RISK_ACCEPTED,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$resolved = Finding::factory()->for($tenant)->create([
|
||||||
|
'status' => Finding::STATUS_RESOLVED,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$list = Livewire::test(ListFindings::class);
|
||||||
|
|
||||||
|
// All tab shows everything
|
||||||
|
$list->assertCanSeeTableRecords([$open, $overdue, $riskAccepted, $resolved]);
|
||||||
|
|
||||||
|
// Needs action tab shows open findings
|
||||||
|
$list->set('activeTab', 'needs_action')
|
||||||
|
->assertCanSeeTableRecords([$open, $overdue])
|
||||||
|
->assertCanNotSeeTableRecords([$riskAccepted, $resolved]);
|
||||||
|
|
||||||
|
// Overdue tab shows only overdue open findings
|
||||||
|
$list->set('activeTab', 'overdue')
|
||||||
|
->assertCanSeeTableRecords([$overdue])
|
||||||
|
->assertCanNotSeeTableRecords([$open, $riskAccepted, $resolved]);
|
||||||
|
|
||||||
|
// Risk accepted tab
|
||||||
|
$list->set('activeTab', 'risk_accepted')
|
||||||
|
->assertCanSeeTableRecords([$riskAccepted])
|
||||||
|
->assertCanNotSeeTableRecords([$open, $overdue, $resolved]);
|
||||||
|
|
||||||
|
// Resolved tab
|
||||||
|
$list->set('activeTab', 'resolved')
|
||||||
|
->assertCanSeeTableRecords([$resolved])
|
||||||
|
->assertCanNotSeeTableRecords([$open, $overdue, $riskAccepted]);
|
||||||
|
});
|
||||||
@ -44,6 +44,66 @@ function findingFilterIndicatorLabels($component): array
|
|||||||
->assertCanNotSeeTableRecords([$notOverdueOpen, $overdueTerminal]);
|
->assertCanNotSeeTableRecords([$notOverdueOpen, $overdueTerminal]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('filters findings by workflow family and governance validity', function (): void {
|
||||||
|
[$user, $tenant] = createUserWithTenant(role: 'manager');
|
||||||
|
[$requester] = createUserWithTenant(tenant: $tenant, role: 'owner');
|
||||||
|
$approver = User::factory()->create();
|
||||||
|
createUserWithTenant(tenant: $tenant, user: $approver, role: 'owner', workspaceRole: 'manager');
|
||||||
|
|
||||||
|
$this->actingAs($user);
|
||||||
|
Filament::setTenant($tenant, true);
|
||||||
|
|
||||||
|
$active = Finding::factory()->for($tenant)->create([
|
||||||
|
'status' => Finding::STATUS_NEW,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$healthyAccepted = Finding::factory()->for($tenant)->create([
|
||||||
|
'status' => Finding::STATUS_RISK_ACCEPTED,
|
||||||
|
]);
|
||||||
|
|
||||||
|
\App\Models\FindingException::query()->create([
|
||||||
|
'workspace_id' => (int) $tenant->workspace_id,
|
||||||
|
'tenant_id' => (int) $tenant->getKey(),
|
||||||
|
'finding_id' => (int) $healthyAccepted->getKey(),
|
||||||
|
'requested_by_user_id' => (int) $requester->getKey(),
|
||||||
|
'owner_user_id' => (int) $requester->getKey(),
|
||||||
|
'approved_by_user_id' => (int) $approver->getKey(),
|
||||||
|
'status' => \App\Models\FindingException::STATUS_ACTIVE,
|
||||||
|
'current_validity_state' => \App\Models\FindingException::VALIDITY_VALID,
|
||||||
|
'request_reason' => 'Healthy governance',
|
||||||
|
'approval_reason' => 'Approved',
|
||||||
|
'requested_at' => now()->subDays(5),
|
||||||
|
'approved_at' => now()->subDays(4),
|
||||||
|
'effective_from' => now()->subDays(4),
|
||||||
|
'review_due_at' => now()->addDays(7),
|
||||||
|
'expires_at' => now()->addDays(14),
|
||||||
|
'evidence_summary' => ['reference_count' => 0],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$lapsedAccepted = Finding::factory()->for($tenant)->create([
|
||||||
|
'status' => Finding::STATUS_RISK_ACCEPTED,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$historical = Finding::factory()->for($tenant)->create([
|
||||||
|
'status' => Finding::STATUS_RESOLVED,
|
||||||
|
'resolved_at' => now()->subDay(),
|
||||||
|
'resolved_reason' => 'no_longer_drifting',
|
||||||
|
]);
|
||||||
|
|
||||||
|
Livewire::test(ListFindings::class)
|
||||||
|
->filterTable('workflow_family', 'historical')
|
||||||
|
->assertCanSeeTableRecords([$historical])
|
||||||
|
->assertCanNotSeeTableRecords([$active, $healthyAccepted, $lapsedAccepted])
|
||||||
|
->removeTableFilters()
|
||||||
|
->filterTable('governance_validity', \App\Models\FindingException::VALIDITY_VALID)
|
||||||
|
->assertCanSeeTableRecords([$healthyAccepted])
|
||||||
|
->assertCanNotSeeTableRecords([$active, $lapsedAccepted, $historical])
|
||||||
|
->removeTableFilters()
|
||||||
|
->filterTable('governance_validity', \App\Models\FindingException::VALIDITY_MISSING_SUPPORT)
|
||||||
|
->assertCanSeeTableRecords([$lapsedAccepted])
|
||||||
|
->assertCanNotSeeTableRecords([$active, $healthyAccepted, $historical]);
|
||||||
|
});
|
||||||
|
|
||||||
it('filters findings by high severity quick filter', function (): void {
|
it('filters findings by high severity quick filter', function (): void {
|
||||||
[$user, $tenant] = createUserWithTenant(role: 'manager');
|
[$user, $tenant] = createUserWithTenant(role: 'manager');
|
||||||
$this->actingAs($user);
|
$this->actingAs($user);
|
||||||
|
|||||||
201
tests/Feature/Guards/DerivedStateConsumerAdoptionGuardTest.php
Normal file
201
tests/Feature/Guards/DerivedStateConsumerAdoptionGuardTest.php
Normal file
@ -0,0 +1,201 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
use Symfony\Component\Yaml\Yaml;
|
||||||
|
use Tests\Support\OpsUx\SourceFileScanner;
|
||||||
|
|
||||||
|
it('keeps covered derived-state consumers on declared access paths without ad hoc caches', function (): void {
|
||||||
|
$root = SourceFileScanner::projectRoot();
|
||||||
|
$contractPath = $root.'/specs/167-derived-state-memoization/contracts/request-scoped-derived-state.logical.openapi.yaml';
|
||||||
|
|
||||||
|
/** @var array<string, mixed> $contract */
|
||||||
|
$contract = Yaml::parseFile($contractPath);
|
||||||
|
$declarations = $contract['x-derived-state-consumers'] ?? [];
|
||||||
|
|
||||||
|
expect($declarations)->toBeArray()->not->toBeEmpty();
|
||||||
|
|
||||||
|
$allowedFamilies = [
|
||||||
|
'artifact_truth',
|
||||||
|
'operation_ux_guidance',
|
||||||
|
'operation_ux_explanation',
|
||||||
|
'related_navigation_primary',
|
||||||
|
'related_navigation_detail',
|
||||||
|
'related_navigation_header',
|
||||||
|
];
|
||||||
|
$allowedAccessPatterns = ['row_safe', 'page_safe', 'direct_once'];
|
||||||
|
$allowedFreshnessPolicies = ['request_stable', 'invalidate_after_mutation', 'no_reuse'];
|
||||||
|
$cachePattern = '/static\s+array\s+\$[A-Za-z0-9_]*cache\b/i';
|
||||||
|
$violations = [];
|
||||||
|
|
||||||
|
foreach ($declarations as $index => $declaration) {
|
||||||
|
if (! is_array($declaration)) {
|
||||||
|
$violations[] = [
|
||||||
|
'surface' => 'contract',
|
||||||
|
'message' => sprintf('Declaration %d must be an object.', $index),
|
||||||
|
];
|
||||||
|
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$surface = trim((string) ($declaration['surface'] ?? ''));
|
||||||
|
$family = trim((string) ($declaration['family'] ?? ''));
|
||||||
|
$variant = trim((string) ($declaration['variant'] ?? ''));
|
||||||
|
$accessPattern = trim((string) ($declaration['accessPattern'] ?? ''));
|
||||||
|
$freshnessPolicy = trim((string) ($declaration['freshnessPolicy'] ?? ''));
|
||||||
|
$scopeInputs = $declaration['scopeInputs'] ?? null;
|
||||||
|
$guardScope = $declaration['guardScope'] ?? null;
|
||||||
|
$requiredMarkers = array_values(array_filter(
|
||||||
|
$declaration['requiredMarkers'] ?? [],
|
||||||
|
static fn (mixed $marker): bool => is_string($marker) && trim($marker) !== '',
|
||||||
|
));
|
||||||
|
$maxOccurrences = $declaration['maxOccurrences'] ?? [];
|
||||||
|
|
||||||
|
if ($surface === '' || $variant === '') {
|
||||||
|
$violations[] = [
|
||||||
|
'surface' => $surface !== '' ? $surface : 'contract',
|
||||||
|
'message' => 'Each declaration must provide non-empty surface and variant values.',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! in_array($family, $allowedFamilies, true)) {
|
||||||
|
$violations[] = [
|
||||||
|
'surface' => $surface !== '' ? $surface : 'contract',
|
||||||
|
'message' => sprintf('Unsupported family "%s".', $family),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! in_array($accessPattern, $allowedAccessPatterns, true)) {
|
||||||
|
$violations[] = [
|
||||||
|
'surface' => $surface !== '' ? $surface : 'contract',
|
||||||
|
'message' => sprintf('Unsupported accessPattern "%s".', $accessPattern),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! in_array($freshnessPolicy, $allowedFreshnessPolicies, true)) {
|
||||||
|
$violations[] = [
|
||||||
|
'surface' => $surface !== '' ? $surface : 'contract',
|
||||||
|
'message' => sprintf('Unsupported freshnessPolicy "%s".', $freshnessPolicy),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! is_array($scopeInputs) || $scopeInputs === []) {
|
||||||
|
$violations[] = [
|
||||||
|
'surface' => $surface !== '' ? $surface : 'contract',
|
||||||
|
'message' => 'Each declaration must include at least one scope input.',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! is_array($guardScope) || $guardScope === []) {
|
||||||
|
$violations[] = [
|
||||||
|
'surface' => $surface !== '' ? $surface : 'contract',
|
||||||
|
'message' => 'Each declaration must include at least one guardScope path.',
|
||||||
|
];
|
||||||
|
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($guardScope as $relativePath) {
|
||||||
|
$relativePath = trim((string) $relativePath);
|
||||||
|
$absolutePath = $root.'/'.$relativePath;
|
||||||
|
|
||||||
|
if ($relativePath === '' || ! is_file($absolutePath)) {
|
||||||
|
$violations[] = [
|
||||||
|
'surface' => $surface !== '' ? $surface : 'contract',
|
||||||
|
'message' => sprintf('Missing guardScope file "%s".', $relativePath),
|
||||||
|
];
|
||||||
|
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$source = SourceFileScanner::read($absolutePath);
|
||||||
|
|
||||||
|
foreach ($requiredMarkers as $marker) {
|
||||||
|
if (str_contains($source, $marker)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$violations[] = [
|
||||||
|
'surface' => $surface,
|
||||||
|
'file' => SourceFileScanner::relativePath($absolutePath),
|
||||||
|
'message' => sprintf('Missing required marker "%s".', $marker),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (preg_match($cachePattern, $source, $match, PREG_OFFSET_CAPTURE) === 1) {
|
||||||
|
$offset = $match[0][1];
|
||||||
|
$line = substr_count(substr($source, 0, is_int($offset) ? $offset : 0), "\n") + 1;
|
||||||
|
|
||||||
|
$violations[] = [
|
||||||
|
'surface' => $surface,
|
||||||
|
'file' => SourceFileScanner::relativePath($absolutePath),
|
||||||
|
'line' => $line,
|
||||||
|
'message' => 'Ad hoc local cache detected in guarded surface.',
|
||||||
|
'snippet' => SourceFileScanner::snippet($source, $line),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! is_array($maxOccurrences)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($maxOccurrences as $occurrenceRule) {
|
||||||
|
if (! is_array($occurrenceRule)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$needle = trim((string) ($occurrenceRule['needle'] ?? ''));
|
||||||
|
$max = (int) ($occurrenceRule['max'] ?? -1);
|
||||||
|
|
||||||
|
if ($needle === '' || $max < 0) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$actual = substr_count($source, $needle);
|
||||||
|
|
||||||
|
if ($actual <= $max) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$offset = strpos($source, $needle);
|
||||||
|
$line = $offset === false ? 1 : substr_count(substr($source, 0, $offset), "\n") + 1;
|
||||||
|
|
||||||
|
$violations[] = [
|
||||||
|
'surface' => $surface,
|
||||||
|
'file' => SourceFileScanner::relativePath($absolutePath),
|
||||||
|
'line' => $line,
|
||||||
|
'message' => sprintf('Found %d occurrences of "%s"; expected at most %d.', $actual, $needle, $max),
|
||||||
|
'snippet' => SourceFileScanner::snippet($source, $line),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($violations !== []) {
|
||||||
|
$messages = array_map(static function (array $violation): string {
|
||||||
|
$location = $violation['surface'] ?? 'contract';
|
||||||
|
|
||||||
|
if (isset($violation['file'])) {
|
||||||
|
$location .= ' @ '.$violation['file'];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isset($violation['line'])) {
|
||||||
|
$location .= ':'.$violation['line'];
|
||||||
|
}
|
||||||
|
|
||||||
|
$message = $location."\n".$violation['message'];
|
||||||
|
|
||||||
|
if (isset($violation['snippet'])) {
|
||||||
|
$message .= "\n".$violation['snippet'];
|
||||||
|
}
|
||||||
|
|
||||||
|
return $message;
|
||||||
|
}, $violations);
|
||||||
|
|
||||||
|
$this->fail(
|
||||||
|
"Derived-state consumer guard violations found:\n\n".implode("\n\n", $messages)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
expect($violations)->toBe([]);
|
||||||
|
});
|
||||||
@ -0,0 +1,161 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
use App\Filament\Resources\BaselineSnapshotResource;
|
||||||
|
use App\Models\BaselineProfile;
|
||||||
|
use App\Models\BaselineSnapshot;
|
||||||
|
use App\Models\Finding;
|
||||||
|
use App\Models\OperationRun;
|
||||||
|
use App\Models\Policy;
|
||||||
|
use App\Models\PolicyVersion;
|
||||||
|
use App\Services\Auth\WorkspaceCapabilityResolver;
|
||||||
|
use App\Support\Navigation\CrossResourceNavigationMatrix;
|
||||||
|
use App\Support\Navigation\RelatedNavigationResolver;
|
||||||
|
use App\Support\Ui\DerivedState\DerivedStateFamily;
|
||||||
|
use App\Support\Ui\DerivedState\DerivedStateKey;
|
||||||
|
use App\Support\Ui\DerivedState\RequestScopedDerivedStateStore;
|
||||||
|
use App\Support\Workspaces\WorkspaceContext;
|
||||||
|
use Filament\Facades\Filament;
|
||||||
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
|
||||||
|
uses(RefreshDatabase::class);
|
||||||
|
|
||||||
|
it('reuses finding related navigation and caches deterministic negative results', function (): void {
|
||||||
|
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||||
|
$this->actingAs($user);
|
||||||
|
Filament::setTenant($tenant, true);
|
||||||
|
|
||||||
|
$profile = BaselineProfile::factory()->active()->create([
|
||||||
|
'workspace_id' => (int) $tenant->workspace_id,
|
||||||
|
'name' => 'Security Baseline',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$snapshot = BaselineSnapshot::factory()->create([
|
||||||
|
'workspace_id' => (int) $tenant->workspace_id,
|
||||||
|
'baseline_profile_id' => (int) $profile->getKey(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$policy = Policy::factory()->create([
|
||||||
|
'tenant_id' => (int) $tenant->getKey(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$version = PolicyVersion::factory()->create([
|
||||||
|
'tenant_id' => (int) $tenant->getKey(),
|
||||||
|
'policy_id' => (int) $policy->getKey(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$run = OperationRun::factory()->for($tenant)->create([
|
||||||
|
'workspace_id' => (int) $tenant->workspace_id,
|
||||||
|
'type' => 'baseline_compare',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$finding = Finding::factory()->for($tenant)->create([
|
||||||
|
'current_operation_run_id' => (int) $run->getKey(),
|
||||||
|
'evidence_jsonb' => [
|
||||||
|
'current' => [
|
||||||
|
'policy_version_id' => (int) $version->getKey(),
|
||||||
|
],
|
||||||
|
'provenance' => [
|
||||||
|
'baseline_profile_id' => (int) $profile->getKey(),
|
||||||
|
'baseline_snapshot_id' => (int) $snapshot->getKey(),
|
||||||
|
'compare_operation_run_id' => (int) $run->getKey(),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$resolver = app(RelatedNavigationResolver::class);
|
||||||
|
$first = $resolver->primaryListAction(CrossResourceNavigationMatrix::SOURCE_FINDING, $finding);
|
||||||
|
$second = $resolver->primaryListAction(CrossResourceNavigationMatrix::SOURCE_FINDING, $finding);
|
||||||
|
|
||||||
|
expect($first?->targetUrl)->toBe($second?->targetUrl);
|
||||||
|
|
||||||
|
$store = app(RequestScopedDerivedStateStore::class);
|
||||||
|
|
||||||
|
expect($store->countStored(
|
||||||
|
DerivedStateFamily::RelatedNavigationPrimary,
|
||||||
|
Finding::class,
|
||||||
|
(string) $finding->getKey(),
|
||||||
|
CrossResourceNavigationMatrix::SOURCE_FINDING,
|
||||||
|
))->toBe(1);
|
||||||
|
|
||||||
|
$orphanedFinding = Finding::factory()->for($tenant)->create([
|
||||||
|
'evidence_jsonb' => [],
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect($resolver->primaryListAction(CrossResourceNavigationMatrix::SOURCE_FINDING, $orphanedFinding))->toBeNull()
|
||||||
|
->and($resolver->primaryListAction(CrossResourceNavigationMatrix::SOURCE_FINDING, $orphanedFinding))->toBeNull();
|
||||||
|
|
||||||
|
$negativeKey = DerivedStateKey::fromModel(
|
||||||
|
DerivedStateFamily::RelatedNavigationPrimary,
|
||||||
|
$orphanedFinding,
|
||||||
|
CrossResourceNavigationMatrix::SOURCE_FINDING,
|
||||||
|
[
|
||||||
|
'source_type' => CrossResourceNavigationMatrix::SOURCE_FINDING,
|
||||||
|
'surface' => CrossResourceNavigationMatrix::SURFACE_LIST_ROW,
|
||||||
|
'active_tenant_id' => (int) $tenant->getKey(),
|
||||||
|
'route_name' => null,
|
||||||
|
'user_id' => (int) $user->getKey(),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
expect($store->resolutionRecord($negativeKey)['negative_result'])->toBeTrue();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reuses operation-run related context across detail and header consumers', function (): void {
|
||||||
|
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||||
|
$this->actingAs($user);
|
||||||
|
Filament::setTenant($tenant, true);
|
||||||
|
|
||||||
|
$run = OperationRun::factory()->for($tenant)->create([
|
||||||
|
'workspace_id' => (int) $tenant->workspace_id,
|
||||||
|
'type' => 'backup_set.add_policies',
|
||||||
|
'context' => [
|
||||||
|
'backup_set_id' => 123,
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$resolver = app(RelatedNavigationResolver::class);
|
||||||
|
|
||||||
|
$resolver->detailEntries(CrossResourceNavigationMatrix::SOURCE_OPERATION_RUN, $run);
|
||||||
|
$resolver->operationLinks($run, $tenant);
|
||||||
|
|
||||||
|
expect(app(RequestScopedDerivedStateStore::class)->countStored(
|
||||||
|
DerivedStateFamily::RelatedNavigationDetail,
|
||||||
|
OperationRun::class,
|
||||||
|
(string) $run->getKey(),
|
||||||
|
CrossResourceNavigationMatrix::SOURCE_OPERATION_RUN,
|
||||||
|
))->toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps related-navigation target routes tenant-safe for non-members and capability-limited members', function (): void {
|
||||||
|
$workspaceTenant = \App\Models\Tenant::factory()->create();
|
||||||
|
[$member, $workspaceTenant] = createUserWithTenant(tenant: $workspaceTenant, role: 'readonly');
|
||||||
|
$nonMember = \App\Models\User::factory()->create();
|
||||||
|
|
||||||
|
$profile = BaselineProfile::factory()->active()->create([
|
||||||
|
'workspace_id' => (int) $workspaceTenant->workspace_id,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$snapshot = BaselineSnapshot::factory()->create([
|
||||||
|
'workspace_id' => (int) $workspaceTenant->workspace_id,
|
||||||
|
'baseline_profile_id' => (int) $profile->getKey(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
Filament::setTenant(null, true);
|
||||||
|
|
||||||
|
$this->actingAs($nonMember)
|
||||||
|
->withSession([WorkspaceContext::SESSION_KEY => (int) $workspaceTenant->workspace_id])
|
||||||
|
->get(BaselineSnapshotResource::getUrl('view', ['record' => $snapshot], panel: 'admin'))
|
||||||
|
->assertNotFound();
|
||||||
|
|
||||||
|
$resolver = \Mockery::mock(WorkspaceCapabilityResolver::class);
|
||||||
|
$resolver->shouldReceive('isMember')->andReturnTrue();
|
||||||
|
$resolver->shouldReceive('can')->andReturnFalse();
|
||||||
|
app()->instance(WorkspaceCapabilityResolver::class, $resolver);
|
||||||
|
|
||||||
|
$this->actingAs($member)
|
||||||
|
->withSession([WorkspaceContext::SESSION_KEY => (int) $workspaceTenant->workspace_id])
|
||||||
|
->get(BaselineSnapshotResource::getUrl('view', ['record' => $snapshot], panel: 'admin'))
|
||||||
|
->assertForbidden();
|
||||||
|
});
|
||||||
@ -598,6 +598,35 @@
|
|||||||
],
|
],
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
it('does not render visibility sync Livewire updates for tenantless runs', function (): void {
|
||||||
|
$workspace = Workspace::factory()->create();
|
||||||
|
$user = User::factory()->create();
|
||||||
|
|
||||||
|
WorkspaceMembership::factory()->create([
|
||||||
|
'workspace_id' => (int) $workspace->getKey(),
|
||||||
|
'user_id' => (int) $user->getKey(),
|
||||||
|
'role' => 'owner',
|
||||||
|
]);
|
||||||
|
|
||||||
|
session()->forget(WorkspaceContext::SESSION_KEY);
|
||||||
|
|
||||||
|
$run = OperationRun::factory()->create([
|
||||||
|
'workspace_id' => (int) $workspace->getKey(),
|
||||||
|
'tenant_id' => null,
|
||||||
|
'type' => 'provider.connection.check',
|
||||||
|
'status' => OperationRunStatus::Queued->value,
|
||||||
|
'outcome' => OperationRunOutcome::Pending->value,
|
||||||
|
'created_at' => now()->subSeconds(5),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->actingAs($user)
|
||||||
|
->get("/admin/operations/{$run->getKey()}")
|
||||||
|
->assertSuccessful()
|
||||||
|
->assertDontSee("opsUxIsTabHidden', document.hidden", escape: false)
|
||||||
|
->assertDontSee('visibilitychange.window', escape: false)
|
||||||
|
->assertSee('wire:poll.1s', escape: false);
|
||||||
|
});
|
||||||
|
|
||||||
it('does not render polling markup for terminal tenantless runs', function (string $outcome): void {
|
it('does not render polling markup for terminal tenantless runs', function (string $outcome): void {
|
||||||
$workspace = Workspace::factory()->create();
|
$workspace = Workspace::factory()->create();
|
||||||
$user = User::factory()->create();
|
$user = User::factory()->create();
|
||||||
|
|||||||
@ -9,11 +9,11 @@
|
|||||||
|
|
||||||
uses(RefreshDatabase::class);
|
uses(RefreshDatabase::class);
|
||||||
|
|
||||||
it('renders resolved status badge with success color', function (): void {
|
it('renders resolved status badge with neutral color', function (): void {
|
||||||
$spec = BadgeCatalog::spec(BadgeDomain::FindingStatus, Finding::STATUS_RESOLVED);
|
$spec = BadgeCatalog::spec(BadgeDomain::FindingStatus, Finding::STATUS_RESOLVED);
|
||||||
|
|
||||||
expect($spec->label)->toBe('Resolved')
|
expect($spec->label)->toBe('Resolved')
|
||||||
->and($spec->color)->toBe('success')
|
->and($spec->color)->toBe('gray')
|
||||||
->and($spec->icon)->toBe('heroicon-o-check-circle');
|
->and($spec->icon)->toBe('heroicon-o-check-circle');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@ -44,9 +44,31 @@
|
|||||||
expect($reopened->label)->toBe('Reopened');
|
expect($reopened->label)->toBe('Reopened');
|
||||||
expect($reopened->color)->toBe('danger');
|
expect($reopened->color)->toBe('danger');
|
||||||
|
|
||||||
|
$resolved = BadgeCatalog::spec(BadgeDomain::FindingStatus, 'resolved');
|
||||||
|
expect($resolved->label)->toBe('Resolved');
|
||||||
|
expect($resolved->color)->toBe('gray');
|
||||||
|
|
||||||
$closed = BadgeCatalog::spec(BadgeDomain::FindingStatus, 'closed');
|
$closed = BadgeCatalog::spec(BadgeDomain::FindingStatus, 'closed');
|
||||||
expect($closed->label)->toBe('Closed');
|
expect($closed->label)->toBe('Closed');
|
||||||
|
|
||||||
$riskAccepted = BadgeCatalog::spec(BadgeDomain::FindingStatus, 'risk_accepted');
|
$riskAccepted = BadgeCatalog::spec(BadgeDomain::FindingStatus, 'risk_accepted');
|
||||||
expect($riskAccepted->label)->toBe('Risk accepted');
|
expect($riskAccepted->label)->toBe('Risk accepted');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('maps finding governance validity values to operator-facing attention colors', function (): void {
|
||||||
|
$valid = BadgeCatalog::spec(BadgeDomain::FindingRiskGovernanceValidity, 'valid');
|
||||||
|
expect($valid->label)->toBe('Valid');
|
||||||
|
expect($valid->color)->toBe('success');
|
||||||
|
|
||||||
|
$expiring = BadgeCatalog::spec(BadgeDomain::FindingRiskGovernanceValidity, 'expiring');
|
||||||
|
expect($expiring->label)->toBe('Expiring');
|
||||||
|
expect($expiring->color)->toBe('warning');
|
||||||
|
|
||||||
|
$rejected = BadgeCatalog::spec(BadgeDomain::FindingRiskGovernanceValidity, 'rejected');
|
||||||
|
expect($rejected->label)->toBe('Rejected');
|
||||||
|
expect($rejected->color)->toBe('danger');
|
||||||
|
|
||||||
|
$missingSupport = BadgeCatalog::spec(BadgeDomain::FindingRiskGovernanceValidity, 'missing_support');
|
||||||
|
expect($missingSupport->label)->toBe('Missing support');
|
||||||
|
expect($missingSupport->color)->toBe('danger');
|
||||||
|
});
|
||||||
|
|||||||
@ -39,5 +39,5 @@
|
|||||||
|
|
||||||
$missingSupport = BadgeCatalog::spec(BadgeDomain::FindingRiskGovernanceValidity, FindingException::VALIDITY_MISSING_SUPPORT);
|
$missingSupport = BadgeCatalog::spec(BadgeDomain::FindingRiskGovernanceValidity, FindingException::VALIDITY_MISSING_SUPPORT);
|
||||||
expect($missingSupport->label)->toBe('Missing support')
|
expect($missingSupport->label)->toBe('Missing support')
|
||||||
->and($missingSupport->color)->toBe('gray');
|
->and($missingSupport->color)->toBe('danger');
|
||||||
});
|
});
|
||||||
|
|||||||
@ -0,0 +1,168 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
use App\Support\Ui\DerivedState\DerivedStateFamily;
|
||||||
|
use App\Support\Ui\DerivedState\DerivedStateKey;
|
||||||
|
use App\Support\Ui\DerivedState\RequestScopedDerivedStateStore;
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
|
||||||
|
it('builds stable key fingerprints for equivalent context payloads', function (): void {
|
||||||
|
$record = new class extends Model
|
||||||
|
{
|
||||||
|
protected $guarded = [];
|
||||||
|
|
||||||
|
public $timestamps = false;
|
||||||
|
};
|
||||||
|
|
||||||
|
$record->forceFill([
|
||||||
|
'id' => 42,
|
||||||
|
'workspace_id' => 12,
|
||||||
|
'tenant_id' => 8,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$left = DerivedStateKey::fromModel(
|
||||||
|
DerivedStateFamily::ArtifactTruth,
|
||||||
|
$record,
|
||||||
|
'tenant_review',
|
||||||
|
[
|
||||||
|
'user_id' => 7,
|
||||||
|
'scope' => ['tenant' => 8, 'workspace' => 12],
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
$right = DerivedStateKey::fromModel(
|
||||||
|
DerivedStateFamily::ArtifactTruth,
|
||||||
|
$record,
|
||||||
|
'tenant_review',
|
||||||
|
[
|
||||||
|
'scope' => ['workspace' => 12, 'tenant' => 8],
|
||||||
|
'user_id' => 7,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
expect($left->fingerprint())->toBe($right->fingerprint())
|
||||||
|
->and($left->workspaceId)->toBe(12)
|
||||||
|
->and($left->tenantId)->toBe(8);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reuses cached values after the first miss', function (): void {
|
||||||
|
$store = new RequestScopedDerivedStateStore('request-a');
|
||||||
|
$key = new DerivedStateKey(DerivedStateFamily::ArtifactTruth, 'App\\Models\\TenantReview', '55', 'tenant_review');
|
||||||
|
$resolutions = 0;
|
||||||
|
|
||||||
|
$first = $store->resolve($key, function () use (&$resolutions): string {
|
||||||
|
$resolutions++;
|
||||||
|
|
||||||
|
return 'derived-result';
|
||||||
|
});
|
||||||
|
|
||||||
|
$second = $store->resolve($key, function () use (&$resolutions): string {
|
||||||
|
$resolutions++;
|
||||||
|
|
||||||
|
return 'unexpected-second-resolution';
|
||||||
|
});
|
||||||
|
|
||||||
|
$record = $store->resolutionRecord($key);
|
||||||
|
|
||||||
|
expect($first)->toBe('derived-result')
|
||||||
|
->and($second)->toBe('derived-result')
|
||||||
|
->and($resolutions)->toBe(1)
|
||||||
|
->and($record)->not->toBeNull()
|
||||||
|
->and($record['negative_result'])->toBeFalse()
|
||||||
|
->and($record['resolved_at'])->toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reuses deterministic negative results when the family allows it', function (): void {
|
||||||
|
$store = new RequestScopedDerivedStateStore('request-b');
|
||||||
|
$key = new DerivedStateKey(DerivedStateFamily::RelatedNavigationPrimary, 'App\\Models\\Finding', '91', 'finding');
|
||||||
|
$resolutions = 0;
|
||||||
|
|
||||||
|
$first = $store->resolve($key, function () use (&$resolutions): ?string {
|
||||||
|
$resolutions++;
|
||||||
|
|
||||||
|
return null;
|
||||||
|
});
|
||||||
|
|
||||||
|
$second = $store->resolve($key, function () use (&$resolutions): string {
|
||||||
|
$resolutions++;
|
||||||
|
|
||||||
|
return 'should-not-run';
|
||||||
|
});
|
||||||
|
|
||||||
|
expect($first)->toBeNull()
|
||||||
|
->and($second)->toBeNull()
|
||||||
|
->and($resolutions)->toBe(1)
|
||||||
|
->and($store->resolutionRecord($key)['negative_result'])->toBeTrue();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps variants isolated for the same family and record', function (): void {
|
||||||
|
$store = new RequestScopedDerivedStateStore('request-c');
|
||||||
|
$resolutions = 0;
|
||||||
|
|
||||||
|
$tenantReviewKey = new DerivedStateKey(DerivedStateFamily::ArtifactTruth, 'App\\Models\\TenantReview', '101', 'tenant_review');
|
||||||
|
$reviewPackKey = new DerivedStateKey(DerivedStateFamily::ArtifactTruth, 'App\\Models\\TenantReview', '101', 'review_pack');
|
||||||
|
|
||||||
|
$tenantReviewValue = $store->resolve($tenantReviewKey, function () use (&$resolutions): string {
|
||||||
|
$resolutions++;
|
||||||
|
|
||||||
|
return 'tenant-review';
|
||||||
|
});
|
||||||
|
|
||||||
|
$reviewPackValue = $store->resolve($reviewPackKey, function () use (&$resolutions): string {
|
||||||
|
$resolutions++;
|
||||||
|
|
||||||
|
return 'review-pack';
|
||||||
|
});
|
||||||
|
|
||||||
|
expect($tenantReviewValue)->toBe('tenant-review')
|
||||||
|
->and($reviewPackValue)->toBe('review-pack')
|
||||||
|
->and($resolutions)->toBe(2)
|
||||||
|
->and($store->entryCount())->toBe(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('invalidates exact keys and whole family slices', function (): void {
|
||||||
|
$store = new RequestScopedDerivedStateStore('request-d');
|
||||||
|
$tenantReviewKey = new DerivedStateKey(DerivedStateFamily::ArtifactTruth, 'App\\Models\\TenantReview', '1', 'tenant_review');
|
||||||
|
$reviewPackKey = new DerivedStateKey(DerivedStateFamily::ArtifactTruth, 'App\\Models\\ReviewPack', '9', 'review_pack');
|
||||||
|
$navigationKey = new DerivedStateKey(DerivedStateFamily::RelatedNavigationPrimary, 'App\\Models\\Finding', '1', 'finding');
|
||||||
|
|
||||||
|
$store->resolve($tenantReviewKey, static fn (): string => 'review');
|
||||||
|
$store->resolve($reviewPackKey, static fn (): string => 'pack');
|
||||||
|
$store->resolve($navigationKey, static fn (): string => 'link');
|
||||||
|
|
||||||
|
expect($store->invalidateKey($tenantReviewKey))->toBe(1)
|
||||||
|
->and($store->resolutionRecord($tenantReviewKey))->toBeNull()
|
||||||
|
->and($store->entryCount())->toBe(2)
|
||||||
|
->and($store->invalidateFamily(DerivedStateFamily::ArtifactTruth))->toBe(1)
|
||||||
|
->and($store->entryCount())->toBe(1)
|
||||||
|
->and($store->countStored(DerivedStateFamily::RelatedNavigationPrimary))->toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('supports no-reuse and fresh-resolution paths for mutation-sensitive reads', function (): void {
|
||||||
|
$store = new RequestScopedDerivedStateStore('request-e');
|
||||||
|
$key = new DerivedStateKey(DerivedStateFamily::OperationUxGuidance, 'App\\Models\\OperationRun', '44', 'surface_guidance');
|
||||||
|
$resolutions = 0;
|
||||||
|
|
||||||
|
$first = $store->resolve($key, function () use (&$resolutions): string {
|
||||||
|
$resolutions++;
|
||||||
|
|
||||||
|
return 'queued';
|
||||||
|
}, RequestScopedDerivedStateStore::FRESHNESS_NO_REUSE);
|
||||||
|
|
||||||
|
$second = $store->resolve($key, function () use (&$resolutions): string {
|
||||||
|
$resolutions++;
|
||||||
|
|
||||||
|
return 'running';
|
||||||
|
}, RequestScopedDerivedStateStore::FRESHNESS_NO_REUSE);
|
||||||
|
|
||||||
|
$store->resolve($key, static fn (): string => 'stale');
|
||||||
|
$fresh = $store->resolveFresh($key, static fn (): string => 'fresh');
|
||||||
|
|
||||||
|
expect($first)->toBe('queued')
|
||||||
|
->and($second)->toBe('running')
|
||||||
|
->and($fresh)->toBe('fresh')
|
||||||
|
->and($resolutions)->toBe(2)
|
||||||
|
->and($store->countStored(DerivedStateFamily::OperationUxGuidance, 'App\\Models\\OperationRun', '44', 'surface_guidance'))->toBe(1)
|
||||||
|
->and($store->invalidations())->toHaveCount(1);
|
||||||
|
});
|
||||||
Loading…
Reference in New Issue
Block a user