Compare commits

...

5 Commits

Author SHA1 Message Date
2752515da5 Spec 235: harden baseline truth and onboarding flows (#271)
Some checks failed
Main Confidence / confidence (push) Failing after 54s
## Summary
- harden baseline capture truth, compare readiness, and monitoring explanations around latest inventory eligibility, blocked prerequisites, and zero-subject outcomes
- improve onboarding verification and bootstrap recovery handling, including admin-consent callback invalidation and queued execution legitimacy/report behavior
- align workspace findings/workspace overview signals and refresh the related spec, roadmap, and spec-candidate artifacts

## Validation
- `cd apps/platform && ./vendor/bin/sail bin pint --dirty --format agent`
- `cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/BaselineDriftEngine/BaselineCaptureAuditEventsTest.php tests/Feature/BaselineDriftEngine/BaselineSnapshotNoTenantIdentifiersTest.php tests/Feature/BaselineDriftEngine/CaptureBaselineContentTest.php tests/Feature/BaselineDriftEngine/CaptureBaselineFullContentOnDemandTest.php tests/Feature/BaselineDriftEngine/CaptureBaselineMetaFallbackTest.php tests/Feature/Baselines/BaselineCaptureTest.php tests/Feature/Baselines/BaselineCompareFindingsTest.php tests/Feature/Baselines/BaselineSnapshotBackfillTest.php tests/Feature/Filament/BaselineCaptureResultExplanationSurfaceTest.php tests/Feature/Filament/BaselineCompareLandingStartSurfaceTest.php tests/Feature/Filament/BaselineProfileCaptureStartSurfaceTest.php tests/Feature/Filament/OperationRunBaselineTruthSurfaceTest.php tests/Feature/Monitoring/AuditCoverageGovernanceTest.php tests/Feature/Monitoring/GovernanceOperationRunSummariesTest.php tests/Feature/Notifications/OperationRunNotificationTest.php tests/Feature/Authorization/OperatorExplanationSurfaceAuthorizationTest.php`
- `cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/AdminConsentCallbackTest.php tests/Feature/Filament/WorkspaceOverviewDbOnlyTest.php tests/Feature/Guards/Spec194GovernanceActionSemanticsGuardTest.php tests/Feature/ManagedTenantOnboardingWizardTest.php tests/Feature/Onboarding/OnboardingVerificationTest.php tests/Feature/Operations/QueuedExecutionAuditTrailTest.php tests/Unit/Operations/QueuedExecutionLegitimacyGateTest.php`

## Notes
- browser validation was not re-run in this pass

Co-authored-by: Ahmed Darrazi <ahmed.darrazi@live.de>
Reviewed-on: #271
2026-04-24 05:44:54 +00:00
603d509b8f cleanup: retire dead transitional residue (#270)
Some checks failed
Main Confidence / confidence (push) Failing after 58s
Heavy Governance Lane / heavy-governance (push) Has been skipped
Browser Lane / browser (push) Has been skipped
## Summary
- remove deprecated baseline profile status alias constants and keep baseline lifecycle semantics on the canonical enum path
- retire the dead tenant app-status badge/default-fixture residue from the active runtime support path
- add the `234-dead-transitional-residue` spec, plan, research, data-model, quickstart, checklist, and task artifacts plus focused regression assertions

## Validation
- not rerun during this PR creation step

Co-authored-by: Ahmed Darrazi <ahmed.darrazi@live.de>
Reviewed-on: #270
2026-04-23 16:54:48 +00:00
6fdd45fb02 feat: surface stale active operation runs (#269)
Some checks failed
Main Confidence / confidence (push) Failing after 53s
## Summary
- keep stale active operation runs visible in the tenant progress overlay and polling state
- align tenant and canonical operation surfaces around the shared stale-active presentation contract
- add Spec 233 artifacts and clean the promoted-candidate backlog entries

## Validation
- browser smoke: `/admin/t/18000000-0000-4000-8000-000000000180` -> stale dashboard CTA -> `/admin/operations?tenant_id=7&activeTab=active_stale_attention&problemClass=active_stale_attention` -> `/admin/operations/15`
- verified healthy vs likely-stale tenant cards, canonical stale list row, and canonical run detail consistency

## Notes
- local smoke fixture seeded with one fresh and one stale running `baseline_compare` operation for browser validation
- Pest suite was not re-run in this session before opening this PR

Co-authored-by: Ahmed Darrazi <ahmed.darrazi@live.de>
Reviewed-on: #269
2026-04-23 15:10:06 +00:00
2bf53f6337 Enforce operation run link contract (#268)
Some checks failed
Main Confidence / confidence (push) Failing after 44s
## Summary
- enforce shared operation run link generation across admin and system surfaces
- add guard coverage to block new raw operation route bypasses outside explicit exceptions
- harden Filament theme asset resolution so stale or wrong-stack hot files fall back to built assets

## Testing
- export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail bin pint --dirty --format agent
- export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/OpsUx/CanonicalViewRunLinksTest.php tests/Feature/Monitoring/OperationsDashboardDrillthroughTest.php tests/Feature/Filament/RecentOperationsSummaryWidgetTest.php tests/Feature/Filament/InventoryCoverageRunContinuityTest.php tests/Feature/ReviewPack/ReviewPackResourceTest.php tests/Feature/144/CanonicalOperationViewerDeepLinkTrustTest.php tests/Feature/078/RelatedLinksOnDetailTest.php tests/Feature/RunAuthorizationTenantIsolationTest.php tests/Feature/System/Spec195/SystemDirectoryResidualSurfaceTest.php tests/Feature/System/Spec113/AuthorizationSemanticsTest.php tests/Feature/Guards/OperationRunLinkContractGuardTest.php tests/Unit/Filament/PanelThemeAssetTest.php

Co-authored-by: Ahmed Darrazi <ahmed.darrazi@live.de>
Reviewed-on: #268
2026-04-23 13:09:53 +00:00
421261a517 feat: implement finding outcome taxonomy (#267)
Some checks failed
Main Confidence / confidence (push) Failing after 48s
## Summary
- implement the finding outcome taxonomy end-to-end with canonical resolve, close, reopen, and verification semantics
- align finding UI, filters, audit metadata, review summaries, and export/read-model consumers to the shared outcome semantics
- add focused Pest coverage and complete the spec artifacts for feature 231

## Details
- manual resolve is limited to the canonical `remediated` outcome
- close and reopen flows now use bounded canonical reasons
- trusted system clear and reopen distinguish verified-clear from verification-failed and recurrence paths
- duplicate lifecycle backfill now closes findings canonically as `duplicate`
- accepted-risk recording now uses the canonical `accepted_risk` reason
- finding detail and list surfaces now expose terminal outcome and verification summaries
- review, snapshot, and review-pack consumers now propagate the same outcome buckets

## Filament / Platform Contract
- Livewire v4.0+ compatibility remains intact
- provider registration is unchanged and remains in `bootstrap/providers.php`
- no new globally searchable resource was introduced; `FindingResource` still has a View page and `TenantReviewResource` remains globally searchable false
- lifecycle mutations still run through confirmed Filament actions with capability enforcement
- no new asset family was added; the existing `filament:assets` deploy step is unchanged

## Verification
- `cd apps/platform && ./vendor/bin/sail bin pint --dirty --format agent`
- `cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Findings/FindingWorkflowServiceTest.php tests/Feature/Findings/FindingRecurrenceTest.php tests/Feature/Findings/FindingsListFiltersTest.php tests/Feature/Filament/FindingResolvedReferencePresentationTest.php tests/Feature/Findings/FindingOutcomeSummaryReportingTest.php tests/Feature/Findings/FindingRiskGovernanceProjectionTest.php`
- `cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Findings tests/Feature/Filament/FindingResolvedReferencePresentationTest.php tests/Feature/Models/FindingResolvedTest.php tests/Unit/Findings/FindingWorkflowServiceTest.php`
- `cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/TenantReview/TenantReviewExplanationSurfaceTest.php tests/Feature/TenantReview/TenantReviewRegisterTest.php tests/Feature/ReviewPack/TenantReviewDerivedReviewPackTest.php`
- browser smoke: `/admin/findings/my-work` -> finding detail resolve flow -> queue regression check passed

## Notes
- this commit also includes the existing `.github/agents/copilot-instructions.md` workspace change that was already present in the worktree when all changes were committed

Co-authored-by: Ahmed Darrazi <ahmed.darrazi@live.de>
Reviewed-on: #267
2026-04-23 07:29:05 +00:00
167 changed files with 12033 additions and 536 deletions

View File

@ -238,6 +238,16 @@ ## Active Technologies
- PostgreSQL via existing `findings`, `audit_logs`, `tenant_memberships`, and `users`; no schema changes planned (225-assignment-hygiene) - PostgreSQL via existing `findings`, `audit_logs`, `tenant_memberships`, and `users`; no schema changes planned (225-assignment-hygiene)
- Markdown artifacts + Astro 6.0.0 + TypeScript 5.9 context for source discovery + Repository spec workflow (`.specify`), Astro website source tree under `apps/website/src`, existing component taxonomy (`primitives`, `content`, `sections`, `layout`) (226-astrodeck-inventory-planning) - Markdown artifacts + Astro 6.0.0 + TypeScript 5.9 context for source discovery + Repository spec workflow (`.specify`), Astro website source tree under `apps/website/src`, existing component taxonomy (`primitives`, `content`, `sections`, `layout`) (226-astrodeck-inventory-planning)
- Filesystem only (`specs/226-astrodeck-inventory-planning/*`) (226-astrodeck-inventory-planning) - Filesystem only (`specs/226-astrodeck-inventory-planning/*`) (226-astrodeck-inventory-planning)
- PHP 8.4.15, Laravel 12, Filament v5, Livewire v4, Blade + `App\Models\Finding`, `App\Filament\Resources\FindingResource`, `App\Services\Findings\FindingWorkflowService`, `App\Services\Baselines\BaselineAutoCloseService`, `App\Services\EntraAdminRoles\EntraAdminRolesFindingGenerator`, `App\Services\PermissionPosture\PermissionPostureFindingGenerator`, `App\Jobs\CompareBaselineToTenantJob`, `App\Filament\Pages\Reviews\ReviewRegister`, `App\Filament\Resources\TenantReviewResource`, `BadgeCatalog`, `BadgeRenderer`, `AuditLog` metadata via `AuditLogger` (231-finding-outcome-taxonomy)
- PostgreSQL via existing `findings`, `finding_exceptions`, `tenant_reviews`, `stored_reports`, and audit-log tables; no schema changes planned (231-finding-outcome-taxonomy)
- PHP 8.4.15, Laravel 12, Filament v5, Livewire v4 + Filament Resources/Pages/Widgets, Pest v4, `App\Support\OperationRunLinks`, `App\Support\System\SystemOperationRunLinks`, `App\Support\Navigation\CanonicalNavigationContext`, `App\Support\Navigation\RelatedNavigationResolver`, existing workspace and tenant authorization helpers (232-operation-run-link-contract)
- PostgreSQL-backed existing `operation_runs`, `tenants`, and `workspaces` records plus current session-backed canonical navigation state; no new persistence (232-operation-run-link-contract)
- PHP 8.4.15, Laravel 12, Filament v5, Livewire v4 + Filament widgets/resources/pages, Pest v4, `App\Models\OperationRun`, `App\Support\Operations\OperationRunFreshnessState`, `App\Services\Operations\OperationLifecycleReconciler`, `App\Support\OpsUx\OperationUxPresenter`, `App\Support\OpsUx\ActiveRuns`, `App\Support\Badges\BadgeCatalog` / `BadgeRenderer`, `App\Support\Workspaces\WorkspaceOverviewBuilder`, `App\Support\OperationRunLinks` (233-stale-run-visibility)
- Existing PostgreSQL `operation_runs` records and current session/query-backed monitoring navigation state; no new persistence (233-stale-run-visibility)
- PHP 8.4.15, Laravel 12, Filament v5, Livewire v4 + `App\Models\BaselineProfile`, `App\Support\Baselines\BaselineProfileStatus`, `App\Support\Badges\BadgeCatalog`, `App\Support\Badges\BadgeDomain`, `Database\Factories\TenantFactory`, `App\Console\Commands\SeedBackupHealthBrowserFixture`, existing tenant-truth and baseline-profile Pest tests (234-dead-transitional-residue)
- Existing PostgreSQL `baseline_profiles` and `tenants` tables; no new persistence and no schema migration in this slice (234-dead-transitional-residue)
- PHP 8.4.15, Laravel 12, Filament v5, Livewire v4 + `BaselineCaptureService`, `CaptureBaselineSnapshotJob`, `BaselineReasonCodes`, `BaselineCompareStats`, `ReasonTranslator`, `GovernanceRunDiagnosticSummaryBuilder`, `OperationRunService`, `BaselineProfile`, `BaselineSnapshot`, `OperationRunOutcome`, existing Filament capture/compare surfaces (235-baseline-capture-truth)
- Existing PostgreSQL tables only; no new table or schema migration is planned in the mainline slice (235-baseline-capture-truth)
- PHP 8.4.15 (feat/005-bulk-operations) - PHP 8.4.15 (feat/005-bulk-operations)
@ -272,10 +282,9 @@ ## Code Style
PHP 8.4.15: Follow standard conventions PHP 8.4.15: Follow standard conventions
## Recent Changes ## Recent Changes
- 226-astrodeck-inventory-planning: Added Markdown artifacts + Astro 6.0.0 + TypeScript 5.9 context for source discovery + Repository spec workflow (`.specify`), Astro website source tree under `apps/website/src`, existing component taxonomy (`primitives`, `content`, `sections`, `layout`) - 235-baseline-capture-truth: Added PHP 8.4.15, Laravel 12, Filament v5, Livewire v4 + `BaselineCaptureService`, `CaptureBaselineSnapshotJob`, `BaselineReasonCodes`, `BaselineCompareStats`, `ReasonTranslator`, `GovernanceRunDiagnosticSummaryBuilder`, `OperationRunService`, `BaselineProfile`, `BaselineSnapshot`, `OperationRunOutcome`, existing Filament capture/compare surfaces
- 225-assignment-hygiene: Added PHP 8.4.15, Laravel 12, Filament v5, Livewire v4, Blade + `Finding`, `FindingResource`, `MyFindingsInbox`, `FindingsIntakeQueue`, `WorkspaceOverviewBuilder`, `EnsureFilamentTenantSelected`, `FindingWorkflowService`, `AuditLog`, `TenantMembership`, Filament page and table primitives - 234-dead-transitional-residue: Added PHP 8.4.15, Laravel 12, Filament v5, Livewire v4 + `App\Models\BaselineProfile`, `App\Support\Baselines\BaselineProfileStatus`, `App\Support\Badges\BadgeCatalog`, `App\Support\Badges\BadgeDomain`, `Database\Factories\TenantFactory`, `App\Console\Commands\SeedBackupHealthBrowserFixture`, existing tenant-truth and baseline-profile Pest tests
- 224-findings-notifications-escalation: Added PHP 8.4.15, Laravel 12, Filament v5, Livewire v4, Blade + Laravel notifications (`database` channel), Filament database notifications, `Finding`, `FindingWorkflowService`, `FindingSlaPolicy`, `AlertRule`, `AlertDelivery`, `AlertDispatchService`, `EvaluateAlertsJob`, `CapabilityResolver`, `WorkspaceContext`, `TenantMembership`, `FindingResource` - 233-stale-run-visibility: Added PHP 8.4.15, Laravel 12, Filament v5, Livewire v4 + Filament widgets/resources/pages, Pest v4, `App\Models\OperationRun`, `App\Support\Operations\OperationRunFreshnessState`, `App\Services\Operations\OperationLifecycleReconciler`, `App\Support\OpsUx\OperationUxPresenter`, `App\Support\OpsUx\ActiveRuns`, `App\Support\Badges\BadgeCatalog` / `BadgeRenderer`, `App\Support\Workspaces\WorkspaceOverviewBuilder`, `App\Support\OperationRunLinks`
- 222-findings-intake-team-queue: Added PHP 8.4.15, Laravel 12, Filament v5, Livewire v4, Blade + Filament admin pages/tables/actions/notifications, `Finding`, `FindingResource`, `FindingWorkflowService`, `FindingPolicy`, `CapabilityResolver`, `CanonicalAdminTenantFilterState`, `CanonicalNavigationContext`, `WorkspaceContext`, and `UiEnforcement`
<!-- MANUAL ADDITIONS START --> <!-- MANUAL ADDITIONS START -->
### Pre-production compatibility check ### Pre-production compatibility check

View File

@ -1,32 +1,28 @@
<!-- <!--
Sync Impact Report Sync Impact Report
- Version change: 2.7.0 -> 2.8.0 - Version change: 2.8.0 -> 2.9.0
- Modified principles: None - Modified principles:
- Added provider-boundary guardrail set under First Provider Is Not
Platform Core (PROV-001 with sub-rules PROV-002 through PROV-005)
- Expanded Governance review expectations for provider-owned vs
platform-core boundaries
- Added sections: - Added sections:
- Pre-Production Lean Doctrine (LEAN-001): forbids legacy aliases, - First Provider Is Not Platform Core (PROV-001): keeps Microsoft as
migration shims, dual-write logic, and compatibility fixtures in a the current first provider without allowing provider-specific
pre-production codebase; includes AI-agent verification checklist, semantics to silently become platform-core truth; requires explicit
review rule, and explicit exit condition at first production deploy review of provider-owned vs platform-core seams and prefers bounded
- Shared Pattern First For Cross-Cutting Interaction Classes extraction over speculative multi-provider frameworks
(XCUT-001): requires shared contracts/presenters/builders for
notifications, status messaging, action links, dashboard signals,
navigation, and similar interaction classes before any local
domain-specific variant is allowed
- Removed sections: None - Removed sections: None
- Templates requiring updates: - Templates requiring updates:
- .specify/templates/spec-template.md: added "Compatibility posture" - .specify/templates/spec-template.md: add provider-boundary platform
default block ✅ core check ✅
- .specify/templates/spec-template.md: add cross-cutting shared-pattern - .specify/templates/plan-template.md: add provider-boundary planning
reuse block ✅ fields + constitution check ✅
- .specify/templates/plan-template.md: add shared pattern and system - .specify/templates/tasks-template.md: add provider-boundary task
fit section ✅
- .specify/templates/tasks-template.md: add cross-cutting reuse task
requirements ✅ requirements ✅
- .specify/templates/checklist-template.md: add shared-pattern reuse - .specify/templates/checklist-template.md: add provider-boundary
review checks ✅ review checks ✅
- .github/agents/copilot-instructions.md: added "Pre-production
compatibility check" agent checklist ✅
- Commands checked: - Commands checked:
- N/A `.specify/templates/commands/*.md` directory is not present - N/A `.specify/templates/commands/*.md` directory is not present
- Follow-up TODOs: None - Follow-up TODOs: None
@ -66,6 +62,15 @@ ### No Premature Abstraction (ABSTR-001)
- Test convenience alone is not sufficient justification for a new abstraction. - 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. - Narrow abstractions are allowed when required for security, tenant isolation, auditability, compliance evidence, or queue/job execution correctness.
### First Provider Is Not Platform Core (PROV-001)
- Microsoft is the current first provider, not the platform core.
- Shared platform-owned contracts, taxonomies, identifiers, compare semantics, and operator vocabulary MUST NOT silently become Microsoft-shaped truth just because Microsoft is the only provider today.
- Shared platform-owned boundaries SHOULD prefer neutral core terms such as `provider`, `connection`, `target scope`, `governed subject`, and `operation` unless the feature is intentionally provider-owned and explicitly bounded.
- Shared core terms at shared boundaries (PROV-002): if a boundary is reused across multiple domains, features, or workflows, the default is neutral platform language rather than provider-specific labels or semantics.
- No accidental deepening of provider coupling (PROV-003): a feature MAY retain provider-specific semantics at a provider-owned seam, but it MUST NOT spread those semantics deeper into platform-core contracts, shared persistence truth, shared taxonomies, or shared UI language without proving that the narrower current-release truth genuinely requires it.
- Shared-boundary review is mandatory (PROV-004): when a feature touches a shared provider/platform seam, the spec, plan, and review MUST state whether the seam is provider-owned or platform-core, what provider-specific semantics remain, and why that choice is the narrowest correct implementation now.
- Prefer bounded extraction over premature generalization (PROV-005): if an existing hotspot is too Microsoft-specific, the default remedy is a bounded normalization or extraction of that hotspot, not a speculative multi-provider framework with unused extension points.
### No New Persisted Truth Without Source-of-Truth Need (PERSIST-001) ### 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. - 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. - 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.
@ -1608,6 +1613,7 @@ ### Scope, Compliance, and Review Expectations
- Specs and PRs that introduce new persisted truth, abstractions, states, DTO/presenter layers, or taxonomies MUST include the proportionality review required by BLOAT-001. - Specs and PRs that introduce new persisted truth, abstractions, states, DTO/presenter layers, or taxonomies MUST include the proportionality review required by BLOAT-001.
- Runtime-changing or test-affecting specs and PRs MUST include testing/lane/runtime impact covering actual test-purpose classification, affected lanes, fixture/helper/factory/seed/context cost changes, any heavy-family expansion, expected budget/baseline/trend effect, escalation decisions, and the minimal validation commands. - Runtime-changing or test-affecting specs and PRs MUST include testing/lane/runtime impact covering actual test-purpose classification, affected lanes, fixture/helper/factory/seed/context cost changes, any heavy-family expansion, expected budget/baseline/trend effect, escalation decisions, and the minimal validation commands.
- Specs, plans, task lists, and review checklists MUST surface the test-governance questions needed to catch lane drift, hidden defaults, and runtime-cost escalation before merge. - Specs, plans, task lists, and review checklists MUST surface the test-governance questions needed to catch lane drift, hidden defaults, and runtime-cost escalation before merge.
- Specs and PRs that touch shared provider/platform seams MUST classify the touched boundary as provider-owned or platform-core, keep provider-specific semantics out of platform-core contracts and vocabulary unless explicitly justified, and record whether any remaining hotspot is resolved in-feature or escalated as a follow-up spec.
- Specs and PRs that change operator-facing surfaces MUST classify each - Specs and PRs that change operator-facing surfaces MUST classify each
affected surface under DECIDE-001 and justify any new Primary affected surface under DECIDE-001 and justify any new Primary
Decision Surface or workflow-first navigation change. Decision Surface or workflow-first navigation change.
@ -1625,4 +1631,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**: 2.7.0 | **Ratified**: 2026-01-03 | **Last Amended**: 2025-07-19 **Version**: 2.9.0 | **Ratified**: 2026-01-03 | **Last Amended**: 2026-04-23

View File

@ -32,18 +32,23 @@ ## Shared Pattern Reuse
- [ ] CHK008 The change extends the shared path where it is sufficient, or the deviation is explicitly documented with product reason, preserved consistency, ownership cost, and spread-control. - [ ] CHK008 The change extends the shared path where it is sufficient, or the deviation is explicitly documented with product reason, preserved consistency, ownership cost, and spread-control.
- [ ] CHK009 The change does not create a parallel operator-facing UX language for the same interaction class unless a bounded exception is recorded. - [ ] CHK009 The change does not create a parallel operator-facing UX language for the same interaction class unless a bounded exception is recorded.
## Provider Boundary And Vocabulary
- [ ] CHK010 The change states whether any touched shared seam is provider-owned, platform-core, or mixed, and provider-specific semantics do not silently spread into platform-core contracts, taxonomy, identifiers, compare semantics, or operator vocabulary.
- [ ] CHK011 Any retained provider-specific shared boundary is justified as a bounded current-release exception or an explicit follow-up-spec need instead of becoming permanent platform truth by default.
## Signals, Exceptions, And Test Depth ## Signals, Exceptions, And Test Depth
- [ ] CHK010 Any triggered repository signal is classified with one handling mode: `hard-stop-candidate`, `review-mandatory`, `exception-required`, or `report-only`. - [ ] CHK012 Any triggered repository signal is classified with one handling mode: `hard-stop-candidate`, `review-mandatory`, `exception-required`, or `report-only`.
- [ ] CHK011 Any deviation from default rules includes a bounded exception record naming the broken rule, product reason, standardized parts, spread-control rule, and the active feature PR close-out entry. - [ ] CHK013 Any deviation from default rules includes a bounded exception record naming the broken rule, product reason, standardized parts, spread-control rule, and the active feature PR close-out entry.
- [ ] CHK012 The required surface test profile is explicit: `shared-detail-family`, `monitoring-state-page`, `global-context-shell`, `exception-coded-surface`, or `standard-native-filament`. - [ ] CHK014 The required surface test profile is explicit: `shared-detail-family`, `monitoring-state-page`, `global-context-shell`, `exception-coded-surface`, or `standard-native-filament`.
- [ ] CHK013 The chosen test family/lane and any manual smoke are the narrowest honest proof for the declared surface class, and `standard-native-filament` relief is used when no special contract exists. - [ ] CHK015 The chosen test family/lane and any manual smoke are the narrowest honest proof for the declared surface class, and `standard-native-filament` relief is used when no special contract exists.
## Review Outcome ## Review Outcome
- [ ] CHK014 One review outcome class is chosen: `blocker`, `strong-warning`, `documentation-required-exception`, or `acceptable-special-case`. - [ ] CHK016 One review outcome class is chosen: `blocker`, `strong-warning`, `documentation-required-exception`, or `acceptable-special-case`.
- [ ] CHK015 One workflow outcome is chosen: `keep`, `split`, `document-in-feature`, `follow-up-spec`, or `reject-or-split`. - [ ] CHK017 One workflow outcome is chosen: `keep`, `split`, `document-in-feature`, `follow-up-spec`, or `reject-or-split`.
- [ ] CHK016 The final note location is explicit: the active feature PR close-out entry for guarded work, or a concise `N/A` note for low-impact changes. - [ ] CHK018 The final note location is explicit: the active feature PR close-out entry for guarded work, or a concise `N/A` note for low-impact changes.
## Notes ## Notes

View File

@ -54,6 +54,17 @@ ## Shared Pattern & System Fit
- **Why the existing abstraction was sufficient or insufficient**: [Short explanation tied to current-release truth] - **Why the existing abstraction was sufficient or insufficient**: [Short explanation tied to current-release truth]
- **Bounded deviation / spread control**: [none / describe the exception boundary and containment rule] - **Bounded deviation / spread control**: [none / describe the exception boundary and containment rule]
## Provider Boundary & Portability Fit
> **Fill when the feature touches shared provider/platform seams, identity scope, governed-subject taxonomy, compare strategy selection, provider connection descriptors, or operator vocabulary that may leak provider-specific semantics into platform-core truth. Docs-only or template-only work may use concise `N/A`.**
- **Shared provider/platform boundary touched?**: [yes / no / N/A]
- **Provider-owned seams**: [List or `N/A`]
- **Platform-core seams**: [List or `N/A`]
- **Neutral platform terms / contracts preserved**: [List or `N/A`]
- **Retained provider-specific semantics and why**: [none / short explanation]
- **Bounded extraction or follow-up path**: [none / document-in-feature / follow-up-spec / N/A]
## Constitution Check ## Constitution Check
*GATE: Must pass before Phase 0 research. Re-check after Phase 1 design.* *GATE: Must pass before Phase 0 research. Re-check after Phase 1 design.*
@ -82,6 +93,7 @@ ## Constitution Check
- Behavioral state (STATE-001): new states/statuses/reason codes change behavior, routing, permissions, lifecycle, audit, retention, or retry handling; presentation-only distinctions 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 - 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
- Shared pattern first (XCUT-001): cross-cutting interaction classes reuse existing shared contracts/presenters/builders/renderers first; any deviation is explicit, bounded, and justified against current-release truth - Shared pattern first (XCUT-001): cross-cutting interaction classes reuse existing shared contracts/presenters/builders/renderers first; any deviation is explicit, bounded, and justified against current-release truth
- Provider boundary (PROV-001): shared provider/platform seams are classified as provider-owned vs platform-core; provider-specific semantics stay out of platform-core contracts, taxonomy, identifiers, compare semantics, and operator vocabulary unless explicitly justified; bounded extraction beats speculative multi-provider frameworks
- 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 - 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 - 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

View File

@ -47,6 +47,16 @@ ## Cross-Cutting / Shared Pattern Reuse *(mandatory when the feature touches not
- **Consistency impact**: [What must stay aligned across interaction structure, copy, status semantics, actions, and deep links] - **Consistency impact**: [What must stay aligned across interaction structure, copy, status semantics, actions, and deep links]
- **Review focus**: [What reviewers must verify to prevent parallel local patterns] - **Review focus**: [What reviewers must verify to prevent parallel local patterns]
## Provider Boundary / Platform Core Check *(mandatory when the feature changes shared provider/platform seams, identity scope, governed-subject taxonomy, compare strategy selection, provider connection descriptors, or operator vocabulary that may leak provider-specific semantics into platform-core truth; otherwise write `N/A - no shared provider/platform boundary touched`)*
- **Shared provider/platform boundary touched?**: [yes/no]
- **Boundary classification**: [provider-owned / platform-core / mixed / N/A]
- **Seams affected**: [contracts, models, taxonomies, query keys, labels, filters, compare strategy, etc.]
- **Neutral platform terms preserved or introduced**: [List them or `N/A`]
- **Provider-specific semantics retained and why**: [none / bounded current-release necessity]
- **Why this does not deepen provider coupling accidentally**: [Short explanation]
- **Follow-up path**: [none / document-in-feature / follow-up-spec]
## UI / Surface Guardrail Impact *(mandatory when operator-facing surfaces are changed; otherwise write `N/A`)* ## UI / Surface Guardrail Impact *(mandatory when operator-facing surfaces are changed; otherwise write `N/A`)*
Use this section to classify UI and surface risk once. If the feature does Use this section to classify UI and surface risk once. If the feature does
@ -234,6 +244,13 @@ ## Requirements *(mandatory)*
- record any allowed deviation, the consistency it must preserve, and its ownership/spread-control cost, - record any allowed deviation, the consistency it must preserve, and its ownership/spread-control cost,
- and make the reviewer focus explicit so parallel local UX paths do not appear silently. - and make the reviewer focus explicit so parallel local UX paths do not appear silently.
**Constitution alignment (PROV-001):** If this feature touches a shared provider/platform seam, the spec MUST:
- classify each touched seam as provider-owned or platform-core,
- keep provider-specific semantics out of platform-core contracts, taxonomies, identifiers, compare semantics, and operator vocabulary unless explicitly justified,
- name the neutral platform terms or shared contracts being preserved,
- explain why any retained provider-specific semantics are the narrowest current-release truth,
- and state whether the remaining hotspot is resolved in-feature or escalated as a follow-up spec.
**Constitution alignment (TEST-GOV-001):** If this feature changes runtime behavior or tests, the spec MUST describe: **Constitution alignment (TEST-GOV-001):** If this feature changes runtime behavior or tests, the spec MUST describe:
- the actual test-purpose classification (`Unit`, `Feature`, `Heavy-Governance`, or `Browser`) and why that classification matches the real proving purpose, - the actual test-purpose classification (`Unit`, `Feature`, `Heavy-Governance`, or `Browser`) and why that classification matches the real proving purpose,
- the affected validation lane(s) and why they are the narrowest sufficient proof, - the affected validation lane(s) and why they are the narrowest sufficient proof,

View File

@ -51,6 +51,11 @@ # Tasks: [FEATURE NAME]
- extending the shared path when it is sufficient for current-release truth, - extending the shared path when it is sufficient for current-release truth,
- or recording a bounded exception task that documents why the shared path is insufficient, what consistency must still be preserved, and how spread is controlled, - or recording a bounded exception task that documents why the shared path is insufficient, what consistency must still be preserved, and how spread is controlled,
- and ensuring reviewer proof covers whether the feature converged on the shared path or knowingly introduced a bounded exception. - and ensuring reviewer proof covers whether the feature converged on the shared path or knowingly introduced a bounded exception.
**Provider Boundary / Platform Core (PROV-001)**: If this feature touches shared provider/platform seams, tasks MUST include:
- classifying each touched seam as provider-owned or platform-core,
- preventing provider-specific semantics from spreading into platform-core contracts, persistence truth, taxonomies, compare semantics, or operator vocabulary unless explicitly justified,
- implementing bounded normalization or extraction where a current hotspot is too provider-shaped, rather than introducing speculative multi-provider frameworks,
- and recording `document-in-feature` or `follow-up-spec` when a bounded provider-specific hotspot remains.
**UI / Surface Guardrails**: If this feature adds or changes operator-facing surfaces or the workflow that governs them, tasks MUST include: **UI / Surface Guardrails**: If this feature adds or changes operator-facing surfaces or the workflow that governs them, tasks MUST include:
- carrying forward the spec's native/custom classification, shared-family relevance, state-layer ownership, and exception need into implementation work without renaming the same decision, - carrying forward the spec's native/custom classification, shared-family relevance, state-layer ownership, and exception need into implementation work without renaming the same decision,
- classifying any triggered repository signals with one handling mode (`hard-stop-candidate`, `review-mandatory`, `exception-required`, or `report-only`), - classifying any triggered repository signals with one handling mode (`hard-stop-candidate`, `review-mandatory`, `exception-required`, or `report-only`),

View File

@ -1 +1 @@
{"name":"color-name","version":"1.1.4","requiresBuild":false,"files":{"package.json":{"checkedAt":1776593337482,"integrity":"sha512-E5CrPeTNIaZAftwqMJpkT8PDNamUJUrubHLTZ6Rjn3l9RvJKSLw6MGXT6SAcRHV3ltLOSTOa1HvkQ7/GUOoaHw==","mode":438,"size":607},"index.js":{"checkedAt":1776593337489,"integrity":"sha512-nek+57RYqda5dmQCKQmtJafLicLP3Y7hmqLhJlZrenqTCyQUOip2+D2/8Z8aZ7CnHek+irJIcgwu4kM5boaUUQ==","mode":438,"size":4617},"LICENSE":{"checkedAt":1776593337495,"integrity":"sha512-/B1lNSwRTHWUyb7fW+QyujnUJv6vUL+PfFLTJ4EyPIS/yaaFMa77VYyX6+RucS4dNdhguh4aarSLSnm4lAklQA==","mode":438,"size":1085},"README.md":{"checkedAt":1776593337500,"integrity":"sha512-/hmGUPmp0gXgx/Ov5oGW6DAU3c4h4aLMa/bE1TkpZHPU7dCx5JFS9hoYM4/+919EWCaPtBhWzK+6pG/6xdx+Ng==","mode":438,"size":384}}} {"name":"color-name","version":"1.1.4","requiresBuild":false,"files":{"package.json":{"checkedAt":1776976148151,"integrity":"sha512-E5CrPeTNIaZAftwqMJpkT8PDNamUJUrubHLTZ6Rjn3l9RvJKSLw6MGXT6SAcRHV3ltLOSTOa1HvkQ7/GUOoaHw==","mode":438,"size":607},"index.js":{"checkedAt":1776976148156,"integrity":"sha512-nek+57RYqda5dmQCKQmtJafLicLP3Y7hmqLhJlZrenqTCyQUOip2+D2/8Z8aZ7CnHek+irJIcgwu4kM5boaUUQ==","mode":438,"size":4617},"LICENSE":{"checkedAt":1776976148162,"integrity":"sha512-/B1lNSwRTHWUyb7fW+QyujnUJv6vUL+PfFLTJ4EyPIS/yaaFMa77VYyX6+RucS4dNdhguh4aarSLSnm4lAklQA==","mode":438,"size":1085},"README.md":{"checkedAt":1776976148168,"integrity":"sha512-/hmGUPmp0gXgx/Ov5oGW6DAU3c4h4aLMa/bE1TkpZHPU7dCx5JFS9hoYM4/+919EWCaPtBhWzK+6pG/6xdx+Ng==","mode":438,"size":384}}}

View File

@ -1 +1 @@
{"name":"@types/estree","version":"1.0.8","requiresBuild":false,"files":{"LICENSE":{"checkedAt":1776593336106,"integrity":"sha512-HQaIQk9pwOcyKutyDk4o2a87WnotwYuLGYFW43emGm4FvIJFKPyg+OYaw5sTegKAKf+C5SKa1ACjzCLivbaHrQ==","mode":420,"size":1141},"README.md":{"checkedAt":1776593336125,"integrity":"sha512-alZQw4vOCWtDJlTmYSm+aEvD0weTLtGERCy5tNbpyvPI5F2j9hEWxHuUdwL+TZU2Nhdx7EGRhitAiv0xuSxaeg==","mode":420,"size":458},"flow.d.ts":{"checkedAt":1776593336132,"integrity":"sha512-f3OqA/2H/A62ZLT0qAZlUCUAiI89dMFcY+XrAU08dNgwHhXSQmFeMc7w/Ee7RE8tHU5RXFoQazarmCUsnCvXxg==","mode":420,"size":4801},"index.d.ts":{"checkedAt":1776593336138,"integrity":"sha512-YwR3YirWettZcjZgr7aNimg/ibEuP+6JMqAvL+cT6ubq2ctYKL9Xv+PgBssGCPES01PG5zKTHSvhShXCjXOrDg==","mode":420,"size":18944},"package.json":{"checkedAt":1776593336144,"integrity":"sha512-KaEBTHEFL2oVUvCrjSJR/H812XIaeRGbSZFP8DBb2Hon+IQwND0zz7oRvrXTm2AzzjneqH+pkB2Lusw29yJ/WA==","mode":420,"size":829}}} {"name":"@types/estree","version":"1.0.8","requiresBuild":false,"files":{"LICENSE":{"checkedAt":1776976148127,"integrity":"sha512-HQaIQk9pwOcyKutyDk4o2a87WnotwYuLGYFW43emGm4FvIJFKPyg+OYaw5sTegKAKf+C5SKa1ACjzCLivbaHrQ==","mode":420,"size":1141},"README.md":{"checkedAt":1776976148139,"integrity":"sha512-alZQw4vOCWtDJlTmYSm+aEvD0weTLtGERCy5tNbpyvPI5F2j9hEWxHuUdwL+TZU2Nhdx7EGRhitAiv0xuSxaeg==","mode":420,"size":458},"flow.d.ts":{"checkedAt":1776976148143,"integrity":"sha512-f3OqA/2H/A62ZLT0qAZlUCUAiI89dMFcY+XrAU08dNgwHhXSQmFeMc7w/Ee7RE8tHU5RXFoQazarmCUsnCvXxg==","mode":420,"size":4801},"index.d.ts":{"checkedAt":1776976148144,"integrity":"sha512-YwR3YirWettZcjZgr7aNimg/ibEuP+6JMqAvL+cT6ubq2ctYKL9Xv+PgBssGCPES01PG5zKTHSvhShXCjXOrDg==","mode":420,"size":18944},"package.json":{"checkedAt":1776976148144,"integrity":"sha512-KaEBTHEFL2oVUvCrjSJR/H812XIaeRGbSZFP8DBb2Hon+IQwND0zz7oRvrXTm2AzzjneqH+pkB2Lusw29yJ/WA==","mode":420,"size":829}}}

View File

@ -1 +1 @@
{"name":"tslib","version":"2.8.1","requiresBuild":false,"files":{"tslib.es6.html":{"checkedAt":1776593335180,"integrity":"sha512-aoAR2zaxE9UtcXO4kE9FbPBgIZEVk7u3Z+nEPmDo6rwcYth07KxrVZejVEdy2XmKvkkcb8O/XM9UK3bPc1iMPw==","mode":420,"size":36},"tslib.html":{"checkedAt":1776593335194,"integrity":"sha512-4dCvZ5WYJpcbIJY4RPUhOBbFud1156Rr7RphuR12/+mXKUeIpCxol2/uWL4WDFNNlSH909M2AY4fiLWJo8+fTw==","mode":420,"size":32},"modules/index.js":{"checkedAt":1776593335198,"integrity":"sha512-DqWTtBt/Q47Jm4z8VzCLSiG/2R+Mwqy8uB60ithBWyofDYamF5C4icYdqbq/NP2IE/TefCT/03uAwA5mujzR7A==","mode":420,"size":1416},"tslib.es6.js":{"checkedAt":1776593335206,"integrity":"sha512-FugydTgfIjlaQrbH9gaIh59iXw8keW2311ILz3FBWn1IHLwPcmWte+ZE8UeXXGTQRc2E8NhQSCzYA6/zX36+7w==","mode":420,"size":19215},"tslib.js":{"checkedAt":1776593335213,"integrity":"sha512-7Gj/3vlZdba9iZH2H2up34pBk5UfN1tWQl3/TjsHzw3Oipw/stl6nko8k4jk+MeDeLPJE3rKz3VoQG5XmgwSmg==","mode":420,"size":23382},"modules/package.json":{"checkedAt":1776593335219,"integrity":"sha512-vm8hQn5MuoMkjJYvBBHTAtsdrcuXmVrKZwL3FEq32oGiKFhY562FoUQTbXv24wk0rwJVpgribUCOIU98IaS9Mg==","mode":420,"size":26},"package.json":{"checkedAt":1776593335230,"integrity":"sha512-72peSY+xgEHIo+YSpUbUl6qsExQ5ZlgeiDVDAiy4QdVmmBkK7RAB/07CCX3gg0SyvvQVJiGgAD36ub3rgE4QCg==","mode":420,"size":1219},"README.md":{"checkedAt":1776593335236,"integrity":"sha512-kCH2ENYjhlxwI7ae89ymMIP2tZeNcJJOcqnfifnmHQiHeK4mWnGc4w8ygoiUIpG1qyaurZkRSrYtwHCEIMNhbA==","mode":420,"size":4033},"SECURITY.md":{"checkedAt":1776593335243,"integrity":"sha512-ix30VBNb4RQLa5M2jgfD6IJ9+1XKmeREKrOYv7rDoWGZCin0605vEx3tTAVb5kNvteCwZwBC+nEGfQ4jHLg9Fw==","mode":420,"size":2757},"tslib.es6.mjs":{"checkedAt":1776593335251,"integrity":"sha512-q8VhXPTjmn6KDh3j6Ewn0V3siY1zNdvXvIUNN36llJUtO5cDafldf1Y2zzToBAbgOdh2pjFks7lFDRzZ/LZnDw==","mode":420,"size":17648},"modules/index.d.ts":{"checkedAt":1776593335258,"integrity":"sha512-XcNprVMjDjhbWmH3OTNZV91Uh9sDaCs8oZa3J7g5wMUHsdMJRENmv4XQ/8yqMlTUxKopv8uiztELREI7cw8BDg==","mode":420,"size":801},"tslib.d.ts":{"checkedAt":1776593335264,"integrity":"sha512-kqzM5TLHelP5iJBElSYyBRocQd2XmWsGIzOG6+Mv+CB7KhoZ6BoFioWM3RR2OCm1p96bbSCGnfHo2rozV/WJYQ==","mode":420,"size":18317},"CopyrightNotice.txt":{"checkedAt":1776593335271,"integrity":"sha512-C0myUddnUhhpZ/UcD9yZyMWodQV4fT2wxcfqb/ToD0Z98nB9WfWBl6koNVWJ+8jzeGWP6wQjz9zdX7Unua0/SQ==","mode":420,"size":822},"LICENSE.txt":{"checkedAt":1776593335278,"integrity":"sha512-9cs1Im06/fLAPBpXOY8fHMD2LgUM3kREaKlOX7S6fLWwbG5G+UqlUrqdkTKloRPeDghECezxOiUfzvW6lnEjDg==","mode":420,"size":655}}} {"name":"tslib","version":"2.8.1","requiresBuild":false,"files":{"tslib.es6.html":{"checkedAt":1776976148162,"integrity":"sha512-aoAR2zaxE9UtcXO4kE9FbPBgIZEVk7u3Z+nEPmDo6rwcYth07KxrVZejVEdy2XmKvkkcb8O/XM9UK3bPc1iMPw==","mode":420,"size":36},"tslib.html":{"checkedAt":1776976148164,"integrity":"sha512-4dCvZ5WYJpcbIJY4RPUhOBbFud1156Rr7RphuR12/+mXKUeIpCxol2/uWL4WDFNNlSH909M2AY4fiLWJo8+fTw==","mode":420,"size":32},"modules/index.js":{"checkedAt":1776976148166,"integrity":"sha512-DqWTtBt/Q47Jm4z8VzCLSiG/2R+Mwqy8uB60ithBWyofDYamF5C4icYdqbq/NP2IE/TefCT/03uAwA5mujzR7A==","mode":420,"size":1416},"tslib.es6.js":{"checkedAt":1776976148173,"integrity":"sha512-FugydTgfIjlaQrbH9gaIh59iXw8keW2311ILz3FBWn1IHLwPcmWte+ZE8UeXXGTQRc2E8NhQSCzYA6/zX36+7w==","mode":420,"size":19215},"tslib.js":{"checkedAt":1776976148180,"integrity":"sha512-7Gj/3vlZdba9iZH2H2up34pBk5UfN1tWQl3/TjsHzw3Oipw/stl6nko8k4jk+MeDeLPJE3rKz3VoQG5XmgwSmg==","mode":420,"size":23382},"modules/package.json":{"checkedAt":1776976148185,"integrity":"sha512-vm8hQn5MuoMkjJYvBBHTAtsdrcuXmVrKZwL3FEq32oGiKFhY562FoUQTbXv24wk0rwJVpgribUCOIU98IaS9Mg==","mode":420,"size":26},"package.json":{"checkedAt":1776976148187,"integrity":"sha512-72peSY+xgEHIo+YSpUbUl6qsExQ5ZlgeiDVDAiy4QdVmmBkK7RAB/07CCX3gg0SyvvQVJiGgAD36ub3rgE4QCg==","mode":420,"size":1219},"README.md":{"checkedAt":1776976148192,"integrity":"sha512-kCH2ENYjhlxwI7ae89ymMIP2tZeNcJJOcqnfifnmHQiHeK4mWnGc4w8ygoiUIpG1qyaurZkRSrYtwHCEIMNhbA==","mode":420,"size":4033},"SECURITY.md":{"checkedAt":1776976148195,"integrity":"sha512-ix30VBNb4RQLa5M2jgfD6IJ9+1XKmeREKrOYv7rDoWGZCin0605vEx3tTAVb5kNvteCwZwBC+nEGfQ4jHLg9Fw==","mode":420,"size":2757},"tslib.es6.mjs":{"checkedAt":1776976148199,"integrity":"sha512-q8VhXPTjmn6KDh3j6Ewn0V3siY1zNdvXvIUNN36llJUtO5cDafldf1Y2zzToBAbgOdh2pjFks7lFDRzZ/LZnDw==","mode":420,"size":17648},"modules/index.d.ts":{"checkedAt":1776976148200,"integrity":"sha512-XcNprVMjDjhbWmH3OTNZV91Uh9sDaCs8oZa3J7g5wMUHsdMJRENmv4XQ/8yqMlTUxKopv8uiztELREI7cw8BDg==","mode":420,"size":801},"tslib.d.ts":{"checkedAt":1776976148210,"integrity":"sha512-kqzM5TLHelP5iJBElSYyBRocQd2XmWsGIzOG6+Mv+CB7KhoZ6BoFioWM3RR2OCm1p96bbSCGnfHo2rozV/WJYQ==","mode":420,"size":18317},"CopyrightNotice.txt":{"checkedAt":1776976148214,"integrity":"sha512-C0myUddnUhhpZ/UcD9yZyMWodQV4fT2wxcfqb/ToD0Z98nB9WfWBl6koNVWJ+8jzeGWP6wQjz9zdX7Unua0/SQ==","mode":420,"size":822},"LICENSE.txt":{"checkedAt":1776976148225,"integrity":"sha512-9cs1Im06/fLAPBpXOY8fHMD2LgUM3kREaKlOX7S6fLWwbG5G+UqlUrqdkTKloRPeDghECezxOiUfzvW6lnEjDg==","mode":420,"size":655}}}

View File

@ -67,7 +67,6 @@ public function handle(): int
'name' => (string) ($scenarioConfig['tenant_name'] ?? 'Spec 180 Blocked Backup Tenant'), 'name' => (string) ($scenarioConfig['tenant_name'] ?? 'Spec 180 Blocked Backup Tenant'),
'tenant_id' => $tenantRouteKey, 'tenant_id' => $tenantRouteKey,
'app_certificate_thumbprint' => null, 'app_certificate_thumbprint' => null,
'app_status' => 'ok',
'app_notes' => null, 'app_notes' => null,
'status' => Tenant::STATUS_ACTIVE, 'status' => Tenant::STATUS_ACTIVE,
'environment' => 'dev', 'environment' => 'dev',

View File

@ -20,6 +20,7 @@
use App\Support\Badges\TagBadgeDomain; use App\Support\Badges\TagBadgeDomain;
use App\Support\Inventory\TenantCoverageTruth; use App\Support\Inventory\TenantCoverageTruth;
use App\Support\Inventory\TenantCoverageTruthResolver; use App\Support\Inventory\TenantCoverageTruthResolver;
use App\Support\OperationRunLinks;
use App\Support\Ui\ActionSurface\ActionSurfaceDeclaration; use App\Support\Ui\ActionSurface\ActionSurfaceDeclaration;
use App\Support\Ui\ActionSurface\ActionSurfaceDefaults; use App\Support\Ui\ActionSurface\ActionSurfaceDefaults;
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceProfile; use App\Support\Ui\ActionSurface\Enums\ActionSurfaceProfile;
@ -535,7 +536,7 @@ public function basisRunSummary(): array
: 'The coverage basis is current, but your role cannot open the cited run detail.', : 'The coverage basis is current, but your role cannot open the cited run detail.',
'badgeLabel' => $badge->label, 'badgeLabel' => $badge->label,
'badgeColor' => $badge->color, 'badgeColor' => $badge->color,
'runUrl' => $canViewRun ? route('admin.operations.view', ['run' => (int) $truth->basisRun->getKey()]) : null, 'runUrl' => $canViewRun ? OperationRunLinks::view($truth->basisRun, $tenant) : null,
'historyUrl' => $canViewRun ? $this->inventorySyncHistoryUrl($tenant) : null, 'historyUrl' => $canViewRun ? $this->inventorySyncHistoryUrl($tenant) : null,
'inventoryItemsUrl' => InventoryItemResource::getUrl('index', panel: 'tenant', tenant: $tenant), 'inventoryItemsUrl' => InventoryItemResource::getUrl('index', panel: 'tenant', tenant: $tenant),
]; ];
@ -560,13 +561,6 @@ protected function coverageTruth(): ?TenantCoverageTruth
private function inventorySyncHistoryUrl(Tenant $tenant): string private function inventorySyncHistoryUrl(Tenant $tenant): string
{ {
return route('admin.operations.index', [ return OperationRunLinks::index($tenant, operationType: 'inventory_sync');
'tenant_id' => (int) $tenant->getKey(),
'tableFilters' => [
'type' => [
'value' => 'inventory_sync',
],
],
]);
} }
} }

View File

@ -110,14 +110,14 @@ protected function getHeaderActions(): array
$actions[] = Action::make('operate_hub_back_to_operations') $actions[] = Action::make('operate_hub_back_to_operations')
->label('Back to Operations') ->label('Back to Operations')
->color('gray') ->color('gray')
->url(fn (): string => route('admin.operations.index')); ->url(fn (): string => OperationRunLinks::index());
} }
if ($activeTenant instanceof Tenant) { if ($activeTenant instanceof Tenant) {
$actions[] = Action::make('operate_hub_show_all_operations') $actions[] = Action::make('operate_hub_show_all_operations')
->label('Show all operations') ->label('Show all operations')
->color('gray') ->color('gray')
->url(fn (): string => route('admin.operations.index')); ->url(fn (): string => OperationRunLinks::index());
} }
$actions[] = Action::make('refresh') $actions[] = Action::make('refresh')
@ -126,7 +126,7 @@ protected function getHeaderActions(): array
->color('primary') ->color('primary')
->url(fn (): string => isset($this->run) ->url(fn (): string => isset($this->run)
? OperationRunLinks::tenantlessView($this->run, $navigationContext) ? OperationRunLinks::tenantlessView($this->run, $navigationContext)
: route('admin.operations.index')); : OperationRunLinks::index());
if (! isset($this->run)) { if (! isset($this->run)) {
return $actions; return $actions;

View File

@ -14,6 +14,7 @@
use App\Support\Badges\BadgeCatalog; use App\Support\Badges\BadgeCatalog;
use App\Support\Badges\BadgeDomain; use App\Support\Badges\BadgeDomain;
use App\Support\Badges\BadgeRenderer; use App\Support\Badges\BadgeRenderer;
use App\Support\Findings\FindingOutcomeSemantics;
use App\Support\Filament\CanonicalAdminTenantFilterState; use App\Support\Filament\CanonicalAdminTenantFilterState;
use App\Support\Filament\FilterPresets; use App\Support\Filament\FilterPresets;
use App\Support\Filament\TablePaginationProfiles; use App\Support\Filament\TablePaginationProfiles;
@ -352,7 +353,14 @@ private function reviewOutcomeBadgeIconColor(TenantReview $record): ?string
private function reviewOutcomeDescription(TenantReview $record): ?string private function reviewOutcomeDescription(TenantReview $record): ?string
{ {
return $this->reviewOutcome($record)->primaryReason; $primaryReason = $this->reviewOutcome($record)->primaryReason;
$findingOutcomeSummary = $this->findingOutcomeSummary($record);
if ($findingOutcomeSummary === null) {
return $primaryReason;
}
return trim($primaryReason.' Terminal outcomes: '.$findingOutcomeSummary.'.');
} }
private function reviewOutcomeNextStep(TenantReview $record): string private function reviewOutcomeNextStep(TenantReview $record): string
@ -373,4 +381,16 @@ private function reviewOutcome(TenantReview $record, bool $fresh = false): Compr
SurfaceCompressionContext::reviewRegister(), SurfaceCompressionContext::reviewRegister(),
); );
} }
private function findingOutcomeSummary(TenantReview $record): ?string
{
$summary = is_array($record->summary) ? $record->summary : [];
$outcomeCounts = $summary['finding_outcomes'] ?? [];
if (! is_array($outcomeCounts)) {
return null;
}
return app(FindingOutcomeSemantics::class)->compactOutcomeSummary($outcomeCounts);
}
} }

View File

@ -598,7 +598,9 @@ public function content(Schema $schema): Schema
->tooltip(fn (): ?string => $this->canStartAnyBootstrap() ->tooltip(fn (): ?string => $this->canStartAnyBootstrap()
? null ? null
: 'You do not have permission to start bootstrap actions.') : 'You do not have permission to start bootstrap actions.')
->action(fn () => $this->startBootstrap((array) ($this->data['bootstrap_operation_types'] ?? []))), ->action(fn (Get $get) => $this->startBootstrap(
$this->normalizeBootstrapOperationTypes((array) ($get('bootstrap_operation_types') ?? [])),
)),
]), ]),
Text::make(fn (): string => $this->bootstrapRunsLabel()) Text::make(fn (): string => $this->bootstrapRunsLabel())
->hidden(fn (): bool => $this->bootstrapRunsLabel() === ''), ->hidden(fn (): bool => $this->bootstrapRunsLabel() === ''),
@ -606,9 +608,11 @@ public function content(Schema $schema): Schema
]) ])
->afterValidation(function (): void { ->afterValidation(function (): void {
$types = $this->data['bootstrap_operation_types'] ?? []; $types = $this->data['bootstrap_operation_types'] ?? [];
$this->selectedBootstrapOperationTypes = is_array($types) $this->selectedBootstrapOperationTypes = $this->normalizeBootstrapOperationTypes(
? array_values(array_filter($types, static fn ($v): bool => is_string($v) && $v !== '')) is_array($types) ? $types : [],
: []; );
$this->persistBootstrapSelection($this->selectedBootstrapOperationTypes);
$this->touchOnboardingSessionStep('bootstrap'); $this->touchOnboardingSessionStep('bootstrap');
}), }),
@ -642,6 +646,10 @@ public function content(Schema $schema): Schema
->badge() ->badge()
->color(fn (): string => $this->completionSummaryBootstrapColor()), ->color(fn (): string => $this->completionSummaryBootstrapColor()),
]), ]),
Callout::make('Bootstrap needs attention')
->description(fn (): string => $this->completionSummaryBootstrapRecoveryMessage())
->warning()
->visible(fn (): bool => $this->showCompletionSummaryBootstrapRecovery()),
Callout::make('After completion') Callout::make('After completion')
->description('This action is recorded in the audit log and cannot be undone from this wizard.') ->description('This action is recorded in the audit log and cannot be undone from this wizard.')
->info() ->info()
@ -733,10 +741,111 @@ private function loadOnboardingDraft(User $user, TenantOnboardingSession|int|str
$bootstrapTypes = $draft->state['bootstrap_operation_types'] ?? []; $bootstrapTypes = $draft->state['bootstrap_operation_types'] ?? [];
$this->selectedBootstrapOperationTypes = is_array($bootstrapTypes) $this->selectedBootstrapOperationTypes = is_array($bootstrapTypes)
? array_values(array_filter($bootstrapTypes, static fn ($v): bool => is_string($v) && $v !== '')) ? $this->normalizeBootstrapOperationTypes($bootstrapTypes)
: []; : [];
} }
/**
* @param array<int|string, mixed> $operationTypes
* @return array<int, string>
*/
private function normalizeBootstrapOperationTypes(array $operationTypes): array
{
$supportedTypes = array_keys($this->supportedBootstrapCapabilities());
$normalized = [];
foreach ($operationTypes as $key => $value) {
if (is_string($value)) {
$normalizedValue = trim($value);
if ($normalizedValue !== '' && in_array($normalizedValue, $supportedTypes, true)) {
$normalized[] = $normalizedValue;
}
continue;
}
if (! is_string($key) || trim($key) === '') {
continue;
}
$isSelected = match (true) {
is_bool($value) => $value,
is_int($value) => $value === 1,
is_string($value) => in_array(strtolower(trim($value)), ['1', 'true', 'on', 'yes'], true),
default => false,
};
$normalizedKey = trim($key);
if ($isSelected && in_array($normalizedKey, $supportedTypes, true)) {
$normalized[] = $normalizedKey;
}
}
return array_values(array_unique($normalized));
}
/**
* @return array<string, string>
*/
private function supportedBootstrapCapabilities(): array
{
return [
'inventory_sync' => Capabilities::WORKSPACE_MANAGED_TENANT_ONBOARD_BOOTSTRAP_INVENTORY_SYNC,
'compliance.snapshot' => Capabilities::WORKSPACE_MANAGED_TENANT_ONBOARD_BOOTSTRAP_POLICY_SYNC,
];
}
/**
* @param array<int, string> $operationTypes
*/
private function persistBootstrapSelection(array $operationTypes): void
{
$user = auth()->user();
if (! $user instanceof User) {
abort(403);
}
if (! $this->onboardingSession instanceof TenantOnboardingSession) {
return;
}
$normalized = $this->normalizeBootstrapOperationTypes($operationTypes);
$existing = $this->onboardingSession->state['bootstrap_operation_types'] ?? null;
$existing = is_array($existing)
? $this->normalizeBootstrapOperationTypes($existing)
: [];
if ($normalized === $existing) {
return;
}
try {
$this->setOnboardingSession($this->mutationService()->mutate(
draft: $this->onboardingSession,
actor: $user,
expectedVersion: $this->expectedDraftVersion(),
incrementVersion: false,
mutator: function (TenantOnboardingSession $draft) use ($normalized): void {
$state = is_array($draft->state) ? $draft->state : [];
$state['bootstrap_operation_types'] = $normalized;
$draft->state = $state;
},
));
} catch (OnboardingDraftConflictException) {
$this->handleDraftConflict();
return;
} catch (OnboardingDraftImmutableException) {
$this->handleImmutableDraft();
return;
}
}
/** /**
* @return Collection<int, TenantOnboardingSession> * @return Collection<int, TenantOnboardingSession>
*/ */
@ -1464,6 +1573,7 @@ private function initializeWizardData(): void
// Ensure all entangled schema state paths exist at render time. // Ensure all entangled schema state paths exist at render time.
// Livewire v4 can throw when entangling to missing nested array keys. // Livewire v4 can throw when entangling to missing nested array keys.
$this->data['notes'] ??= ''; $this->data['notes'] ??= '';
$this->data['bootstrap_operation_types'] ??= [];
$this->data['override_blocked'] ??= false; $this->data['override_blocked'] ??= false;
$this->data['override_reason'] ??= ''; $this->data['override_reason'] ??= '';
$this->data['new_connection'] ??= []; $this->data['new_connection'] ??= [];
@ -1534,7 +1644,7 @@ private function initializeWizardData(): void
$types = $draft->state['bootstrap_operation_types'] ?? null; $types = $draft->state['bootstrap_operation_types'] ?? null;
if (is_array($types)) { if (is_array($types)) {
$this->data['bootstrap_operation_types'] = array_values(array_filter($types, static fn ($v): bool => is_string($v) && $v !== '')); $this->data['bootstrap_operation_types'] = $this->normalizeBootstrapOperationTypes($types);
} }
} }
@ -2966,7 +3076,7 @@ public function startBootstrap(array $operationTypes): void
} }
$registry = app(ProviderOperationRegistry::class); $registry = app(ProviderOperationRegistry::class);
$types = array_values(array_unique(array_filter($operationTypes, static fn ($v): bool => is_string($v) && trim($v) !== ''))); $types = $this->normalizeBootstrapOperationTypes($operationTypes);
$types = array_values(array_filter( $types = array_values(array_filter(
$types, $types,
@ -3236,18 +3346,18 @@ private function bootstrapOperationSucceeded(TenantOnboardingSession $draft, str
private function resolveBootstrapCapability(string $operationType): ?string private function resolveBootstrapCapability(string $operationType): ?string
{ {
return match ($operationType) { return $this->supportedBootstrapCapabilities()[$operationType] ?? null;
'inventory_sync' => Capabilities::WORKSPACE_MANAGED_TENANT_ONBOARD_BOOTSTRAP_INVENTORY_SYNC,
'compliance.snapshot' => Capabilities::WORKSPACE_MANAGED_TENANT_ONBOARD_BOOTSTRAP_POLICY_SYNC,
default => null,
};
} }
private function canStartAnyBootstrap(): bool private function canStartAnyBootstrap(): bool
{ {
return $this->currentUserCan(Capabilities::WORKSPACE_MANAGED_TENANT_ONBOARD_BOOTSTRAP_INVENTORY_SYNC) foreach ($this->supportedBootstrapCapabilities() as $capability) {
|| $this->currentUserCan(Capabilities::WORKSPACE_MANAGED_TENANT_ONBOARD_BOOTSTRAP_POLICY_SYNC) if ($this->currentUserCan($capability)) {
|| $this->currentUserCan(Capabilities::WORKSPACE_MANAGED_TENANT_ONBOARD_BOOTSTRAP_BACKUP_BOOTSTRAP); return true;
}
}
return false;
} }
private function currentUserCan(string $capability): bool private function currentUserCan(string $capability): bool
@ -3498,33 +3608,59 @@ private function completionSummaryVerificationDetail(): string
private function completionSummaryBootstrapLabel(): string private function completionSummaryBootstrapLabel(): string
{ {
if (! $this->onboardingSession instanceof TenantOnboardingSession) { if (! $this->onboardingSession instanceof TenantOnboardingSession) {
return 'Skipped'; return $this->completionSummarySelectedBootstrapTypes() === []
? 'Skipped'
: 'Selected';
}
if ($this->completionSummaryBootstrapActionRequiredDetail() !== null) {
return 'Action required';
} }
$runs = $this->onboardingSession->state['bootstrap_operation_runs'] ?? null; $runs = $this->onboardingSession->state['bootstrap_operation_runs'] ?? null;
$runs = is_array($runs) ? $runs : []; $runs = is_array($runs) ? $runs : [];
if ($runs === []) { if ($runs !== []) {
return 'Skipped'; return 'Started';
} }
return 'Started'; return $this->completionSummarySelectedBootstrapTypes() === []
? 'Skipped'
: 'Selected';
} }
private function completionSummaryBootstrapDetail(): string private function completionSummaryBootstrapDetail(): string
{ {
if (! $this->onboardingSession instanceof TenantOnboardingSession) { if (! $this->onboardingSession instanceof TenantOnboardingSession) {
return 'No bootstrap actions selected'; $selectedTypes = $this->completionSummarySelectedBootstrapTypes();
return $selectedTypes === []
? 'No bootstrap actions selected'
: sprintf('%d action(s) selected', count($selectedTypes));
} }
$runs = $this->onboardingSession->state['bootstrap_operation_runs'] ?? null; $runs = $this->onboardingSession->state['bootstrap_operation_runs'] ?? null;
$runs = is_array($runs) ? $runs : []; $runs = is_array($runs) ? $runs : [];
$selectedTypes = $this->completionSummarySelectedBootstrapTypes();
$actionRequiredDetail = $this->completionSummaryBootstrapActionRequiredDetail();
if ($runs === []) { if ($selectedTypes === []) {
return 'No bootstrap actions selected'; return 'No bootstrap actions selected';
} }
return sprintf('%d operation(s) started', count($runs)); if ($actionRequiredDetail !== null) {
return $actionRequiredDetail;
}
if ($runs === []) {
return sprintf('%d action(s) selected', count($selectedTypes));
}
if (count($runs) < count($selectedTypes)) {
return sprintf('%d of %d action(s) started', count($runs), count($selectedTypes));
}
return sprintf('%d action(s) started', count($runs));
} }
private function completionSummaryBootstrapSummary(): string private function completionSummaryBootstrapSummary(): string
@ -3536,11 +3672,130 @@ private function completionSummaryBootstrapSummary(): string
); );
} }
private function showCompletionSummaryBootstrapRecovery(): bool
{
return $this->completionSummaryBootstrapActionRequiredDetail() !== null;
}
private function completionSummaryBootstrapRecoveryMessage(): string
{
return 'Selected bootstrap actions must complete before activation. Return to Bootstrap to remove the selected actions and skip this optional step, or resolve the required permission and start the blocked action again.';
}
private function completionSummaryBootstrapColor(): string private function completionSummaryBootstrapColor(): string
{ {
return $this->completionSummaryBootstrapLabel() === 'Started' return match ($this->completionSummaryBootstrapLabel()) {
? 'info' 'Action required' => 'warning',
: 'gray'; 'Started' => 'info',
'Selected' => 'warning',
default => 'gray',
};
}
private function completionSummaryBootstrapActionRequiredDetail(): ?string
{
$reasonCode = $this->completionSummaryBootstrapReasonCode();
if (! in_array($reasonCode, ['bootstrap_failed', 'bootstrap_partial_failure'], true)) {
return null;
}
$run = $this->completionSummaryBootstrapFailedRun();
if (! $run instanceof OperationRun) {
return $reasonCode === 'bootstrap_partial_failure'
? 'A bootstrap action needs attention'
: 'A bootstrap action failed';
}
$context = is_array($run->context ?? null) ? $run->context : [];
$operatorLabel = data_get($context, 'reason_translation.operator_label');
if (is_string($operatorLabel) && trim($operatorLabel) !== '') {
return trim($operatorLabel);
}
return match ($run->outcome) {
OperationRunOutcome::PartiallySucceeded->value => 'A bootstrap action needs attention',
OperationRunOutcome::Blocked->value => 'A bootstrap action was blocked',
default => 'A bootstrap action failed',
};
}
private function completionSummaryBootstrapReasonCode(): ?string
{
if (! $this->onboardingSession instanceof TenantOnboardingSession) {
return null;
}
$reasonCode = $this->lifecycleService()->snapshot($this->onboardingSession)['reason_code'] ?? null;
return is_string($reasonCode) ? $reasonCode : null;
}
private function completionSummaryBootstrapFailedRun(): ?OperationRun
{
return once(function (): ?OperationRun {
if (! $this->onboardingSession instanceof TenantOnboardingSession) {
return null;
}
$runMap = $this->onboardingSession->state['bootstrap_operation_runs'] ?? null;
if (! is_array($runMap)) {
return null;
}
$runIds = array_values(array_filter(array_map(
static fn (mixed $value): ?int => is_numeric($value) ? (int) $value : null,
$runMap,
)));
if ($runIds === []) {
return null;
}
return OperationRun::query()
->whereIn('id', $runIds)
->where('status', OperationRunStatus::Completed->value)
->whereIn('outcome', [
OperationRunOutcome::Blocked->value,
OperationRunOutcome::Failed->value,
OperationRunOutcome::PartiallySucceeded->value,
])
->latest('id')
->first();
});
}
/**
* @return array<int, string>
*/
private function completionSummarySelectedBootstrapTypes(): array
{
$selectedTypes = $this->data['bootstrap_operation_types'] ?? null;
if (is_array($selectedTypes)) {
$normalized = $this->normalizeBootstrapOperationTypes($selectedTypes);
if ($normalized !== []) {
return $normalized;
}
}
if ($this->selectedBootstrapOperationTypes !== []) {
return $this->normalizeBootstrapOperationTypes($this->selectedBootstrapOperationTypes);
}
if (! $this->onboardingSession instanceof TenantOnboardingSession) {
return [];
}
$persistedTypes = $this->onboardingSession->state['bootstrap_operation_types'] ?? null;
return is_array($persistedTypes)
? $this->normalizeBootstrapOperationTypes($persistedTypes)
: [];
} }
public function completeOnboarding(): void public function completeOnboarding(): void
@ -4139,9 +4394,10 @@ public function updateSelectedProviderConnectionInline(int $providerConnectionId
private function bootstrapOperationOptions(): array private function bootstrapOperationOptions(): array
{ {
$registry = app(ProviderOperationRegistry::class); $registry = app(ProviderOperationRegistry::class);
$supportedTypes = array_keys($this->supportedBootstrapCapabilities());
return collect($registry->all()) return collect($registry->all())
->reject(fn (array $definition, string $type): bool => $type === 'provider.connection.check') ->filter(fn (array $definition, string $type): bool => in_array($type, $supportedTypes, true))
->mapWithKeys(fn (array $definition, string $type): array => [$type => (string) ($definition['label'] ?? $type)]) ->mapWithKeys(fn (array $definition, string $type): array => [$type => (string) ($definition['label'] ?? $type)])
->all(); ->all();
} }

View File

@ -9,6 +9,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\OperationRun;
use App\Models\Tenant; use App\Models\Tenant;
use App\Models\User; use App\Models\User;
use App\Models\Workspace; use App\Models\Workspace;
@ -840,7 +841,17 @@ private static function compareReadinessIcon(BaselineProfile $profile): ?string
private static function profileNextStep(BaselineProfile $profile): string private static function profileNextStep(BaselineProfile $profile): string
{ {
return match (self::compareAvailabilityReason($profile)) { $compareAvailabilityReason = self::compareAvailabilityReason($profile);
if ($compareAvailabilityReason === null) {
$latestCaptureEnvelope = self::latestBaselineCaptureEnvelope($profile);
if ($latestCaptureEnvelope instanceof ReasonResolutionEnvelope && trim($latestCaptureEnvelope->shortExplanation) !== '') {
return $latestCaptureEnvelope->shortExplanation;
}
}
return match ($compareAvailabilityReason) {
BaselineReasonCodes::COMPARE_INVALID_SCOPE, BaselineReasonCodes::COMPARE_INVALID_SCOPE,
BaselineReasonCodes::COMPARE_MIXED_SCOPE, BaselineReasonCodes::COMPARE_MIXED_SCOPE,
BaselineReasonCodes::COMPARE_UNSUPPORTED_SCOPE => 'Review the governed subject selection before starting compare.', BaselineReasonCodes::COMPARE_UNSUPPORTED_SCOPE => 'Review the governed subject selection before starting compare.',
@ -858,6 +869,30 @@ private static function latestAttemptedSnapshot(BaselineProfile $profile): ?Base
return app(BaselineSnapshotTruthResolver::class)->resolveLatestAttemptedSnapshot($profile); return app(BaselineSnapshotTruthResolver::class)->resolveLatestAttemptedSnapshot($profile);
} }
private static function latestBaselineCaptureEnvelope(BaselineProfile $profile): ?ReasonResolutionEnvelope
{
$run = OperationRun::query()
->where('workspace_id', (int) $profile->workspace_id)
->where('type', 'baseline_capture')
->where('context->baseline_profile_id', (int) $profile->getKey())
->where('status', 'completed')
->orderByDesc('completed_at')
->orderByDesc('id')
->first();
if (! $run instanceof OperationRun) {
return null;
}
$reasonCode = data_get($run->context, 'reason_code');
if (! is_string($reasonCode) || trim($reasonCode) === '') {
return null;
}
return app(ReasonPresenter::class)->forOperationRun($run, 'artifact_truth');
}
private static function compareAvailabilityReason(BaselineProfile $profile): ?string private static function compareAvailabilityReason(BaselineProfile $profile): ?string
{ {
$status = $profile->status instanceof BaselineProfileStatus $status = $profile->status instanceof BaselineProfileStatus

View File

@ -19,6 +19,7 @@
use App\Support\OperationRunLinks; use App\Support\OperationRunLinks;
use App\Support\OpsUx\OperationUxPresenter; use App\Support\OpsUx\OperationUxPresenter;
use App\Support\OpsUx\OpsUxBrowserEvents; use App\Support\OpsUx\OpsUxBrowserEvents;
use App\Support\ReasonTranslation\ReasonPresenter;
use App\Support\Rbac\WorkspaceUiEnforcement; use App\Support\Rbac\WorkspaceUiEnforcement;
use App\Support\Workspaces\WorkspaceContext; use App\Support\Workspaces\WorkspaceContext;
use Filament\Actions\Action; use Filament\Actions\Action;
@ -105,15 +106,10 @@ private function captureAction(): Action
if (! $result['ok']) { if (! $result['ok']) {
$reasonCode = is_string($result['reason_code'] ?? null) ? (string) $result['reason_code'] : 'unknown'; $reasonCode = is_string($result['reason_code'] ?? null) ? (string) $result['reason_code'] : 'unknown';
$translation = app(ReasonPresenter::class)->forArtifactTruth($reasonCode, 'artifact_truth');
$message = match ($reasonCode) { $message = is_string($translation?->shortExplanation) && trim($translation->shortExplanation) !== ''
BaselineReasonCodes::CAPTURE_ROLLOUT_DISABLED => 'Full-content baseline capture is currently disabled for controlled rollout.', ? trim($translation->shortExplanation)
BaselineReasonCodes::CAPTURE_PROFILE_NOT_ACTIVE => 'This baseline profile is not active.', : 'Reason: '.str_replace('.', ' ', $reasonCode);
BaselineReasonCodes::CAPTURE_MISSING_SOURCE_TENANT => 'The selected tenant is not available for this baseline profile.',
BaselineReasonCodes::CAPTURE_INVALID_SCOPE => 'This baseline profile has an invalid governed-subject scope. Review the baseline definition before capturing.',
BaselineReasonCodes::CAPTURE_UNSUPPORTED_SCOPE => 'This baseline profile includes governed subjects that are not currently supported for capture.',
default => 'Reason: '.str_replace('.', ' ', $reasonCode),
};
Notification::make() Notification::make()
->title('Cannot start capture') ->title('Cannot start capture')

View File

@ -21,6 +21,7 @@
use App\Support\Badges\BadgeRenderer; use App\Support\Badges\BadgeRenderer;
use App\Support\Filament\FilterOptionCatalog; use App\Support\Filament\FilterOptionCatalog;
use App\Support\Filament\FilterPresets; use App\Support\Filament\FilterPresets;
use App\Support\Findings\FindingOutcomeSemantics;
use App\Support\Filament\TablePaginationProfiles; use App\Support\Filament\TablePaginationProfiles;
use App\Support\Navigation\CanonicalNavigationContext; use App\Support\Navigation\CanonicalNavigationContext;
use App\Support\Navigation\CrossResourceNavigationMatrix; use App\Support\Navigation\CrossResourceNavigationMatrix;
@ -156,6 +157,14 @@ public static function infolist(Schema $schema): Schema
->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)),
TextEntry::make('finding_terminal_outcome')
->label('Terminal outcome')
->state(fn (Finding $record): ?string => static::terminalOutcomeLabel($record))
->visible(fn (Finding $record): bool => static::terminalOutcomeLabel($record) !== null),
TextEntry::make('finding_verification_state')
->label('Verification')
->state(fn (Finding $record): ?string => static::verificationStateLabel($record))
->visible(fn (Finding $record): bool => static::verificationStateLabel($record) !== null),
TextEntry::make('severity') TextEntry::make('severity')
->badge() ->badge()
->formatStateUsing(BadgeRenderer::label(BadgeDomain::FindingSeverity)) ->formatStateUsing(BadgeRenderer::label(BadgeDomain::FindingSeverity))
@ -292,9 +301,15 @@ public static function infolist(Schema $schema): Schema
TextEntry::make('in_progress_at')->label('In progress at')->dateTime()->placeholder('—'), TextEntry::make('in_progress_at')->label('In progress at')->dateTime()->placeholder('—'),
TextEntry::make('reopened_at')->label('Reopened at')->dateTime()->placeholder('—'), TextEntry::make('reopened_at')->label('Reopened at')->dateTime()->placeholder('—'),
TextEntry::make('resolved_at')->label('Resolved at')->dateTime()->placeholder('—'), TextEntry::make('resolved_at')->label('Resolved at')->dateTime()->placeholder('—'),
TextEntry::make('resolved_reason')->label('Resolved reason')->placeholder('—'), TextEntry::make('resolved_reason')
->label('Resolved reason')
->formatStateUsing(fn (?string $state): string => static::resolveReasonLabel($state) ?? '—')
->placeholder('—'),
TextEntry::make('closed_at')->label('Closed at')->dateTime()->placeholder('—'), TextEntry::make('closed_at')->label('Closed at')->dateTime()->placeholder('—'),
TextEntry::make('closed_reason')->label('Closed/risk reason')->placeholder('—'), TextEntry::make('closed_reason')
->label('Closed/risk reason')
->formatStateUsing(fn (?string $state): string => static::closeReasonLabel($state) ?? '—')
->placeholder('—'),
TextEntry::make('closed_by_user_id') TextEntry::make('closed_by_user_id')
->label('Closed by') ->label('Closed by')
->formatStateUsing(fn (mixed $state, Finding $record): string => $record->closedByUser?->name ?? ($state ? 'User #'.$state : '—')), ->formatStateUsing(fn (mixed $state, Finding $record): string => $record->closedByUser?->name ?? ($state ? 'User #'.$state : '—')),
@ -726,7 +741,7 @@ public static function table(Table $table): Table
->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))
->description(fn (Finding $record): string => static::primaryNarrative($record)), ->description(fn (Finding $record): string => static::statusDescription($record)),
Tables\Columns\TextColumn::make('governance_validity') Tables\Columns\TextColumn::make('governance_validity')
->label('Governance') ->label('Governance')
->badge() ->badge()
@ -820,6 +835,14 @@ 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('terminal_outcome')
->label('Terminal outcome')
->options(FilterOptionCatalog::findingTerminalOutcomes())
->query(fn (Builder $query, array $data): Builder => static::applyTerminalOutcomeFilter($query, $data['value'] ?? null)),
Tables\Filters\SelectFilter::make('verification_state')
->label('Verification')
->options(FilterOptionCatalog::findingVerificationStates())
->query(fn (Builder $query, array $data): Builder => static::applyVerificationStateFilter($query, $data['value'] ?? null)),
Tables\Filters\SelectFilter::make('workflow_family') Tables\Filters\SelectFilter::make('workflow_family')
->label('Workflow family') ->label('Workflow family')
->options(FilterOptionCatalog::findingWorkflowFamilies()) ->options(FilterOptionCatalog::findingWorkflowFamilies())
@ -1092,16 +1115,20 @@ public static function table(Table $table): Table
UiEnforcement::forBulkAction( UiEnforcement::forBulkAction(
BulkAction::make('resolve_selected') BulkAction::make('resolve_selected')
->label('Resolve selected') ->label(GovernanceActionCatalog::rule('resolve_finding')->canonicalLabel.' selected')
->icon('heroicon-o-check-badge') ->icon('heroicon-o-check-badge')
->color('success') ->color('success')
->requiresConfirmation() ->requiresConfirmation()
->modalHeading(GovernanceActionCatalog::rule('resolve_finding')->modalHeading)
->modalDescription(GovernanceActionCatalog::rule('resolve_finding')->modalDescription)
->form([ ->form([
Textarea::make('resolved_reason') Select::make('resolved_reason')
->label('Resolution reason') ->label('Resolution outcome')
->rows(3) ->options(static::resolveReasonOptions())
->helperText('Use the canonical manual remediation outcome. Trusted verification is recorded later by system evidence.')
->native(false)
->required() ->required()
->maxLength(255), ->selectablePlaceholder(false),
]) ])
->action(function (Collection $records, array $data, FindingWorkflowService $workflow): void { ->action(function (Collection $records, array $data, FindingWorkflowService $workflow): void {
$tenant = static::resolveTenantContextForCurrentPanel(); $tenant = static::resolveTenantContextForCurrentPanel();
@ -1145,7 +1172,7 @@ public static function table(Table $table): Table
} }
} }
$body = "Resolved {$resolvedCount} finding".($resolvedCount === 1 ? '' : 's').'.'; $body = "Resolved {$resolvedCount} finding".($resolvedCount === 1 ? '' : 's')." pending verification.";
if ($skippedCount > 0) { if ($skippedCount > 0) {
$body .= " Skipped {$skippedCount}."; $body .= " Skipped {$skippedCount}.";
} }
@ -1167,18 +1194,20 @@ public static function table(Table $table): Table
UiEnforcement::forBulkAction( UiEnforcement::forBulkAction(
BulkAction::make('close_selected') BulkAction::make('close_selected')
->label('Close selected') ->label(GovernanceActionCatalog::rule('close_finding')->canonicalLabel.' selected')
->icon('heroicon-o-x-circle') ->icon('heroicon-o-x-circle')
->color('warning') ->color('warning')
->requiresConfirmation() ->requiresConfirmation()
->modalHeading(GovernanceActionCatalog::rule('close_finding')->modalHeading) ->modalHeading(GovernanceActionCatalog::rule('close_finding')->modalHeading)
->modalDescription(GovernanceActionCatalog::rule('close_finding')->modalDescription) ->modalDescription(GovernanceActionCatalog::rule('close_finding')->modalDescription)
->form([ ->form([
Textarea::make('closed_reason') Select::make('closed_reason')
->label('Close reason') ->label('Close reason')
->rows(3) ->options(static::closeReasonOptions())
->helperText('Use the canonical administrative closure outcome for this finding.')
->native(false)
->required() ->required()
->maxLength(255), ->selectablePlaceholder(false),
]) ])
->action(function (Collection $records, array $data, FindingWorkflowService $workflow): void { ->action(function (Collection $records, array $data, FindingWorkflowService $workflow): void {
$tenant = static::resolveTenantContextForCurrentPanel(); $tenant = static::resolveTenantContextForCurrentPanel();
@ -1448,24 +1477,30 @@ public static function assignAction(): Actions\Action
public static function resolveAction(): Actions\Action public static function resolveAction(): Actions\Action
{ {
$rule = GovernanceActionCatalog::rule('resolve_finding');
return UiEnforcement::forAction( return UiEnforcement::forAction(
Actions\Action::make('resolve') Actions\Action::make('resolve')
->label('Resolve') ->label($rule->canonicalLabel)
->icon('heroicon-o-check-badge') ->icon('heroicon-o-check-badge')
->color('success') ->color('success')
->visible(fn (Finding $record): bool => $record->hasOpenStatus()) ->visible(fn (Finding $record): bool => $record->hasOpenStatus())
->requiresConfirmation() ->requiresConfirmation()
->modalHeading($rule->modalHeading)
->modalDescription($rule->modalDescription)
->form([ ->form([
Textarea::make('resolved_reason') Select::make('resolved_reason')
->label('Resolution reason') ->label('Resolution outcome')
->rows(3) ->options(static::resolveReasonOptions())
->helperText('Use the canonical manual remediation outcome. Trusted verification is recorded later by system evidence.')
->native(false)
->required() ->required()
->maxLength(255), ->selectablePlaceholder(false),
]) ])
->action(function (Finding $record, array $data, FindingWorkflowService $workflow): void { ->action(function (Finding $record, array $data, FindingWorkflowService $workflow) use ($rule): void {
static::runWorkflowMutation( static::runWorkflowMutation(
record: $record, record: $record,
successTitle: 'Finding resolved', successTitle: $rule->successTitle,
callback: fn (Finding $finding, Tenant $tenant, User $user): Finding => $workflow->resolve( callback: fn (Finding $finding, Tenant $tenant, User $user): Finding => $workflow->resolve(
$finding, $finding,
$tenant, $tenant,
@ -1495,11 +1530,13 @@ public static function closeAction(): Actions\Action
->modalHeading($rule->modalHeading) ->modalHeading($rule->modalHeading)
->modalDescription($rule->modalDescription) ->modalDescription($rule->modalDescription)
->form([ ->form([
Textarea::make('closed_reason') Select::make('closed_reason')
->label('Close reason') ->label('Close reason')
->rows(3) ->options(static::closeReasonOptions())
->helperText('Use the canonical administrative closure outcome for this finding.')
->native(false)
->required() ->required()
->maxLength(255), ->selectablePlaceholder(false),
]) ])
->action(function (Finding $record, array $data, FindingWorkflowService $workflow) use ($rule): void { ->action(function (Finding $record, array $data, FindingWorkflowService $workflow) use ($rule): void {
static::runWorkflowMutation( static::runWorkflowMutation(
@ -1694,12 +1731,17 @@ public static function reopenAction(): Actions\Action
->modalHeading($rule->modalHeading) ->modalHeading($rule->modalHeading)
->modalDescription($rule->modalDescription) ->modalDescription($rule->modalDescription)
->visible(fn (Finding $record): bool => Finding::isTerminalStatus((string) $record->status)) ->visible(fn (Finding $record): bool => Finding::isTerminalStatus((string) $record->status))
->fillForm([
'reopen_reason' => Finding::REOPEN_REASON_MANUAL_REASSESSMENT,
])
->form([ ->form([
Textarea::make('reopen_reason') Select::make('reopen_reason')
->label('Reopen reason') ->label('Reopen reason')
->rows(3) ->options(static::reopenReasonOptions())
->helperText('Use the canonical reopen reason that best describes why this finding needs active workflow again.')
->native(false)
->required() ->required()
->maxLength(255), ->selectablePlaceholder(false),
]) ])
->action(function (Finding $record, array $data, FindingWorkflowService $workflow) use ($rule): void { ->action(function (Finding $record, array $data, FindingWorkflowService $workflow) use ($rule): void {
static::runWorkflowMutation( static::runWorkflowMutation(
@ -2138,6 +2180,150 @@ private static function governanceValidityState(Finding $finding): ?string
->resolveGovernanceValidity($finding, static::resolvedFindingException($finding)); ->resolveGovernanceValidity($finding, static::resolvedFindingException($finding));
} }
private static function findingOutcomeSemantics(): FindingOutcomeSemantics
{
return app(FindingOutcomeSemantics::class);
}
/**
* @return array{
* terminal_outcome_key: ?string,
* label: ?string,
* verification_state: string,
* verification_label: ?string,
* report_bucket: ?string
* }
*/
private static function findingOutcome(Finding $finding): array
{
return static::findingOutcomeSemantics()->describe($finding);
}
/**
* @return array<string, string>
*/
private static function resolveReasonOptions(): array
{
return [
Finding::RESOLVE_REASON_REMEDIATED => 'Remediated',
];
}
/**
* @return array<string, string>
*/
private static function closeReasonOptions(): array
{
return [
Finding::CLOSE_REASON_FALSE_POSITIVE => 'False positive',
Finding::CLOSE_REASON_DUPLICATE => 'Duplicate',
Finding::CLOSE_REASON_NO_LONGER_APPLICABLE => 'No longer applicable',
];
}
/**
* @return array<string, string>
*/
private static function reopenReasonOptions(): array
{
return [
Finding::REOPEN_REASON_RECURRED_AFTER_RESOLUTION => 'Recurred after resolution',
Finding::REOPEN_REASON_VERIFICATION_FAILED => 'Verification failed',
Finding::REOPEN_REASON_MANUAL_REASSESSMENT => 'Manual reassessment',
];
}
private static function resolveReasonLabel(?string $reason): ?string
{
return static::resolveReasonOptions()[$reason] ?? match ($reason) {
Finding::RESOLVE_REASON_NO_LONGER_DRIFTING => 'No longer drifting',
Finding::RESOLVE_REASON_PERMISSION_GRANTED => 'Permission granted',
Finding::RESOLVE_REASON_PERMISSION_REMOVED_FROM_REGISTRY => 'Permission removed from registry',
Finding::RESOLVE_REASON_ROLE_ASSIGNMENT_REMOVED => 'Role assignment removed',
Finding::RESOLVE_REASON_GA_COUNT_WITHIN_THRESHOLD => 'GA count within threshold',
default => null,
};
}
private static function closeReasonLabel(?string $reason): ?string
{
return static::closeReasonOptions()[$reason] ?? match ($reason) {
Finding::CLOSE_REASON_ACCEPTED_RISK => 'Accepted risk',
default => null,
};
}
private static function reopenReasonLabel(?string $reason): ?string
{
return static::reopenReasonOptions()[$reason] ?? null;
}
private static function terminalOutcomeLabel(Finding $finding): ?string
{
return static::findingOutcome($finding)['label'] ?? null;
}
private static function verificationStateLabel(Finding $finding): ?string
{
return static::findingOutcome($finding)['verification_label'] ?? null;
}
private static function statusDescription(Finding $finding): string
{
return static::terminalOutcomeLabel($finding) ?? static::primaryNarrative($finding);
}
private static function applyTerminalOutcomeFilter(Builder $query, mixed $value): Builder
{
if (! is_string($value) || $value === '') {
return $query;
}
return match ($value) {
FindingOutcomeSemantics::OUTCOME_RESOLVED_PENDING_VERIFICATION => $query
->where('status', Finding::STATUS_RESOLVED)
->whereIn('resolved_reason', Finding::manualResolveReasonKeys()),
FindingOutcomeSemantics::OUTCOME_VERIFIED_CLEARED => $query
->where('status', Finding::STATUS_RESOLVED)
->whereIn('resolved_reason', Finding::systemResolveReasonKeys()),
FindingOutcomeSemantics::OUTCOME_CLOSED_FALSE_POSITIVE => $query
->where('status', Finding::STATUS_CLOSED)
->where('closed_reason', Finding::CLOSE_REASON_FALSE_POSITIVE),
FindingOutcomeSemantics::OUTCOME_CLOSED_DUPLICATE => $query
->where('status', Finding::STATUS_CLOSED)
->where('closed_reason', Finding::CLOSE_REASON_DUPLICATE),
FindingOutcomeSemantics::OUTCOME_CLOSED_NO_LONGER_APPLICABLE => $query
->where('status', Finding::STATUS_CLOSED)
->where('closed_reason', Finding::CLOSE_REASON_NO_LONGER_APPLICABLE),
FindingOutcomeSemantics::OUTCOME_RISK_ACCEPTED => $query
->where('status', Finding::STATUS_RISK_ACCEPTED),
default => $query,
};
}
private static function applyVerificationStateFilter(Builder $query, mixed $value): Builder
{
if (! is_string($value) || $value === '') {
return $query;
}
return match ($value) {
FindingOutcomeSemantics::VERIFICATION_PENDING => $query
->where('status', Finding::STATUS_RESOLVED)
->whereIn('resolved_reason', Finding::manualResolveReasonKeys()),
FindingOutcomeSemantics::VERIFICATION_VERIFIED => $query
->where('status', Finding::STATUS_RESOLVED)
->whereIn('resolved_reason', Finding::systemResolveReasonKeys()),
FindingOutcomeSemantics::VERIFICATION_NOT_APPLICABLE => $query->where(function (Builder $verificationQuery): void {
$verificationQuery
->where('status', '!=', Finding::STATUS_RESOLVED)
->orWhereNull('resolved_reason')
->orWhereNotIn('resolved_reason', Finding::resolveReasonKeys());
}),
default => $query,
};
}
private static function primaryNarrative(Finding $finding): string private static function primaryNarrative(Finding $finding): string
{ {
return app(FindingRiskGovernanceResolver::class) return app(FindingRiskGovernanceResolver::class)

View File

@ -17,6 +17,7 @@
use App\Support\Badges\TagBadgeRenderer; use App\Support\Badges\TagBadgeRenderer;
use App\Support\Filament\FilterOptionCatalog; use App\Support\Filament\FilterOptionCatalog;
use App\Support\Inventory\InventoryPolicyTypeMeta; use App\Support\Inventory\InventoryPolicyTypeMeta;
use App\Support\OperationRunLinks;
use App\Support\Ui\ActionSurface\ActionSurfaceDeclaration; use App\Support\Ui\ActionSurface\ActionSurfaceDeclaration;
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceInspectAffordance; use App\Support\Ui\ActionSurface\Enums\ActionSurfaceInspectAffordance;
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceProfile; use App\Support\Ui\ActionSurface\Enums\ActionSurfaceProfile;
@ -148,7 +149,13 @@ public static function infolist(Schema $schema): Schema
return null; return null;
} }
return route('admin.operations.view', ['run' => (int) $record->last_seen_operation_run_id]); $tenant = $record->tenant;
if ($tenant instanceof Tenant) {
return OperationRunLinks::view((int) $record->last_seen_operation_run_id, $tenant);
}
return OperationRunLinks::tenantlessView((int) $record->last_seen_operation_run_id);
}) })
->openUrlInNewTab(), ->openUrlInNewTab(),
TextEntry::make('support_restore') TextEntry::make('support_restore')

View File

@ -13,6 +13,7 @@
use App\Support\Badges\BadgeCatalog; use App\Support\Badges\BadgeCatalog;
use App\Support\Badges\BadgeDomain; use App\Support\Badges\BadgeDomain;
use App\Support\Badges\BadgeRenderer; use App\Support\Badges\BadgeRenderer;
use App\Support\OperationRunLinks;
use App\Support\OpsUx\OperationUxPresenter; use App\Support\OpsUx\OperationUxPresenter;
use App\Support\Rbac\UiEnforcement; use App\Support\Rbac\UiEnforcement;
use App\Support\ReviewPackStatus; use App\Support\ReviewPackStatus;
@ -199,9 +200,19 @@ public static function infolist(Schema $schema): Schema
->placeholder('—'), ->placeholder('—'),
TextEntry::make('operationRun.id') TextEntry::make('operationRun.id')
->label('Operation') ->label('Operation')
->url(fn (ReviewPack $record): ?string => $record->operation_run_id ->url(function (ReviewPack $record): ?string {
? route('admin.operations.view', ['run' => (int) $record->operation_run_id]) if (! $record->operation_run_id) {
: null) return null;
}
$tenant = $record->tenant;
if ($tenant instanceof Tenant) {
return OperationRunLinks::view((int) $record->operation_run_id, $tenant);
}
return OperationRunLinks::tenantlessView((int) $record->operation_run_id);
})
->openUrlInNewTab() ->openUrlInNewTab()
->placeholder('—'), ->placeholder('—'),
TextEntry::make('fingerprint')->label('Fingerprint')->copyable()->placeholder('—'), TextEntry::make('fingerprint')->label('Fingerprint')->copyable()->placeholder('—'),

View File

@ -18,6 +18,7 @@
use App\Support\Badges\BadgeCatalog; use App\Support\Badges\BadgeCatalog;
use App\Support\Badges\BadgeDomain; use App\Support\Badges\BadgeDomain;
use App\Support\Badges\BadgeRenderer; use App\Support\Badges\BadgeRenderer;
use App\Support\Findings\FindingOutcomeSemantics;
use App\Support\OperationRunLinks; use App\Support\OperationRunLinks;
use App\Support\OperationRunType; use App\Support\OperationRunType;
use App\Support\OpsUx\OperationUxPresenter; use App\Support\OpsUx\OperationUxPresenter;
@ -540,12 +541,19 @@ private static function summaryPresentation(TenantReview $record): array
$summary = is_array($record->summary) ? $record->summary : []; $summary = is_array($record->summary) ? $record->summary : [];
$truthEnvelope = static::truthEnvelope($record); $truthEnvelope = static::truthEnvelope($record);
$reasonPresenter = app(ReasonPresenter::class); $reasonPresenter = app(ReasonPresenter::class);
$highlights = is_array($summary['highlights'] ?? null) ? $summary['highlights'] : [];
$findingOutcomeSummary = static::findingOutcomeSummary($summary);
$findingOutcomes = is_array($summary['finding_outcomes'] ?? null) ? $summary['finding_outcomes'] : [];
if ($findingOutcomeSummary !== null) {
$highlights[] = 'Terminal outcomes: '.$findingOutcomeSummary.'.';
}
return [ return [
'operator_explanation' => $truthEnvelope->operatorExplanation?->toArray(), 'operator_explanation' => $truthEnvelope->operatorExplanation?->toArray(),
'compressed_outcome' => static::compressedOutcome($record)->toArray(), 'compressed_outcome' => static::compressedOutcome($record)->toArray(),
'reason_semantics' => $reasonPresenter->semantics($truthEnvelope->reason?->toReasonResolutionEnvelope()), 'reason_semantics' => $reasonPresenter->semantics($truthEnvelope->reason?->toReasonResolutionEnvelope()),
'highlights' => is_array($summary['highlights'] ?? null) ? $summary['highlights'] : [], 'highlights' => $highlights,
'next_actions' => is_array($summary['recommended_next_actions'] ?? null) ? $summary['recommended_next_actions'] : [], 'next_actions' => is_array($summary['recommended_next_actions'] ?? null) ? $summary['recommended_next_actions'] : [],
'publish_blockers' => is_array($summary['publish_blockers'] ?? null) ? $summary['publish_blockers'] : [], 'publish_blockers' => is_array($summary['publish_blockers'] ?? null) ? $summary['publish_blockers'] : [],
'context_links' => static::summaryContextLinks($record), 'context_links' => static::summaryContextLinks($record),
@ -554,6 +562,8 @@ private static function summaryPresentation(TenantReview $record): array
['label' => 'Reports', 'value' => (string) ($summary['report_count'] ?? 0)], ['label' => 'Reports', 'value' => (string) ($summary['report_count'] ?? 0)],
['label' => 'Operations', 'value' => (string) ($summary['operation_count'] ?? 0)], ['label' => 'Operations', 'value' => (string) ($summary['operation_count'] ?? 0)],
['label' => 'Sections', 'value' => (string) ($summary['section_count'] ?? 0)], ['label' => 'Sections', 'value' => (string) ($summary['section_count'] ?? 0)],
['label' => 'Pending verification', 'value' => (string) ($findingOutcomes[FindingOutcomeSemantics::OUTCOME_RESOLVED_PENDING_VERIFICATION] ?? 0)],
['label' => 'Verified cleared', 'value' => (string) ($findingOutcomes[FindingOutcomeSemantics::OUTCOME_VERIFIED_CLEARED] ?? 0)],
], ],
]; ];
} }
@ -655,4 +665,18 @@ private static function compressedOutcome(TenantReview $record, bool $fresh = fa
SurfaceCompressionContext::tenantReview(), SurfaceCompressionContext::tenantReview(),
); );
} }
/**
* @param array<string, mixed> $summary
*/
private static function findingOutcomeSummary(array $summary): ?string
{
$outcomeCounts = $summary['finding_outcomes'] ?? [];
if (! is_array($outcomeCounts)) {
return null;
}
return app(FindingOutcomeSemantics::class)->compactOutcomeSummary($outcomeCounts);
}
} }

View File

@ -41,7 +41,7 @@ protected function getViewData(): array
return [ return [
'tenant' => null, 'tenant' => null,
'runs' => collect(), 'runs' => collect(),
'operationsIndexUrl' => route('admin.operations.index'), 'operationsIndexUrl' => OperationRunLinks::index(),
'operationsIndexLabel' => OperationRunLinks::openCollectionLabel(), 'operationsIndexLabel' => OperationRunLinks::openCollectionLabel(),
'operationsIndexDescription' => OperationRunLinks::collectionScopeDescription(), 'operationsIndexDescription' => OperationRunLinks::collectionScopeDescription(),
]; ];
@ -68,7 +68,7 @@ protected function getViewData(): array
return [ return [
'tenant' => $tenant, 'tenant' => $tenant,
'runs' => $runs, 'runs' => $runs,
'operationsIndexUrl' => route('admin.operations.index'), 'operationsIndexUrl' => OperationRunLinks::index($tenant),
'operationsIndexLabel' => OperationRunLinks::openCollectionLabel(), 'operationsIndexLabel' => OperationRunLinks::openCollectionLabel(),
'operationsIndexDescription' => OperationRunLinks::collectionScopeDescription(), 'operationsIndexDescription' => OperationRunLinks::collectionScopeDescription(),
]; ];

View File

@ -4,6 +4,7 @@
use App\Models\ProviderConnection; use App\Models\ProviderConnection;
use App\Models\Tenant; use App\Models\Tenant;
use App\Models\TenantOnboardingSession;
use App\Services\Intune\AuditLogger; use App\Services\Intune\AuditLogger;
use App\Support\Providers\ProviderConnectionType; use App\Support\Providers\ProviderConnectionType;
use App\Support\Providers\ProviderConsentStatus; use App\Support\Providers\ProviderConsentStatus;
@ -54,6 +55,8 @@ public function __invoke(
error: $error, error: $error,
); );
$this->invalidateResumableOnboardingVerificationState($tenant, $connection);
$legacyStatus = $status === 'ok' ? 'success' : 'failed'; $legacyStatus = $status === 'ok' ? 'success' : 'failed';
$auditMetadata = [ $auditMetadata = [
'source' => 'admin.consent.callback', 'source' => 'admin.consent.callback',
@ -98,6 +101,7 @@ public function __invoke(
'status' => $status, 'status' => $status,
'error' => $error, 'error' => $error,
'consentGranted' => $consentGranted, 'consentGranted' => $consentGranted,
'verificationStateLabel' => $this->verificationStateLabel($connection),
]); ]);
} }
@ -197,4 +201,48 @@ private function parseState(?string $state): ?string
return $state; return $state;
} }
private function verificationStateLabel(ProviderConnection $connection): string
{
$verificationStatus = $connection->verification_status instanceof ProviderVerificationStatus
? $connection->verification_status
: ProviderVerificationStatus::tryFrom((string) $connection->verification_status);
if ($verificationStatus === ProviderVerificationStatus::Unknown) {
return $connection->consent_status === ProviderConsentStatus::Granted
? 'Needs verification'
: 'Not verified';
}
return ucfirst(str_replace('_', ' ', $verificationStatus?->value ?? 'unknown'));
}
private function invalidateResumableOnboardingVerificationState(Tenant $tenant, ProviderConnection $connection): void
{
TenantOnboardingSession::query()
->where('tenant_id', (int) $tenant->getKey())
->resumable()
->each(function (TenantOnboardingSession $draft) use ($connection): void {
$state = is_array($draft->state) ? $draft->state : [];
$providerConnectionId = $state['provider_connection_id'] ?? null;
$providerConnectionId = is_numeric($providerConnectionId) ? (int) $providerConnectionId : null;
if ($providerConnectionId !== null && $providerConnectionId !== (int) $connection->getKey()) {
return;
}
unset(
$state['verification_operation_run_id'],
$state['verification_run_id'],
$state['bootstrap_operation_runs'],
$state['bootstrap_operation_types'],
$state['bootstrap_run_ids'],
);
$state['connection_recently_updated'] = true;
$draft->state = $state;
$draft->save();
});
}
} }

View File

@ -345,9 +345,12 @@ private function consolidateDriftDuplicates(Tenant $tenant, CarbonImmutable $bac
} }
$finding->forceFill([ $finding->forceFill([
'status' => Finding::STATUS_RESOLVED, 'status' => Finding::STATUS_CLOSED,
'resolved_at' => $backfillStartedAt, 'resolved_at' => null,
'resolved_reason' => 'consolidated_duplicate', 'resolved_reason' => null,
'closed_at' => $backfillStartedAt,
'closed_reason' => Finding::CLOSE_REASON_DUPLICATE,
'closed_by_user_id' => null,
'recurrence_key' => null, 'recurrence_key' => null,
])->save(); ])->save();

View File

@ -325,9 +325,12 @@ private function consolidateDriftDuplicates(Tenant $tenant, CarbonImmutable $bac
} }
$finding->forceFill([ $finding->forceFill([
'status' => Finding::STATUS_RESOLVED, 'status' => Finding::STATUS_CLOSED,
'resolved_at' => $backfillStartedAt, 'resolved_at' => null,
'resolved_reason' => 'consolidated_duplicate', 'resolved_reason' => null,
'closed_at' => $backfillStartedAt,
'closed_reason' => Finding::CLOSE_REASON_DUPLICATE,
'closed_by_user_id' => null,
'recurrence_key' => null, 'recurrence_key' => null,
])->save(); ])->save();

View File

@ -11,6 +11,7 @@
use App\Models\OperationRun; use App\Models\OperationRun;
use App\Models\Tenant; use App\Models\Tenant;
use App\Models\User; use App\Models\User;
use App\Services\Baselines\BaselineCaptureService;
use App\Services\Baselines\BaselineContentCapturePhase; use App\Services\Baselines\BaselineContentCapturePhase;
use App\Services\Baselines\BaselineSnapshotIdentity; use App\Services\Baselines\BaselineSnapshotIdentity;
use App\Services\Baselines\BaselineSnapshotItemNormalizer; use App\Services\Baselines\BaselineSnapshotItemNormalizer;
@ -29,7 +30,6 @@
use App\Support\Inventory\InventoryPolicyTypeMeta; use App\Support\Inventory\InventoryPolicyTypeMeta;
use App\Support\OperationRunOutcome; use App\Support\OperationRunOutcome;
use App\Support\OperationRunStatus; use App\Support\OperationRunStatus;
use App\Support\OperationRunType;
use Illuminate\Bus\Queueable; use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue; use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable; use Illuminate\Foundation\Bus\Dispatchable;
@ -71,13 +71,24 @@ public function handle(
InventoryMetaContract $metaContract, InventoryMetaContract $metaContract,
AuditLogger $auditLogger, AuditLogger $auditLogger,
OperationRunService $operationRunService, OperationRunService $operationRunService,
?CurrentStateHashResolver $hashResolver = null, mixed $arg5 = null,
?BaselineContentCapturePhase $contentCapturePhase = null, mixed $arg6 = null,
?BaselineSnapshotItemNormalizer $snapshotItemNormalizer = null, ?BaselineSnapshotItemNormalizer $snapshotItemNormalizer = null,
?BaselineFullContentRolloutGate $rolloutGate = null, ?BaselineFullContentRolloutGate $rolloutGate = null,
): void { ): void {
$hashResolver ??= app(CurrentStateHashResolver::class); $captureService = $arg5 instanceof BaselineCaptureService
$contentCapturePhase ??= app(BaselineContentCapturePhase::class); ? $arg5
: app(BaselineCaptureService::class);
$hashResolver = $arg5 instanceof CurrentStateHashResolver
? $arg5
: ($arg6 instanceof CurrentStateHashResolver
? $arg6
: app(CurrentStateHashResolver::class));
$contentCapturePhase = $arg5 instanceof BaselineContentCapturePhase
? $arg5
: ($arg6 instanceof BaselineContentCapturePhase
? $arg6
: app(BaselineContentCapturePhase::class));
$snapshotItemNormalizer ??= app(BaselineSnapshotItemNormalizer::class); $snapshotItemNormalizer ??= app(BaselineSnapshotItemNormalizer::class);
$rolloutGate ??= app(BaselineFullContentRolloutGate::class); $rolloutGate ??= app(BaselineFullContentRolloutGate::class);
@ -118,10 +129,124 @@ public function handle(
$rolloutGate->assertEnabled(); $rolloutGate->assertEnabled();
} }
$latestInventorySyncRun = $this->resolveLatestInventorySyncRun($sourceTenant); $previousCurrentSnapshot = $profile->resolveCurrentConsumableSnapshot();
$latestInventorySyncRunId = $latestInventorySyncRun instanceof OperationRun $previousCurrentSnapshotId = $previousCurrentSnapshot instanceof BaselineSnapshot
? (int) $latestInventorySyncRun->getKey() ? (int) $previousCurrentSnapshot->getKey()
: null; : null;
$previousCurrentSnapshotExists = $previousCurrentSnapshotId !== null;
$preflightEligibility = is_array(data_get($context, 'baseline_capture.eligibility'))
? data_get($context, 'baseline_capture.eligibility')
: [];
$inventoryEligibility = $captureService->latestInventoryEligibilityDecision($sourceTenant, $effectiveScope, $truthfulTypes);
$latestInventorySyncRunId = is_numeric($inventoryEligibility['inventory_sync_run_id'] ?? null)
? (int) $inventoryEligibility['inventory_sync_run_id']
: null;
$eligibilityContext = $captureService->eligibilityContextPayload($inventoryEligibility, phase: 'runtime_recheck');
$eligibilityContext['changed_after_enqueue'] = ($preflightEligibility['ok'] ?? null) === true
&& ! ($inventoryEligibility['ok'] ?? false);
$eligibilityContext['preflight_inventory_sync_run_id'] = is_numeric($preflightEligibility['inventory_sync_run_id'] ?? null)
? (int) $preflightEligibility['inventory_sync_run_id']
: null;
$eligibilityContext['preflight_reason_code'] = is_string($preflightEligibility['reason_code'] ?? null)
? (string) $preflightEligibility['reason_code']
: null;
$context['baseline_capture'] = array_merge(
is_array($context['baseline_capture'] ?? null) ? $context['baseline_capture'] : [],
[
'inventory_sync_run_id' => $latestInventorySyncRunId,
'eligibility' => $eligibilityContext,
'previous_current_snapshot_id' => $previousCurrentSnapshotId,
'previous_current_snapshot_exists' => $previousCurrentSnapshotExists,
],
);
$this->operationRun->update(['context' => $context]);
$this->operationRun->refresh();
$context = is_array($this->operationRun->context) ? $this->operationRun->context : [];
if (! ($inventoryEligibility['ok'] ?? false)) {
$reasonCode = is_string($inventoryEligibility['reason_code'] ?? null)
? (string) $inventoryEligibility['reason_code']
: BaselineReasonCodes::CAPTURE_INVENTORY_MISSING;
$summaryCounts = [
'total' => 0,
'processed' => 0,
'succeeded' => 0,
'failed' => 0,
];
$blockedContext = $context;
$blockedContext['reason_code'] = $reasonCode;
$blockedContext['baseline_capture'] = array_merge(
is_array($blockedContext['baseline_capture'] ?? null) ? $blockedContext['baseline_capture'] : [],
[
'reason_code' => $reasonCode,
'subjects_total' => 0,
'current_baseline_changed' => false,
],
);
$blockedContext['result'] = array_merge(
is_array($blockedContext['result'] ?? null) ? $blockedContext['result'] : [],
[
'current_baseline_changed' => false,
],
);
$this->operationRun->update([
'context' => $blockedContext,
'summary_counts' => $summaryCounts,
]);
$this->operationRun->refresh();
$this->auditStarted(
auditLogger: $auditLogger,
tenant: $sourceTenant,
profile: $profile,
initiator: $initiator,
captureMode: $captureMode,
subjectsTotal: 0,
effectiveScope: $effectiveScope,
inventorySyncRunId: $latestInventorySyncRunId,
);
$operationRunService->finalizeBlockedRun(
run: $this->operationRun,
reasonCode: $reasonCode,
message: $this->blockedInventoryMessage(
$reasonCode,
(bool) ($eligibilityContext['changed_after_enqueue'] ?? false),
),
);
$this->operationRun->refresh();
$this->auditCompleted(
auditLogger: $auditLogger,
tenant: $sourceTenant,
profile: $profile,
snapshot: null,
initiator: $initiator,
captureMode: $captureMode,
subjectsTotal: 0,
inventorySyncRunId: $latestInventorySyncRunId,
wasNewSnapshot: false,
evidenceCaptureStats: [
'requested' => 0,
'succeeded' => 0,
'skipped' => 0,
'failed' => 0,
'throttled' => 0,
],
gaps: [
'count' => 0,
'by_reason' => [],
],
currentBaselineChanged: false,
reasonCode: $reasonCode,
);
return;
}
$inventoryResult = $this->collectInventorySubjects( $inventoryResult = $this->collectInventorySubjects(
sourceTenant: $sourceTenant, sourceTenant: $sourceTenant,
@ -154,6 +279,7 @@ public function handle(
'failed' => 0, 'failed' => 0,
'throttled' => 0, 'throttled' => 0,
]; ];
$phaseResult = [];
$phaseGaps = []; $phaseGaps = [];
$resumeToken = null; $resumeToken = null;
@ -222,6 +348,91 @@ public function handle(
], ],
]; ];
if ($subjectsTotal === 0) {
$snapshotResult = $this->captureNoDataSnapshotArtifact(
$profile,
$identityHash,
$snapshotSummary,
);
$snapshot = $snapshotResult['snapshot'];
$wasNewSnapshot = $snapshotResult['was_new_snapshot'];
$summaryCounts = [
'total' => 0,
'processed' => 0,
'succeeded' => 0,
'failed' => 0,
];
$updatedContext = is_array($this->operationRun->context) ? $this->operationRun->context : [];
$updatedContext['reason_code'] = BaselineReasonCodes::CAPTURE_ZERO_SUBJECTS;
$updatedContext['baseline_capture'] = array_merge(
is_array($updatedContext['baseline_capture'] ?? null) ? $updatedContext['baseline_capture'] : [],
[
'reason_code' => BaselineReasonCodes::CAPTURE_ZERO_SUBJECTS,
'subjects_total' => 0,
'inventory_sync_run_id' => $latestInventorySyncRunId,
'evidence_capture' => $phaseStats,
'gaps' => [
'count' => $gapsCount,
'by_reason' => $gapsByReason,
'subjects' => is_array($phaseResult['gap_subjects'] ?? null) && $phaseResult['gap_subjects'] !== []
? array_values($phaseResult['gap_subjects'])
: null,
],
'resume_token' => $resumeToken,
'current_baseline_changed' => false,
'previous_current_snapshot_id' => $previousCurrentSnapshotId,
'previous_current_snapshot_exists' => $previousCurrentSnapshotExists,
],
);
$updatedContext['result'] = array_merge(
is_array($updatedContext['result'] ?? null) ? $updatedContext['result'] : [],
[
'snapshot_id' => (int) $snapshot->getKey(),
'snapshot_identity_hash' => $identityHash,
'was_new_snapshot' => $wasNewSnapshot,
'items_captured' => 0,
'snapshot_lifecycle' => $snapshot->lifecycleState()->value,
'current_baseline_changed' => false,
],
);
$this->operationRun->update([
'context' => $updatedContext,
'summary_counts' => $summaryCounts,
]);
$this->operationRun->refresh();
$operationRunService->updateRun(
$this->operationRun,
status: OperationRunStatus::Completed->value,
outcome: OperationRunOutcome::PartiallySucceeded->value,
summaryCounts: $summaryCounts,
);
$this->operationRun->refresh();
$this->auditCompleted(
auditLogger: $auditLogger,
tenant: $sourceTenant,
profile: $profile,
snapshot: $snapshot,
initiator: $initiator,
captureMode: $captureMode,
subjectsTotal: 0,
inventorySyncRunId: $latestInventorySyncRunId,
wasNewSnapshot: $wasNewSnapshot,
evidenceCaptureStats: $phaseStats,
gaps: [
'count' => $gapsCount,
'by_reason' => $gapsByReason,
],
currentBaselineChanged: false,
reasonCode: BaselineReasonCodes::CAPTURE_ZERO_SUBJECTS,
);
return;
}
$snapshotResult = $this->captureSnapshotArtifact( $snapshotResult = $this->captureSnapshotArtifact(
$profile, $profile,
$identityHash, $identityHash,
@ -236,6 +447,9 @@ public function handle(
$profile->update(['active_snapshot_id' => $snapshot->getKey()]); $profile->update(['active_snapshot_id' => $snapshot->getKey()]);
} }
$profile->refresh();
$currentBaselineChanged = $this->currentBaselineChanged($profile, $previousCurrentSnapshotId);
$warningsRecorded = $gapsByReason !== [] || $resumeToken !== null; $warningsRecorded = $gapsByReason !== [] || $resumeToken !== null;
$warningsRecorded = $warningsRecorded || ($captureMode === BaselineCaptureMode::FullContent && ($snapshotItems['fidelity_counts']['meta'] ?? 0) > 0); $warningsRecorded = $warningsRecorded || ($captureMode === BaselineCaptureMode::FullContent && ($snapshotItems['fidelity_counts']['meta'] ?? 0) > 0);
$outcome = $warningsRecorded ? OperationRunOutcome::PartiallySucceeded->value : OperationRunOutcome::Succeeded->value; $outcome = $warningsRecorded ? OperationRunOutcome::PartiallySucceeded->value : OperationRunOutcome::Succeeded->value;
@ -269,6 +483,9 @@ public function handle(
: null, : null,
], ],
'resume_token' => $resumeToken, 'resume_token' => $resumeToken,
'current_baseline_changed' => $currentBaselineChanged,
'previous_current_snapshot_id' => $previousCurrentSnapshotId,
'previous_current_snapshot_exists' => $previousCurrentSnapshotExists,
], ],
); );
$updatedContext['result'] = [ $updatedContext['result'] = [
@ -277,6 +494,7 @@ public function handle(
'was_new_snapshot' => $wasNewSnapshot, 'was_new_snapshot' => $wasNewSnapshot,
'items_captured' => $snapshotItems['items_count'], 'items_captured' => $snapshotItems['items_count'],
'snapshot_lifecycle' => $snapshot->lifecycleState()->value, 'snapshot_lifecycle' => $snapshot->lifecycleState()->value,
'current_baseline_changed' => $currentBaselineChanged,
]; ];
$this->operationRun->update(['context' => $updatedContext]); $this->operationRun->update(['context' => $updatedContext]);
@ -295,6 +513,8 @@ public function handle(
'count' => $gapsCount, 'count' => $gapsCount,
'by_reason' => $gapsByReason, 'by_reason' => $gapsByReason,
], ],
currentBaselineChanged: $currentBaselineChanged,
reasonCode: null,
); );
} }
@ -651,6 +871,51 @@ private function captureSnapshotArtifact(
} }
} }
/**
* @param array<string, mixed> $summaryJsonb
* @return array{snapshot: BaselineSnapshot, was_new_snapshot: bool}
*/
private function captureNoDataSnapshotArtifact(
BaselineProfile $profile,
string $identityHash,
array $summaryJsonb,
): array {
$snapshot = $this->createBuildingSnapshot($profile, $identityHash, $summaryJsonb, 0);
$this->rememberSnapshotOnRun(
snapshot: $snapshot,
identityHash: $identityHash,
wasNewSnapshot: true,
expectedItems: 0,
persistedItems: 0,
reasonCode: BaselineReasonCodes::CAPTURE_ZERO_SUBJECTS,
);
$snapshot->markIncomplete(BaselineReasonCodes::CAPTURE_ZERO_SUBJECTS, [
'expected_identity_hash' => $identityHash,
'expected_items' => 0,
'persisted_items' => 0,
'producer_run_id' => (int) $this->operationRun->getKey(),
'was_empty_capture' => true,
]);
$snapshot->refresh();
$this->rememberSnapshotOnRun(
snapshot: $snapshot,
identityHash: $identityHash,
wasNewSnapshot: true,
expectedItems: 0,
persistedItems: 0,
reasonCode: BaselineReasonCodes::CAPTURE_ZERO_SUBJECTS,
);
return [
'snapshot' => $snapshot,
'was_new_snapshot' => true,
];
}
private function findExistingConsumableSnapshot(BaselineProfile $profile, string $identityHash): ?BaselineSnapshot private function findExistingConsumableSnapshot(BaselineProfile $profile, string $identityHash): ?BaselineSnapshot
{ {
$existing = BaselineSnapshot::query() $existing = BaselineSnapshot::query()
@ -783,6 +1048,32 @@ private function countByPolicyType(array $items): array
return $counts; return $counts;
} }
private function currentBaselineChanged(BaselineProfile $profile, ?int $previousCurrentSnapshotId): bool
{
$currentSnapshot = $profile->resolveCurrentConsumableSnapshot();
$currentSnapshotId = $currentSnapshot instanceof BaselineSnapshot
? (int) $currentSnapshot->getKey()
: null;
return $currentSnapshotId !== null && $currentSnapshotId !== $previousCurrentSnapshotId;
}
private function blockedInventoryMessage(string $reasonCode, bool $changedAfterEnqueue): string
{
return match ($reasonCode) {
BaselineReasonCodes::CAPTURE_INVENTORY_BLOCKED => $changedAfterEnqueue
? 'Capture blocked because the latest inventory sync changed after the run was queued.'
: 'Capture blocked because the latest inventory sync was blocked.',
BaselineReasonCodes::CAPTURE_INVENTORY_FAILED => $changedAfterEnqueue
? 'Capture blocked because the latest inventory sync failed after the run was queued.'
: 'Capture blocked because the latest inventory sync failed.',
BaselineReasonCodes::CAPTURE_UNUSABLE_COVERAGE => $changedAfterEnqueue
? 'Capture blocked because the latest inventory coverage became unusable after the run was queued.'
: 'Capture blocked because the latest inventory coverage was not usable for this baseline scope.',
default => 'Capture blocked because no credible inventory basis was available.',
};
}
private function auditStarted( private function auditStarted(
AuditLogger $auditLogger, AuditLogger $auditLogger,
Tenant $tenant, Tenant $tenant,
@ -820,7 +1111,7 @@ private function auditCompleted(
AuditLogger $auditLogger, AuditLogger $auditLogger,
Tenant $tenant, Tenant $tenant,
BaselineProfile $profile, BaselineProfile $profile,
BaselineSnapshot $snapshot, ?BaselineSnapshot $snapshot,
?User $initiator, ?User $initiator,
BaselineCaptureMode $captureMode, BaselineCaptureMode $captureMode,
int $subjectsTotal, int $subjectsTotal,
@ -828,6 +1119,8 @@ private function auditCompleted(
bool $wasNewSnapshot, bool $wasNewSnapshot,
array $evidenceCaptureStats, array $evidenceCaptureStats,
array $gaps, array $gaps,
bool $currentBaselineChanged,
?string $reasonCode,
): void { ): void {
$auditLogger->log( $auditLogger->log(
tenant: $tenant, tenant: $tenant,
@ -841,8 +1134,10 @@ private function auditCompleted(
'capture_mode' => $captureMode->value, 'capture_mode' => $captureMode->value,
'inventory_sync_run_id' => $inventorySyncRunId, 'inventory_sync_run_id' => $inventorySyncRunId,
'subjects_total' => $subjectsTotal, 'subjects_total' => $subjectsTotal,
'snapshot_id' => (int) $snapshot->getKey(), 'snapshot_id' => $snapshot?->getKey(),
'snapshot_identity_hash' => (string) $snapshot->snapshot_identity_hash, 'snapshot_identity_hash' => $snapshot instanceof BaselineSnapshot ? (string) $snapshot->snapshot_identity_hash : null,
'reason_code' => $reasonCode,
'current_baseline_changed' => $currentBaselineChanged,
'was_new_snapshot' => $wasNewSnapshot, 'was_new_snapshot' => $wasNewSnapshot,
'evidence_capture' => $evidenceCaptureStats, 'evidence_capture' => $evidenceCaptureStats,
'gaps' => $gaps, 'gaps' => $gaps,
@ -878,17 +1173,4 @@ private function mergeGapCounts(array ...$gaps): array
return $merged; return $merged;
} }
private function resolveLatestInventorySyncRun(Tenant $tenant): ?OperationRun
{
$run = OperationRun::query()
->where('tenant_id', (int) $tenant->getKey())
->where('type', OperationRunType::InventorySync->value)
->where('status', OperationRunStatus::Completed->value)
->orderByDesc('completed_at')
->orderByDesc('id')
->first();
return $run instanceof OperationRun ? $run : null;
}
} }

View File

@ -4,7 +4,6 @@
use App\Models\OperationRun; use App\Models\OperationRun;
use App\Models\Tenant; use App\Models\Tenant;
use App\Support\OpsUx\ActiveRuns;
use App\Support\OpsUx\OpsUxBrowserEvents; use App\Support\OpsUx\OpsUxBrowserEvents;
use Filament\Facades\Filament; use Filament\Facades\Filament;
use Illuminate\Support\Collection; use Illuminate\Support\Collection;
@ -86,13 +85,13 @@ public function refreshRuns(): void
$query = OperationRun::query() $query = OperationRun::query()
->where('tenant_id', $tenantId) ->where('tenant_id', $tenantId)
->healthyActive() ->active()
->orderByDesc('created_at'); ->orderByDesc('created_at');
$activeCount = (clone $query)->count(); $activeCount = (clone $query)->count();
$this->runs = (clone $query)->limit(6)->get(); $this->runs = (clone $query)->limit(6)->get();
$this->overflowCount = max(0, $activeCount - 5); $this->overflowCount = max(0, $activeCount - 5);
$this->hasActiveRuns = ActiveRuns::existForTenantId($tenantId); $this->hasActiveRuns = $activeCount > 0;
} }
public function render(): \Illuminate\Contracts\View\View public function render(): \Illuminate\Contracts\View\View

View File

@ -20,21 +20,6 @@ class BaselineProfile extends Model
{ {
use HasFactory; use HasFactory;
/**
* @deprecated Use BaselineProfileStatus::Draft instead.
*/
public const string STATUS_DRAFT = 'draft';
/**
* @deprecated Use BaselineProfileStatus::Active instead.
*/
public const string STATUS_ACTIVE = 'active';
/**
* @deprecated Use BaselineProfileStatus::Archived instead.
*/
public const string STATUS_ARCHIVED = 'archived';
/** @var list<string> */ /** @var list<string> */
protected $fillable = [ protected $fillable = [
'workspace_id', 'workspace_id',

View File

@ -47,6 +47,32 @@ class Finding extends Model
public const string STATUS_RISK_ACCEPTED = 'risk_accepted'; public const string STATUS_RISK_ACCEPTED = 'risk_accepted';
public const string RESOLVE_REASON_REMEDIATED = 'remediated';
public const string RESOLVE_REASON_NO_LONGER_DRIFTING = 'no_longer_drifting';
public const string RESOLVE_REASON_PERMISSION_GRANTED = 'permission_granted';
public const string RESOLVE_REASON_PERMISSION_REMOVED_FROM_REGISTRY = 'permission_removed_from_registry';
public const string RESOLVE_REASON_ROLE_ASSIGNMENT_REMOVED = 'role_assignment_removed';
public const string RESOLVE_REASON_GA_COUNT_WITHIN_THRESHOLD = 'ga_count_within_threshold';
public const string CLOSE_REASON_FALSE_POSITIVE = 'false_positive';
public const string CLOSE_REASON_DUPLICATE = 'duplicate';
public const string CLOSE_REASON_NO_LONGER_APPLICABLE = 'no_longer_applicable';
public const string CLOSE_REASON_ACCEPTED_RISK = 'accepted_risk';
public const string REOPEN_REASON_RECURRED_AFTER_RESOLUTION = 'recurred_after_resolution';
public const string REOPEN_REASON_VERIFICATION_FAILED = 'verification_failed';
public const string REOPEN_REASON_MANUAL_REASSESSMENT = 'manual_reassessment';
public const string RESPONSIBILITY_STATE_ORPHANED_ACCOUNTABILITY = 'orphaned_accountability'; public const string RESPONSIBILITY_STATE_ORPHANED_ACCOUNTABILITY = 'orphaned_accountability';
public const string RESPONSIBILITY_STATE_OWNED_UNASSIGNED = 'owned_unassigned'; public const string RESPONSIBILITY_STATE_OWNED_UNASSIGNED = 'owned_unassigned';
@ -160,6 +186,113 @@ public static function highSeverityValues(): array
]; ];
} }
/**
* @return array<int, string>
*/
public static function manualResolveReasonKeys(): array
{
return [
self::RESOLVE_REASON_REMEDIATED,
];
}
/**
* @return array<int, string>
*/
public static function systemResolveReasonKeys(): array
{
return [
self::RESOLVE_REASON_NO_LONGER_DRIFTING,
self::RESOLVE_REASON_PERMISSION_GRANTED,
self::RESOLVE_REASON_PERMISSION_REMOVED_FROM_REGISTRY,
self::RESOLVE_REASON_ROLE_ASSIGNMENT_REMOVED,
self::RESOLVE_REASON_GA_COUNT_WITHIN_THRESHOLD,
];
}
/**
* @return array<int, string>
*/
public static function resolveReasonKeys(): array
{
return [
...self::manualResolveReasonKeys(),
...self::systemResolveReasonKeys(),
];
}
/**
* @return array<int, string>
*/
public static function closeReasonKeys(): array
{
return [
self::CLOSE_REASON_FALSE_POSITIVE,
self::CLOSE_REASON_DUPLICATE,
self::CLOSE_REASON_NO_LONGER_APPLICABLE,
self::CLOSE_REASON_ACCEPTED_RISK,
];
}
/**
* @return array<int, string>
*/
public static function manualCloseReasonKeys(): array
{
return [
self::CLOSE_REASON_FALSE_POSITIVE,
self::CLOSE_REASON_DUPLICATE,
self::CLOSE_REASON_NO_LONGER_APPLICABLE,
];
}
/**
* @return array<int, string>
*/
public static function reopenReasonKeys(): array
{
return [
self::REOPEN_REASON_RECURRED_AFTER_RESOLUTION,
self::REOPEN_REASON_VERIFICATION_FAILED,
self::REOPEN_REASON_MANUAL_REASSESSMENT,
];
}
public static function isResolveReason(?string $reason): bool
{
return is_string($reason) && in_array($reason, self::resolveReasonKeys(), true);
}
public static function isManualResolveReason(?string $reason): bool
{
return is_string($reason) && in_array($reason, self::manualResolveReasonKeys(), true);
}
public static function isSystemResolveReason(?string $reason): bool
{
return is_string($reason) && in_array($reason, self::systemResolveReasonKeys(), true);
}
public static function isCloseReason(?string $reason): bool
{
return is_string($reason) && in_array($reason, self::closeReasonKeys(), true);
}
public static function isManualCloseReason(?string $reason): bool
{
return is_string($reason) && in_array($reason, self::manualCloseReasonKeys(), true);
}
public static function isRiskAcceptedReason(?string $reason): bool
{
return $reason === self::CLOSE_REASON_ACCEPTED_RISK;
}
public static function isReopenReason(?string $reason): bool
{
return is_string($reason) && in_array($reason, self::reopenReasonKeys(), true);
}
public static function canonicalizeStatus(?string $status): ?string public static function canonicalizeStatus(?string $status): ?string
{ {
if ($status === self::STATUS_ACKNOWLEDGED) { if ($status === self::STATUS_ACKNOWLEDGED) {

View File

@ -25,12 +25,17 @@ public function toDatabase(object $notifiable): array
{ {
$message = OperationUxPresenter::terminalDatabaseNotificationMessage($this->run, $notifiable); $message = OperationUxPresenter::terminalDatabaseNotificationMessage($this->run, $notifiable);
$reasonEnvelope = app(ReasonPresenter::class)->forOperationRun($this->run, 'notification'); $reasonEnvelope = app(ReasonPresenter::class)->forOperationRun($this->run, 'notification');
$baselineTruthChanged = data_get($this->run->context, 'baseline_capture.current_baseline_changed');
if ($reasonEnvelope !== null) { if ($reasonEnvelope !== null) {
$message['reason_translation'] = $reasonEnvelope->toArray(); $message['reason_translation'] = $reasonEnvelope->toArray();
$message['diagnostic_reason_code'] = $reasonEnvelope->diagnosticCode(); $message['diagnostic_reason_code'] = $reasonEnvelope->diagnosticCode();
} }
if (is_bool($baselineTruthChanged)) {
$message['baseline_truth_changed'] = $baselineTruthChanged;
}
return $message; return $message;
} }
} }

View File

@ -16,6 +16,9 @@
use App\Support\Baselines\BaselineReasonCodes; use App\Support\Baselines\BaselineReasonCodes;
use App\Support\Baselines\BaselineScope; use App\Support\Baselines\BaselineScope;
use App\Support\Baselines\BaselineSupportCapabilityGuard; use App\Support\Baselines\BaselineSupportCapabilityGuard;
use App\Support\Inventory\InventoryCoverage;
use App\Support\OperationRunOutcome;
use App\Support\OperationRunStatus;
use App\Support\OperationRunType; use App\Support\OperationRunType;
use InvalidArgumentException; use InvalidArgumentException;
@ -62,6 +65,16 @@ public function startCapture(
]; ];
} }
$truthfulTypes = $effectiveScope->toEffectiveScopeContext($this->capabilityGuard, 'capture')['truthful_types'] ?? null;
$inventoryEligibility = $this->latestInventoryEligibilityDecision($sourceTenant, $effectiveScope, is_array($truthfulTypes) ? $truthfulTypes : null);
if (! $inventoryEligibility['ok']) {
return [
'ok' => false,
'reason_code' => $inventoryEligibility['reason_code'],
];
}
$captureMode = $profile->capture_mode instanceof BaselineCaptureMode $captureMode = $profile->capture_mode instanceof BaselineCaptureMode
? $profile->capture_mode ? $profile->capture_mode
: BaselineCaptureMode::Opportunistic; : BaselineCaptureMode::Opportunistic;
@ -75,6 +88,10 @@ public function startCapture(
'source_tenant_id' => (int) $sourceTenant->getKey(), 'source_tenant_id' => (int) $sourceTenant->getKey(),
'effective_scope' => $effectiveScope->toEffectiveScopeContext($this->capabilityGuard, 'capture'), 'effective_scope' => $effectiveScope->toEffectiveScopeContext($this->capabilityGuard, 'capture'),
'capture_mode' => $captureMode->value, 'capture_mode' => $captureMode->value,
'baseline_capture' => [
'inventory_sync_run_id' => $inventoryEligibility['inventory_sync_run_id'],
'eligibility' => $this->eligibilityContextPayload($inventoryEligibility, phase: 'preflight'),
],
]; ];
$run = $this->runs->ensureRunWithIdentity( $run = $this->runs->ensureRunWithIdentity(
@ -114,4 +131,134 @@ private function validatePreconditions(BaselineProfile $profile, Tenant $sourceT
return null; return null;
} }
/**
* @param list<string>|null $truthfulTypes
* @return array{
* ok: bool,
* reason_code: ?string,
* inventory_sync_run_id: ?int,
* inventory_outcome: ?string,
* effective_types: list<string>,
* covered_types: list<string>,
* uncovered_types: list<string>
* }
*/
public function latestInventoryEligibilityDecision(
Tenant $sourceTenant,
BaselineScope $effectiveScope,
?array $truthfulTypes = null,
): array {
$effectiveTypes = is_array($truthfulTypes) && $truthfulTypes !== []
? array_values(array_unique(array_filter($truthfulTypes, 'is_string')))
: $effectiveScope->allTypes();
sort($effectiveTypes, SORT_STRING);
$run = OperationRun::query()
->where('tenant_id', (int) $sourceTenant->getKey())
->where('type', OperationRunType::InventorySync->value)
->where('status', OperationRunStatus::Completed->value)
->orderByDesc('completed_at')
->orderByDesc('id')
->first();
if (! $run instanceof OperationRun) {
return [
'ok' => false,
'reason_code' => BaselineReasonCodes::CAPTURE_INVENTORY_MISSING,
'inventory_sync_run_id' => null,
'inventory_outcome' => null,
'effective_types' => $effectiveTypes,
'covered_types' => [],
'uncovered_types' => $effectiveTypes,
];
}
$outcome = is_string($run->outcome) ? trim($run->outcome) : null;
if ($outcome === OperationRunOutcome::Blocked->value) {
return [
'ok' => false,
'reason_code' => BaselineReasonCodes::CAPTURE_INVENTORY_BLOCKED,
'inventory_sync_run_id' => (int) $run->getKey(),
'inventory_outcome' => $outcome,
'effective_types' => $effectiveTypes,
'covered_types' => [],
'uncovered_types' => $effectiveTypes,
];
}
if ($outcome === OperationRunOutcome::Failed->value) {
return [
'ok' => false,
'reason_code' => BaselineReasonCodes::CAPTURE_INVENTORY_FAILED,
'inventory_sync_run_id' => (int) $run->getKey(),
'inventory_outcome' => $outcome,
'effective_types' => $effectiveTypes,
'covered_types' => [],
'uncovered_types' => $effectiveTypes,
];
}
$coverage = InventoryCoverage::fromContext($run->context);
$coveredTypes = $coverage instanceof InventoryCoverage
? array_values(array_intersect($effectiveTypes, $coverage->coveredTypes()))
: [];
sort($coveredTypes, SORT_STRING);
$uncoveredTypes = array_values(array_diff($effectiveTypes, $coveredTypes));
sort($uncoveredTypes, SORT_STRING);
if ($coveredTypes === []) {
return [
'ok' => false,
'reason_code' => BaselineReasonCodes::CAPTURE_UNUSABLE_COVERAGE,
'inventory_sync_run_id' => (int) $run->getKey(),
'inventory_outcome' => $outcome,
'effective_types' => $effectiveTypes,
'covered_types' => [],
'uncovered_types' => $effectiveTypes,
];
}
return [
'ok' => true,
'reason_code' => null,
'inventory_sync_run_id' => (int) $run->getKey(),
'inventory_outcome' => $outcome,
'effective_types' => $effectiveTypes,
'covered_types' => $coveredTypes,
'uncovered_types' => $uncoveredTypes,
];
}
/**
* @param array{
* ok: bool,
* reason_code: ?string,
* inventory_sync_run_id: ?int,
* inventory_outcome: ?string,
* effective_types: list<string>,
* covered_types: list<string>,
* uncovered_types: list<string>
* } $decision
* @return array<string, mixed>
*/
public function eligibilityContextPayload(array $decision, string $phase): array
{
return [
'phase' => $phase,
'ok' => (bool) ($decision['ok'] ?? false),
'reason_code' => is_string($decision['reason_code'] ?? null) ? $decision['reason_code'] : null,
'inventory_sync_run_id' => is_numeric($decision['inventory_sync_run_id'] ?? null)
? (int) $decision['inventory_sync_run_id']
: null,
'inventory_outcome' => is_string($decision['inventory_outcome'] ?? null) ? $decision['inventory_outcome'] : null,
'effective_types' => array_values(array_filter((array) ($decision['effective_types'] ?? []), 'is_string')),
'covered_types' => array_values(array_filter((array) ($decision['covered_types'] ?? []), 'is_string')),
'uncovered_types' => array_values(array_filter((array) ($decision['uncovered_types'] ?? []), 'is_string')),
];
}
} }

View File

@ -213,6 +213,12 @@ public function buildSnapshotPayload(Tenant $tenant): array
'state' => $item['state'], 'state' => $item['state'],
'required' => $item['required'], 'required' => $item['required'],
], $items), ], $items),
'finding_outcomes' => is_array($findingsSummary['outcome_counts'] ?? null)
? $findingsSummary['outcome_counts']
: [],
'finding_report_buckets' => is_array($findingsSummary['report_bucket_counts'] ?? null)
? $findingsSummary['report_bucket_counts']
: [],
'risk_acceptance' => is_array($findingsSummary['risk_acceptance'] ?? null) 'risk_acceptance' => is_array($findingsSummary['risk_acceptance'] ?? null)
? $findingsSummary['risk_acceptance'] ? $findingsSummary['risk_acceptance']
: [ : [

View File

@ -8,12 +8,14 @@
use App\Models\Tenant; use App\Models\Tenant;
use App\Services\Evidence\Contracts\EvidenceSourceProvider; use App\Services\Evidence\Contracts\EvidenceSourceProvider;
use App\Services\Findings\FindingRiskGovernanceResolver; use App\Services\Findings\FindingRiskGovernanceResolver;
use App\Support\Findings\FindingOutcomeSemantics;
use App\Support\Evidence\EvidenceCompletenessState; use App\Support\Evidence\EvidenceCompletenessState;
final class FindingsSummarySource implements EvidenceSourceProvider final class FindingsSummarySource implements EvidenceSourceProvider
{ {
public function __construct( public function __construct(
private readonly FindingRiskGovernanceResolver $governanceResolver, private readonly FindingRiskGovernanceResolver $governanceResolver,
private readonly FindingOutcomeSemantics $findingOutcomeSemantics,
) {} ) {}
public function key(): string public function key(): string
@ -33,6 +35,7 @@ public function collect(Tenant $tenant): array
$entries = $findings->map(function (Finding $finding): array { $entries = $findings->map(function (Finding $finding): array {
$governanceState = $this->governanceResolver->resolveFindingState($finding, $finding->findingException); $governanceState = $this->governanceResolver->resolveFindingState($finding, $finding->findingException);
$governanceWarning = $this->governanceResolver->resolveWarningMessage($finding, $finding->findingException); $governanceWarning = $this->governanceResolver->resolveWarningMessage($finding, $finding->findingException);
$outcome = $this->findingOutcomeSemantics->describe($finding);
return [ return [
'id' => (int) $finding->getKey(), 'id' => (int) $finding->getKey(),
@ -43,10 +46,42 @@ public function collect(Tenant $tenant): array
'description' => $finding->description, 'description' => $finding->description,
'created_at' => $finding->created_at?->toIso8601String(), 'created_at' => $finding->created_at?->toIso8601String(),
'updated_at' => $finding->updated_at?->toIso8601String(), 'updated_at' => $finding->updated_at?->toIso8601String(),
'verification_state' => $outcome['verification_state'],
'report_bucket' => $outcome['report_bucket'],
'terminal_outcome_key' => $outcome['terminal_outcome_key'],
'terminal_outcome_label' => $outcome['label'],
'terminal_outcome' => $outcome['terminal_outcome_key'] !== null ? [
'key' => $outcome['terminal_outcome_key'],
'label' => $outcome['label'],
'verification_state' => $outcome['verification_state'],
'report_bucket' => $outcome['report_bucket'],
'governance_state' => $governanceState,
] : null,
'governance_state' => $governanceState, 'governance_state' => $governanceState,
'governance_warning' => $governanceWarning, 'governance_warning' => $governanceWarning,
]; ];
}); });
$outcomeCounts = array_fill_keys($this->findingOutcomeSemantics->orderedOutcomeKeys(), 0);
$reportBucketCounts = [
'remediation_pending_verification' => 0,
'remediation_verified' => 0,
'administrative_closure' => 0,
'accepted_risk' => 0,
];
foreach ($entries as $entry) {
$terminalOutcomeKey = $entry['terminal_outcome_key'] ?? null;
$reportBucket = $entry['report_bucket'] ?? null;
if (is_string($terminalOutcomeKey) && array_key_exists($terminalOutcomeKey, $outcomeCounts)) {
$outcomeCounts[$terminalOutcomeKey]++;
}
if (is_string($reportBucket) && array_key_exists($reportBucket, $reportBucketCounts)) {
$reportBucketCounts[$reportBucket]++;
}
}
$riskAcceptedEntries = $entries->filter( $riskAcceptedEntries = $entries->filter(
static fn (array $entry): bool => ($entry['status'] ?? null) === Finding::STATUS_RISK_ACCEPTED, static fn (array $entry): bool => ($entry['status'] ?? null) === Finding::STATUS_RISK_ACCEPTED,
); );
@ -78,6 +113,8 @@ public function collect(Tenant $tenant): array
'revoked_count' => $riskAcceptedEntries->where('governance_state', 'revoked_exception')->count(), 'revoked_count' => $riskAcceptedEntries->where('governance_state', 'revoked_exception')->count(),
'missing_exception_count' => $riskAcceptedEntries->where('governance_state', 'risk_accepted_without_valid_exception')->count(), 'missing_exception_count' => $riskAcceptedEntries->where('governance_state', 'risk_accepted_without_valid_exception')->count(),
], ],
'outcome_counts' => $outcomeCounts,
'report_bucket_counts' => $reportBucketCounts,
'entries' => $entries->all(), 'entries' => $entries->all(),
]; ];

View File

@ -68,12 +68,27 @@ public function issueQuery(
string $reasonFilter = self::FILTER_ALL, string $reasonFilter = self::FILTER_ALL,
bool $applyOrdering = true, bool $applyOrdering = true,
): Builder { ): Builder {
$visibleTenants = $this->visibleTenants($workspace, $user); return $this->issueQueryForVisibleTenantIds(
$visibleTenantIds = array_map( $workspace,
static fn (Tenant $tenant): int => (int) $tenant->getKey(), $this->visibleTenantIds($workspace, $user),
$visibleTenants, $tenantId,
$reasonFilter,
$applyOrdering,
); );
}
/**
* @param array<int, int> $visibleTenantIds
* @return Builder<Finding>
*/
private function issueQueryForVisibleTenantIds(
Workspace $workspace,
array $visibleTenantIds,
?int $tenantId = null,
string $reasonFilter = self::FILTER_ALL,
bool $applyOrdering = true,
): Builder {
if ($tenantId !== null && ! in_array($tenantId, $visibleTenantIds, true)) { if ($tenantId !== null && ! in_array($tenantId, $visibleTenantIds, true)) {
$visibleTenantIds = []; $visibleTenantIds = [];
} elseif ($tenantId !== null) { } elseif ($tenantId !== null) {
@ -155,9 +170,22 @@ function ($join): void {
*/ */
public function summary(Workspace $workspace, User $user, ?int $tenantId = null): array public function summary(Workspace $workspace, User $user, ?int $tenantId = null): array
{ {
$allIssues = $this->issueQuery($workspace, $user, $tenantId, self::FILTER_ALL, applyOrdering: false); return $this->summaryForVisibleTenantIds(
$brokenAssignments = $this->issueQuery($workspace, $user, $tenantId, self::REASON_BROKEN_ASSIGNMENT, applyOrdering: false); $workspace,
$staleInProgress = $this->issueQuery($workspace, $user, $tenantId, self::REASON_STALE_IN_PROGRESS, applyOrdering: false); $this->visibleTenantIds($workspace, $user),
$tenantId,
);
}
/**
* @param array<int, int> $visibleTenantIds
* @return array{unique_issue_count: int, broken_assignment_count: int, stale_in_progress_count: int}
*/
public function summaryForVisibleTenantIds(Workspace $workspace, array $visibleTenantIds, ?int $tenantId = null): array
{
$allIssues = $this->issueQueryForVisibleTenantIds($workspace, $visibleTenantIds, $tenantId, self::FILTER_ALL, applyOrdering: false);
$brokenAssignments = $this->issueQueryForVisibleTenantIds($workspace, $visibleTenantIds, $tenantId, self::REASON_BROKEN_ASSIGNMENT, applyOrdering: false);
$staleInProgress = $this->issueQueryForVisibleTenantIds($workspace, $visibleTenantIds, $tenantId, self::REASON_STALE_IN_PROGRESS, applyOrdering: false);
return [ return [
'unique_issue_count' => (clone $allIssues)->count(), 'unique_issue_count' => (clone $allIssues)->count(),
@ -166,6 +194,17 @@ public function summary(Workspace $workspace, User $user, ?int $tenantId = null)
]; ];
} }
/**
* @return array<int, int>
*/
public function visibleTenantIds(Workspace $workspace, User $user): array
{
return array_map(
static fn (Tenant $tenant): int => (int) $tenant->getKey(),
$this->visibleTenants($workspace, $user),
);
}
/** /**
* @return array<string, string> * @return array<string, string>
*/ */

View File

@ -857,7 +857,7 @@ private function evidenceSummary(array $references): array
private function findingRiskAcceptedReason(string $approvalReason): string private function findingRiskAcceptedReason(string $approvalReason): string
{ {
return mb_substr($approvalReason, 0, 255); return Finding::CLOSE_REASON_ACCEPTED_RISK;
} }
private function metadataDate(FindingException $exception, string $key): ?CarbonImmutable private function metadataDate(FindingException $exception, string $key): ?CarbonImmutable

View File

@ -7,11 +7,16 @@
use App\Models\Finding; use App\Models\Finding;
use App\Models\FindingException; use App\Models\FindingException;
use App\Models\FindingExceptionDecision; use App\Models\FindingExceptionDecision;
use App\Support\Findings\FindingOutcomeSemantics;
use Carbon\CarbonImmutable; use Carbon\CarbonImmutable;
use Illuminate\Support\Carbon; use Illuminate\Support\Carbon;
final class FindingRiskGovernanceResolver final class FindingRiskGovernanceResolver
{ {
public function __construct(
private readonly FindingOutcomeSemantics $findingOutcomeSemantics,
) {}
public function resolveWorkflowFamily(Finding $finding): string public function resolveWorkflowFamily(Finding $finding): string
{ {
return match (Finding::canonicalizeStatus((string) $finding->status)) { return match (Finding::canonicalizeStatus((string) $finding->status)) {
@ -218,11 +223,7 @@ public function resolvePrimaryNarrative(Finding $finding, ?FindingException $exc
'accepted_risk' => $this->resolveGovernanceAttention($finding, $exception, $now) === 'healthy' 'accepted_risk' => $this->resolveGovernanceAttention($finding, $exception, $now) === 'healthy'
? 'Accepted risk remains visible because current governance is still valid.' ? '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.', : '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) { 'historical' => $this->historicalPrimaryNarrative($finding),
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 => match ($finding->responsibilityState()) { default => match ($finding->responsibilityState()) {
Finding::RESPONSIBILITY_STATE_ORPHANED_ACCOUNTABILITY => 'This finding is still active workflow work and currently has orphaned accountability.', Finding::RESPONSIBILITY_STATE_ORPHANED_ACCOUNTABILITY => 'This finding is still active workflow work and currently has orphaned accountability.',
Finding::RESPONSIBILITY_STATE_OWNED_UNASSIGNED => 'This finding has an accountable owner, but the active remediation work is still unassigned.', Finding::RESPONSIBILITY_STATE_OWNED_UNASSIGNED => 'This finding has an accountable owner, but the active remediation work is still unassigned.',
@ -253,8 +254,14 @@ public function resolvePrimaryNextAction(Finding $finding, ?FindingException $ex
}; };
} }
if ((string) $finding->status === Finding::STATUS_RESOLVED || (string) $finding->status === Finding::STATUS_CLOSED) { if ((string) $finding->status === Finding::STATUS_RESOLVED) {
return 'Review the closure context and reopen the finding if the issue has returned or governance has lapsed.'; return $this->findingOutcomeSemantics->verificationState($finding) === FindingOutcomeSemantics::VERIFICATION_PENDING
? 'Wait for later trusted evidence to confirm the issue is actually clear, or reopen the finding if verification still fails.'
: 'Keep the finding closed unless later trusted evidence shows the issue has returned.';
}
if ((string) $finding->status === Finding::STATUS_CLOSED) {
return 'Review the administrative closure context and reopen the finding if the tenant reality no longer matches that decision.';
} }
return match ($finding->responsibilityState()) { return match ($finding->responsibilityState()) {
@ -340,23 +347,33 @@ private function renewalAwareDate(FindingException $exception, string $metadataK
private function resolvedHistoricalContext(Finding $finding): ?string private function resolvedHistoricalContext(Finding $finding): ?string
{ {
$reason = (string) ($finding->resolved_reason ?? ''); return match ($this->findingOutcomeSemantics->terminalOutcomeKey($finding)) {
FindingOutcomeSemantics::OUTCOME_RESOLVED_PENDING_VERIFICATION => 'This finding was resolved manually and is still waiting for trusted evidence to confirm the issue is actually gone.',
return match ($reason) { FindingOutcomeSemantics::OUTCOME_VERIFIED_CLEARED => 'Trusted evidence later confirmed the triggering condition was no longer present at the last observed check.',
'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.', default => 'Resolved records the workflow outcome. Review the reason and latest evidence before treating it as technical proof.',
}; };
} }
private function closedHistoricalContext(Finding $finding): ?string private function closedHistoricalContext(Finding $finding): ?string
{ {
return match ((string) ($finding->closed_reason ?? '')) { return match ($this->findingOutcomeSemantics->terminalOutcomeKey($finding)) {
'accepted_risk' => 'Closed reflects workflow handling. Governance validity still determines whether accepted risk remains safe to rely on.', FindingOutcomeSemantics::OUTCOME_CLOSED_FALSE_POSITIVE => 'This finding was closed as a false positive, which is an administrative closure rather than proof of remediation.',
FindingOutcomeSemantics::OUTCOME_CLOSED_DUPLICATE => 'This finding was closed as a duplicate, which is an administrative closure rather than proof of remediation.',
FindingOutcomeSemantics::OUTCOME_CLOSED_NO_LONGER_APPLICABLE => 'This finding was closed as no longer applicable, which is an administrative closure rather than proof of remediation.',
FindingOutcomeSemantics::OUTCOME_RISK_ACCEPTED => '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.', default => 'Closed records the workflow outcome. Review the recorded reason before treating it as technical proof.',
}; };
} }
private function historicalPrimaryNarrative(Finding $finding): string
{
return match ($this->findingOutcomeSemantics->terminalOutcomeKey($finding)) {
FindingOutcomeSemantics::OUTCOME_RESOLVED_PENDING_VERIFICATION => 'Resolved pending verification means an operator declared the remediation complete, but trusted verification has not confirmed it yet.',
FindingOutcomeSemantics::OUTCOME_VERIFIED_CLEARED => 'Verified cleared means trusted evidence later confirmed the issue was no longer present.',
FindingOutcomeSemantics::OUTCOME_CLOSED_FALSE_POSITIVE,
FindingOutcomeSemantics::OUTCOME_CLOSED_DUPLICATE,
FindingOutcomeSemantics::OUTCOME_CLOSED_NO_LONGER_APPLICABLE => 'This finding is closed for an administrative reason and should not be read as a remediation outcome.',
default => 'This finding is historical workflow context.',
};
}
} }

View File

@ -14,6 +14,7 @@
use App\Support\Audit\AuditActionId; use App\Support\Audit\AuditActionId;
use App\Support\Audit\AuditActorType; use App\Support\Audit\AuditActorType;
use App\Support\Auth\Capabilities; use App\Support\Auth\Capabilities;
use App\Support\Findings\FindingOutcomeSemantics;
use Carbon\CarbonImmutable; use Carbon\CarbonImmutable;
use Illuminate\Auth\Access\AuthorizationException; use Illuminate\Auth\Access\AuthorizationException;
use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\DB;
@ -28,6 +29,7 @@ public function __construct(
private readonly AuditLogger $auditLogger, private readonly AuditLogger $auditLogger,
private readonly CapabilityResolver $capabilityResolver, private readonly CapabilityResolver $capabilityResolver,
private readonly FindingNotificationService $findingNotificationService, private readonly FindingNotificationService $findingNotificationService,
private readonly FindingOutcomeSemantics $findingOutcomeSemantics,
) {} ) {}
/** /**
@ -273,7 +275,7 @@ public function resolve(Finding $finding, Tenant $tenant, User $actor, string $r
throw new InvalidArgumentException('Only open findings can be resolved.'); throw new InvalidArgumentException('Only open findings can be resolved.');
} }
$reason = $this->validatedReason($reason, 'resolved_reason'); $reason = $this->validatedReason($reason, 'resolved_reason', Finding::manualResolveReasonKeys());
$now = CarbonImmutable::now(); $now = CarbonImmutable::now();
return $this->mutateAndAudit( return $this->mutateAndAudit(
@ -299,7 +301,7 @@ public function close(Finding $finding, Tenant $tenant, User $actor, string $rea
{ {
$this->authorize($finding, $tenant, $actor, [Capabilities::TENANT_FINDINGS_CLOSE]); $this->authorize($finding, $tenant, $actor, [Capabilities::TENANT_FINDINGS_CLOSE]);
$reason = $this->validatedReason($reason, 'closed_reason'); $reason = $this->validatedReason($reason, 'closed_reason', Finding::manualCloseReasonKeys());
$now = CarbonImmutable::now(); $now = CarbonImmutable::now();
return $this->mutateAndAudit( return $this->mutateAndAudit(
@ -342,7 +344,7 @@ private function riskAcceptWithoutAuthorization(Finding $finding, Tenant $tenant
throw new InvalidArgumentException('Only open findings can be marked as risk accepted.'); throw new InvalidArgumentException('Only open findings can be marked as risk accepted.');
} }
$reason = $this->validatedReason($reason, 'closed_reason'); $reason = $this->validatedReason($reason, 'closed_reason', [Finding::CLOSE_REASON_ACCEPTED_RISK]);
$now = CarbonImmutable::now(); $now = CarbonImmutable::now();
return $this->mutateAndAudit( return $this->mutateAndAudit(
@ -376,7 +378,7 @@ public function reopen(Finding $finding, Tenant $tenant, User $actor, string $re
throw new InvalidArgumentException('Only terminal findings can be reopened.'); throw new InvalidArgumentException('Only terminal findings can be reopened.');
} }
$reason = $this->validatedReason($reason, 'reopen_reason'); $reason = $this->validatedReason($reason, 'reopen_reason', Finding::reopenReasonKeys());
$now = CarbonImmutable::now(); $now = CarbonImmutable::now();
$slaDays = $this->slaPolicy->daysForFinding($finding, $tenant); $slaDays = $this->slaPolicy->daysForFinding($finding, $tenant);
$dueAt = $this->slaPolicy->dueAtForSeverity((string) $finding->severity, $tenant, $now); $dueAt = $this->slaPolicy->dueAtForSeverity((string) $finding->severity, $tenant, $now);
@ -418,11 +420,11 @@ public function resolveBySystem(
): Finding { ): Finding {
$this->assertFindingOwnedByTenant($finding, $tenant); $this->assertFindingOwnedByTenant($finding, $tenant);
if (! $finding->hasOpenStatus()) { if (! $finding->hasOpenStatus() && (string) $finding->status !== Finding::STATUS_RESOLVED) {
throw new InvalidArgumentException('Only open findings can be resolved.'); throw new InvalidArgumentException('Only open or manually resolved findings can be system-cleared.');
} }
$reason = $this->validatedReason($reason, 'resolved_reason'); $reason = $this->validatedReason($reason, 'resolved_reason', Finding::systemResolveReasonKeys());
return $this->mutateAndAudit( return $this->mutateAndAudit(
finding: $finding, finding: $finding,
@ -456,6 +458,7 @@ public function reopenBySystem(
CarbonImmutable $reopenedAt, CarbonImmutable $reopenedAt,
?int $operationRunId = null, ?int $operationRunId = null,
?callable $mutate = null, ?callable $mutate = null,
?string $reason = null,
): Finding { ): Finding {
$this->assertFindingOwnedByTenant($finding, $tenant); $this->assertFindingOwnedByTenant($finding, $tenant);
@ -463,6 +466,11 @@ public function reopenBySystem(
throw new InvalidArgumentException('Only terminal findings can be reopened.'); throw new InvalidArgumentException('Only terminal findings can be reopened.');
} }
$reason = $this->validatedReason(
$reason ?? $this->findingOutcomeSemantics->systemReopenReasonFor($finding),
'reopen_reason',
Finding::reopenReasonKeys(),
);
$slaDays = $this->slaPolicy->daysForFinding($finding, $tenant); $slaDays = $this->slaPolicy->daysForFinding($finding, $tenant);
$dueAt = $this->slaPolicy->dueAtForSeverity((string) $finding->severity, $tenant, $reopenedAt); $dueAt = $this->slaPolicy->dueAtForSeverity((string) $finding->severity, $tenant, $reopenedAt);
@ -474,6 +482,7 @@ public function reopenBySystem(
context: [ context: [
'metadata' => [ 'metadata' => [
'reopened_at' => $reopenedAt->toIso8601String(), 'reopened_at' => $reopenedAt->toIso8601String(),
'reopened_reason' => $reason,
'sla_days' => $slaDays, 'sla_days' => $slaDays,
'due_at' => $dueAt->toIso8601String(), 'due_at' => $dueAt->toIso8601String(),
'system_origin' => true, 'system_origin' => true,
@ -574,7 +583,10 @@ private function assertTenantMemberOrNull(Tenant $tenant, ?int $userId, string $
} }
} }
private function validatedReason(string $reason, string $field): string /**
* @param array<int, string> $allowedReasons
*/
private function validatedReason(string $reason, string $field, array $allowedReasons): string
{ {
$reason = trim($reason); $reason = trim($reason);
@ -586,6 +598,14 @@ private function validatedReason(string $reason, string $field): string
throw new InvalidArgumentException(sprintf('%s must be at most 255 characters.', $field)); throw new InvalidArgumentException(sprintf('%s must be at most 255 characters.', $field));
} }
if (! in_array($reason, $allowedReasons, true)) {
throw new InvalidArgumentException(sprintf(
'%s must be one of: %s.',
$field,
implode(', ', $allowedReasons),
));
}
return $reason; return $reason;
} }
@ -637,12 +657,17 @@ private function mutateAndAudit(
$record->save(); $record->save();
$after = $this->auditSnapshot($record); $after = $this->auditSnapshot($record);
$outcome = $this->findingOutcomeSemantics->describe($record);
$auditMetadata = array_merge($metadata, [ $auditMetadata = array_merge($metadata, [
'finding_id' => (int) $record->getKey(), 'finding_id' => (int) $record->getKey(),
'before_status' => $before['status'] ?? null, 'before_status' => $before['status'] ?? null,
'after_status' => $after['status'] ?? null, 'after_status' => $after['status'] ?? null,
'before' => $before, 'before' => $before,
'after' => $after, 'after' => $after,
'terminal_outcome_key' => $outcome['terminal_outcome_key'],
'terminal_outcome_label' => $outcome['label'],
'verification_state' => $outcome['verification_state'],
'report_bucket' => $outcome['report_bucket'],
'_dedupe_key' => $this->dedupeKey($action, $record, $before, $after, $metadata, $actor, $actorType), '_dedupe_key' => $this->dedupeKey($action, $record, $before, $after, $metadata, $actor, $actorType),
]); ]);
@ -713,6 +738,7 @@ private function dedupeKey(
'owner_user_id' => $metadata['owner_user_id'] ?? null, 'owner_user_id' => $metadata['owner_user_id'] ?? null,
'resolved_reason' => $metadata['resolved_reason'] ?? null, 'resolved_reason' => $metadata['resolved_reason'] ?? null,
'closed_reason' => $metadata['closed_reason'] ?? null, 'closed_reason' => $metadata['closed_reason'] ?? null,
'reopened_reason' => $metadata['reopened_reason'] ?? null,
]; ];
$encoded = json_encode($payload); $encoded = json_encode($payload);

View File

@ -29,6 +29,8 @@
use App\Support\ReasonTranslation\ReasonResolutionEnvelope; use App\Support\ReasonTranslation\ReasonResolutionEnvelope;
use App\Support\ReasonTranslation\ReasonTranslator; use App\Support\ReasonTranslation\ReasonTranslator;
use App\Support\Tenants\TenantOperabilityReasonCode; use App\Support\Tenants\TenantOperabilityReasonCode;
use App\Support\Verification\BlockedVerificationReportFactory;
use App\Support\Verification\VerificationReportWriter;
use Illuminate\Database\QueryException; use Illuminate\Database\QueryException;
use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\DB;
use InvalidArgumentException; use InvalidArgumentException;
@ -942,11 +944,23 @@ public function finalizeExecutionLegitimacyBlockedRun(
'context' => $context, 'context' => $context,
]); ]);
return $this->finalizeBlockedRun( $run = $this->finalizeBlockedRun(
run: $run->fresh(), run: $run->fresh(),
reasonCode: $decision->reasonCode?->value ?? ExecutionDenialReasonCode::ExecutionPrerequisiteInvalid->value, reasonCode: $decision->reasonCode?->value ?? ExecutionDenialReasonCode::ExecutionPrerequisiteInvalid->value,
message: $decision->reasonCode?->message() ?? 'Operation blocked before queued execution could begin.', message: $decision->reasonCode?->message() ?? 'Operation blocked before queued execution could begin.',
); );
if ($run->type === 'provider.connection.check') {
VerificationReportWriter::write(
run: $run,
checks: BlockedVerificationReportFactory::checks($run),
identity: BlockedVerificationReportFactory::identity($run),
);
$run->refresh();
}
return $run;
} }
private function invokeDispatcher(callable $dispatcher, OperationRun $run): void private function invokeDispatcher(callable $dispatcher, OperationRun $run): void

View File

@ -11,6 +11,7 @@
use App\Models\Tenant; use App\Models\Tenant;
use App\Models\User; use App\Models\User;
use App\Services\Auth\CapabilityResolver; use App\Services\Auth\CapabilityResolver;
use App\Services\Auth\WorkspaceCapabilityResolver;
use App\Services\Tenants\TenantOperabilityService; use App\Services\Tenants\TenantOperabilityService;
use App\Support\Operations\ExecutionAuthorityMode; use App\Support\Operations\ExecutionAuthorityMode;
use App\Support\Operations\ExecutionDenialReasonCode; use App\Support\Operations\ExecutionDenialReasonCode;
@ -34,6 +35,7 @@ class QueuedExecutionLegitimacyGate
public function __construct( public function __construct(
private readonly OperationRunCapabilityResolver $operationRunCapabilityResolver, private readonly OperationRunCapabilityResolver $operationRunCapabilityResolver,
private readonly CapabilityResolver $capabilityResolver, private readonly CapabilityResolver $capabilityResolver,
private readonly WorkspaceCapabilityResolver $workspaceCapabilityResolver,
private readonly TenantOperabilityService $tenantOperabilityService, private readonly TenantOperabilityService $tenantOperabilityService,
private readonly WriteGateInterface $writeGate, private readonly WriteGateInterface $writeGate,
) {} ) {}
@ -71,12 +73,8 @@ public function evaluate(OperationRun $run): QueuedExecutionLegitimacyDecision
return QueuedExecutionLegitimacyDecision::deny($context, $checks, ExecutionDenialReasonCode::InitiatorNotEntitled); return QueuedExecutionLegitimacyDecision::deny($context, $checks, ExecutionDenialReasonCode::InitiatorNotEntitled);
} }
if ($context->requiredCapability !== null && $context->tenant instanceof Tenant) { if ($context->requiredCapability !== null) {
$checks['capability'] = $this->capabilityResolver->can( $checks['capability'] = $this->initiatorHasRequiredCapability($context) ? 'passed' : 'failed';
$context->initiator,
$context->tenant,
$context->requiredCapability,
) ? 'passed' : 'failed';
if ($checks['capability'] === 'failed') { if ($checks['capability'] === 'failed') {
return QueuedExecutionLegitimacyDecision::deny( return QueuedExecutionLegitimacyDecision::deny(
@ -106,7 +104,7 @@ public function evaluate(OperationRun $run): QueuedExecutionLegitimacyDecision
tenant: $context->tenant, tenant: $context->tenant,
question: $operabilityQuestion, question: $operabilityQuestion,
workspaceId: $context->workspaceId, workspaceId: $context->workspaceId,
lane: TenantInteractionLane::AdministrativeManagement, lane: $this->laneForContext($context),
); );
$checks['tenant_operability'] = $operability->allowed ? 'passed' : 'failed'; $checks['tenant_operability'] = $operability->allowed ? 'passed' : 'failed';
@ -228,6 +226,35 @@ private function resolveProviderConnectionId(array $context): ?int
return is_numeric($providerConnectionId) ? (int) $providerConnectionId : null; return is_numeric($providerConnectionId) ? (int) $providerConnectionId : null;
} }
private function initiatorHasRequiredCapability(QueuedExecutionContext $context): bool
{
if (! $context->initiator instanceof User || ! is_string($context->requiredCapability) || $context->requiredCapability === '') {
return false;
}
if (str_starts_with($context->requiredCapability, 'workspace')) {
if ($context->workspaceId <= 0) {
return false;
}
return $this->workspaceCapabilityResolver->can(
$context->initiator,
$context->run->tenant?->workspace ?? $context->run->workspace()->firstOrFail(),
$context->requiredCapability,
);
}
if (! $context->tenant instanceof Tenant) {
return false;
}
return $this->capabilityResolver->can(
$context->initiator,
$context->tenant,
$context->requiredCapability,
);
}
/** /**
* @return list<string> * @return list<string>
*/ */
@ -262,4 +289,16 @@ private function requiresWriteGate(QueuedExecutionContext $context): bool
{ {
return in_array('write_gate', $context->prerequisiteClasses, true); return in_array('write_gate', $context->prerequisiteClasses, true);
} }
private function laneForContext(QueuedExecutionContext $context): TenantInteractionLane
{
$runContext = is_array($context->run->context) ? $context->run->context : [];
$wizardFlow = data_get($runContext, 'wizard.flow');
if (is_string($wizardFlow) && trim($wizardFlow) === 'managed_tenant_onboarding') {
return TenantInteractionLane::OnboardingWorkflow;
}
return TenantInteractionLane::AdministrativeManagement;
}
} }

View File

@ -83,6 +83,12 @@ public function generate(Tenant $tenant, User $user, array $options = []): Revie
'status' => ReviewPackStatus::Queued->value, 'status' => ReviewPackStatus::Queued->value,
'options' => $options, 'options' => $options,
'summary' => [ 'summary' => [
'finding_outcomes' => is_array($snapshot->summary['finding_outcomes'] ?? null)
? $snapshot->summary['finding_outcomes']
: [],
'finding_report_buckets' => is_array($snapshot->summary['finding_report_buckets'] ?? null)
? $snapshot->summary['finding_report_buckets']
: [],
'risk_acceptance' => is_array($snapshot->summary['risk_acceptance'] ?? null) 'risk_acceptance' => is_array($snapshot->summary['risk_acceptance'] ?? null)
? $snapshot->summary['risk_acceptance'] ? $snapshot->summary['risk_acceptance']
: [], : [],
@ -168,6 +174,12 @@ public function generateFromReview(TenantReview $review, User $user, array $opti
'review_status' => (string) $review->status, 'review_status' => (string) $review->status,
'review_completeness_state' => (string) $review->completeness_state, 'review_completeness_state' => (string) $review->completeness_state,
'section_count' => $review->sections->count(), 'section_count' => $review->sections->count(),
'finding_outcomes' => is_array($review->summary['finding_outcomes'] ?? null)
? $review->summary['finding_outcomes']
: [],
'finding_report_buckets' => is_array($review->summary['finding_report_buckets'] ?? null)
? $review->summary['finding_report_buckets']
: [],
'evidence_resolution' => [ 'evidence_resolution' => [
'outcome' => 'resolved', 'outcome' => 'resolved',
'snapshot_id' => (int) $snapshot->getKey(), 'snapshot_id' => (int) $snapshot->getKey(),

View File

@ -59,6 +59,12 @@ public function compose(EvidenceSnapshot $snapshot, ?TenantReview $review = null
'publish_blockers' => $blockers, 'publish_blockers' => $blockers,
'has_ready_export' => false, 'has_ready_export' => false,
'finding_count' => (int) data_get($sections, '0.summary_payload.finding_count', 0), 'finding_count' => (int) data_get($sections, '0.summary_payload.finding_count', 0),
'finding_outcomes' => is_array(data_get($sections, '0.summary_payload.finding_outcomes'))
? data_get($sections, '0.summary_payload.finding_outcomes')
: [],
'finding_report_buckets' => is_array(data_get($sections, '0.summary_payload.finding_report_buckets'))
? data_get($sections, '0.summary_payload.finding_report_buckets')
: [],
'report_count' => 2, 'report_count' => 2,
'operation_count' => (int) data_get($sections, '5.summary_payload.operation_count', 0), 'operation_count' => (int) data_get($sections, '5.summary_payload.operation_count', 0),
'highlights' => data_get($sections, '0.render_payload.highlights', []), 'highlights' => data_get($sections, '0.render_payload.highlights', []),

View File

@ -6,12 +6,17 @@
use App\Models\EvidenceSnapshot; use App\Models\EvidenceSnapshot;
use App\Models\EvidenceSnapshotItem; use App\Models\EvidenceSnapshotItem;
use App\Support\Findings\FindingOutcomeSemantics;
use App\Support\TenantReviewCompletenessState; use App\Support\TenantReviewCompletenessState;
use Illuminate\Support\Arr; use Illuminate\Support\Arr;
use Illuminate\Support\Collection; use Illuminate\Support\Collection;
final class TenantReviewSectionFactory final class TenantReviewSectionFactory
{ {
public function __construct(
private readonly FindingOutcomeSemantics $findingOutcomeSemantics,
) {}
/** /**
* @return list<array<string, mixed>> * @return list<array<string, mixed>>
*/ */
@ -47,6 +52,8 @@ private function executiveSummarySection(
$rolesSummary = $this->summary($rolesItem); $rolesSummary = $this->summary($rolesItem);
$baselineSummary = $this->summary($baselineItem); $baselineSummary = $this->summary($baselineItem);
$operationsSummary = $this->summary($operationsItem); $operationsSummary = $this->summary($operationsItem);
$findingOutcomes = is_array($findingsSummary['outcome_counts'] ?? null) ? $findingsSummary['outcome_counts'] : [];
$findingReportBuckets = is_array($findingsSummary['report_bucket_counts'] ?? null) ? $findingsSummary['report_bucket_counts'] : [];
$riskAcceptance = is_array($findingsSummary['risk_acceptance'] ?? null) ? $findingsSummary['risk_acceptance'] : []; $riskAcceptance = is_array($findingsSummary['risk_acceptance'] ?? null) ? $findingsSummary['risk_acceptance'] : [];
$openCount = (int) ($findingsSummary['open_count'] ?? 0); $openCount = (int) ($findingsSummary['open_count'] ?? 0);
@ -55,9 +62,11 @@ private function executiveSummarySection(
$postureScore = $permissionSummary['posture_score'] ?? null; $postureScore = $permissionSummary['posture_score'] ?? null;
$operationFailures = (int) ($operationsSummary['failed_count'] ?? 0); $operationFailures = (int) ($operationsSummary['failed_count'] ?? 0);
$partialOperations = (int) ($operationsSummary['partial_count'] ?? 0); $partialOperations = (int) ($operationsSummary['partial_count'] ?? 0);
$outcomeSummary = $this->findingOutcomeSemantics->compactOutcomeSummary($findingOutcomes);
$highlights = array_values(array_filter([ $highlights = array_values(array_filter([
sprintf('%d open risks from %d tracked findings.', $openCount, $findingCount), sprintf('%d open risks from %d tracked findings.', $openCount, $findingCount),
$outcomeSummary !== null ? 'Terminal outcomes: '.$outcomeSummary.'.' : null,
$postureScore !== null ? sprintf('Permission posture score is %s.', $postureScore) : 'Permission posture report is unavailable.', $postureScore !== null ? sprintf('Permission posture score is %s.', $postureScore) : 'Permission posture report is unavailable.',
sprintf('%d baseline drift findings remain open.', $driftCount), sprintf('%d baseline drift findings remain open.', $driftCount),
sprintf('%d recent operations failed and %d completed with warnings.', $operationFailures, $partialOperations), sprintf('%d recent operations failed and %d completed with warnings.', $operationFailures, $partialOperations),
@ -81,6 +90,8 @@ private function executiveSummarySection(
'summary_payload' => [ 'summary_payload' => [
'finding_count' => $findingCount, 'finding_count' => $findingCount,
'open_risk_count' => $openCount, 'open_risk_count' => $openCount,
'finding_outcomes' => $findingOutcomes,
'finding_report_buckets' => $findingReportBuckets,
'posture_score' => $postureScore, 'posture_score' => $postureScore,
'baseline_drift_count' => $driftCount, 'baseline_drift_count' => $driftCount,
'failed_operation_count' => $operationFailures, 'failed_operation_count' => $operationFailures,

View File

@ -38,7 +38,6 @@ final class BadgeCatalog
BadgeDomain::BooleanEnabled->value => Domains\BooleanEnabledBadge::class, BadgeDomain::BooleanEnabled->value => Domains\BooleanEnabledBadge::class,
BadgeDomain::BooleanHasErrors->value => Domains\BooleanHasErrorsBadge::class, BadgeDomain::BooleanHasErrors->value => Domains\BooleanHasErrorsBadge::class,
BadgeDomain::TenantStatus->value => Domains\TenantStatusBadge::class, BadgeDomain::TenantStatus->value => Domains\TenantStatusBadge::class,
BadgeDomain::TenantAppStatus->value => Domains\TenantAppStatusBadge::class,
BadgeDomain::TenantRbacStatus->value => Domains\TenantRbacStatusBadge::class, BadgeDomain::TenantRbacStatus->value => Domains\TenantRbacStatusBadge::class,
BadgeDomain::TenantPermissionStatus->value => Domains\TenantPermissionStatusBadge::class, BadgeDomain::TenantPermissionStatus->value => Domains\TenantPermissionStatusBadge::class,
BadgeDomain::PolicySnapshotMode->value => Domains\PolicySnapshotModeBadge::class, BadgeDomain::PolicySnapshotMode->value => Domains\PolicySnapshotModeBadge::class,

View File

@ -29,7 +29,6 @@ enum BadgeDomain: string
case BooleanEnabled = 'boolean_enabled'; case BooleanEnabled = 'boolean_enabled';
case BooleanHasErrors = 'boolean_has_errors'; case BooleanHasErrors = 'boolean_has_errors';
case TenantStatus = 'tenant_status'; case TenantStatus = 'tenant_status';
case TenantAppStatus = 'tenant_app_status';
case TenantRbacStatus = 'tenant_rbac_status'; case TenantRbacStatus = 'tenant_rbac_status';
case TenantPermissionStatus = 'tenant_permission_status'; case TenantPermissionStatus = 'tenant_permission_status';
case PolicySnapshotMode = 'policy_snapshot_mode'; case PolicySnapshotMode = 'policy_snapshot_mode';

View File

@ -1,24 +0,0 @@
<?php
namespace App\Support\Badges\Domains;
use App\Support\Badges\BadgeCatalog;
use App\Support\Badges\BadgeMapper;
use App\Support\Badges\BadgeSpec;
final class TenantAppStatusBadge implements BadgeMapper
{
public function spec(mixed $value): BadgeSpec
{
$state = BadgeCatalog::normalizeState($value);
return match ($state) {
'ok' => new BadgeSpec('OK', 'success', 'heroicon-m-check-circle'),
'configured' => new BadgeSpec('Configured', 'success', 'heroicon-m-check-circle'),
'pending' => new BadgeSpec('Pending', 'warning', 'heroicon-m-clock'),
'requires_consent', 'consent_required' => new BadgeSpec('Consent required', 'warning', 'heroicon-m-exclamation-triangle'),
'error' => new BadgeSpec('Error', 'danger', 'heroicon-m-x-circle'),
default => BadgeSpec::unknown(),
};
}
}

View File

@ -14,6 +14,7 @@
use App\Services\Baselines\BaselineSnapshotTruthResolver; use App\Services\Baselines\BaselineSnapshotTruthResolver;
use App\Support\OperationRunStatus; use App\Support\OperationRunStatus;
use App\Support\OperationRunType; use App\Support\OperationRunType;
use App\Support\ReasonTranslation\ReasonPresenter;
use App\Support\Ui\OperatorExplanation\CountDescriptor; use App\Support\Ui\OperatorExplanation\CountDescriptor;
use App\Support\Ui\OperatorExplanation\OperatorExplanationPattern; use App\Support\Ui\OperatorExplanation\OperatorExplanationPattern;
use Illuminate\Support\Facades\Cache; use Illuminate\Support\Facades\Cache;
@ -120,7 +121,8 @@ public static function forTenant(?Tenant $tenant): self
$effectiveSnapshot = $truthResolution['effective_snapshot'] ?? null; $effectiveSnapshot = $truthResolution['effective_snapshot'] ?? null;
$snapshotId = $effectiveSnapshot instanceof BaselineSnapshot ? (int) $effectiveSnapshot->getKey() : null; $snapshotId = $effectiveSnapshot instanceof BaselineSnapshot ? (int) $effectiveSnapshot->getKey() : null;
$snapshotReasonCode = is_string($truthResolution['reason_code'] ?? null) ? (string) $truthResolution['reason_code'] : null; $snapshotReasonCode = is_string($truthResolution['reason_code'] ?? null) ? (string) $truthResolution['reason_code'] : null;
$snapshotReasonMessage = self::missingSnapshotMessage($snapshotReasonCode); $latestCaptureRun = self::latestBaselineCaptureRun($profile);
$snapshotReasonMessage = self::missingSnapshotMessage($snapshotReasonCode, $latestCaptureRun);
try { try {
$profileScope = $profile->normalizedScope(); $profileScope = $profile->normalizedScope();
@ -905,8 +907,35 @@ private static function empty(
); );
} }
private static function missingSnapshotMessage(?string $reasonCode): ?string private static function latestBaselineCaptureRun(BaselineProfile $profile): ?OperationRun
{ {
return OperationRun::query()
->where('workspace_id', (int) $profile->workspace_id)
->where('type', OperationRunType::BaselineCapture->value)
->where('context->baseline_profile_id', (int) $profile->getKey())
->where('status', OperationRunStatus::Completed->value)
->orderByDesc('completed_at')
->orderByDesc('id')
->first();
}
private static function missingSnapshotMessage(?string $reasonCode, ?OperationRun $latestCaptureRun = null): ?string
{
$latestCaptureEnvelope = $latestCaptureRun instanceof OperationRun
? app(ReasonPresenter::class)->forOperationRun($latestCaptureRun, 'artifact_truth')
: null;
if ($latestCaptureEnvelope !== null
&& in_array($latestCaptureEnvelope->internalCode, [
BaselineReasonCodes::CAPTURE_INVENTORY_BLOCKED,
BaselineReasonCodes::CAPTURE_INVENTORY_FAILED,
BaselineReasonCodes::CAPTURE_UNUSABLE_COVERAGE,
BaselineReasonCodes::CAPTURE_ZERO_SUBJECTS,
], true)
&& trim($latestCaptureEnvelope->shortExplanation) !== '') {
return $latestCaptureEnvelope->shortExplanation;
}
return match ($reasonCode) { return match ($reasonCode) {
BaselineReasonCodes::COMPARE_SNAPSHOT_BUILDING => 'The latest baseline capture is still building. Compare becomes available after a complete snapshot is finalized.', BaselineReasonCodes::COMPARE_SNAPSHOT_BUILDING => 'The latest baseline capture is still building. Compare becomes available after a complete snapshot is finalized.',
BaselineReasonCodes::COMPARE_SNAPSHOT_INCOMPLETE => 'The latest baseline capture is incomplete and there is no current complete snapshot to compare against.', BaselineReasonCodes::COMPARE_SNAPSHOT_INCOMPLETE => 'The latest baseline capture is incomplete and there is no current complete snapshot to compare against.',

View File

@ -22,6 +22,16 @@ final class BaselineReasonCodes
public const string CAPTURE_UNSUPPORTED_SCOPE = 'baseline.capture.unsupported_scope'; public const string CAPTURE_UNSUPPORTED_SCOPE = 'baseline.capture.unsupported_scope';
public const string CAPTURE_INVENTORY_MISSING = 'baseline.capture.inventory_missing';
public const string CAPTURE_INVENTORY_BLOCKED = 'baseline.capture.inventory_blocked';
public const string CAPTURE_INVENTORY_FAILED = 'baseline.capture.inventory_failed';
public const string CAPTURE_UNUSABLE_COVERAGE = 'baseline.capture.unusable_coverage';
public const string CAPTURE_ZERO_SUBJECTS = 'baseline.capture.zero_subjects';
public const string SNAPSHOT_BUILDING = 'baseline.snapshot.building'; public const string SNAPSHOT_BUILDING = 'baseline.snapshot.building';
public const string SNAPSHOT_INCOMPLETE = 'baseline.snapshot.incomplete'; public const string SNAPSHOT_INCOMPLETE = 'baseline.snapshot.incomplete';
@ -73,6 +83,11 @@ public static function all(): array
self::CAPTURE_ROLLOUT_DISABLED, self::CAPTURE_ROLLOUT_DISABLED,
self::CAPTURE_INVALID_SCOPE, self::CAPTURE_INVALID_SCOPE,
self::CAPTURE_UNSUPPORTED_SCOPE, self::CAPTURE_UNSUPPORTED_SCOPE,
self::CAPTURE_INVENTORY_MISSING,
self::CAPTURE_INVENTORY_BLOCKED,
self::CAPTURE_INVENTORY_FAILED,
self::CAPTURE_UNUSABLE_COVERAGE,
self::CAPTURE_ZERO_SUBJECTS,
self::SNAPSHOT_BUILDING, self::SNAPSHOT_BUILDING,
self::SNAPSHOT_INCOMPLETE, self::SNAPSHOT_INCOMPLETE,
self::SNAPSHOT_SUPERSEDED, self::SNAPSHOT_SUPERSEDED,
@ -128,7 +143,12 @@ public static function trustImpact(?string $reasonCode): ?string
self::CAPTURE_MISSING_SOURCE_TENANT, self::CAPTURE_MISSING_SOURCE_TENANT,
self::CAPTURE_PROFILE_NOT_ACTIVE, self::CAPTURE_PROFILE_NOT_ACTIVE,
self::CAPTURE_INVALID_SCOPE, self::CAPTURE_INVALID_SCOPE,
self::CAPTURE_UNSUPPORTED_SCOPE => 'unusable', self::CAPTURE_UNSUPPORTED_SCOPE,
self::CAPTURE_INVENTORY_MISSING,
self::CAPTURE_INVENTORY_BLOCKED,
self::CAPTURE_INVENTORY_FAILED,
self::CAPTURE_UNUSABLE_COVERAGE,
self::CAPTURE_ZERO_SUBJECTS => 'unusable',
default => null, default => null,
}; };
} }
@ -148,6 +168,10 @@ public static function absencePattern(?string $reasonCode): ?string
self::CAPTURE_MISSING_SOURCE_TENANT, self::CAPTURE_MISSING_SOURCE_TENANT,
self::CAPTURE_PROFILE_NOT_ACTIVE, self::CAPTURE_PROFILE_NOT_ACTIVE,
self::CAPTURE_ROLLOUT_DISABLED, self::CAPTURE_ROLLOUT_DISABLED,
self::CAPTURE_INVENTORY_MISSING,
self::CAPTURE_INVENTORY_BLOCKED,
self::CAPTURE_INVENTORY_FAILED,
self::CAPTURE_UNUSABLE_COVERAGE,
self::COMPARE_NO_ASSIGNMENT, self::COMPARE_NO_ASSIGNMENT,
self::COMPARE_PROFILE_NOT_ACTIVE, self::COMPARE_PROFILE_NOT_ACTIVE,
self::COMPARE_NO_ELIGIBLE_TARGET, self::COMPARE_NO_ELIGIBLE_TARGET,
@ -159,6 +183,7 @@ public static function absencePattern(?string $reasonCode): ?string
self::SNAPSHOT_SUPERSEDED, self::SNAPSHOT_SUPERSEDED,
self::COMPARE_SNAPSHOT_SUPERSEDED => 'blocked_prerequisite', self::COMPARE_SNAPSHOT_SUPERSEDED => 'blocked_prerequisite',
self::SNAPSHOT_CAPTURE_FAILED => 'unavailable', self::SNAPSHOT_CAPTURE_FAILED => 'unavailable',
self::CAPTURE_ZERO_SUBJECTS => 'missing_input',
self::CAPTURE_INVALID_SCOPE, self::CAPTURE_INVALID_SCOPE,
self::CAPTURE_UNSUPPORTED_SCOPE => 'unavailable', self::CAPTURE_UNSUPPORTED_SCOPE => 'unavailable',
default => null, default => null,

View File

@ -13,6 +13,7 @@
use App\Support\Badges\BadgeCatalog; use App\Support\Badges\BadgeCatalog;
use App\Support\Badges\BadgeDomain; use App\Support\Badges\BadgeDomain;
use App\Support\Baselines\BaselineProfileStatus; use App\Support\Baselines\BaselineProfileStatus;
use App\Support\Findings\FindingOutcomeSemantics;
use App\Support\Inventory\InventoryPolicyTypeMeta; use App\Support\Inventory\InventoryPolicyTypeMeta;
use App\Support\OperationCatalog; use App\Support\OperationCatalog;
use App\Support\RestoreRunStatus; use App\Support\RestoreRunStatus;
@ -142,6 +143,22 @@ public static function findingWorkflowFamilies(): array
]; ];
} }
/**
* @return array<string, string>
*/
public static function findingTerminalOutcomes(): array
{
return app(FindingOutcomeSemantics::class)->terminalOutcomeOptions();
}
/**
* @return array<string, string>
*/
public static function findingVerificationStates(): array
{
return app(FindingOutcomeSemantics::class)->verificationStateOptions();
}
/** /**
* @return array<string, string> * @return array<string, string>
*/ */

View File

@ -6,19 +6,89 @@
class PanelThemeAsset class PanelThemeAsset
{ {
/**
* @var array<string, bool>
*/
private static array $hotAssetReachability = [];
public static function resolve(string $entry): ?string public static function resolve(string $entry): ?string
{ {
if (app()->runningUnitTests()) { if (app()->runningUnitTests()) {
return static::resolveFromManifest($entry); return static::resolveFromManifest($entry);
} }
if (is_file(public_path('hot'))) { if (static::shouldUseHotAsset($entry)) {
return Vite::asset($entry); return Vite::asset($entry);
} }
return static::resolveFromManifest($entry); return static::resolveFromManifest($entry);
} }
private static function shouldUseHotAsset(string $entry): bool
{
$hotFile = public_path('hot');
if (! is_file($hotFile)) {
return false;
}
$hotUrl = trim((string) file_get_contents($hotFile));
if ($hotUrl === '') {
return false;
}
$assetUrl = Vite::asset($entry);
if ($assetUrl === '') {
return false;
}
if (array_key_exists($assetUrl, static::$hotAssetReachability)) {
return static::$hotAssetReachability[$assetUrl];
}
$parts = parse_url($assetUrl);
if (! is_array($parts)) {
return static::$hotAssetReachability[$assetUrl] = false;
}
$host = $parts['host'] ?? null;
if (! is_string($host) || $host === '') {
return static::$hotAssetReachability[$assetUrl] = false;
}
$scheme = $parts['scheme'] ?? 'http';
$port = $parts['port'] ?? ($scheme === 'https' ? 443 : 80);
$transport = $scheme === 'https' ? 'ssl://' : '';
$connection = @fsockopen($transport.$host, $port, $errorNumber, $errorMessage, 0.2);
if (! is_resource($connection)) {
return static::$hotAssetReachability[$assetUrl] = false;
}
$path = ($parts['path'] ?? '/').(isset($parts['query']) ? '?'.$parts['query'] : '');
$hostHeader = isset($parts['port']) ? $host.':'.$port : $host;
stream_set_timeout($connection, 0, 200000);
fwrite(
$connection,
"HEAD {$path} HTTP/1.1\r\nHost: {$hostHeader}\r\nConnection: close\r\n\r\n",
);
$statusLine = fgets($connection);
fclose($connection);
if (! is_string($statusLine)) {
return static::$hotAssetReachability[$assetUrl] = false;
}
return static::$hotAssetReachability[$assetUrl] = preg_match('/^HTTP\/\d\.\d\s+[23]\d\d\b/', $statusLine) === 1;
}
private static function resolveFromManifest(string $entry): ?string private static function resolveFromManifest(string $entry): ?string
{ {
$manifest = public_path('build/manifest.json'); $manifest = public_path('build/manifest.json');

View File

@ -0,0 +1,203 @@
<?php
declare(strict_types=1);
namespace App\Support\Findings;
use App\Models\Finding;
final class FindingOutcomeSemantics
{
public const string VERIFICATION_PENDING = 'pending_verification';
public const string VERIFICATION_VERIFIED = 'verified_cleared';
public const string VERIFICATION_NOT_APPLICABLE = 'not_applicable';
public const string OUTCOME_RESOLVED_PENDING_VERIFICATION = 'resolved_pending_verification';
public const string OUTCOME_VERIFIED_CLEARED = 'verified_cleared';
public const string OUTCOME_CLOSED_FALSE_POSITIVE = 'closed_false_positive';
public const string OUTCOME_CLOSED_DUPLICATE = 'closed_duplicate';
public const string OUTCOME_CLOSED_NO_LONGER_APPLICABLE = 'closed_no_longer_applicable';
public const string OUTCOME_RISK_ACCEPTED = 'risk_accepted';
/**
* @return array{
* terminal_outcome_key: ?string,
* label: ?string,
* verification_state: string,
* verification_label: ?string,
* report_bucket: ?string
* }
*/
public function describe(Finding $finding): array
{
$terminalOutcomeKey = $this->terminalOutcomeKey($finding);
$verificationState = $this->verificationState($finding);
return [
'terminal_outcome_key' => $terminalOutcomeKey,
'label' => $terminalOutcomeKey !== null ? $this->terminalOutcomeLabel($terminalOutcomeKey) : null,
'verification_state' => $verificationState,
'verification_label' => $verificationState !== self::VERIFICATION_NOT_APPLICABLE
? $this->verificationStateLabel($verificationState)
: null,
'report_bucket' => $terminalOutcomeKey !== null ? $this->reportBucket($terminalOutcomeKey) : null,
];
}
public function terminalOutcomeKey(Finding $finding): ?string
{
return match ((string) $finding->status) {
Finding::STATUS_RESOLVED => $this->resolvedTerminalOutcomeKey((string) ($finding->resolved_reason ?? '')),
Finding::STATUS_CLOSED => $this->closedTerminalOutcomeKey((string) ($finding->closed_reason ?? '')),
Finding::STATUS_RISK_ACCEPTED => self::OUTCOME_RISK_ACCEPTED,
default => null,
};
}
public function verificationState(Finding $finding): string
{
if ((string) $finding->status !== Finding::STATUS_RESOLVED) {
return self::VERIFICATION_NOT_APPLICABLE;
}
$reason = (string) ($finding->resolved_reason ?? '');
if (Finding::isSystemResolveReason($reason)) {
return self::VERIFICATION_VERIFIED;
}
if (Finding::isManualResolveReason($reason)) {
return self::VERIFICATION_PENDING;
}
return self::VERIFICATION_NOT_APPLICABLE;
}
public function systemReopenReasonFor(Finding $finding): string
{
return $this->verificationState($finding) === self::VERIFICATION_PENDING
? Finding::REOPEN_REASON_VERIFICATION_FAILED
: Finding::REOPEN_REASON_RECURRED_AFTER_RESOLUTION;
}
/**
* @return array<string, string>
*/
public function terminalOutcomeOptions(): array
{
return [
self::OUTCOME_RESOLVED_PENDING_VERIFICATION => $this->terminalOutcomeLabel(self::OUTCOME_RESOLVED_PENDING_VERIFICATION),
self::OUTCOME_VERIFIED_CLEARED => $this->terminalOutcomeLabel(self::OUTCOME_VERIFIED_CLEARED),
self::OUTCOME_CLOSED_FALSE_POSITIVE => $this->terminalOutcomeLabel(self::OUTCOME_CLOSED_FALSE_POSITIVE),
self::OUTCOME_CLOSED_DUPLICATE => $this->terminalOutcomeLabel(self::OUTCOME_CLOSED_DUPLICATE),
self::OUTCOME_CLOSED_NO_LONGER_APPLICABLE => $this->terminalOutcomeLabel(self::OUTCOME_CLOSED_NO_LONGER_APPLICABLE),
self::OUTCOME_RISK_ACCEPTED => $this->terminalOutcomeLabel(self::OUTCOME_RISK_ACCEPTED),
];
}
/**
* @return array<string, string>
*/
public function verificationStateOptions(): array
{
return [
self::VERIFICATION_PENDING => $this->verificationStateLabel(self::VERIFICATION_PENDING),
self::VERIFICATION_VERIFIED => $this->verificationStateLabel(self::VERIFICATION_VERIFIED),
self::VERIFICATION_NOT_APPLICABLE => $this->verificationStateLabel(self::VERIFICATION_NOT_APPLICABLE),
];
}
public function terminalOutcomeLabel(string $terminalOutcomeKey): string
{
return match ($terminalOutcomeKey) {
self::OUTCOME_RESOLVED_PENDING_VERIFICATION => 'Resolved pending verification',
self::OUTCOME_VERIFIED_CLEARED => 'Verified cleared',
self::OUTCOME_CLOSED_FALSE_POSITIVE => 'Closed as false positive',
self::OUTCOME_CLOSED_DUPLICATE => 'Closed as duplicate',
self::OUTCOME_CLOSED_NO_LONGER_APPLICABLE => 'Closed as no longer applicable',
self::OUTCOME_RISK_ACCEPTED => 'Risk accepted',
default => 'Unknown outcome',
};
}
public function verificationStateLabel(string $verificationState): string
{
return match ($verificationState) {
self::VERIFICATION_PENDING => 'Pending verification',
self::VERIFICATION_VERIFIED => 'Verified cleared',
default => 'Not applicable',
};
}
public function reportBucket(string $terminalOutcomeKey): string
{
return match ($terminalOutcomeKey) {
self::OUTCOME_RESOLVED_PENDING_VERIFICATION => 'remediation_pending_verification',
self::OUTCOME_VERIFIED_CLEARED => 'remediation_verified',
self::OUTCOME_RISK_ACCEPTED => 'accepted_risk',
default => 'administrative_closure',
};
}
public function compactOutcomeSummary(array $counts): ?string
{
$parts = [];
foreach ($this->orderedOutcomeKeys() as $outcomeKey) {
$count = (int) ($counts[$outcomeKey] ?? 0);
if ($count < 1) {
continue;
}
$parts[] = sprintf('%d %s', $count, strtolower($this->terminalOutcomeLabel($outcomeKey)));
}
return $parts === [] ? null : implode(', ', $parts);
}
/**
* @return array<int, string>
*/
public function orderedOutcomeKeys(): array
{
return [
self::OUTCOME_RESOLVED_PENDING_VERIFICATION,
self::OUTCOME_VERIFIED_CLEARED,
self::OUTCOME_CLOSED_FALSE_POSITIVE,
self::OUTCOME_CLOSED_DUPLICATE,
self::OUTCOME_CLOSED_NO_LONGER_APPLICABLE,
self::OUTCOME_RISK_ACCEPTED,
];
}
private function resolvedTerminalOutcomeKey(string $reason): ?string
{
if (Finding::isSystemResolveReason($reason)) {
return self::OUTCOME_VERIFIED_CLEARED;
}
if (Finding::isManualResolveReason($reason)) {
return self::OUTCOME_RESOLVED_PENDING_VERIFICATION;
}
return null;
}
private function closedTerminalOutcomeKey(string $reason): ?string
{
return match ($reason) {
Finding::CLOSE_REASON_FALSE_POSITIVE => self::OUTCOME_CLOSED_FALSE_POSITIVE,
Finding::CLOSE_REASON_DUPLICATE => self::OUTCOME_CLOSED_DUPLICATE,
Finding::CLOSE_REASON_NO_LONGER_APPLICABLE => self::OUTCOME_CLOSED_NO_LONGER_APPLICABLE,
default => null,
};
}
}

View File

@ -202,7 +202,7 @@ public function auditTargetLink(AuditLog $record): ?array
->whereKey($resourceId) ->whereKey($resourceId)
->where('workspace_id', (int) $workspace->getKey()) ->where('workspace_id', (int) $workspace->getKey())
->exists() ->exists()
? ['label' => OperationRunLinks::openLabel(), 'url' => route('admin.operations.view', ['run' => $resourceId])] ? ['label' => OperationRunLinks::openLabel(), 'url' => OperationRunLinks::tenantlessView($resourceId)]
: null, : null,
'baseline_profile' => $workspace instanceof Workspace 'baseline_profile' => $workspace instanceof Workspace
&& $this->workspaceCapabilityResolver->isMember($user, $workspace) && $this->workspaceCapabilityResolver->isMember($user, $workspace)

View File

@ -81,6 +81,7 @@ public static function index(
?string $activeTab = null, ?string $activeTab = null,
bool $allTenants = false, bool $allTenants = false,
?string $problemClass = null, ?string $problemClass = null,
?string $operationType = null,
): string { ): string {
$parameters = $context?->toQuery() ?? []; $parameters = $context?->toQuery() ?? [];
@ -106,6 +107,10 @@ public static function index(
} }
} }
if (is_string($operationType) && $operationType !== '') {
$parameters['tableFilters']['type']['value'] = $operationType;
}
return route('admin.operations.index', $parameters); return route('admin.operations.index', $parameters);
} }

View File

@ -8,6 +8,7 @@
use App\Support\Badges\BadgeCatalog; use App\Support\Badges\BadgeCatalog;
use App\Support\Badges\BadgeDomain; use App\Support\Badges\BadgeDomain;
use App\Support\Baselines\BaselineCompareReasonCode; use App\Support\Baselines\BaselineCompareReasonCode;
use App\Support\Baselines\BaselineReasonCodes;
use App\Support\OperationCatalog; use App\Support\OperationCatalog;
use App\Support\ReasonTranslation\ReasonPresenter; use App\Support\ReasonTranslation\ReasonPresenter;
use App\Support\ReasonTranslation\ReasonResolutionEnvelope; use App\Support\ReasonTranslation\ReasonResolutionEnvelope;
@ -141,9 +142,37 @@ private function baselineCaptureHeadline(
array $counts, array $counts,
?OperatorExplanationPattern $operatorExplanation, ?OperatorExplanationPattern $operatorExplanation,
): string { ): string {
$reasonCode = (string) data_get($context, 'baseline_capture.reason_code', data_get($context, 'reason_code', ''));
$subjectsTotal = $this->intValue(data_get($context, 'baseline_capture.subjects_total')); $subjectsTotal = $this->intValue(data_get($context, 'baseline_capture.subjects_total'));
$resumeToken = data_get($context, 'baseline_capture.resume_token'); $resumeToken = data_get($context, 'baseline_capture.resume_token');
$gapCount = $this->intValue(data_get($context, 'baseline_capture.gaps.count')); $gapCount = $this->intValue(data_get($context, 'baseline_capture.gaps.count'));
$changedAfterEnqueue = data_get($context, 'baseline_capture.eligibility.changed_after_enqueue') === true;
if ($reasonCode === BaselineReasonCodes::CAPTURE_INVENTORY_MISSING) {
return 'The baseline capture could not continue because no current inventory basis was available.';
}
if ($reasonCode === BaselineReasonCodes::CAPTURE_INVENTORY_BLOCKED) {
return $changedAfterEnqueue
? 'The baseline capture stopped because the latest inventory sync changed after the run was queued.'
: 'The baseline capture was blocked because the latest inventory sync was blocked.';
}
if ($reasonCode === BaselineReasonCodes::CAPTURE_INVENTORY_FAILED) {
return $changedAfterEnqueue
? 'The baseline capture stopped because the latest inventory sync failed after the run was queued.'
: 'The baseline capture was blocked because the latest inventory sync failed.';
}
if ($reasonCode === BaselineReasonCodes::CAPTURE_UNUSABLE_COVERAGE) {
return $changedAfterEnqueue
? 'The baseline capture stopped because the latest inventory coverage became unusable after the run was queued.'
: 'The baseline capture could not produce a usable baseline because the latest inventory coverage was not credible.';
}
if ($reasonCode === BaselineReasonCodes::CAPTURE_ZERO_SUBJECTS) {
return 'The baseline capture finished without a usable baseline because no governed subjects were in scope.';
}
if ($subjectsTotal === 0) { if ($subjectsTotal === 0) {
return 'No baseline was captured because no governed subjects were ready.'; return 'No baseline was captured because no governed subjects were ready.';
@ -629,9 +658,55 @@ private function pushCandidate(array &$candidates, ?string $code, ?string $label
*/ */
private function baselineCaptureCandidates(array &$candidates, array $context): void private function baselineCaptureCandidates(array &$candidates, array $context): void
{ {
$reasonCode = (string) data_get($context, 'baseline_capture.reason_code', data_get($context, 'reason_code', ''));
$subjectsTotal = $this->intValue(data_get($context, 'baseline_capture.subjects_total')); $subjectsTotal = $this->intValue(data_get($context, 'baseline_capture.subjects_total'));
$gapCount = $this->intValue(data_get($context, 'baseline_capture.gaps.count')); $gapCount = $this->intValue(data_get($context, 'baseline_capture.gaps.count'));
$resumeToken = data_get($context, 'baseline_capture.resume_token'); $resumeToken = data_get($context, 'baseline_capture.resume_token');
$changedAfterEnqueue = data_get($context, 'baseline_capture.eligibility.changed_after_enqueue') === true;
if ($reasonCode === BaselineReasonCodes::CAPTURE_INVENTORY_MISSING) {
$this->pushCandidate($candidates, $reasonCode, 'Run tenant sync first', 'No current inventory basis was available for this baseline capture.', 95);
}
if ($reasonCode === BaselineReasonCodes::CAPTURE_INVENTORY_BLOCKED) {
$this->pushCandidate(
$candidates,
$reasonCode,
'Latest inventory sync was blocked',
$changedAfterEnqueue
? 'The latest inventory sync changed after the run was queued and blocked the capture.'
: 'The latest inventory sync was blocked before this capture could produce a trustworthy baseline.',
95,
);
}
if ($reasonCode === BaselineReasonCodes::CAPTURE_INVENTORY_FAILED) {
$this->pushCandidate(
$candidates,
$reasonCode,
'Latest inventory sync failed',
$changedAfterEnqueue
? 'The latest inventory sync failed after the run was queued, so the capture stopped without refreshing baseline truth.'
: 'The latest inventory sync failed before this capture could produce a trustworthy baseline.',
95,
);
}
if ($reasonCode === BaselineReasonCodes::CAPTURE_UNUSABLE_COVERAGE) {
$this->pushCandidate(
$candidates,
$reasonCode,
'Latest inventory coverage unusable',
$changedAfterEnqueue
? 'The latest inventory coverage became unusable after the run was queued, so the capture stopped without refreshing baseline truth.'
: 'The latest inventory sync did not produce usable governed-subject coverage for this baseline capture.',
95,
);
}
if ($reasonCode === BaselineReasonCodes::CAPTURE_ZERO_SUBJECTS) {
$this->pushCandidate($candidates, $reasonCode, 'No subjects were in scope', 'No governed subjects were available for this baseline capture.', 95);
}
if ($subjectsTotal === 0) { if ($subjectsTotal === 0) {
$this->pushCandidate($candidates, 'no_subjects_in_scope', 'No governed subjects captured', 'No governed subjects were available for this baseline capture.', 95); $this->pushCandidate($candidates, 'no_subjects_in_scope', 'No governed subjects captured', 'No governed subjects were available for this baseline capture.', 95);

View File

@ -547,6 +547,11 @@ private static function terminalSupportingLines(OperationRun $run): array
$lines[] = $guidance; $lines[] = $guidance;
} }
$baselineTruthChange = self::baselineTruthChangeLine($run);
if ($baselineTruthChange !== null) {
$lines[] = $baselineTruthChange;
}
$summary = SummaryCountsNormalizer::renderSummaryLine(is_array($run->summary_counts) ? $run->summary_counts : []); $summary = SummaryCountsNormalizer::renderSummaryLine(is_array($run->summary_counts) ? $run->summary_counts : []);
if ($summary !== null) { if ($summary !== null) {
$lines[] = $summary; $lines[] = $summary;
@ -560,6 +565,25 @@ private static function terminalSupportingLines(OperationRun $run): array
return array_values(array_filter($lines, static fn (string $line): bool => trim($line) !== '')); return array_values(array_filter($lines, static fn (string $line): bool => trim($line) !== ''));
} }
private static function baselineTruthChangeLine(OperationRun $run): ?string
{
if ((string) $run->type !== 'baseline_capture') {
return null;
}
$changed = data_get($run->context, 'baseline_capture.current_baseline_changed');
if ($changed === true) {
return 'Current baseline truth was updated.';
}
if ($changed === false) {
return 'Current baseline truth was unchanged.';
}
return null;
}
/** /**
* @return array{label:string, url:?string, target:string} * @return array{label:string, url:?string, target:string}
*/ */

View File

@ -44,6 +44,7 @@ public function forOperationRun(OperationRun $run, string $surface = 'detail'):
$contextReasonCode = data_get($context, 'execution_legitimacy.reason_code') $contextReasonCode = data_get($context, 'execution_legitimacy.reason_code')
?? data_get($context, 'reason_code') ?? data_get($context, 'reason_code')
?? data_get($context, 'baseline_capture.reason_code')
?? data_get($context, 'baseline_compare.reason_code'); ?? data_get($context, 'baseline_compare.reason_code');
if (is_string($contextReasonCode) && trim($contextReasonCode) !== '') { if (is_string($contextReasonCode) && trim($contextReasonCode) !== '') {

View File

@ -51,8 +51,8 @@ public function translate(
$artifactKey === null && $this->providerReasonTranslator->canTranslate($reasonCode) => $this->providerReasonTranslator->translate($reasonCode, $surface, $context), $artifactKey === null && $this->providerReasonTranslator->canTranslate($reasonCode) => $this->providerReasonTranslator->translate($reasonCode, $surface, $context),
$artifactKey === self::GOVERNANCE_ARTIFACT_TRUTH_ARTIFACT && BaselineCompareReasonCode::tryFrom($reasonCode) instanceof BaselineCompareReasonCode => $this->translateBaselineCompareReason($reasonCode), $artifactKey === self::GOVERNANCE_ARTIFACT_TRUTH_ARTIFACT && BaselineCompareReasonCode::tryFrom($reasonCode) instanceof BaselineCompareReasonCode => $this->translateBaselineCompareReason($reasonCode),
$artifactKey === null && BaselineCompareReasonCode::tryFrom($reasonCode) instanceof BaselineCompareReasonCode => $this->translateBaselineCompareReason($reasonCode), $artifactKey === null && BaselineCompareReasonCode::tryFrom($reasonCode) instanceof BaselineCompareReasonCode => $this->translateBaselineCompareReason($reasonCode),
$artifactKey === self::GOVERNANCE_ARTIFACT_TRUTH_ARTIFACT && BaselineReasonCodes::isKnown($reasonCode) => $this->translateBaselineReason($reasonCode), $artifactKey === self::GOVERNANCE_ARTIFACT_TRUTH_ARTIFACT && BaselineReasonCodes::isKnown($reasonCode) => $this->translateBaselineReason($reasonCode, $context),
$artifactKey === null && BaselineReasonCodes::isKnown($reasonCode) => $this->translateBaselineReason($reasonCode), $artifactKey === null && BaselineReasonCodes::isKnown($reasonCode) => $this->translateBaselineReason($reasonCode, $context),
$artifactKey === self::EXECUTION_DENIAL_ARTIFACT, $artifactKey === self::EXECUTION_DENIAL_ARTIFACT,
$artifactKey === null && ExecutionDenialReasonCode::tryFrom($reasonCode) instanceof ExecutionDenialReasonCode => ExecutionDenialReasonCode::tryFrom($reasonCode)?->toReasonResolutionEnvelope($surface, $context), $artifactKey === null && ExecutionDenialReasonCode::tryFrom($reasonCode) instanceof ExecutionDenialReasonCode => ExecutionDenialReasonCode::tryFrom($reasonCode)?->toReasonResolutionEnvelope($surface, $context),
$artifactKey === null && LifecycleReconciliationReason::tryFrom($reasonCode) instanceof LifecycleReconciliationReason => LifecycleReconciliationReason::tryFrom($reasonCode)?->toReasonResolutionEnvelope($surface, $context), $artifactKey === null && LifecycleReconciliationReason::tryFrom($reasonCode) instanceof LifecycleReconciliationReason => LifecycleReconciliationReason::tryFrom($reasonCode)?->toReasonResolutionEnvelope($surface, $context),
@ -116,7 +116,10 @@ private function fallbackTranslate(
return $this->fallbackReasonTranslator->translate($reasonCode, $surface, $context); return $this->fallbackReasonTranslator->translate($reasonCode, $surface, $context);
} }
private function translateBaselineReason(string $reasonCode): ReasonResolutionEnvelope /**
* @param array<string, mixed> $context
*/
private function translateBaselineReason(string $reasonCode, array $context = []): ReasonResolutionEnvelope
{ {
[$operatorLabel, $shortExplanation, $actionability, $nextStep] = match ($reasonCode) { [$operatorLabel, $shortExplanation, $actionability, $nextStep] = match ($reasonCode) {
BaselineReasonCodes::CAPTURE_MISSING_SOURCE_TENANT => [ BaselineReasonCodes::CAPTURE_MISSING_SOURCE_TENANT => [
@ -138,6 +141,51 @@ private function translateBaselineReason(string $reasonCode): ReasonResolutionEn
'prerequisite_missing', 'prerequisite_missing',
'Enable the rollout before retrying full-content baseline work.', 'Enable the rollout before retrying full-content baseline work.',
], ],
BaselineReasonCodes::CAPTURE_INVENTORY_MISSING => [
'Run tenant sync first',
$this->baselineCaptureTruthImpactExplanation(
'No current inventory basis was available for this baseline capture.',
$context,
),
'prerequisite_missing',
'Run inventory sync for this tenant, then capture the baseline again.',
],
BaselineReasonCodes::CAPTURE_INVENTORY_BLOCKED => [
'Latest inventory sync was blocked',
$this->baselineCaptureTruthImpactExplanation(
'The latest inventory sync was blocked, so this capture could not use a credible upstream basis.',
$context,
),
'prerequisite_missing',
'Review the blocked inventory sync, fix the prerequisite, and rerun sync before capturing again.',
],
BaselineReasonCodes::CAPTURE_INVENTORY_FAILED => [
'Latest inventory sync failed',
$this->baselineCaptureTruthImpactExplanation(
'The latest inventory sync failed, so this capture could not use a credible upstream basis.',
$context,
),
'prerequisite_missing',
'Review the failed inventory sync, fix the error, and rerun sync before capturing again.',
],
BaselineReasonCodes::CAPTURE_UNUSABLE_COVERAGE => [
'Latest inventory coverage unusable',
$this->baselineCaptureTruthImpactExplanation(
'The latest inventory sync did not produce usable governed-subject coverage for this baseline capture.',
$context,
),
'prerequisite_missing',
'Run inventory sync until the governed subject types show current coverage, then capture again.',
],
BaselineReasonCodes::CAPTURE_ZERO_SUBJECTS => [
'No subjects were in scope',
$this->baselineCaptureTruthImpactExplanation(
'The latest inventory basis was credible, but no governed subjects were in scope for this baseline capture.',
$context,
),
'prerequisite_missing',
'Review the baseline scope and tenant inventory, then capture again when governed subjects are available.',
],
BaselineReasonCodes::SNAPSHOT_BUILDING, BaselineReasonCodes::SNAPSHOT_BUILDING,
BaselineReasonCodes::COMPARE_SNAPSHOT_BUILDING => [ BaselineReasonCodes::COMPARE_SNAPSHOT_BUILDING => [
'Baseline still building', 'Baseline still building',
@ -242,6 +290,29 @@ private function translateBaselineReason(string $reasonCode): ReasonResolutionEn
); );
} }
/**
* @param array<string, mixed> $context
*/
private function baselineCaptureTruthImpactExplanation(string $baseExplanation, array $context): string
{
$changed = data_get($context, 'baseline_capture.current_baseline_changed');
$previousSnapshotExists = data_get($context, 'baseline_capture.previous_current_snapshot_exists');
if ($changed === true) {
return $baseExplanation.' TenantPilot updated the current baseline truth with a new consumable snapshot.';
}
if ($previousSnapshotExists === true) {
return $baseExplanation.' TenantPilot kept the last trustworthy baseline in place.';
}
if ($previousSnapshotExists === false) {
return $baseExplanation.' No current trustworthy baseline is available yet.';
}
return $baseExplanation;
}
private function translateBaselineCompareReason(string $reasonCode): ReasonResolutionEnvelope private function translateBaselineCompareReason(string $reasonCode): ReasonResolutionEnvelope
{ {
$enum = BaselineCompareReasonCode::tryFrom($reasonCode); $enum = BaselineCompareReasonCode::tryFrom($reasonCode);

View File

@ -70,7 +70,7 @@ public static function families(): array
'canonicalObject' => 'finding', 'canonicalObject' => 'finding',
'panels' => ['tenant'], 'panels' => ['tenant'],
'surfaceKeys' => ['view_finding', 'finding_list_row', 'finding_bulk'], 'surfaceKeys' => ['view_finding', 'finding_list_row', 'finding_bulk'],
'defaultActionOrder' => ['close_finding', 'reopen_finding'], 'defaultActionOrder' => ['resolve_finding', 'close_finding', 'reopen_finding'],
'supportsDocumentedDeviation' => false, 'supportsDocumentedDeviation' => false,
'defaultMutationScopeSource' => 'finding lifecycle', 'defaultMutationScopeSource' => 'finding lifecycle',
], ],
@ -260,6 +260,20 @@ public static function rules(): array
serviceOwner: 'OperationRunTriageService', serviceOwner: 'OperationRunTriageService',
surfaceKeys: ['system_view_run'], surfaceKeys: ['system_view_run'],
), ),
'resolve_finding' => new GovernanceActionRule(
actionKey: 'resolve_finding',
familyKey: 'finding_lifecycle',
frictionClass: GovernanceFrictionClass::F2,
reasonPolicy: GovernanceReasonPolicy::Required,
dangerPolicy: 'none',
canonicalLabel: 'Resolve',
modalHeading: 'Resolve finding',
modalDescription: 'Resolve this finding for the current tenant. TenantPilot records a canonical remediation outcome and keeps the finding in a pending-verification state until trusted evidence later confirms it is actually clear.',
successTitle: 'Finding resolved pending verification',
auditVerb: 'resolve finding',
serviceOwner: 'FindingWorkflowService',
surfaceKeys: ['view_finding', 'finding_list_row', 'finding_bulk'],
),
'close_finding' => new GovernanceActionRule( 'close_finding' => new GovernanceActionRule(
actionKey: 'close_finding', actionKey: 'close_finding',
familyKey: 'finding_lifecycle', familyKey: 'finding_lifecycle',
@ -268,7 +282,7 @@ public static function rules(): array
dangerPolicy: 'none', dangerPolicy: 'none',
canonicalLabel: 'Close', canonicalLabel: 'Close',
modalHeading: 'Close finding', modalHeading: 'Close finding',
modalDescription: 'Close this finding for the current tenant. TenantPilot records the closing rationale and closes the finding lifecycle.', modalDescription: 'Close this finding for the current tenant. TenantPilot records a canonical administrative closure reason such as false positive, duplicate, or no longer applicable.',
successTitle: 'Finding closed', successTitle: 'Finding closed',
auditVerb: 'close finding', auditVerb: 'close finding',
serviceOwner: 'FindingWorkflowService', serviceOwner: 'FindingWorkflowService',
@ -282,7 +296,7 @@ public static function rules(): array
dangerPolicy: 'none', dangerPolicy: 'none',
canonicalLabel: 'Reopen', canonicalLabel: 'Reopen',
modalHeading: 'Reopen finding', modalHeading: 'Reopen finding',
modalDescription: 'Reopen this closed finding for the current tenant. TenantPilot records why the lifecycle is being reopened and recalculates due attention.', modalDescription: 'Reopen this terminal finding for the current tenant. TenantPilot records a canonical reopen reason and recalculates due attention.',
successTitle: 'Finding reopened', successTitle: 'Finding reopened',
auditVerb: 'reopen finding', auditVerb: 'reopen finding',
serviceOwner: 'FindingWorkflowService', serviceOwner: 'FindingWorkflowService',
@ -489,6 +503,17 @@ public static function surfaceBindings(): array
'uiFieldKey' => 'reason', 'uiFieldKey' => 'reason',
'auditChannel' => 'system_audit', 'auditChannel' => 'system_audit',
], ],
[
'surfaceKey' => 'view_finding',
'pageClass' => 'App\\Filament\\Resources\\FindingResource\\Pages\\ViewFinding',
'actionName' => 'resolve',
'familyKey' => 'finding_lifecycle',
'statePredicate' => 'finding has open status',
'primaryOrSecondary' => 'primary',
'capabilityKey' => 'tenant_findings.resolve',
'uiFieldKey' => 'resolved_reason',
'auditChannel' => 'tenant_audit',
],
[ [
'surfaceKey' => 'view_finding', 'surfaceKey' => 'view_finding',
'pageClass' => 'App\\Filament\\Resources\\FindingResource\\Pages\\ViewFinding', 'pageClass' => 'App\\Filament\\Resources\\FindingResource\\Pages\\ViewFinding',

View File

@ -71,6 +71,7 @@ public function build(Workspace $workspace, User $user): array
->all(); ->all();
$this->capabilityResolver->primeMemberships($user, $accessibleTenantIds); $this->capabilityResolver->primeMemberships($user, $accessibleTenantIds);
$visibleFindingsTenantIds = $this->visibleFindingTenantIds($accessibleTenants, $user);
$canViewAlerts = $this->workspaceCapabilityResolver->can($user, $workspace, Capabilities::ALERTS_VIEW); $canViewAlerts = $this->workspaceCapabilityResolver->can($user, $workspace, Capabilities::ALERTS_VIEW);
$navigationContext = $this->workspaceOverviewNavigationContext(); $navigationContext = $this->workspaceOverviewNavigationContext();
@ -136,8 +137,8 @@ public function build(Workspace $workspace, User $user): array
'action_url' => $calmness['next_action']['url'] ?? ChooseTenant::getUrl(panel: 'admin'), 'action_url' => $calmness['next_action']['url'] ?? ChooseTenant::getUrl(panel: 'admin'),
]; ];
$myFindingsSignal = $this->myFindingsSignal($workspaceId, $accessibleTenants, $user); $myFindingsSignal = $this->myFindingsSignal($workspaceId, $visibleFindingsTenantIds, $user);
$findingsHygieneSignal = $this->findingsHygieneSignal($workspace, $user); $findingsHygieneSignal = $this->findingsHygieneSignal($workspace, $visibleFindingsTenantIds);
$zeroTenantState = null; $zeroTenantState = null;
@ -210,18 +211,11 @@ private function accessibleTenants(Workspace $workspace, User $user): Collection
} }
/** /**
* @param Collection<int, Tenant> $accessibleTenants * @param array<int, int> $visibleTenantIds
* @return array<string, mixed> * @return array<string, mixed>
*/ */
private function myFindingsSignal(int $workspaceId, Collection $accessibleTenants, User $user): array private function myFindingsSignal(int $workspaceId, array $visibleTenantIds, User $user): array
{ {
$visibleTenantIds = $accessibleTenants
->filter(fn (Tenant $tenant): bool => $this->capabilityResolver->can($user, $tenant, Capabilities::TENANT_FINDINGS_VIEW))
->pluck('id')
->map(static fn (mixed $id): int => (int) $id)
->values()
->all();
$assignedCounts = $visibleTenantIds === [] $assignedCounts = $visibleTenantIds === []
? null ? null
: $this->scopeToVisibleTenants( : $this->scopeToVisibleTenants(
@ -271,9 +265,9 @@ private function myFindingsSignal(int $workspaceId, Collection $accessibleTenant
/** /**
* @return array<string, mixed> * @return array<string, mixed>
*/ */
private function findingsHygieneSignal(Workspace $workspace, User $user): array private function findingsHygieneSignal(Workspace $workspace, array $visibleTenantIds): array
{ {
$summary = $this->findingAssignmentHygieneService->summary($workspace, $user); $summary = $this->findingAssignmentHygieneService->summaryForVisibleTenantIds($workspace, $visibleTenantIds);
$uniqueIssueCount = $summary['unique_issue_count']; $uniqueIssueCount = $summary['unique_issue_count'];
$brokenAssignmentCount = $summary['broken_assignment_count']; $brokenAssignmentCount = $summary['broken_assignment_count'];
$staleInProgressCount = $summary['stale_in_progress_count']; $staleInProgressCount = $summary['stale_in_progress_count'];
@ -297,6 +291,20 @@ private function findingsHygieneSignal(Workspace $workspace, User $user): array
]; ];
} }
/**
* @param Collection<int, Tenant> $accessibleTenants
* @return array<int, int>
*/
private function visibleFindingTenantIds(Collection $accessibleTenants, User $user): array
{
return $accessibleTenants
->filter(fn (Tenant $tenant): bool => $this->capabilityResolver->can($user, $tenant, Capabilities::TENANT_FINDINGS_VIEW))
->pluck('id')
->map(static fn (mixed $id): int => (int) $id)
->values()
->all();
}
private function findingsHygieneDescription(int $brokenAssignmentCount, int $staleInProgressCount): string private function findingsHygieneDescription(int $brokenAssignmentCount, int $staleInProgressCount): string
{ {
if ($brokenAssignmentCount === 0 && $staleInProgressCount === 0) { if ($brokenAssignmentCount === 0 && $staleInProgressCount === 0) {

View File

@ -120,7 +120,16 @@ public function resolved(): static
return $this->state(fn (array $attributes): array => [ return $this->state(fn (array $attributes): array => [
'status' => Finding::STATUS_RESOLVED, 'status' => Finding::STATUS_RESOLVED,
'resolved_at' => now(), 'resolved_at' => now(),
'resolved_reason' => 'permission_granted', 'resolved_reason' => Finding::RESOLVE_REASON_REMEDIATED,
]);
}
public function verifiedCleared(string $reason = Finding::RESOLVE_REASON_NO_LONGER_DRIFTING): static
{
return $this->state(fn (array $attributes): array => [
'status' => Finding::STATUS_RESOLVED,
'resolved_at' => now(),
'resolved_reason' => $reason,
]); ]);
} }
@ -176,7 +185,7 @@ public function closed(): static
return $this->state(fn (array $attributes): array => [ return $this->state(fn (array $attributes): array => [
'status' => Finding::STATUS_CLOSED, 'status' => Finding::STATUS_CLOSED,
'closed_at' => now(), 'closed_at' => now(),
'closed_reason' => 'duplicate', 'closed_reason' => Finding::CLOSE_REASON_DUPLICATE,
]); ]);
} }
@ -188,7 +197,7 @@ public function riskAccepted(): static
return $this->state(fn (array $attributes): array => [ return $this->state(fn (array $attributes): array => [
'status' => Finding::STATUS_RISK_ACCEPTED, 'status' => Finding::STATUS_RISK_ACCEPTED,
'closed_at' => now(), 'closed_at' => now(),
'closed_reason' => 'accepted_risk', 'closed_reason' => Finding::CLOSE_REASON_ACCEPTED_RISK,
]); ]);
} }

View File

@ -42,7 +42,6 @@ public function definition(): array
'app_client_id' => fake()->uuid(), 'app_client_id' => fake()->uuid(),
'app_client_secret' => null, // Skip encryption in tests 'app_client_secret' => null, // Skip encryption in tests
'app_certificate_thumbprint' => null, 'app_certificate_thumbprint' => null,
'app_status' => 'ok',
'app_notes' => null, 'app_notes' => null,
'status' => 'active', 'status' => 'active',
'environment' => 'other', 'environment' => 'other',

View File

@ -156,7 +156,9 @@ private function classifyLegacySnapshot(object $row, array $summary, int $persis
'was_empty_capture' => ($expectedItems ?? $producerExpectedItems ?? $producerSubjectsTotal) === 0 && $persistedItems === 0, 'was_empty_capture' => ($expectedItems ?? $producerExpectedItems ?? $producerSubjectsTotal) === 0 && $persistedItems === 0,
]; ];
if ($expectedItems !== null && $expectedItems === $persistedItems) { if ($expectedItems !== null
&& $expectedItems === $persistedItems
&& ! ($expectedItems === 0 && $persistedItems === 0)) {
return [ return [
'lifecycle_state' => BaselineSnapshotLifecycleState::Complete->value, 'lifecycle_state' => BaselineSnapshotLifecycleState::Complete->value,
'completed_at' => $producerRun->completed_at ?? $row->captured_at ?? $row->created_at ?? $row->updated_at, 'completed_at' => $producerRun->completed_at ?? $row->captured_at ?? $row->created_at ?? $row->updated_at,
@ -167,7 +169,10 @@ private function classifyLegacySnapshot(object $row, array $summary, int $persis
]; ];
} }
if ($producerSucceeded && $producerExpectedItems !== null && $producerExpectedItems === $persistedItems) { if ($producerSucceeded
&& $producerExpectedItems !== null
&& $producerExpectedItems === $persistedItems
&& ! ($producerExpectedItems === 0 && $persistedItems === 0)) {
return [ return [
'lifecycle_state' => BaselineSnapshotLifecycleState::Complete->value, 'lifecycle_state' => BaselineSnapshotLifecycleState::Complete->value,
'completed_at' => $producerRun->completed_at ?? $row->captured_at ?? $row->created_at ?? $row->updated_at, 'completed_at' => $producerRun->completed_at ?? $row->captured_at ?? $row->created_at ?? $row->updated_at,
@ -184,11 +189,11 @@ private function classifyLegacySnapshot(object $row, array $summary, int $persis
$producerSubjectsTotal, $producerSubjectsTotal,
], static fn (?int $value): bool => $value !== null), true)) { ], static fn (?int $value): bool => $value !== null), true)) {
return [ return [
'lifecycle_state' => BaselineSnapshotLifecycleState::Complete->value, 'lifecycle_state' => BaselineSnapshotLifecycleState::Incomplete->value,
'completed_at' => $producerRun->completed_at ?? $row->captured_at ?? $row->created_at ?? $row->updated_at, 'completed_at' => null,
'failed_at' => null, 'failed_at' => $producerRun->completed_at ?? $row->updated_at ?? $row->captured_at ?? $row->created_at,
'completion_meta' => $completionMeta + [ 'completion_meta' => $completionMeta + [
'finalization_reason_code' => 'baseline.snapshot.legacy_empty_capture_proof', 'finalization_reason_code' => BaselineReasonCodes::CAPTURE_ZERO_SUBJECTS,
], ],
]; ];
} }

View File

@ -20,7 +20,7 @@
<p><strong>Tenant:</strong> {{ $tenant->name }} ({{ $tenant->graphTenantId() }})</p> <p><strong>Tenant:</strong> {{ $tenant->name }} ({{ $tenant->graphTenantId() }})</p>
@isset($connection) @isset($connection)
<p><strong>Connection:</strong> {{ $connection->connection_type->value === 'platform' ? 'Platform connection' : 'Dedicated connection' }}</p> <p><strong>Connection:</strong> {{ $connection->connection_type->value === 'platform' ? 'Platform connection' : 'Dedicated connection' }}</p>
<p><strong>Verification state:</strong> {{ ucfirst($connection->verification_status->value) }}</p> <p><strong>Verification state:</strong> {{ $verificationStateLabel ?? ucfirst($connection->verification_status->value) }}</p>
@endisset @endisset
<p> <p>
<span class="status {{ $status === 'ok' ? 'ok' : ($status === 'consent_denied' ? 'warning' : 'error') }}"> <span class="status {{ $status === 'ok' ? 'ok' : ($status === 'consent_denied' ? 'warning' : 'error') }}">

View File

@ -1,6 +1,8 @@
@php($runs = $runs ?? collect()) @php
@php($overflowCount = (int) ($overflowCount ?? 0)) $runs = $runs ?? collect();
@php($tenant = $tenant ?? null) $overflowCount = (int) ($overflowCount ?? 0);
$tenant = $tenant ?? null;
@endphp
{{-- Cleanup is delegated to the shared poller helper, which uses teardownObserver and new MutationObserver. --}} {{-- Cleanup is delegated to the shared poller helper, which uses teardownObserver and new MutationObserver. --}}
@ -16,6 +18,17 @@
@if($runs->isNotEmpty()) @if($runs->isNotEmpty())
<div class="fixed bottom-4 right-4 z-[999999] w-96 space-y-2" style="pointer-events: auto;"> <div class="fixed bottom-4 right-4 z-[999999] w-96 space-y-2" style="pointer-events: auto;">
@foreach ($runs->take(5) as $run) @foreach ($runs->take(5) as $run)
@php
$statusSpec = \App\Support\Badges\BadgeRenderer::spec(
\App\Support\Badges\BadgeDomain::OperationRunStatus,
[
'status' => (string) $run->status,
'freshness_state' => $run->freshnessState()->value,
],
);
$lifecycleAttention = \App\Support\OpsUx\OperationUxPresenter::lifecycleAttentionSummary($run);
$guidance = \App\Support\OpsUx\OperationUxPresenter::surfaceGuidance($run);
@endphp
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-xl border-2 border-primary-500 dark:border-primary-400 p-4 transition-all animate-in slide-in-from-right duration-300" <div class="bg-white dark:bg-gray-800 rounded-lg shadow-xl border-2 border-primary-500 dark:border-primary-400 p-4 transition-all animate-in slide-in-from-right duration-300"
wire:key="run-{{ $run->id }}"> wire:key="run-{{ $run->id }}">
<div class="flex items-start justify-between gap-4"> <div class="flex items-start justify-between gap-4">
@ -30,6 +43,21 @@
Running {{ ($run->started_at ?? $run->created_at)?->diffForHumans(null, true, true) }} Running {{ ($run->started_at ?? $run->created_at)?->diffForHumans(null, true, true) }}
@endif @endif
</p> </p>
<div class="mt-2 flex flex-wrap items-center gap-2">
<x-filament::badge :color="$statusSpec->color" size="sm">
{{ $statusSpec->label }}
</x-filament::badge>
@if ($lifecycleAttention)
<span class="inline-flex items-center rounded-full border border-warning-200 bg-warning-50 px-2 py-0.5 text-xs font-medium text-warning-800 dark:border-warning-600/40 dark:bg-warning-500/10 dark:text-warning-100">
{{ $lifecycleAttention }}
</span>
@endif
</div>
@if ($guidance)
<p class="mt-2 text-xs leading-5 text-gray-600 dark:text-gray-300">
{{ $guidance }}
</p>
@endif
</div> </div>
@if ($tenant) @if ($tenant)

View File

@ -54,7 +54,7 @@
]); ]);
session()->put(WorkspaceContext::SESSION_KEY, (int) $tenant->workspace_id); session()->put(WorkspaceContext::SESSION_KEY, (int) $tenant->workspace_id);
$operationsIndexUrl = route('admin.operations.index'); $operationsIndexUrl = OperationRunLinks::index($tenant);
$page = visit(TenantResource::getUrl('view', ['record' => $tenant->getRouteKey()], panel: 'admin')); $page = visit(TenantResource::getUrl('view', ['record' => $tenant->getRouteKey()], panel: 'admin'));

View File

@ -7,6 +7,7 @@
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;
@ -63,7 +64,7 @@ public function test_shows_only_generic_links_for_tenantless_runs_on_canonical_d
->get(route('admin.operations.view', ['run' => (int) $run->getKey()])) ->get(route('admin.operations.view', ['run' => (int) $run->getKey()]))
->assertOk() ->assertOk()
->assertSee('Operations') ->assertSee('Operations')
->assertSee(route('admin.operations.index'), false) ->assertSee(OperationRunLinks::index(), false)
->assertDontSee('View restore run'); ->assertDontSee('View restore run');
} }

View File

@ -75,6 +75,34 @@ public function test_trusts_notification_style_run_links_with_no_selected_tenant
->assertSee('Canonical workspace view'); ->assertSee('Canonical workspace view');
} }
public function test_uses_canonical_collection_link_for_default_back_and_show_all_fallbacks(): void
{
$runTenant = Tenant::factory()->create();
[$user, $runTenant] = createUserWithTenant(tenant: $runTenant, role: 'owner');
$otherTenant = Tenant::factory()->create([
'workspace_id' => (int) $runTenant->workspace_id,
]);
createUserWithTenant(tenant: $otherTenant, user: $user, role: 'owner');
$run = OperationRun::factory()->create([
'workspace_id' => (int) $runTenant->workspace_id,
'tenant_id' => (int) $runTenant->getKey(),
'type' => 'inventory_sync',
]);
Filament::setTenant($otherTenant, true);
$this->actingAs($user)
->withSession([WorkspaceContext::SESSION_KEY => (int) $runTenant->workspace_id])
->get(OperationRunLinks::tenantlessView($run))
->assertOk()
->assertSee('Back to Operations')
->assertSee('Show all operations')
->assertSee(OperationRunLinks::index(), false);
}
public function test_trusts_verification_surface_run_links_with_no_selected_tenant_context(): void public function test_trusts_verification_surface_run_links_with_no_selected_tenant_context(): void
{ {
$tenant = Tenant::factory()->create(); $tenant = Tenant::factory()->create();

View File

@ -2,7 +2,9 @@
use App\Models\ProviderConnection; use App\Models\ProviderConnection;
use App\Models\Tenant; use App\Models\Tenant;
use App\Models\TenantOnboardingSession;
use App\Models\Workspace; use App\Models\Workspace;
use App\Models\OperationRun;
use App\Support\Providers\ProviderReasonCodes; use App\Support\Providers\ProviderReasonCodes;
use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Foundation\Testing\RefreshDatabase;
@ -20,6 +22,8 @@
])); ]));
$response->assertOk(); $response->assertOk();
$response->assertSeeText('Verification state:');
$response->assertSeeText('Needs verification');
$response->assertSee( $response->assertSee(
route('filament.admin.resources.tenants.view', ['tenant' => $tenant->external_id, 'record' => $tenant]), route('filament.admin.resources.tenants.view', ['tenant' => $tenant->external_id, 'record' => $tenant]),
false, false,
@ -60,6 +64,57 @@
$response->assertSee(route('admin.onboarding'), false); $response->assertSee(route('admin.onboarding'), false);
}); });
it('invalidates resumable onboarding verification state for the same platform connection after a successful callback', function () {
$tenant = Tenant::factory()->create([
'tenant_id' => 'tenant-verify-reset',
'name' => 'Reset Tenant',
'status' => Tenant::STATUS_ONBOARDING,
]);
$connection = ProviderConnection::factory()->create([
'tenant_id' => (int) $tenant->getKey(),
'workspace_id' => (int) $tenant->workspace_id,
'provider' => 'microsoft',
'entra_tenant_id' => $tenant->graphTenantId(),
'is_default' => true,
]);
$run = OperationRun::factory()->create([
'tenant_id' => (int) $tenant->getKey(),
'workspace_id' => (int) $tenant->workspace_id,
'type' => 'provider.connection.check',
]);
$draft = TenantOnboardingSession::query()->create([
'workspace_id' => (int) $tenant->workspace_id,
'tenant_id' => (int) $tenant->getKey(),
'entra_tenant_id' => (string) $tenant->tenant_id,
'current_step' => 'verify',
'state' => [
'provider_connection_id' => (int) $connection->getKey(),
'verification_operation_run_id' => (int) $run->getKey(),
'verification_run_id' => (int) $run->getKey(),
'bootstrap_operation_runs' => [123, 456],
'bootstrap_operation_types' => ['inventory_sync'],
'bootstrap_run_ids' => [123, 456],
],
]);
$this->get(route('admin.consent.callback', [
'tenant' => $tenant->tenant_id,
'admin_consent' => 'true',
]))->assertOk();
$draft->refresh();
expect($draft->state['verification_operation_run_id'] ?? null)->toBeNull()
->and($draft->state['verification_run_id'] ?? null)->toBeNull()
->and($draft->state['bootstrap_operation_runs'] ?? null)->toBeNull()
->and($draft->state['bootstrap_operation_types'] ?? null)->toBeNull()
->and($draft->state['bootstrap_run_ids'] ?? null)->toBeNull()
->and($draft->state['connection_recently_updated'] ?? null)->toBeTrue();
});
it('creates tenant and provider connection when callback tenant does not exist', function () { it('creates tenant and provider connection when callback tenant does not exist', function () {
$workspace = Workspace::factory()->create(); $workspace = Workspace::factory()->create();
@ -101,6 +156,8 @@
])); ]));
$response->assertOk(); $response->assertOk();
$response->assertSeeText('Verification state:');
$response->assertSeeText('Not verified');
$connection = ProviderConnection::query() $connection = ProviderConnection::query()
->where('tenant_id', (int) $tenant->getKey()) ->where('tenant_id', (int) $tenant->getKey())

View File

@ -15,6 +15,7 @@
use App\Models\Workspace; use App\Models\Workspace;
use App\Models\WorkspaceMembership; use App\Models\WorkspaceMembership;
use App\Services\Auth\WorkspaceCapabilityResolver; use App\Services\Auth\WorkspaceCapabilityResolver;
use App\Support\Baselines\BaselineReasonCodes;
use App\Support\Workspaces\WorkspaceContext; use App\Support\Workspaces\WorkspaceContext;
use Filament\Facades\Filament; use Filament\Facades\Filament;
use Livewire\Livewire; use Livewire\Livewire;
@ -135,6 +136,35 @@
->assertSee('Ambiguous matches'); ->assertSee('Ambiguous matches');
}); });
it('allows entitled viewers to open blocked baseline-capture run detail surfaces', function (): void {
$tenant = 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,
'type' => 'baseline_capture',
'status' => 'completed',
'outcome' => 'blocked',
'context' => [
'reason_code' => BaselineReasonCodes::CAPTURE_INVENTORY_FAILED,
'baseline_capture' => [
'reason_code' => BaselineReasonCodes::CAPTURE_INVENTORY_FAILED,
'current_baseline_changed' => false,
],
],
'completed_at' => now(),
]);
Filament::setTenant(null, true);
$this->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('Latest inventory sync failed');
});
it('keeps governance summary surfaces deny-as-not-found for workspace members without tenant entitlement', function (): void { it('keeps governance summary surfaces deny-as-not-found for workspace members without tenant entitlement', function (): void {
$workspace = Workspace::factory()->create(); $workspace = Workspace::factory()->create();
$tenant = Tenant::factory()->create([ $tenant = Tenant::factory()->create([

View File

@ -18,6 +18,9 @@
'capture_mode' => BaselineCaptureMode::Opportunistic->value, 'capture_mode' => BaselineCaptureMode::Opportunistic->value,
'scope_jsonb' => ['policy_types' => ['deviceConfiguration'], 'foundation_types' => []], 'scope_jsonb' => ['policy_types' => ['deviceConfiguration'], 'foundation_types' => []],
]); ]);
$inventorySyncRun = createInventorySyncOperationRunWithCoverage($tenant, [
'deviceConfiguration' => 'succeeded',
]);
InventoryItem::factory()->create([ InventoryItem::factory()->create([
'tenant_id' => (int) $tenant->getKey(), 'tenant_id' => (int) $tenant->getKey(),
@ -26,6 +29,7 @@
'policy_type' => 'deviceConfiguration', 'policy_type' => 'deviceConfiguration',
'display_name' => 'Audit Policy A', 'display_name' => 'Audit Policy A',
'meta_jsonb' => ['odata_type' => '#microsoft.graph.deviceConfiguration', 'etag' => 'E_AUDIT'], 'meta_jsonb' => ['odata_type' => '#microsoft.graph.deviceConfiguration', 'etag' => 'E_AUDIT'],
'last_seen_operation_run_id' => (int) $inventorySyncRun->getKey(),
]); ]);
$opService = app(OperationRunService::class); $opService = app(OperationRunService::class);

View File

@ -34,12 +34,9 @@
'display_name' => 'Isolated Policy', 'display_name' => 'Isolated Policy',
]); ]);
$lastSeenRun = OperationRun::factory()->create([ $lastSeenRun = createInventorySyncOperationRunWithCoverage($tenant, [
'tenant_id' => (int) $tenant->getKey(), 'deviceConfiguration' => 'succeeded',
'workspace_id' => (int) $tenant->workspace_id, ], attributes: [
'type' => OperationRunType::InventorySync->value,
'status' => OperationRunStatus::Completed->value,
'outcome' => OperationRunOutcome::Succeeded->value,
'completed_at' => now(), 'completed_at' => now(),
]); ]);

View File

@ -20,6 +20,9 @@
'workspace_id' => $tenant->workspace_id, 'workspace_id' => $tenant->workspace_id,
'scope_jsonb' => ['policy_types' => ['deviceConfiguration'], 'foundation_types' => []], 'scope_jsonb' => ['policy_types' => ['deviceConfiguration'], 'foundation_types' => []],
]); ]);
$inventorySyncRun = createInventorySyncOperationRunWithCoverage($tenant, [
'deviceConfiguration' => 'succeeded',
]);
$policy = Policy::factory()->create([ $policy = Policy::factory()->create([
'tenant_id' => (int) $tenant->getKey(), 'tenant_id' => (int) $tenant->getKey(),
@ -41,6 +44,7 @@
'assignment_target_count' => 1, 'assignment_target_count' => 1,
], ],
'last_seen_at' => now()->subHour(), 'last_seen_at' => now()->subHour(),
'last_seen_operation_run_id' => (int) $inventorySyncRun->getKey(),
]); ]);
$snapshotPayload = [ $snapshotPayload = [

View File

@ -29,6 +29,9 @@
'capture_mode' => BaselineCaptureMode::FullContent->value, 'capture_mode' => BaselineCaptureMode::FullContent->value,
'scope_jsonb' => ['policy_types' => ['deviceConfiguration'], 'foundation_types' => []], 'scope_jsonb' => ['policy_types' => ['deviceConfiguration'], 'foundation_types' => []],
]); ]);
$inventorySyncRun = createInventorySyncOperationRunWithCoverage($tenant, [
'deviceConfiguration' => 'succeeded',
]);
$policy = Policy::factory()->create([ $policy = Policy::factory()->create([
'tenant_id' => (int) $tenant->getKey(), 'tenant_id' => (int) $tenant->getKey(),
@ -50,6 +53,7 @@
'assignment_target_count' => 1, 'assignment_target_count' => 1,
], ],
'last_seen_at' => now()->subHour(), 'last_seen_at' => now()->subHour(),
'last_seen_operation_run_id' => (int) $inventorySyncRun->getKey(),
]); ]);
expect(PolicyVersion::query()->where('policy_id', (int) $policy->getKey())->count())->toBe(0); expect(PolicyVersion::query()->where('policy_id', (int) $policy->getKey())->count())->toBe(0);

View File

@ -34,12 +34,9 @@
'display_name' => 'Policy Capture Meta', 'display_name' => 'Policy Capture Meta',
]); ]);
$lastSeenRun = OperationRun::factory()->create([ $lastSeenRun = createInventorySyncOperationRunWithCoverage($tenant, [
'tenant_id' => (int) $tenant->getKey(), 'deviceConfiguration' => 'succeeded',
'workspace_id' => (int) $tenant->workspace_id, ], attributes: [
'type' => OperationRunType::InventorySync->value,
'status' => OperationRunStatus::Completed->value,
'outcome' => OperationRunOutcome::Succeeded->value,
'completed_at' => now(), 'completed_at' => now(),
]); ]);

View File

@ -6,6 +6,7 @@
use App\Models\BaselineSnapshotItem; use App\Models\BaselineSnapshotItem;
use App\Models\InventoryItem; use App\Models\InventoryItem;
use App\Models\OperationRun; use App\Models\OperationRun;
use App\Models\Tenant;
use App\Services\Baselines\BaselineCaptureService; use App\Services\Baselines\BaselineCaptureService;
use App\Services\Baselines\BaselineSnapshotIdentity; use App\Services\Baselines\BaselineSnapshotIdentity;
use App\Services\Baselines\InventoryMetaContract; use App\Services\Baselines\InventoryMetaContract;
@ -18,6 +19,28 @@
use App\Support\Baselines\BaselineSubjectKey; use App\Support\Baselines\BaselineSubjectKey;
use Illuminate\Support\Facades\Queue; use Illuminate\Support\Facades\Queue;
function createBaselineCaptureInventoryBasis(
Tenant $tenant,
array $statusByType,
array $attributes = [],
): OperationRun {
return createInventorySyncOperationRunWithCoverage($tenant, $statusByType, [], $attributes);
}
function runBaselineCaptureJob(
OperationRun $run,
?OperationRunService $operationRunService = null,
): void {
$operationRunService ??= app(OperationRunService::class);
(new CaptureBaselineSnapshotJob($run))->handle(
app(BaselineSnapshotIdentity::class),
app(InventoryMetaContract::class),
app(AuditLogger::class),
$operationRunService,
);
}
// --- T031: Capture enqueue + precondition tests --- // --- T031: Capture enqueue + precondition tests ---
it('enqueues capture for an active profile and creates an operation run', function () { it('enqueues capture for an active profile and creates an operation run', function () {
@ -29,6 +52,9 @@
'workspace_id' => $tenant->workspace_id, 'workspace_id' => $tenant->workspace_id,
'scope_jsonb' => ['policy_types' => ['deviceConfiguration'], 'foundation_types' => []], 'scope_jsonb' => ['policy_types' => ['deviceConfiguration'], 'foundation_types' => []],
]); ]);
$inventorySyncRun = createBaselineCaptureInventoryBasis($tenant, [
'deviceConfiguration' => 'succeeded',
]);
/** @var BaselineCaptureService $service */ /** @var BaselineCaptureService $service */
$service = app(BaselineCaptureService::class); $service = app(BaselineCaptureService::class);
@ -53,10 +79,119 @@
expect($effectiveScope['foundation_types'])->toBe([]); expect($effectiveScope['foundation_types'])->toBe([]);
expect($effectiveScope['all_types'])->toBe(['deviceConfiguration']); expect($effectiveScope['all_types'])->toBe(['deviceConfiguration']);
expect($effectiveScope['foundations_included'])->toBeFalse(); expect($effectiveScope['foundations_included'])->toBeFalse();
expect(data_get($context, 'baseline_capture.inventory_sync_run_id'))->toBe((int) $inventorySyncRun->getKey());
expect(data_get($context, 'baseline_capture.eligibility.phase'))->toBe('preflight');
expect(data_get($context, 'baseline_capture.eligibility.ok'))->toBeTrue();
expect(data_get($context, 'baseline_capture.eligibility.covered_types'))->toBe(['deviceConfiguration']);
expect(data_get($context, 'baseline_capture.eligibility.uncovered_types'))->toBe([]);
Queue::assertPushed(CaptureBaselineSnapshotJob::class); Queue::assertPushed(CaptureBaselineSnapshotJob::class);
}); });
it('rejects capture when no current inventory sync exists', function () {
Queue::fake();
[$user, $tenant] = createUserWithTenant(role: 'owner');
$profile = BaselineProfile::factory()->active()->create([
'workspace_id' => $tenant->workspace_id,
'scope_jsonb' => ['policy_types' => ['deviceConfiguration'], 'foundation_types' => []],
]);
$result = app(BaselineCaptureService::class)->startCapture($profile, $tenant, $user);
expect($result['ok'])->toBeFalse();
expect($result['reason_code'])->toBe(BaselineReasonCodes::CAPTURE_INVENTORY_MISSING);
Queue::assertNotPushed(CaptureBaselineSnapshotJob::class);
expect(OperationRun::query()->where('type', 'baseline_capture')->count())->toBe(0);
});
it('rejects capture when the latest inventory sync was blocked', function () {
Queue::fake();
[$user, $tenant] = createUserWithTenant(role: 'owner');
$profile = BaselineProfile::factory()->active()->create([
'workspace_id' => $tenant->workspace_id,
'scope_jsonb' => ['policy_types' => ['deviceConfiguration'], 'foundation_types' => []],
]);
createBaselineCaptureInventoryBasis($tenant, [
'deviceConfiguration' => 'succeeded',
], [
'completed_at' => now()->subMinute(),
]);
createBaselineCaptureInventoryBasis($tenant, [
'deviceConfiguration' => 'failed',
], [
'outcome' => 'blocked',
'completed_at' => now(),
]);
$result = app(BaselineCaptureService::class)->startCapture($profile, $tenant, $user);
expect($result['ok'])->toBeFalse();
expect($result['reason_code'])->toBe(BaselineReasonCodes::CAPTURE_INVENTORY_BLOCKED);
Queue::assertNotPushed(CaptureBaselineSnapshotJob::class);
expect(OperationRun::query()->where('type', 'baseline_capture')->count())->toBe(0);
});
it('rejects capture when the latest inventory sync failed without falling back to an older success', function () {
Queue::fake();
[$user, $tenant] = createUserWithTenant(role: 'owner');
$profile = BaselineProfile::factory()->active()->create([
'workspace_id' => $tenant->workspace_id,
'scope_jsonb' => ['policy_types' => ['deviceConfiguration'], 'foundation_types' => []],
]);
createBaselineCaptureInventoryBasis($tenant, [
'deviceConfiguration' => 'succeeded',
], [
'completed_at' => now()->subMinute(),
]);
createBaselineCaptureInventoryBasis($tenant, [
'deviceConfiguration' => 'failed',
], [
'outcome' => 'failed',
'completed_at' => now(),
]);
$result = app(BaselineCaptureService::class)->startCapture($profile, $tenant, $user);
expect($result['ok'])->toBeFalse();
expect($result['reason_code'])->toBe(BaselineReasonCodes::CAPTURE_INVENTORY_FAILED);
Queue::assertNotPushed(CaptureBaselineSnapshotJob::class);
expect(OperationRun::query()->where('type', 'baseline_capture')->count())->toBe(0);
});
it('rejects capture when the latest inventory coverage is unusable for the baseline scope', function () {
Queue::fake();
[$user, $tenant] = createUserWithTenant(role: 'owner');
$profile = BaselineProfile::factory()->active()->create([
'workspace_id' => $tenant->workspace_id,
'scope_jsonb' => ['policy_types' => ['deviceConfiguration'], 'foundation_types' => []],
]);
createBaselineCaptureInventoryBasis($tenant, [
'deviceCompliancePolicy' => 'succeeded',
]);
$result = app(BaselineCaptureService::class)->startCapture($profile, $tenant, $user);
expect($result['ok'])->toBeFalse();
expect($result['reason_code'])->toBe(BaselineReasonCodes::CAPTURE_UNUSABLE_COVERAGE);
Queue::assertNotPushed(CaptureBaselineSnapshotJob::class);
expect(OperationRun::query()->where('type', 'baseline_capture')->count())->toBe(0);
});
it('rejects capture for a draft profile with reason code', function () { it('rejects capture for a draft profile with reason code', function () {
Queue::fake(); Queue::fake();
@ -126,6 +261,9 @@
'workspace_id' => $tenant->workspace_id, 'workspace_id' => $tenant->workspace_id,
'scope_jsonb' => ['policy_types' => ['deviceConfiguration'], 'foundation_types' => []], 'scope_jsonb' => ['policy_types' => ['deviceConfiguration'], 'foundation_types' => []],
]); ]);
createBaselineCaptureInventoryBasis($tenant, [
'deviceConfiguration' => 'succeeded',
]);
$service = app(BaselineCaptureService::class); $service = app(BaselineCaptureService::class);
@ -148,6 +286,9 @@
'workspace_id' => $tenant->workspace_id, 'workspace_id' => $tenant->workspace_id,
'scope_jsonb' => ['policy_types' => ['deviceConfiguration'], 'foundation_types' => []], 'scope_jsonb' => ['policy_types' => ['deviceConfiguration'], 'foundation_types' => []],
]); ]);
$inventorySyncRun = createBaselineCaptureInventoryBasis($tenant, [
'deviceConfiguration' => 'succeeded',
]);
$inventoryA = InventoryItem::factory()->create([ $inventoryA = InventoryItem::factory()->create([
'tenant_id' => $tenant->getKey(), 'tenant_id' => $tenant->getKey(),
@ -156,6 +297,7 @@
'policy_type' => 'deviceConfiguration', 'policy_type' => 'deviceConfiguration',
'display_name' => 'Policy A', 'display_name' => 'Policy A',
'meta_jsonb' => ['odata_type' => '#microsoft.graph.deviceConfiguration', 'etag' => 'E1'], 'meta_jsonb' => ['odata_type' => '#microsoft.graph.deviceConfiguration', 'etag' => 'E1'],
'last_seen_operation_run_id' => (int) $inventorySyncRun->getKey(),
]); ]);
$inventoryB = InventoryItem::factory()->create([ $inventoryB = InventoryItem::factory()->create([
'tenant_id' => $tenant->getKey(), 'tenant_id' => $tenant->getKey(),
@ -164,6 +306,7 @@
'policy_type' => 'deviceConfiguration', 'policy_type' => 'deviceConfiguration',
'display_name' => 'Policy B', 'display_name' => 'Policy B',
'meta_jsonb' => ['odata_type' => '#microsoft.graph.deviceConfiguration', 'etag' => 'E2'], 'meta_jsonb' => ['odata_type' => '#microsoft.graph.deviceConfiguration', 'etag' => 'E2'],
'last_seen_operation_run_id' => (int) $inventorySyncRun->getKey(),
]); ]);
$inventoryC = InventoryItem::factory()->create([ $inventoryC = InventoryItem::factory()->create([
'tenant_id' => $tenant->getKey(), 'tenant_id' => $tenant->getKey(),
@ -172,6 +315,7 @@
'policy_type' => 'deviceConfiguration', 'policy_type' => 'deviceConfiguration',
'display_name' => 'Policy C', 'display_name' => 'Policy C',
'meta_jsonb' => ['odata_type' => '#microsoft.graph.deviceConfiguration', 'etag' => 'E3'], 'meta_jsonb' => ['odata_type' => '#microsoft.graph.deviceConfiguration', 'etag' => 'E3'],
'last_seen_operation_run_id' => (int) $inventorySyncRun->getKey(),
]); ]);
$opService = app(OperationRunService::class); $opService = app(OperationRunService::class);
@ -187,13 +331,7 @@
initiator: $user, initiator: $user,
); );
$job = new CaptureBaselineSnapshotJob($run); runBaselineCaptureJob($run, $opService);
$job->handle(
app(BaselineSnapshotIdentity::class),
app(InventoryMetaContract::class),
app(AuditLogger::class),
$opService,
);
$run->refresh(); $run->refresh();
expect($run->status)->toBe('completed'); expect($run->status)->toBe('completed');
@ -269,6 +407,14 @@
expect(data_get($meta, 'meta_contract'))->toBeNull(); expect(data_get($meta, 'meta_contract'))->toBeNull();
} }
expect(data_get($run->context, 'baseline_capture.inventory_sync_run_id'))->toBe((int) $inventorySyncRun->getKey());
expect(data_get($run->context, 'baseline_capture.eligibility.phase'))->toBe('runtime_recheck');
expect(data_get($run->context, 'baseline_capture.eligibility.ok'))->toBeTrue();
expect(data_get($run->context, 'baseline_capture.eligibility.changed_after_enqueue'))->toBeFalse();
expect(data_get($run->context, 'baseline_capture.current_baseline_changed'))->toBeTrue();
expect(data_get($run->context, 'baseline_capture.previous_current_snapshot_exists'))->toBeFalse();
expect(data_get($run->context, 'result.current_baseline_changed'))->toBeTrue();
$profile->refresh(); $profile->refresh();
expect($profile->active_snapshot_id)->toBe((int) $snapshot->getKey()); expect($profile->active_snapshot_id)->toBe((int) $snapshot->getKey());
}); });
@ -311,12 +457,16 @@
'workspace_id' => $tenant->workspace_id, 'workspace_id' => $tenant->workspace_id,
'scope_jsonb' => ['policy_types' => ['deviceConfiguration'], 'foundation_types' => []], 'scope_jsonb' => ['policy_types' => ['deviceConfiguration'], 'foundation_types' => []],
]); ]);
$inventorySyncRun = createBaselineCaptureInventoryBasis($tenant, [
'deviceConfiguration' => 'succeeded',
]);
InventoryItem::factory()->count(2)->create([ InventoryItem::factory()->count(2)->create([
'tenant_id' => $tenant->getKey(), 'tenant_id' => $tenant->getKey(),
'workspace_id' => $tenant->workspace_id, 'workspace_id' => $tenant->workspace_id,
'policy_type' => 'deviceConfiguration', 'policy_type' => 'deviceConfiguration',
'meta_jsonb' => ['odata_type' => '#microsoft.graph.deviceConfiguration', 'stable_field' => 'value'], 'meta_jsonb' => ['odata_type' => '#microsoft.graph.deviceConfiguration', 'stable_field' => 'value'],
'last_seen_operation_run_id' => (int) $inventorySyncRun->getKey(),
]); ]);
$opService = app(OperationRunService::class); $opService = app(OperationRunService::class);
@ -336,8 +486,7 @@
initiator: $user, initiator: $user,
); );
$job1 = new CaptureBaselineSnapshotJob($run1); (new CaptureBaselineSnapshotJob($run1))->handle($idService, $metaContract, $auditLogger, $opService);
$job1->handle($idService, $metaContract, $auditLogger, $opService);
$snapshotCountAfterFirst = BaselineSnapshot::query() $snapshotCountAfterFirst = BaselineSnapshot::query()
->where('baseline_profile_id', $profile->getKey()) ->where('baseline_profile_id', $profile->getKey())
@ -361,8 +510,7 @@
], ],
]); ]);
$job2 = new CaptureBaselineSnapshotJob($run2); (new CaptureBaselineSnapshotJob($run2))->handle($idService, $metaContract, $auditLogger, $opService);
$job2->handle($idService, $metaContract, $auditLogger, $opService);
$snapshotCountAfterSecond = BaselineSnapshot::query() $snapshotCountAfterSecond = BaselineSnapshot::query()
->where('baseline_profile_id', $profile->getKey()) ->where('baseline_profile_id', $profile->getKey())
@ -371,14 +519,68 @@
expect($snapshotCountAfterSecond)->toBe(1); expect($snapshotCountAfterSecond)->toBe(1);
}); });
// --- EC-005: Empty scope produces empty snapshot without errors --- it('blocks a queued capture when the latest inventory basis fails after enqueue and keeps the prior current baseline', function () {
it('captures an empty snapshot when no inventory items match the scope', function () {
[$user, $tenant] = createUserWithTenant(role: 'owner'); [$user, $tenant] = createUserWithTenant(role: 'owner');
$profile = BaselineProfile::factory()->active()->create([ $profile = BaselineProfile::factory()->active()->create([
'workspace_id' => $tenant->workspace_id, 'workspace_id' => $tenant->workspace_id,
'scope_jsonb' => ['policy_types' => ['nonExistentPolicyType'], 'foundation_types' => []], 'scope_jsonb' => ['policy_types' => ['deviceConfiguration'], 'foundation_types' => []],
]);
$previousSnapshot = BaselineSnapshot::factory()->complete()->create([
'workspace_id' => $tenant->workspace_id,
'baseline_profile_id' => (int) $profile->getKey(),
]);
$profile->update(['active_snapshot_id' => (int) $previousSnapshot->getKey()]);
createBaselineCaptureInventoryBasis($tenant, [
'deviceConfiguration' => 'succeeded',
], [
'completed_at' => now()->subMinute(),
]);
Queue::fake();
$result = app(BaselineCaptureService::class)->startCapture($profile, $tenant, $user);
expect($result['ok'])->toBeTrue();
createBaselineCaptureInventoryBasis($tenant, [
'deviceConfiguration' => 'failed',
], [
'outcome' => 'failed',
'completed_at' => now(),
]);
/** @var OperationRun $run */
$run = $result['run'];
runBaselineCaptureJob($run);
$run->refresh();
$profile->refresh();
expect($run->status)->toBe('completed');
expect($run->outcome)->toBe('blocked');
expect($profile->active_snapshot_id)->toBe((int) $previousSnapshot->getKey());
expect(data_get($run->context, 'reason_code'))->toBe(BaselineReasonCodes::CAPTURE_INVENTORY_FAILED);
expect(data_get($run->context, 'baseline_capture.reason_code'))->toBe(BaselineReasonCodes::CAPTURE_INVENTORY_FAILED);
expect(data_get($run->context, 'baseline_capture.current_baseline_changed'))->toBeFalse();
expect(data_get($run->context, 'baseline_capture.previous_current_snapshot_exists'))->toBeTrue();
expect(data_get($run->context, 'baseline_capture.eligibility.changed_after_enqueue'))->toBeTrue();
expect(data_get($run->context, 'result.current_baseline_changed'))->toBeFalse();
});
// --- EC-005: Zero-subject captures stay visible but non-authoritative ---
it('records a zero-subject capture as partially succeeded with a non-consumable snapshot', function () {
[$user, $tenant] = createUserWithTenant(role: 'owner');
$profile = BaselineProfile::factory()->active()->create([
'workspace_id' => $tenant->workspace_id,
'scope_jsonb' => ['policy_types' => ['deviceConfiguration'], 'foundation_types' => []],
]);
createBaselineCaptureInventoryBasis($tenant, [
'deviceConfiguration' => 'succeeded',
]); ]);
$opService = app(OperationRunService::class); $opService = app(OperationRunService::class);
@ -389,22 +591,22 @@
context: [ context: [
'baseline_profile_id' => (int) $profile->getKey(), 'baseline_profile_id' => (int) $profile->getKey(),
'source_tenant_id' => (int) $tenant->getKey(), 'source_tenant_id' => (int) $tenant->getKey(),
'effective_scope' => ['policy_types' => ['nonExistentPolicyType'], 'foundation_types' => []], 'effective_scope' => ['policy_types' => ['deviceConfiguration'], 'foundation_types' => []],
], ],
initiator: $user, initiator: $user,
); );
$job = new CaptureBaselineSnapshotJob($run); runBaselineCaptureJob($run, $opService);
$job->handle(
app(BaselineSnapshotIdentity::class),
app(InventoryMetaContract::class),
app(AuditLogger::class),
$opService,
);
$run->refresh(); $run->refresh();
expect($run->status)->toBe('completed'); expect($run->status)->toBe('completed');
expect($run->outcome)->toBe('succeeded'); expect($run->outcome)->toBe('partially_succeeded');
expect(data_get($run->context, 'reason_code'))->toBe(BaselineReasonCodes::CAPTURE_ZERO_SUBJECTS);
expect(data_get($run->context, 'baseline_capture.reason_code'))->toBe(BaselineReasonCodes::CAPTURE_ZERO_SUBJECTS);
expect(data_get($run->context, 'baseline_capture.subjects_total'))->toBe(0);
expect(data_get($run->context, 'baseline_capture.current_baseline_changed'))->toBeFalse();
expect(data_get($run->context, 'result.current_baseline_changed'))->toBeFalse();
expect(data_get($run->context, 'result.snapshot_lifecycle'))->toBe(BaselineSnapshotLifecycleState::Incomplete->value);
$counts = is_array($run->summary_counts) ? $run->summary_counts : []; $counts = is_array($run->summary_counts) ? $run->summary_counts : [];
expect((int) ($counts['total'] ?? 0))->toBe(0); expect((int) ($counts['total'] ?? 0))->toBe(0);
@ -415,7 +617,12 @@
->first(); ->first();
expect($snapshot)->not->toBeNull(); expect($snapshot)->not->toBeNull();
expect($snapshot?->lifecycleState())->toBe(BaselineSnapshotLifecycleState::Incomplete);
expect(data_get($snapshot?->completion_meta_jsonb ?? [], 'finalization_reason_code'))->toBe(BaselineReasonCodes::CAPTURE_ZERO_SUBJECTS);
expect(BaselineSnapshotItem::query()->where('baseline_snapshot_id', $snapshot->getKey())->count())->toBe(0); expect(BaselineSnapshotItem::query()->where('baseline_snapshot_id', $snapshot->getKey())->count())->toBe(0);
$profile->refresh();
expect($profile->active_snapshot_id)->toBeNull();
}); });
it('captures all inventory items when scope has empty policy_types (all types)', function () { it('captures all inventory items when scope has empty policy_types (all types)', function () {
@ -425,17 +632,23 @@
'workspace_id' => $tenant->workspace_id, 'workspace_id' => $tenant->workspace_id,
'scope_jsonb' => ['policy_types' => [], 'foundation_types' => []], 'scope_jsonb' => ['policy_types' => [], 'foundation_types' => []],
]); ]);
$inventorySyncRun = createBaselineCaptureInventoryBasis($tenant, [
'deviceCompliancePolicy' => 'succeeded',
'deviceConfiguration' => 'succeeded',
]);
InventoryItem::factory()->create([ InventoryItem::factory()->create([
'tenant_id' => $tenant->getKey(), 'tenant_id' => $tenant->getKey(),
'workspace_id' => $tenant->workspace_id, 'workspace_id' => $tenant->workspace_id,
'policy_type' => 'deviceConfiguration', 'policy_type' => 'deviceConfiguration',
'last_seen_operation_run_id' => (int) $inventorySyncRun->getKey(),
]); ]);
InventoryItem::factory()->create([ InventoryItem::factory()->create([
'tenant_id' => $tenant->getKey(), 'tenant_id' => $tenant->getKey(),
'workspace_id' => $tenant->workspace_id, 'workspace_id' => $tenant->workspace_id,
'policy_type' => 'deviceCompliancePolicy', 'policy_type' => 'deviceCompliancePolicy',
'last_seen_operation_run_id' => (int) $inventorySyncRun->getKey(),
]); ]);
// Foundation types are excluded by default (unless foundation_types is selected). // Foundation types are excluded by default (unless foundation_types is selected).
@ -443,6 +656,7 @@
'tenant_id' => $tenant->getKey(), 'tenant_id' => $tenant->getKey(),
'workspace_id' => $tenant->workspace_id, 'workspace_id' => $tenant->workspace_id,
'policy_type' => 'assignmentFilter', 'policy_type' => 'assignmentFilter',
'last_seen_operation_run_id' => (int) $inventorySyncRun->getKey(),
]); ]);
$opService = app(OperationRunService::class); $opService = app(OperationRunService::class);
@ -458,13 +672,7 @@
initiator: $user, initiator: $user,
); );
$job = new CaptureBaselineSnapshotJob($run); runBaselineCaptureJob($run, $opService);
$job->handle(
app(BaselineSnapshotIdentity::class),
app(InventoryMetaContract::class),
app(AuditLogger::class),
$opService,
);
$run->refresh(); $run->refresh();
expect($run->status)->toBe('completed'); expect($run->status)->toBe('completed');

View File

@ -1100,7 +1100,7 @@
$finding->forceFill([ $finding->forceFill([
'status' => Finding::STATUS_RESOLVED, 'status' => Finding::STATUS_RESOLVED,
'resolved_at' => now()->subMinute(), 'resolved_at' => now()->subMinute(),
'resolved_reason' => 'manually_resolved', 'resolved_reason' => Finding::RESOLVE_REASON_REMEDIATED,
])->save(); ])->save();
$firstRun->update(['completed_at' => now()->subMinute()]); $firstRun->update(['completed_at' => now()->subMinute()]);
@ -1335,12 +1335,19 @@
'scope_jsonb' => ['policy_types' => ['deviceConfiguration'], 'foundation_types' => []], 'scope_jsonb' => ['policy_types' => ['deviceConfiguration'], 'foundation_types' => []],
]); ]);
$inventorySyncRun = createInventorySyncOperationRunWithCoverage(
tenant: $tenant,
statusByType: ['deviceConfiguration' => 'succeeded'],
);
InventoryItem::factory()->create([ InventoryItem::factory()->create([
'tenant_id' => (int) $tenant->getKey(), 'tenant_id' => (int) $tenant->getKey(),
'workspace_id' => (int) $tenant->workspace_id, 'workspace_id' => (int) $tenant->workspace_id,
'external_id' => 'policy-a', 'external_id' => 'policy-a',
'policy_type' => 'deviceConfiguration', 'policy_type' => 'deviceConfiguration',
'meta_jsonb' => ['odata_type' => '#microsoft.graph.deviceConfiguration', 'etag' => 'E1'], 'meta_jsonb' => ['odata_type' => '#microsoft.graph.deviceConfiguration', 'etag' => 'E1'],
'last_seen_operation_run_id' => (int) $inventorySyncRun->getKey(),
'last_seen_at' => now(),
]); ]);
$operationRuns = app(OperationRunService::class); $operationRuns = app(OperationRunService::class);
@ -1366,18 +1373,6 @@
$captureRun->refresh(); $captureRun->refresh();
$inventorySyncRun = createInventorySyncOperationRunWithCoverage(
tenant: $tenant,
statusByType: ['deviceConfiguration' => 'succeeded'],
);
InventoryItem::query()
->where('tenant_id', (int) $tenant->getKey())
->update([
'last_seen_operation_run_id' => (int) $inventorySyncRun->getKey(),
'last_seen_at' => now(),
]);
$snapshotId = (int) ($profile->fresh()?->active_snapshot_id ?? 0); $snapshotId = (int) ($profile->fresh()?->active_snapshot_id ?? 0);
expect($snapshotId)->toBeGreaterThan(0); expect($snapshotId)->toBeGreaterThan(0);

View File

@ -43,6 +43,10 @@
it('archives baseline profiles for authorized workspace members', function (): void { it('archives baseline profiles for authorized workspace members', function (): void {
[$user, $tenant] = createUserWithTenant(role: 'owner'); [$user, $tenant] = createUserWithTenant(role: 'owner');
expect(defined(BaselineProfile::class.'::STATUS_DRAFT'))->toBeFalse()
->and(defined(BaselineProfile::class.'::STATUS_ACTIVE'))->toBeFalse()
->and(defined(BaselineProfile::class.'::STATUS_ARCHIVED'))->toBeFalse();
$profile = BaselineProfile::factory()->active()->create([ $profile = BaselineProfile::factory()->active()->create([
'workspace_id' => (int) $tenant->workspace_id, 'workspace_id' => (int) $tenant->workspace_id,
]); ]);

View File

@ -3,6 +3,8 @@
declare(strict_types=1); declare(strict_types=1);
use App\Filament\Resources\BaselineProfileResource; use App\Filament\Resources\BaselineProfileResource;
use App\Models\BaselineProfile;
use App\Support\Baselines\BaselineProfileStatus;
use Filament\Facades\Filament; use Filament\Facades\Filament;
it('keeps baseline profiles out of tenant panel registration and tenant navigation URLs', function (): void { it('keeps baseline profiles out of tenant panel registration and tenant navigation URLs', function (): void {
@ -23,6 +25,11 @@
it('keeps baseline profile urls workspace-owned even when a tenant context exists', function (): void { it('keeps baseline profile urls workspace-owned even when a tenant context exists', function (): void {
[$user, $tenant] = createUserWithTenant(role: 'owner'); [$user, $tenant] = createUserWithTenant(role: 'owner');
$profile = BaselineProfile::factory()->create([
'workspace_id' => (int) $tenant->workspace_id,
'status' => BaselineProfileStatus::Archived->value,
]);
$this->actingAs($user) $this->actingAs($user)
->withSession([\App\Support\Workspaces\WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id]); ->withSession([\App\Support\Workspaces\WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id]);
@ -32,5 +39,8 @@
expect($workspaceUrl)->not->toContain("/admin/t/{$tenant->external_id}/baseline-profiles"); expect($workspaceUrl)->not->toContain("/admin/t/{$tenant->external_id}/baseline-profiles");
$this->get($workspaceUrl)->assertOk(); $this->get($workspaceUrl)->assertOk();
$this->get(BaselineProfileResource::getUrl('view', ['record' => $profile], panel: 'admin'))->assertOk();
$this->get("/admin/t/{$tenant->external_id}/baseline-profiles")->assertNotFound(); $this->get("/admin/t/{$tenant->external_id}/baseline-profiles")->assertNotFound();
expect($profile->fresh()->status)->toBe(BaselineProfileStatus::Archived);
}); });

View File

@ -60,7 +60,7 @@ function classifyLegacySnapshotForTest(BaselineSnapshot $snapshot): array
->and(data_get($classification, 'completion_meta.persisted_items'))->toBe(2); ->and(data_get($classification, 'completion_meta.persisted_items'))->toBe(2);
}); });
it('classifies proven empty legacy captures as complete when the producer run confirms zero subjects', function (): void { it('classifies proven empty legacy captures as incomplete no-data snapshots when the producer run confirms zero subjects', function (): void {
$workspace = Workspace::factory()->create(); $workspace = Workspace::factory()->create();
$profile = BaselineProfile::factory()->active()->create([ $profile = BaselineProfile::factory()->active()->create([
'workspace_id' => (int) $workspace->getKey(), 'workspace_id' => (int) $workspace->getKey(),
@ -86,8 +86,9 @@ function classifyLegacySnapshotForTest(BaselineSnapshot $snapshot): array
$classification = classifyLegacySnapshotForTest($snapshot); $classification = classifyLegacySnapshotForTest($snapshot);
expect($classification['lifecycle_state'])->toBe(BaselineSnapshotLifecycleState::Complete->value) expect($classification['lifecycle_state'])->toBe(BaselineSnapshotLifecycleState::Incomplete->value)
->and(data_get($classification, 'completion_meta.was_empty_capture'))->toBeTrue(); ->and(data_get($classification, 'completion_meta.was_empty_capture'))->toBeTrue()
->and(data_get($classification, 'completion_meta.finalization_reason_code'))->toBe(BaselineReasonCodes::CAPTURE_ZERO_SUBJECTS);
}); });
it('classifies ambiguous legacy snapshots as incomplete with a conservative reason code', function (): void { it('classifies ambiguous legacy snapshots as incomplete with a conservative reason code', function (): void {

View File

@ -42,3 +42,31 @@
->assertSee($explanation?->trustworthinessLabel() ?? '') ->assertSee($explanation?->trustworthinessLabel() ?? '')
->assertSee($explanation?->nextActionText ?? ''); ->assertSee($explanation?->nextActionText ?? '');
}); });
it('renders no-data baseline-capture result surfaces with the shared zero-subject explanation', function (): void {
[$user, $tenant] = createUserWithTenant(role: 'owner');
$profile = BaselineProfile::factory()->active()->create([
'workspace_id' => (int) $tenant->workspace_id,
]);
$snapshot = BaselineSnapshot::factory()->incomplete(BaselineReasonCodes::CAPTURE_ZERO_SUBJECTS)->create([
'workspace_id' => (int) $tenant->workspace_id,
'baseline_profile_id' => (int) $profile->getKey(),
]);
setAdminPanelContext();
session()->put(WorkspaceContext::SESSION_KEY, (int) $tenant->workspace_id);
$truth = app(ArtifactTruthPresenter::class)->forBaselineSnapshot($snapshot->fresh());
$explanation = $truth->operatorExplanation;
$this->actingAs($user)
->get(BaselineSnapshotResource::getUrl('view', ['record' => $snapshot], panel: 'admin'))
->assertOk()
->assertSee('Result meaning')
->assertSee($explanation?->evaluationResultLabel() ?? '')
->assertSee('Result trust')
->assertSee($explanation?->trustworthinessLabel() ?? '')
->assertSee($explanation?->nextActionText ?? '');
});

View File

@ -10,6 +10,7 @@
use App\Models\BaselineSnapshot; use App\Models\BaselineSnapshot;
use App\Models\BaselineTenantAssignment; use App\Models\BaselineTenantAssignment;
use App\Models\OperationRun; use App\Models\OperationRun;
use App\Support\Baselines\BaselineReasonCodes;
use App\Support\Baselines\Compare\CompareStrategyRegistry; use App\Support\Baselines\Compare\CompareStrategyRegistry;
use App\Support\Baselines\Compare\IntuneCompareStrategy; use App\Support\Baselines\Compare\IntuneCompareStrategy;
use App\Support\Governance\GovernanceSubjectTaxonomyRegistry; use App\Support\Governance\GovernanceSubjectTaxonomyRegistry;
@ -407,3 +408,92 @@
->assertSet('uncoveredTypes', ['deviceCompliancePolicy']) ->assertSet('uncoveredTypes', ['deviceCompliancePolicy'])
->assertSet('fidelity', 'meta'); ->assertSet('fidelity', 'meta');
}); });
it('shows the latest blocked capture explanation when no consumable baseline exists yet', function (): void {
[$user, $tenant] = createUserWithTenant(role: 'owner');
$this->actingAs($user);
$tenant->makeCurrent();
Filament::setTenant($tenant, true);
$profile = BaselineProfile::factory()->active()->create([
'workspace_id' => (int) $tenant->workspace_id,
'scope_jsonb' => ['policy_types' => ['deviceConfiguration'], 'foundation_types' => []],
]);
BaselineTenantAssignment::factory()->create([
'workspace_id' => (int) $tenant->workspace_id,
'tenant_id' => (int) $tenant->getKey(),
'baseline_profile_id' => (int) $profile->getKey(),
]);
OperationRun::factory()->create([
'tenant_id' => (int) $tenant->getKey(),
'workspace_id' => (int) $tenant->workspace_id,
'type' => OperationRunType::BaselineCapture->value,
'status' => OperationRunStatus::Completed->value,
'outcome' => OperationRunOutcome::Blocked->value,
'completed_at' => now(),
'context' => [
'baseline_profile_id' => (int) $profile->getKey(),
'reason_code' => BaselineReasonCodes::CAPTURE_INVENTORY_FAILED,
'baseline_capture' => [
'reason_code' => BaselineReasonCodes::CAPTURE_INVENTORY_FAILED,
'current_baseline_changed' => false,
],
],
]);
Livewire::test(BaselineCompareLanding::class)
->assertSet('state', 'no_snapshot')
->assertSet('snapshotId', null)
->assertSet('message', 'The latest inventory sync failed, so this capture could not use a credible upstream basis.');
});
it('keeps compare available against the prior consumable snapshot after a zero-subject capture', function (): void {
[$user, $tenant] = createUserWithTenant(role: 'owner');
$this->actingAs($user);
$tenant->makeCurrent();
Filament::setTenant($tenant, true);
$profile = BaselineProfile::factory()->active()->create([
'workspace_id' => (int) $tenant->workspace_id,
]);
$snapshot = BaselineSnapshot::factory()->complete()->create([
'workspace_id' => (int) $tenant->workspace_id,
'baseline_profile_id' => (int) $profile->getKey(),
]);
$profile->update(['active_snapshot_id' => (int) $snapshot->getKey()]);
BaselineTenantAssignment::factory()->create([
'workspace_id' => (int) $tenant->workspace_id,
'tenant_id' => (int) $tenant->getKey(),
'baseline_profile_id' => (int) $profile->getKey(),
]);
OperationRun::factory()->create([
'tenant_id' => (int) $tenant->getKey(),
'workspace_id' => (int) $tenant->workspace_id,
'type' => OperationRunType::BaselineCapture->value,
'status' => OperationRunStatus::Completed->value,
'outcome' => OperationRunOutcome::PartiallySucceeded->value,
'completed_at' => now(),
'context' => [
'baseline_profile_id' => (int) $profile->getKey(),
'reason_code' => BaselineReasonCodes::CAPTURE_ZERO_SUBJECTS,
'baseline_capture' => [
'reason_code' => BaselineReasonCodes::CAPTURE_ZERO_SUBJECTS,
'current_baseline_changed' => false,
'previous_current_snapshot_exists' => true,
],
],
]);
Livewire::test(BaselineCompareLanding::class)
->assertSet('state', 'idle')
->assertSet('snapshotId', (int) $snapshot->getKey())
->assertActionEnabled('compareNow');
});

View File

@ -93,6 +93,9 @@ function seedCaptureProfileForTenant(
[$user, $tenant] = createUserWithTenant(role: 'owner'); [$user, $tenant] = createUserWithTenant(role: 'owner');
$profile = seedCaptureProfileForTenant($tenant); $profile = seedCaptureProfileForTenant($tenant);
$inventorySyncRun = createInventorySyncOperationRunWithCoverage($tenant, [
'deviceConfiguration' => 'succeeded',
]);
session()->put(WorkspaceContext::SESSION_KEY, (int) $tenant->workspace_id); session()->put(WorkspaceContext::SESSION_KEY, (int) $tenant->workspace_id);
@ -124,6 +127,31 @@ function seedCaptureProfileForTenant(
expect($run)->not->toBeNull(); expect($run)->not->toBeNull();
expect($run?->status)->toBe('queued'); expect($run?->status)->toBe('queued');
expect(data_get($run?->context, 'baseline_capture.inventory_sync_run_id'))->toBe((int) $inventorySyncRun->getKey());
});
it('shows the shared capture block on the start surface when no credible inventory basis exists', function (): void {
Queue::fake();
[$user, $tenant] = createUserWithTenant(role: 'owner');
$profile = seedCaptureProfileForTenant($tenant, BaselineCaptureMode::Opportunistic, [
'scope_jsonb' => ['policy_types' => ['deviceConfiguration'], 'foundation_types' => []],
]);
session()->put(WorkspaceContext::SESSION_KEY, (int) $tenant->workspace_id);
Livewire::actingAs($user)
->test(ViewBaselineProfile::class, ['record' => $profile->getKey()])
->assertActionVisible('capture')
->assertActionHasLabel('capture', 'Capture baseline')
->assertActionEnabled('capture')
->callAction('capture', data: ['source_tenant_id' => (int) $tenant->getKey()])
->assertNotified('Cannot start capture')
->assertStatus(200);
Queue::assertNotPushed(CaptureBaselineSnapshotJob::class);
expect(OperationRun::query()->where('type', 'baseline_capture')->count())->toBe(0);
}); });
it('does not start full-content capture when rollout is disabled', function (): void { it('does not start full-content capture when rollout is disabled', function (): void {

View File

@ -14,6 +14,8 @@
it('filters baseline profiles by status inside the current workspace', function (): void { it('filters baseline profiles by status inside the current workspace', function (): void {
[$user, $tenant] = createUserWithTenant(role: 'owner'); [$user, $tenant] = createUserWithTenant(role: 'owner');
expect(defined(BaselineProfile::class.'::STATUS_ACTIVE'))->toBeFalse();
$active = BaselineProfile::factory()->active()->create([ $active = BaselineProfile::factory()->active()->create([
'workspace_id' => (int) $tenant->workspace_id, 'workspace_id' => (int) $tenant->workspace_id,
]); ]);

View File

@ -7,6 +7,7 @@
use App\Filament\Resources\BaselineProfileResource\Pages\EditBaselineProfile; use App\Filament\Resources\BaselineProfileResource\Pages\EditBaselineProfile;
use App\Filament\Resources\BaselineProfileResource\Pages\ViewBaselineProfile; use App\Filament\Resources\BaselineProfileResource\Pages\ViewBaselineProfile;
use App\Models\BaselineProfile; use App\Models\BaselineProfile;
use App\Support\Baselines\BaselineProfileStatus;
use App\Support\Governance\GovernanceSubjectTaxonomyRegistry; use App\Support\Governance\GovernanceSubjectTaxonomyRegistry;
use App\Support\Workspaces\WorkspaceContext; use App\Support\Workspaces\WorkspaceContext;
use Illuminate\Validation\ValidationException; use Illuminate\Validation\ValidationException;
@ -45,7 +46,7 @@
expect($profile->scope_jsonb)->toBe([ expect($profile->scope_jsonb)->toBe([
'policy_types' => ['deviceConfiguration'], 'policy_types' => ['deviceConfiguration'],
'foundation_types' => ['assignmentFilter'], 'foundation_types' => ['assignmentFilter'],
]); ])->and($profile->status)->toBe(BaselineProfileStatus::Draft);
expect($profile->canonicalScopeJsonb())->toBe([ expect($profile->canonicalScopeJsonb())->toBe([
'version' => 2, 'version' => 2,
@ -83,7 +84,7 @@
'name' => 'Legacy baseline profile', 'name' => 'Legacy baseline profile',
'description' => null, 'description' => null,
'version_label' => null, 'version_label' => null,
'status' => 'active', 'status' => BaselineProfileStatus::Active->value,
'capture_mode' => 'opportunistic', 'capture_mode' => 'opportunistic',
'scope_jsonb' => json_encode([ 'scope_jsonb' => json_encode([
'policy_types' => [], 'policy_types' => [],
@ -178,7 +179,7 @@
'name' => 'Legacy lineage profile', 'name' => 'Legacy lineage profile',
'description' => null, 'description' => null,
'version_label' => null, 'version_label' => null,
'status' => 'active', 'status' => BaselineProfileStatus::Active->value,
'capture_mode' => 'opportunistic', 'capture_mode' => 'opportunistic',
'scope_jsonb' => json_encode([ 'scope_jsonb' => json_encode([
'policy_types' => ['deviceConfiguration'], 'policy_types' => ['deviceConfiguration'],
@ -224,4 +225,4 @@
]))->toThrow(ValidationException::class, 'Filters are not supported'); ]))->toThrow(ValidationException::class, 'Filters are not supported');
expect(BaselineProfile::query()->where('name', 'Invalid filtered baseline')->exists())->toBeFalse(); expect(BaselineProfile::query()->where('name', 'Invalid filtered baseline')->exists())->toBeFalse();
}); });

View File

@ -63,3 +63,52 @@
->assertSee('Baseline compare') ->assertSee('Baseline compare')
->assertSee('Operation #'.$run->getKey()); ->assertSee('Operation #'.$run->getKey());
}); });
it('shows canonical manual terminal outcome and verification labels on finding detail', function (): void {
[$user, $tenant] = createUserWithTenant(role: 'owner');
$this->actingAs($user);
Filament::setTenant($tenant, true);
$finding = Finding::factory()->for($tenant)->create([
'status' => Finding::STATUS_RESOLVED,
'resolved_reason' => Finding::RESOLVE_REASON_REMEDIATED,
'resolved_at' => now()->subHour(),
]);
$this->get(FindingResource::getUrl('view', ['record' => $finding], tenant: $tenant))
->assertOk()
->assertSee('Terminal outcome')
->assertSee('Resolved pending verification')
->assertSee('Verification')
->assertSee('Pending verification')
->assertSee('Resolved reason')
->assertSee('Remediated');
});
it('shows verified clear and administrative closure labels on finding detail', function (): void {
[$user, $tenant] = createUserWithTenant(role: 'owner');
$this->actingAs($user);
Filament::setTenant($tenant, true);
$verifiedFinding = Finding::factory()->for($tenant)->create([
'status' => Finding::STATUS_RESOLVED,
'resolved_reason' => Finding::RESOLVE_REASON_NO_LONGER_DRIFTING,
'resolved_at' => now()->subHour(),
]);
$closedFinding = Finding::factory()->for($tenant)->create([
'status' => Finding::STATUS_CLOSED,
'closed_reason' => Finding::CLOSE_REASON_DUPLICATE,
'closed_at' => now()->subHour(),
]);
$this->get(FindingResource::getUrl('view', ['record' => $verifiedFinding], tenant: $tenant))
->assertOk()
->assertSee('Verified cleared')
->assertSee('No longer drifting');
$this->get(FindingResource::getUrl('view', ['record' => $closedFinding], tenant: $tenant))
->assertOk()
->assertSee('Closed as duplicate')
->assertSee('Duplicate');
});

View File

@ -4,9 +4,11 @@
use App\Filament\Pages\InventoryCoverage; use App\Filament\Pages\InventoryCoverage;
use App\Filament\Resources\InventoryItemResource; use App\Filament\Resources\InventoryItemResource;
use App\Models\InventoryItem;
use App\Models\OperationRun; use App\Models\OperationRun;
use App\Models\Tenant; use App\Models\Tenant;
use App\Support\Inventory\InventoryCoverage as InventoryCoveragePayload; use App\Support\Inventory\InventoryCoverage as InventoryCoveragePayload;
use App\Support\OperationRunLinks;
use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class); uses(RefreshDatabase::class);
@ -40,21 +42,14 @@ function seedCoverageBasisRun(Tenant $tenant): OperationRun
$run = seedCoverageBasisRun($tenant); $run = seedCoverageBasisRun($tenant);
$historyUrl = route('admin.operations.index', [ $historyUrl = OperationRunLinks::index($tenant, operationType: 'inventory_sync');
'tenant_id' => (int) $tenant->getKey(),
'tableFilters' => [
'type' => [
'value' => 'inventory_sync',
],
],
]);
$this->actingAs($user) $this->actingAs($user)
->get(InventoryCoverage::getUrl(tenant: $tenant)) ->get(InventoryCoverage::getUrl(tenant: $tenant))
->assertOk() ->assertOk()
->assertSee('Latest coverage-bearing sync completed') ->assertSee('Latest coverage-bearing sync completed')
->assertSee('Open basis run') ->assertSee('Open basis run')
->assertSee(route('admin.operations.view', ['run' => (int) $run->getKey()]), false) ->assertSee(OperationRunLinks::view($run, $tenant), false)
->assertSee($historyUrl, false) ->assertSee($historyUrl, false)
->assertSee('Review the cited inventory sync to inspect provider or permission issues in detail.'); ->assertSee('Review the cited inventory sync to inspect provider or permission issues in detail.');
}); });
@ -78,6 +73,26 @@ function seedCoverageBasisRun(Tenant $tenant): OperationRun
->assertDontSee('Open basis run'); ->assertDontSee('Open basis run');
}); });
it('shows the last inventory sync as a canonical admin operation detail link', function (): void {
$tenant = Tenant::factory()->create();
[$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'owner');
$run = OperationRun::factory()->forTenant($tenant)->create([
'type' => 'inventory_sync',
]);
$item = InventoryItem::factory()->create([
'tenant_id' => (int) $tenant->getKey(),
'last_seen_operation_run_id' => (int) $run->getKey(),
]);
$this->actingAs($user)
->get(InventoryItemResource::getUrl('view', ['record' => $item], tenant: $tenant))
->assertOk()
->assertSee('Last inventory sync')
->assertSee(OperationRunLinks::view($run, $tenant), false);
});
it('keeps the no-basis fallback explicit on the inventory items list', function (): void { it('keeps the no-basis fallback explicit on the inventory items list', function (): void {
$tenant = Tenant::factory()->create(); $tenant = Tenant::factory()->create();
[$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'owner'); [$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'owner');

View File

@ -93,6 +93,44 @@ function visibleLivewireText(Testable $component): string
->and(mb_strpos($pageText, 'Decision'))->toBeLessThan(mb_strpos($pageText, 'Artifact truth details')); ->and(mb_strpos($pageText, 'Decision'))->toBeLessThan(mb_strpos($pageText, 'Artifact truth details'));
}); });
it('shows the shared blocked-inventory explanation for baseline capture runs without a usable snapshot', function (): void {
[$user, $tenant] = createUserWithTenant(role: 'owner');
$run = OperationRun::factory()->create([
'tenant_id' => (int) $tenant->getKey(),
'workspace_id' => (int) $tenant->workspace_id,
'type' => 'baseline_capture',
'status' => 'completed',
'outcome' => 'blocked',
'context' => [
'reason_code' => BaselineReasonCodes::CAPTURE_INVENTORY_FAILED,
'baseline_capture' => [
'reason_code' => BaselineReasonCodes::CAPTURE_INVENTORY_FAILED,
'current_baseline_changed' => false,
],
],
'failure_summary' => [
['reason_code' => BaselineReasonCodes::CAPTURE_INVENTORY_FAILED, 'message' => 'Capture blocked because the latest inventory sync failed.'],
],
'completed_at' => now(),
]);
$truth = app(ArtifactTruthPresenter::class)->forOperationRun($run->fresh());
$explanation = $truth->operatorExplanation;
Filament::setTenant(null, true);
$this->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($explanation?->evaluationResultLabel() ?? '')
->assertSee($explanation?->trustworthinessLabel() ?? '')
->assertSee('Latest inventory sync failed')
->assertSee($explanation?->nextActionText ?? '');
});
it('shows operator explanation facts for baseline compare runs with nested compare reason context', function (): void { it('shows operator explanation facts for baseline compare runs with nested compare reason context', function (): void {
[$user, $tenant] = createUserWithTenant(role: 'owner'); [$user, $tenant] = createUserWithTenant(role: 'owner');
@ -328,6 +366,9 @@ function visibleLivewireText(Testable $component): string
'workspace_id' => (int) $tenant->workspace_id, 'workspace_id' => (int) $tenant->workspace_id,
'scope_jsonb' => ['policy_types' => ['deviceConfiguration'], 'foundation_types' => []], 'scope_jsonb' => ['policy_types' => ['deviceConfiguration'], 'foundation_types' => []],
]); ]);
$inventorySyncRun = createInventorySyncOperationRunWithCoverage($tenant, [
'deviceConfiguration' => 'succeeded',
]);
$result = app(BaselineCaptureService::class)->startCapture($profile, $tenant, $user); $result = app(BaselineCaptureService::class)->startCapture($profile, $tenant, $user);
@ -344,7 +385,10 @@ function visibleLivewireText(Testable $component): string
->and(data_get($effectiveScope, 'legacy_projection.foundation_types'))->toBe([]) ->and(data_get($effectiveScope, 'legacy_projection.foundation_types'))->toBe([])
->and(data_get($effectiveScope, 'selected_type_keys'))->toBe(['deviceConfiguration']) ->and(data_get($effectiveScope, 'selected_type_keys'))->toBe(['deviceConfiguration'])
->and(data_get($effectiveScope, 'allowed_type_keys'))->toBe(['deviceConfiguration']) ->and(data_get($effectiveScope, 'allowed_type_keys'))->toBe(['deviceConfiguration'])
->and(data_get($effectiveScope, 'unsupported_type_keys'))->toBe([]); ->and(data_get($effectiveScope, 'unsupported_type_keys'))->toBe([])
->and(data_get($run->context, 'baseline_capture.inventory_sync_run_id'))->toBe((int) $inventorySyncRun->getKey())
->and(data_get($run->context, 'baseline_capture.eligibility.phase'))->toBe('preflight')
->and(data_get($run->context, 'baseline_capture.eligibility.ok'))->toBeTrue();
}); });
it('normalizes legacy compare assignment overrides into canonical effective scope without rewriting the override row', function (): void { it('normalizes legacy compare assignment overrides into canonical effective scope without rewriting the override row', function (): void {

View File

@ -16,7 +16,7 @@
$this->actingAs($user); $this->actingAs($user);
OperationRun::factory()->create([ $run = OperationRun::factory()->create([
'workspace_id' => (int) $tenant->workspace_id, 'workspace_id' => (int) $tenant->workspace_id,
'tenant_id' => (int) $tenant->getKey(), 'tenant_id' => (int) $tenant->getKey(),
'type' => 'provider.connection.check', 'type' => 'provider.connection.check',
@ -32,6 +32,8 @@
->assertSee('Open operation') ->assertSee('Open operation')
->assertSee(OperationRunLinks::openCollectionLabel()) ->assertSee(OperationRunLinks::openCollectionLabel())
->assertSee(OperationRunLinks::collectionScopeDescription()) ->assertSee(OperationRunLinks::collectionScopeDescription())
->assertSee(OperationRunLinks::index($tenant), false)
->assertSee(OperationRunLinks::tenantlessView($run), false)
->assertSee('No action needed.') ->assertSee('No action needed.')
->assertDontSee('No operations yet.'); ->assertDontSee('No operations yet.');
}); });

View File

@ -25,6 +25,8 @@
role: 'owner', role: 'owner',
); );
expect($tenant->fresh()->app_status)->toBe('consent_required');
$this->actingAs($user); $this->actingAs($user);
Filament::setTenant($tenant, true); Filament::setTenant($tenant, true);
@ -33,11 +35,14 @@
->assertSee('Lifecycle summary') ->assertSee('Lifecycle summary')
->assertSee('This tenant is still onboarding. It remains visible on management and review surfaces, but it is not selectable as active context until onboarding completes.') ->assertSee('This tenant is still onboarding. It remains visible on management and review surfaces, but it is not selectable as active context until onboarding completes.')
->assertDontSee('App status') ->assertDontSee('App status')
->assertDontSee('Consent required')
->assertSee('RBAC status') ->assertSee('RBAC status')
->assertSee('Failed'); ->assertSee('Failed');
}); });
it('keeps referenced tenant lifecycle context separate from run status in the tenantless operations viewer', function (): void { it('keeps referenced tenant lifecycle context separate from run status in the tenantless operations viewer', function (): void {
expect(array_key_exists('app_status', Tenant::factory()->onboarding()->raw()))->toBeFalse();
$tenant = Tenant::factory()->onboarding()->create([ $tenant = Tenant::factory()->onboarding()->create([
'name' => 'Viewer Separation Tenant', 'name' => 'Viewer Separation Tenant',
]); ]);

View File

@ -38,6 +38,8 @@
'verification_status' => ProviderVerificationStatus::Unknown->value, 'verification_status' => ProviderVerificationStatus::Unknown->value,
]); ]);
expect($tenant->fresh()->app_status)->toBe('ok');
$this->actingAs($user); $this->actingAs($user);
session()->put(WorkspaceContext::SESSION_KEY, (int) $tenant->workspace_id); session()->put(WorkspaceContext::SESSION_KEY, (int) $tenant->workspace_id);
@ -61,6 +63,17 @@
->and($visibleColumnNames)->not->toContain('provider_connection_state'); ->and($visibleColumnNames)->not->toContain('provider_connection_state');
}); });
it('keeps legacy app status as opt-in test setup instead of a factory default', function (): void {
expect(array_key_exists('app_status', Tenant::factory()->raw()))->toBeFalse();
$tenant = Tenant::factory()->create([
'name' => 'Explicit Historical App Status Tenant',
'app_status' => 'error',
]);
expect($tenant->fresh()->app_status)->toBe('error');
});
it('keeps lifecycle and rbac separate while leading the provider summary with consent and verification', function (): void { it('keeps lifecycle and rbac separate while leading the provider summary with consent and verification', function (): void {
$tenant = Tenant::factory()->create([ $tenant = Tenant::factory()->create([
'status' => Tenant::STATUS_ONBOARDING, 'status' => Tenant::STATUS_ONBOARDING,
@ -86,6 +99,8 @@
'verification_status' => ProviderVerificationStatus::Blocked->value, 'verification_status' => ProviderVerificationStatus::Blocked->value,
]); ]);
expect($tenant->fresh()->app_status)->toBe('consent_required');
$this->actingAs($user); $this->actingAs($user);
session()->put(WorkspaceContext::SESSION_KEY, (int) $tenant->workspace_id); session()->put(WorkspaceContext::SESSION_KEY, (int) $tenant->workspace_id);
@ -97,6 +112,7 @@
->assertSee('RBAC status') ->assertSee('RBAC status')
->assertSee('Failed') ->assertSee('Failed')
->assertDontSee('App status') ->assertDontSee('App status')
->assertDontSee('Consent required')
->assertSee('Truth Cleanup Connection') ->assertSee('Truth Cleanup Connection')
->assertSee('Lifecycle') ->assertSee('Lifecycle')
->assertSee('Disabled') ->assertSee('Disabled')

View File

@ -71,5 +71,5 @@
->assertSee('Recent operations'); ->assertSee('Recent operations');
}); });
expect(count(DB::getQueryLog()))->toBeLessThan(80); expect(count(DB::getQueryLog()))->toBeLessThanOrEqual(86);
}); });

View File

@ -20,9 +20,9 @@
$service->triage($finding, $tenant, $user); $service->triage($finding, $tenant, $user);
$service->assign($finding->refresh(), $tenant, $user, null, (int) $user->getKey()); $service->assign($finding->refresh(), $tenant, $user, null, (int) $user->getKey());
$service->resolve($finding->refresh(), $tenant, $user, 'patched'); $service->resolve($finding->refresh(), $tenant, $user, Finding::RESOLVE_REASON_REMEDIATED);
$service->reopen($finding->refresh(), $tenant, $user, 'The issue recurred after validation.'); $service->reopen($finding->refresh(), $tenant, $user, Finding::REOPEN_REASON_MANUAL_REASSESSMENT);
$service->close($finding->refresh(), $tenant, $user, 'duplicate'); $service->close($finding->refresh(), $tenant, $user, Finding::CLOSE_REASON_DUPLICATE);
expect(AuditLog::query() expect(AuditLog::query()
->where('tenant_id', (int) $tenant->getKey()) ->where('tenant_id', (int) $tenant->getKey())
@ -37,14 +37,14 @@
->and($closedAudit->targetDisplayLabel())->toContain('finding') ->and($closedAudit->targetDisplayLabel())->toContain('finding')
->and(data_get($closedAudit->metadata, 'before_status'))->toBe(Finding::STATUS_REOPENED) ->and(data_get($closedAudit->metadata, 'before_status'))->toBe(Finding::STATUS_REOPENED)
->and(data_get($closedAudit->metadata, 'after_status'))->toBe(Finding::STATUS_CLOSED) ->and(data_get($closedAudit->metadata, 'after_status'))->toBe(Finding::STATUS_CLOSED)
->and(data_get($closedAudit->metadata, 'closed_reason'))->toBe('duplicate') ->and(data_get($closedAudit->metadata, 'closed_reason'))->toBe(Finding::CLOSE_REASON_DUPLICATE)
->and(data_get($closedAudit->metadata, 'before.evidence_jsonb'))->toBeNull() ->and(data_get($closedAudit->metadata, 'before.evidence_jsonb'))->toBeNull()
->and(data_get($closedAudit->metadata, 'after.evidence_jsonb'))->toBeNull(); ->and(data_get($closedAudit->metadata, 'after.evidence_jsonb'))->toBeNull();
$reopenedAudit = $this->latestFindingAudit($finding, AuditActionId::FindingReopened); $reopenedAudit = $this->latestFindingAudit($finding, AuditActionId::FindingReopened);
expect($reopenedAudit)->not->toBeNull() expect($reopenedAudit)->not->toBeNull()
->and(data_get($reopenedAudit->metadata, 'reopened_reason'))->toBe('The issue recurred after validation.'); ->and(data_get($reopenedAudit->metadata, 'reopened_reason'))->toBe(Finding::REOPEN_REASON_MANUAL_REASSESSMENT);
}); });
it('deduplicates repeated finding audit writes for the same successful mutation payload', function (): void { it('deduplicates repeated finding audit writes for the same successful mutation payload', function (): void {

View File

@ -6,6 +6,7 @@
use App\Models\Finding; use App\Models\Finding;
use App\Models\User; use App\Models\User;
use App\Services\Findings\FindingWorkflowService; use App\Services\Findings\FindingWorkflowService;
use App\Support\Findings\FindingOutcomeSemantics;
use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class); uses(RefreshDatabase::class);
@ -24,7 +25,7 @@
$service = app(FindingWorkflowService::class); $service = app(FindingWorkflowService::class);
$service->triage($finding, $tenant, $user); $service->triage($finding, $tenant, $user);
$service->resolve($finding->refresh(), $tenant, $user, 'fixed'); $service->resolve($finding->refresh(), $tenant, $user, Finding::RESOLVE_REASON_REMEDIATED);
$audit = AuditLog::query() $audit = AuditLog::query()
->where('tenant_id', (int) $tenant->getKey()) ->where('tenant_id', (int) $tenant->getKey())
@ -40,7 +41,9 @@
->and(data_get($audit->metadata, 'finding_id'))->toBe((int) $finding->getKey()) ->and(data_get($audit->metadata, 'finding_id'))->toBe((int) $finding->getKey())
->and(data_get($audit->metadata, 'before_status'))->toBe(Finding::STATUS_TRIAGED) ->and(data_get($audit->metadata, 'before_status'))->toBe(Finding::STATUS_TRIAGED)
->and(data_get($audit->metadata, 'after_status'))->toBe(Finding::STATUS_RESOLVED) ->and(data_get($audit->metadata, 'after_status'))->toBe(Finding::STATUS_RESOLVED)
->and(data_get($audit->metadata, 'resolved_reason'))->toBe('fixed') ->and(data_get($audit->metadata, 'resolved_reason'))->toBe(Finding::RESOLVE_REASON_REMEDIATED)
->and(data_get($audit->metadata, 'terminal_outcome_key'))->toBe(FindingOutcomeSemantics::OUTCOME_RESOLVED_PENDING_VERIFICATION)
->and(data_get($audit->metadata, 'verification_state'))->toBe(FindingOutcomeSemantics::VERIFICATION_PENDING)
->and(data_get($audit->metadata, 'before'))->toBeArray() ->and(data_get($audit->metadata, 'before'))->toBeArray()
->and(data_get($audit->metadata, 'after'))->toBeArray() ->and(data_get($audit->metadata, 'after'))->toBeArray()
->and(data_get($audit->metadata, 'evidence_jsonb'))->toBeNull() ->and(data_get($audit->metadata, 'evidence_jsonb'))->toBeNull()

View File

@ -120,7 +120,7 @@ function invokeAutomationBaselineCompareUpsertFindings(
expect($audit)->not->toBeNull() expect($audit)->not->toBeNull()
->and($audit->actorSnapshot()->type)->toBe(AuditActorType::System) ->and($audit->actorSnapshot()->type)->toBe(AuditActorType::System)
->and(data_get($audit->metadata, 'system_origin'))->toBeTrue() ->and(data_get($audit->metadata, 'system_origin'))->toBeTrue()
->and(data_get($audit->metadata, 'resolved_reason'))->toBe('no_longer_drifting'); ->and(data_get($audit->metadata, 'resolved_reason'))->toBe(Finding::RESOLVE_REASON_NO_LONGER_DRIFTING);
}); });
it('writes system-origin audit rows for permission posture auto-resolve and recurrence reopen', function (): void { it('writes system-origin audit rows for permission posture auto-resolve and recurrence reopen', function (): void {
@ -185,7 +185,7 @@ function invokeAutomationBaselineCompareUpsertFindings(
expect($resolvedAudit)->not->toBeNull() expect($resolvedAudit)->not->toBeNull()
->and($resolvedAudit->actorSnapshot()->type)->toBe(AuditActorType::System) ->and($resolvedAudit->actorSnapshot()->type)->toBe(AuditActorType::System)
->and(data_get($resolvedAudit->metadata, 'resolved_reason'))->toBe('permission_granted') ->and(data_get($resolvedAudit->metadata, 'resolved_reason'))->toBe(Finding::RESOLVE_REASON_PERMISSION_GRANTED)
->and($reopenedAudit)->not->toBeNull() ->and($reopenedAudit)->not->toBeNull()
->and($reopenedAudit->actorSnapshot()->type)->toBe(AuditActorType::System) ->and($reopenedAudit->actorSnapshot()->type)->toBe(AuditActorType::System)
->and(data_get($reopenedAudit->metadata, 'after_status'))->toBe(Finding::STATUS_REOPENED); ->and(data_get($reopenedAudit->metadata, 'after_status'))->toBe(Finding::STATUS_REOPENED);
@ -299,7 +299,7 @@ function invokeAutomationBaselineCompareUpsertFindings(
$finding->forceFill([ $finding->forceFill([
'status' => Finding::STATUS_RESOLVED, 'status' => Finding::STATUS_RESOLVED,
'resolved_at' => CarbonImmutable::parse('2026-03-18T09:00:00Z'), 'resolved_at' => CarbonImmutable::parse('2026-03-18T09:00:00Z'),
'resolved_reason' => 'fixed', 'resolved_reason' => Finding::RESOLVE_REASON_REMEDIATED,
])->save(); ])->save();
$run2 = OperationRun::factory()->create([ $run2 = OperationRun::factory()->create([

View File

@ -91,7 +91,7 @@
'subject_external_id' => 'policy-dupe', 'subject_external_id' => 'policy-dupe',
'status' => Finding::STATUS_RESOLVED, 'status' => Finding::STATUS_RESOLVED,
'resolved_at' => CarbonImmutable::parse('2026-02-21T00:00:00Z'), 'resolved_at' => CarbonImmutable::parse('2026-02-21T00:00:00Z'),
'resolved_reason' => 'fixed', 'resolved_reason' => Finding::RESOLVE_REASON_REMEDIATED,
'recurrence_key' => null, 'recurrence_key' => null,
'evidence_jsonb' => $evidence, 'evidence_jsonb' => $evidence,
'first_seen_at' => null, 'first_seen_at' => null,
@ -126,9 +126,11 @@
->and($open->status)->toBe(Finding::STATUS_NEW); ->and($open->status)->toBe(Finding::STATUS_NEW);
expect($duplicate->recurrence_key)->toBeNull() expect($duplicate->recurrence_key)->toBeNull()
->and($duplicate->status)->toBe(Finding::STATUS_RESOLVED) ->and($duplicate->status)->toBe(Finding::STATUS_CLOSED)
->and($duplicate->resolved_reason)->toBe('consolidated_duplicate') ->and($duplicate->resolved_reason)->toBeNull()
->and($duplicate->resolved_at?->toIso8601String())->toBe('2026-02-24T10:00:00+00:00'); ->and($duplicate->resolved_at)->toBeNull()
->and($duplicate->closed_reason)->toBe(Finding::CLOSE_REASON_DUPLICATE)
->and($duplicate->closed_at?->toIso8601String())->toBe('2026-02-24T10:00:00+00:00');
CarbonImmutable::setTestNow(); CarbonImmutable::setTestNow();
}); });

View File

@ -88,7 +88,7 @@ function findingBulkAuditResourceIds(int $tenantId, string $action): array
$component $component
->callTableBulkAction('resolve_selected', $resolveFindings, data: [ ->callTableBulkAction('resolve_selected', $resolveFindings, data: [
'resolved_reason' => 'fixed', 'resolved_reason' => Finding::RESOLVE_REASON_REMEDIATED,
]) ])
->assertHasNoTableBulkActionErrors(); ->assertHasNoTableBulkActionErrors();
@ -96,7 +96,7 @@ function findingBulkAuditResourceIds(int $tenantId, string $action): array
$finding->refresh(); $finding->refresh();
expect($finding->status)->toBe(Finding::STATUS_RESOLVED) expect($finding->status)->toBe(Finding::STATUS_RESOLVED)
->and($finding->resolved_reason)->toBe('fixed') ->and($finding->resolved_reason)->toBe(Finding::RESOLVE_REASON_REMEDIATED)
->and($finding->resolved_at)->not->toBeNull(); ->and($finding->resolved_at)->not->toBeNull();
}); });
@ -114,7 +114,7 @@ function findingBulkAuditResourceIds(int $tenantId, string $action): array
$component $component
->callTableBulkAction('close_selected', $closeFindings, data: [ ->callTableBulkAction('close_selected', $closeFindings, data: [
'closed_reason' => 'not applicable', 'closed_reason' => Finding::CLOSE_REASON_NO_LONGER_APPLICABLE,
]) ])
->assertHasNoTableBulkActionErrors(); ->assertHasNoTableBulkActionErrors();
@ -122,7 +122,7 @@ function findingBulkAuditResourceIds(int $tenantId, string $action): array
$finding->refresh(); $finding->refresh();
expect($finding->status)->toBe(Finding::STATUS_CLOSED) expect($finding->status)->toBe(Finding::STATUS_CLOSED)
->and($finding->closed_reason)->toBe('not applicable') ->and($finding->closed_reason)->toBe(Finding::CLOSE_REASON_NO_LONGER_APPLICABLE)
->and($finding->closed_at)->not->toBeNull() ->and($finding->closed_at)->not->toBeNull()
->and($finding->closed_by_user_id)->not->toBeNull(); ->and($finding->closed_by_user_id)->not->toBeNull();
}); });

View File

@ -0,0 +1,145 @@
<?php
declare(strict_types=1);
use App\Filament\Pages\Reviews\ReviewRegister;
use App\Filament\Resources\TenantReviewResource;
use App\Models\EvidenceSnapshot;
use App\Models\Finding;
use App\Services\Evidence\EvidenceSnapshotService;
use App\Services\Evidence\Sources\FindingsSummarySource;
use App\Services\ReviewPackService;
use App\Support\Evidence\EvidenceSnapshotStatus;
use App\Support\Findings\FindingOutcomeSemantics;
use App\Support\Workspaces\WorkspaceContext;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Livewire\Livewire;
uses(RefreshDatabase::class);
function seedFindingOutcomeMatrix(\App\Models\Tenant $tenant): array
{
return [
'pending_verification' => Finding::factory()->for($tenant)->create([
'status' => Finding::STATUS_RESOLVED,
'resolved_reason' => Finding::RESOLVE_REASON_REMEDIATED,
]),
'verified_cleared' => Finding::factory()->for($tenant)->create([
'status' => Finding::STATUS_RESOLVED,
'resolved_reason' => Finding::RESOLVE_REASON_NO_LONGER_DRIFTING,
]),
'closed_duplicate' => Finding::factory()->for($tenant)->create([
'status' => Finding::STATUS_CLOSED,
'closed_reason' => Finding::CLOSE_REASON_DUPLICATE,
]),
'risk_accepted' => Finding::factory()->for($tenant)->create([
'status' => Finding::STATUS_RISK_ACCEPTED,
'closed_reason' => Finding::CLOSE_REASON_ACCEPTED_RISK,
]),
];
}
function materializeFindingOutcomeSnapshot(\App\Models\Tenant $tenant): EvidenceSnapshot
{
$payload = app(EvidenceSnapshotService::class)->buildSnapshotPayload($tenant);
$snapshot = EvidenceSnapshot::query()->create([
'tenant_id' => (int) $tenant->getKey(),
'workspace_id' => (int) $tenant->workspace_id,
'status' => EvidenceSnapshotStatus::Active->value,
'fingerprint' => $payload['fingerprint'],
'completeness_state' => $payload['completeness'],
'summary' => $payload['summary'],
'generated_at' => now(),
]);
foreach ($payload['items'] as $item) {
$snapshot->items()->create([
'tenant_id' => (int) $tenant->getKey(),
'workspace_id' => (int) $tenant->workspace_id,
'dimension_key' => $item['dimension_key'],
'state' => $item['state'],
'required' => $item['required'],
'source_kind' => $item['source_kind'],
'source_record_type' => $item['source_record_type'],
'source_record_id' => $item['source_record_id'],
'source_fingerprint' => $item['source_fingerprint'],
'measured_at' => $item['measured_at'],
'freshness_at' => $item['freshness_at'],
'summary_payload' => $item['summary_payload'],
'sort_order' => $item['sort_order'],
]);
}
return $snapshot->load('items');
}
it('summarizes canonical terminal outcomes and report buckets from findings evidence', function (): void {
[, $tenant] = createUserWithTenant(role: 'owner');
$findings = seedFindingOutcomeMatrix($tenant);
$summary = app(FindingsSummarySource::class)->collect($tenant)['summary_payload'] ?? [];
expect(data_get($summary, 'outcome_counts.'.FindingOutcomeSemantics::OUTCOME_RESOLVED_PENDING_VERIFICATION))->toBe(1)
->and(data_get($summary, 'outcome_counts.'.FindingOutcomeSemantics::OUTCOME_VERIFIED_CLEARED))->toBe(1)
->and(data_get($summary, 'outcome_counts.'.FindingOutcomeSemantics::OUTCOME_CLOSED_DUPLICATE))->toBe(1)
->and(data_get($summary, 'outcome_counts.'.FindingOutcomeSemantics::OUTCOME_RISK_ACCEPTED))->toBe(1)
->and(data_get($summary, 'report_bucket_counts.remediation_pending_verification'))->toBe(1)
->and(data_get($summary, 'report_bucket_counts.remediation_verified'))->toBe(1)
->and(data_get($summary, 'report_bucket_counts.administrative_closure'))->toBe(1)
->and(data_get($summary, 'report_bucket_counts.accepted_risk'))->toBe(1);
$pendingEntry = collect($summary['entries'] ?? [])->firstWhere('id', (int) $findings['pending_verification']->getKey());
$verifiedEntry = collect($summary['entries'] ?? [])->firstWhere('id', (int) $findings['verified_cleared']->getKey());
expect(data_get($pendingEntry, 'terminal_outcome.key'))->toBe(FindingOutcomeSemantics::OUTCOME_RESOLVED_PENDING_VERIFICATION)
->and(data_get($pendingEntry, 'terminal_outcome.verification_state'))->toBe(FindingOutcomeSemantics::VERIFICATION_PENDING)
->and(data_get($verifiedEntry, 'terminal_outcome.key'))->toBe(FindingOutcomeSemantics::OUTCOME_VERIFIED_CLEARED)
->and(data_get($verifiedEntry, 'terminal_outcome.verification_state'))->toBe(FindingOutcomeSemantics::VERIFICATION_VERIFIED);
});
it('propagates finding outcome summaries into evidence snapshots tenant reviews and review packs', function (): void {
[$user, $tenant] = createUserWithTenant(role: 'owner');
seedFindingOutcomeMatrix($tenant);
$snapshot = materializeFindingOutcomeSnapshot($tenant);
expect(data_get($snapshot->summary, 'finding_outcomes.'.FindingOutcomeSemantics::OUTCOME_RESOLVED_PENDING_VERIFICATION))->toBe(1)
->and(data_get($snapshot->summary, 'finding_outcomes.'.FindingOutcomeSemantics::OUTCOME_VERIFIED_CLEARED))->toBe(1)
->and(data_get($snapshot->summary, 'finding_report_buckets.accepted_risk'))->toBe(1);
$review = composeTenantReviewForTest($tenant, $user, $snapshot);
expect(data_get($review->summary, 'finding_outcomes.'.FindingOutcomeSemantics::OUTCOME_CLOSED_DUPLICATE))->toBe(1)
->and(data_get($review->summary, 'finding_report_buckets.administrative_closure'))->toBe(1);
setTenantPanelContext($tenant);
$this->actingAs($user)
->get(TenantReviewResource::tenantScopedUrl('view', ['record' => $review], $tenant))
->assertOk()
->assertSee('Terminal outcomes:')
->assertSee('resolved pending verification')
->assertSee('verified cleared')
->assertSee('closed as duplicate')
->assertSee('risk accepted');
setAdminPanelContext();
session()->put(WorkspaceContext::SESSION_KEY, (int) $tenant->workspace_id);
Livewire::actingAs($user)
->test(ReviewRegister::class)
->assertCanSeeTableRecords([$review])
->assertSee('Terminal outcomes:')
->assertSee('resolved pending verification');
$pack = app(ReviewPackService::class)->generateFromReview($review, $user, [
'include_pii' => false,
'include_operations' => false,
]);
expect(data_get($pack->summary, 'finding_outcomes.'.FindingOutcomeSemantics::OUTCOME_RISK_ACCEPTED))->toBe(1)
->and(data_get($pack->summary, 'finding_report_buckets.accepted_risk'))->toBe(1);
});

Some files were not shown because too many files have changed in this diff Show More